Skip to content

Commit 28a2a9f

Browse files
kaizhou-labzk
andauthored
feat(chat): live terminal card for client-hosted ACP terminals (#3861)
* feat(chat): live terminal card for client-hosted ACP terminals New acp_terminal_output stream frame renders a terminal-style card: mono command header, tail-following output pane, and a per-command Stop button that calls POST /conversations/{id}/terminals/{terminalId}/kill — killing just that command while the agent and turn continue. Cards are keyed by terminal_id (all frames of a turn share one msg_id, so a dedicated index stops two terminals collapsing into one card) and are live-only: they are never persisted and vanish on reload. New i18n keys conversation.terminal.* in all 13 locales. * test(e2e): client-hosted terminal card and stop button Two Playwright specs against codebuddy through the real UI: a delegated command must render the live terminal card (mono command header + output pane fed by acp_terminal_output frames), and the card's Stop button must kill just that command — output stops mid-stream and the agent keeps replying. Terminal card gains stable testids for both specs. Note on the stop prompt: a bare long is not usable — codebuddy treats it as a timeout risk and reroutes it to its own background-task tool, never touching terminal/*. A ticking loop goes through the delegated terminal and stays killable mid-stream. --------- Co-authored-by: zk <zk@users.noreply.local>
1 parent f641c3e commit 28a2a9f

21 files changed

Lines changed: 445 additions & 3 deletions

File tree

packages/desktop/src/common/adapter/ipcBridge.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,10 @@ export const conversation = {
264264
(p) => `/api/conversations/${p.conversation_id}/cancel`,
265265
(p) => ({ turn_id: p.turn_id })
266266
),
267+
killTerminal: httpPost<void, { conversation_id: string; terminal_id: string }>(
268+
(p) => `/api/conversations/${p.conversation_id}/terminals/${encodeURIComponent(p.terminal_id)}/kill`,
269+
() => undefined
270+
),
267271
activeCount: httpGet<{ count: number }>('/api/conversations/active-count'),
268272
sendMessage: httpPost<ISendMessageResult, ISendMessageParams>(
269273
(p) => `/api/conversations/${p.conversation_id}/messages`,

packages/desktop/src/common/chat/chatLib.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ type TMessageType =
6868
| 'acp_tool_call'
6969
| 'plan'
7070
| 'thinking'
71-
| 'available_commands';
71+
| 'available_commands'
72+
| 'acp_terminal_output';
7273

7374
interface IMessage<T extends TMessageType, Content extends Record<string, any>> {
7475
/**
@@ -324,6 +325,19 @@ export type IMessagePermission = IMessage<'permission', IConfirmation>;
324325

325326
export type IMessageAcpToolCall = IMessage<'acp_tool_call', ToolCallUpdate>;
326327

328+
/** Live snapshot of a client-hosted terminal (ACP terminal/*). Stream-only —
329+
* never persisted; the card disappears on conversation reload. */
330+
export interface AcpTerminalOutputContent {
331+
terminal_id: string;
332+
command: string;
333+
/** Cumulative output (backend sends the full buffer each frame). */
334+
output: string;
335+
truncated: boolean;
336+
exit_status?: { exit_code?: number | null; signaled?: boolean } | null;
337+
}
338+
339+
export type IMessageAcpTerminalOutput = IMessage<'acp_terminal_output', AcpTerminalOutputContent>;
340+
327341
export const mergeAcpToolCallContent = (
328342
existing: IMessageAcpToolCall['content'],
329343
incoming: IMessageAcpToolCall['content']
@@ -406,7 +420,8 @@ export type TMessage =
406420
| IMessageAcpToolCall
407421
| IMessagePlan
408422
| IMessageThinking
409-
| IMessageAvailableCommands;
423+
| IMessageAvailableCommands
424+
| IMessageAcpTerminalOutput;
410425

411426
// 统一所有需要用户交互的用户类型
412427
export interface IConfirmation<Option extends any = any> {
@@ -806,6 +821,21 @@ const transformMessageInner = (message: IResponseMessage): TMessage | undefined
806821
content: message.data as any,
807822
};
808823
}
824+
case 'acp_terminal_output': {
825+
const terminal = message.data as any;
826+
return {
827+
// Deterministic id per terminal: every frame of a turn shares one
828+
// msg_id, so a uuid per frame would stack cards and the msg_id
829+
// fallback merge would collapse two terminals into one.
830+
id: `term:${message.msg_id}:${terminal?.terminal_id ?? ''}`,
831+
type: 'acp_terminal_output',
832+
msg_id: message.msg_id,
833+
position: 'left',
834+
conversation_id: message.conversation_id,
835+
created_at,
836+
content: terminal,
837+
};
838+
}
809839
case 'acp_tool_call': {
810840
return {
811841
id: uuid(),

packages/desktop/src/renderer/pages/conversation/Messages/MessageList.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Down } from '@icon-park/react';
1616
import MessageAcpPermission from '@renderer/pages/conversation/Messages/acp/MessageAcpPermission';
1717
import MessageQuestion from './MessageQuestion';
1818
import MessagePermission from './components/MessagePermission';
19+
import MessageAcpTerminalOutput from '@renderer/pages/conversation/Messages/acp/MessageAcpTerminalOutput';
1920
import MessageAcpToolCall from '@renderer/pages/conversation/Messages/acp/MessageAcpToolCall';
2021
import classNames from 'classnames';
2122
import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -293,6 +294,8 @@ const MessageItem: React.FC<{
293294
return <MessageQuestion message={message}></MessageQuestion>;
294295
case 'acp_tool_call':
295296
return <MessageAcpToolCall message={message}></MessageAcpToolCall>;
297+
case 'acp_terminal_output':
298+
return <MessageAcpTerminalOutput message={message}></MessageAcpTerminalOutput>;
296299
case 'plan':
297300
return <MessagePlan message={message}></MessagePlan>;
298301
case 'thinking':
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @license
3+
* Copyright 2025 AionUi (aionui.com)
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { ipcBridge } from '@/common';
8+
import type { IMessageAcpTerminalOutput } from '@/common/chat/chatLib';
9+
import { Button, Card, Tag } from '@arco-design/web-react';
10+
import React, { useCallback, useEffect, useRef, useState } from 'react';
11+
import { useTranslation } from 'react-i18next';
12+
13+
/**
14+
* Live card for a client-hosted terminal (ACP terminal/*): the delegated
15+
* command runs in OUR process tree, so output streams in real time and the
16+
* stop button kills just this command — the agent observes the signal exit
17+
* and the turn continues.
18+
*/
19+
const MessageAcpTerminalOutput: React.FC<{ message: IMessageAcpTerminalOutput }> = ({ message }) => {
20+
const { t } = useTranslation();
21+
const { content, conversation_id } = message;
22+
const outputRef = useRef<HTMLPreElement>(null);
23+
const [killing, setKilling] = useState(false);
24+
25+
const running = !content?.exit_status;
26+
27+
// Tail-follow the output while the command runs.
28+
useEffect(() => {
29+
const el = outputRef.current;
30+
if (el && running) {
31+
el.scrollTop = el.scrollHeight;
32+
}
33+
}, [content?.output, running]);
34+
35+
const handleStop = useCallback(async () => {
36+
if (!conversation_id || !content?.terminal_id) return;
37+
setKilling(true);
38+
try {
39+
await ipcBridge.conversation.killTerminal.invoke({
40+
conversation_id,
41+
terminal_id: content.terminal_id,
42+
});
43+
} catch (error) {
44+
console.error('[MessageAcpTerminalOutput] kill failed:', error);
45+
setKilling(false);
46+
}
47+
}, [conversation_id, content?.terminal_id]);
48+
49+
if (!content?.terminal_id) {
50+
return null;
51+
}
52+
53+
const exit = content.exit_status;
54+
const statusTag = running ? (
55+
<Tag color='arcoblue' size='small' data-testid='terminal-card-status'>
56+
{t('conversation.terminal.running', { defaultValue: 'Running' })}
57+
</Tag>
58+
) : exit?.signaled ? (
59+
<Tag color='orange' size='small' data-testid='terminal-card-status'>
60+
{t('conversation.terminal.stopped', { defaultValue: 'Stopped' })}
61+
</Tag>
62+
) : (
63+
<Tag color={exit?.exit_code === 0 ? 'green' : 'red'} size='small' data-testid='terminal-card-status'>
64+
{t('conversation.terminal.exited', { defaultValue: 'Exit {{code}}', code: exit?.exit_code ?? '?' })}
65+
</Tag>
66+
);
67+
68+
return (
69+
<Card className='w-full mb-2' size='small' bordered>
70+
<div className='flex items-center gap-2 mb-2 min-w-0'>
71+
<code className='text-13px font-mono text-t-primary truncate flex-1' data-testid='terminal-card-command'>
72+
$ {content.command}
73+
</code>
74+
{statusTag}
75+
{running && (
76+
<Button size='mini' status='danger' loading={killing} onClick={handleStop} data-testid='terminal-card-stop'>
77+
{t('conversation.terminal.stop', { defaultValue: 'Stop' })}
78+
</Button>
79+
)}
80+
</div>
81+
{(content.output || running) && (
82+
<pre
83+
ref={outputRef}
84+
data-testid='terminal-card-output'
85+
className='bg-1 p-2 rounded text-xs font-mono overflow-x-auto overflow-y-auto max-h-320px whitespace-pre-wrap m-0'
86+
>
87+
{content.truncated
88+
? `…${t('conversation.terminal.truncated', { defaultValue: '(earlier output truncated)' })}\n`
89+
: ''}
90+
{content.output || ''}
91+
</pre>
92+
)}
93+
</Card>
94+
);
95+
};
96+
97+
export default MessageAcpTerminalOutput;

packages/desktop/src/renderer/pages/conversation/Messages/hooks.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ interface MessageIndex {
5757
call_idIndex: Map<string, number>; // tool_call.call_id -> index
5858
tool_call_idIndex: Map<string, number>; // acp_tool_call.update.tool_call_id -> index
5959
permission_call_idIndex: Map<string, number>; // permission.content.call_id -> index
60+
terminal_idIndex: Map<string, number>; // acp_terminal_output.content.terminal_id -> index
6061
}
6162

6263
function getMessageIndexKey(message: TMessage): string | undefined {
@@ -88,6 +89,7 @@ export function buildMessageIndex(list: TMessage[]): MessageIndex {
8889
const call_idIndex = new Map<string, number>();
8990
const tool_call_idIndex = new Map<string, number>();
9091
const permission_call_idIndex = new Map<string, number>();
92+
const terminal_idIndex = new Map<string, number>();
9193

9294
for (let i = 0; i < list.length; i++) {
9395
const msg = list[i];
@@ -104,9 +106,12 @@ export function buildMessageIndex(list: TMessage[]): MessageIndex {
104106
if (msg.type === 'permission' && msg.content?.call_id) {
105107
permission_call_idIndex.set(msg.content.call_id, i);
106108
}
109+
if (msg.type === 'acp_terminal_output' && msg.content?.terminal_id) {
110+
terminal_idIndex.set(msg.content.terminal_id, i);
111+
}
107112
}
108113

109-
return { msgIdIndex, call_idIndex, tool_call_idIndex, permission_call_idIndex };
114+
return { msgIdIndex, call_idIndex, tool_call_idIndex, permission_call_idIndex, terminal_idIndex };
110115
}
111116

112117
// 获取或构建索引(带缓存)
@@ -204,6 +209,24 @@ export function composeMessageWithIndex(
204209
return list.concat(message);
205210
}
206211

212+
// acp_terminal_output: one live card per terminal_id, replaced in place
213+
// (every frame of a turn shares msg_id, so the generic msg_id arm would
214+
// collapse two terminals of the same turn into one card).
215+
if (message.type === 'acp_terminal_output' && message.content?.terminal_id) {
216+
const existingIdx = index.terminal_idIndex.get(message.content.terminal_id);
217+
if (existingIdx !== undefined && existingIdx < list.length) {
218+
const existingMsg = list[existingIdx];
219+
if (existingMsg.type === 'acp_terminal_output') {
220+
const newList = list.slice();
221+
newList[existingIdx] = { ...existingMsg, content: message.content };
222+
return newList;
223+
}
224+
}
225+
const newIdx = list.length;
226+
index.terminal_idIndex.set(message.content.terminal_id, newIdx);
227+
return list.concat(message);
228+
}
229+
207230
// acp_tool_call: use tool_call_idIndex for fast lookup
208231
if (message.type === 'acp_tool_call' && message.content?.update?.tool_call_id) {
209232
const existingIdx = index.tool_call_idIndex.get(message.content.update.tool_call_id);
@@ -413,6 +436,9 @@ export const useMergeLiveMessage = () => {
413436
if (msg.type === 'permission' && msg.content?.call_id) {
414437
index.permission_call_idIndex.set(msg.content.call_id, newIdx);
415438
}
439+
if (msg.type === 'acp_terminal_output' && msg.content?.terminal_id) {
440+
index.terminal_idIndex.set(msg.content.terminal_id, newIdx);
441+
}
416442
newList = newList.concat(msg);
417443
} else {
418444
// 使用索引优化的消息合并

packages/desktop/src/renderer/pages/conversation/platforms/acp/useAcpMessage.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,12 @@ export const useAcpMessage = (
478478
}
479479
break;
480480
}
481+
case 'acp_terminal_output':
482+
// Live client-hosted terminal snapshot. Merge the card only — the
483+
// frame can trail the turn's Finish (final exit snapshot), so it
484+
// must not re-light turn state like the default arm does.
485+
mergeLiveMessage(transformedMessage);
486+
break;
481487
case 'acp_context_usage': {
482488
const usageData = message.data as {
483489
used: number;

packages/desktop/src/renderer/services/i18n/i18n-keys.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,11 @@ export type I18nKey =
688688
| 'conversation.skill_generator.type_skill'
689689
| 'conversation.skills.loaded'
690690
| 'conversation.skills.slashHint'
691+
| 'conversation.terminal.exited'
692+
| 'conversation.terminal.running'
693+
| 'conversation.terminal.stop'
694+
| 'conversation.terminal.stopped'
695+
| 'conversation.terminal.truncated'
691696
| 'conversation.thinking.complete'
692697
| 'conversation.thinking.label'
693698
| 'conversation.welcome.clearWorkspace'

packages/desktop/src/renderer/services/i18n/locales/de-DE/conversation.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,5 +631,12 @@
631631
"empty": "Keine passenden Dateien",
632632
"limitReached": "Erste {{count}} werden angezeigt — eingrenzen"
633633
}
634+
},
635+
"terminal": {
636+
"running": "Läuft",
637+
"stopped": "Gestoppt",
638+
"exited": "Exit {{code}}",
639+
"stop": "Stopp",
640+
"truncated": "(frühere Ausgabe gekürzt)"
634641
}
635642
}

packages/desktop/src/renderer/services/i18n/locales/en-US/conversation.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,5 +631,12 @@
631631
"empty": "No files match",
632632
"limitReached": "Showing first {{count}} — refine to narrow"
633633
}
634+
},
635+
"terminal": {
636+
"running": "Running",
637+
"stopped": "Stopped",
638+
"exited": "Exit {{code}}",
639+
"stop": "Stop",
640+
"truncated": "(earlier output truncated)"
634641
}
635642
}

packages/desktop/src/renderer/services/i18n/locales/es-ES/conversation.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,5 +631,12 @@
631631
"empty": "Ningún archivo coincide",
632632
"limitReached": "Mostrando los primeros {{count}} — afina la búsqueda"
633633
}
634+
},
635+
"terminal": {
636+
"running": "En ejecución",
637+
"stopped": "Detenido",
638+
"exited": "Salida {{code}}",
639+
"stop": "Detener",
640+
"truncated": "(salida anterior truncada)"
634641
}
635642
}

0 commit comments

Comments
 (0)