From 94bc5b54fcd787cbabcf8f9c1958b71e57dcf9c4 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 24 Jun 2026 21:15:10 +0800 Subject: [PATCH 01/18] feat: add shell mode (`!`) to the CLI Add shell mode, letting users run shell commands directly from the prompt with `!`. Output streams live into the transcript, supports backgrounding (ctrl+b), cancellation (Esc / Ctrl+C), input queuing while running, and enters the conversation context with resume support. --- .changeset/add-shell-mode.md | 5 + .../tui/components/editor/custom-editor.ts | 42 +++- .../src/tui/components/messages/shell-run.ts | 110 +++++++++ .../tui/components/messages/status-message.ts | 25 ++- .../src/tui/controllers/editor-keyboard.ts | 11 + .../tui/controllers/session-event-handler.ts | 4 + .../src/tui/controllers/session-replay.ts | 30 +++ apps/kimi-code/src/tui/kimi-tui.ts | 212 +++++++++++++++++- apps/kimi-code/src/tui/theme/colors.ts | 8 + apps/kimi-code/src/tui/types.ts | 7 +- .../kimi-code/src/tui/utils/message-replay.ts | 3 + apps/kimi-code/src/tui/utils/shell-output.ts | 23 ++ .../test/tui/components/chrome/footer.test.ts | 1 + .../tui/components/chrome/welcome.test.ts | 1 + .../test/tui/create-tui-state.test.ts | 1 + .../agent-core/src/agent/context/index.ts | 42 ++++ .../agent-core/src/agent/context/types.ts | 9 + packages/agent-core/src/agent/index.ts | 2 + packages/agent-core/src/agent/tool/index.ts | 85 ++++++- packages/agent-core/src/loop/types.ts | 6 + packages/agent-core/src/rpc/core-api.ts | 25 +++ packages/agent-core/src/rpc/core-impl.ts | 10 + packages/agent-core/src/session/rpc.ts | 10 + .../src/tools/builtin/shell/bash.ts | 8 +- .../agent-core/test/agent/context.test.ts | 65 +++++- packages/node-sdk/src/rpc.ts | 25 +++ packages/node-sdk/src/session.ts | 21 ++ .../node-sdk/test/session-event-types.test.ts | 2 + packages/protocol/src/events.ts | 56 +++++ 29 files changed, 831 insertions(+), 18 deletions(-) create mode 100644 .changeset/add-shell-mode.md create mode 100644 apps/kimi-code/src/tui/components/messages/shell-run.ts create mode 100644 apps/kimi-code/src/tui/utils/shell-output.ts diff --git a/.changeset/add-shell-mode.md b/.changeset/add-shell-mode.md new file mode 100644 index 0000000000..205ad77e32 --- /dev/null +++ b/.changeset/add-shell-mode.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add shell mode (`!`) for running shell commands from the prompt with live output, background execution, and cancellation. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 6008175470..1ddb150b88 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -134,6 +134,10 @@ export class CustomEditor extends Editor { public onUpArrowEmpty?: () => boolean; public onDownArrowEmpty?: () => boolean; public onShiftTab?: () => void; + /** 'bash' when entering a `!` shell command. The `!` is never part of the + * text buffer — it is a separate mode + prompt symbol (see handleInput). */ + public inputMode: 'prompt' | 'bash' = 'prompt'; + public onInputModeChange?: (mode: 'prompt' | 'bash') => void; public connectedAbove = false; public borderHighlighted = false; /** @@ -247,7 +251,12 @@ export class CustomEditor extends Editor { } const firstContent = lines[firstContentIdx]; if (firstContent !== undefined) { - const withPrompt = injectPromptSymbol(firstContent); + const isBash = this.inputMode === 'bash'; + const withPrompt = injectPromptSymbol( + firstContent, + isBash ? '!' : '>', + isBash ? (s) => this.borderColor(s) : undefined, + ); if (withPrompt !== undefined) { lines[firstContentIdx] = withPrompt; } @@ -375,6 +384,19 @@ export class CustomEditor extends Editor { this.onUndo?.(); } + // Exit bash mode: Backspace/Escape on an empty `!` prompt returns to prompt + // mode. Because the `!` is not in the buffer, "deleting" it is really + // "delete on empty bash input". + if ( + this.inputMode === 'bash' && + this.getText().length === 0 && + (matchesKey(normalized, Key.escape) || matchesKey(normalized, Key.backspace)) + ) { + this.inputMode = 'prompt'; + this.onInputModeChange?.('prompt'); + return; + } + const newlineInput = getNewlineInput(normalized); if (newlineInput !== undefined) { this.onInsertNewline?.(); @@ -411,6 +433,15 @@ export class CustomEditor extends Editor { return; } + // Enter bash mode: typing `!` at the start of an empty prompt. The `!` is + // not inserted into the buffer — it becomes the mode + prompt symbol, so the + // cursor never has to skip over it and submit never has to strip it. + if (this.inputMode === 'prompt' && normalized === '!' && this.getText().length === 0) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + return; + } + super.handleInput(normalized); this.reopenAutocompleteAfterInput(); } @@ -593,12 +624,17 @@ function truncateHint(hint: string, maxLen: number): string { * default foreground colour renders the symbol. Returns `undefined` if the * line is too short or doesn't begin with the expected padding. */ -export function injectPromptSymbol(line: string): string | undefined { +export function injectPromptSymbol( + line: string, + symbol = '>', + paint?: (s: string) => string, +): string | undefined { if (line.length < 4) return undefined; for (let i = 0; i < 4; i++) { if (line[i] !== ' ') return undefined; } - return ' > ' + line.slice(4); + const rendered = paint ? paint(symbol) : symbol; + return ' ' + rendered + ' ' + line.slice(4); } /** diff --git a/apps/kimi-code/src/tui/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts new file mode 100644 index 0000000000..4e5d7f59ac --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -0,0 +1,110 @@ +import { Container, Text } from '@earendil-works/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +import { formatBashOutputForDisplay, stripAnsi } from '#/tui/utils/shell-output'; + +const RUNNING_TAIL_LINES = 5; +const TIMER_INTERVAL_MS = 1000; + +/** + * Live view for a user-initiated `!` shell command. Two phases: + * + * - running: dim, ANSI-stripped tail of the combined output, a `+N lines` + * overflow marker, an elapsed `(Xs)` timer that ticks every second, and a + * `(ctrl+b to run in background)` hint — matching claude-code's running card + * so warnings are grey rather than red while the command works. + * - finished: the standard `formatBashOutputForDisplay` view (stderr red only + * on failure), the timer stopped and the running chrome removed. + */ +export class ShellRunComponent extends Container { + private readonly textComponent: Text; + private combined = ''; + private running = true; + private backgrounded = false; + private finalStdout = ''; + private finalStderr = ''; + private finalIsError?: boolean; + private readonly startedAt = Date.now(); + private timer: ReturnType | undefined; + + constructor(private readonly requestRender: () => void) { + super(); + this.textComponent = new Text(this.renderText(), 0, 0); + this.addChild(this.textComponent); + this.timer = setInterval(() => this.tick(), TIMER_INTERVAL_MS); + } + + append(text: string): void { + if (!this.running || text.length === 0) return; + this.combined += text; + this.flush(); + } + + finish(stdout: string, stderr: string, isError?: boolean): void { + if (!this.running) return; + this.running = false; + this.finalStdout = stdout; + this.finalStderr = stderr; + this.finalIsError = isError; + this.clearTimer(); + this.flush(); + } + + finishBackgrounded(): void { + if (!this.running) return; + this.running = false; + this.backgrounded = true; + this.clearTimer(); + this.flush(); + } + + dispose(): void { + this.clearTimer(); + } + + private tick(): void { + if (!this.running) return; + this.flush(); + } + + private flush(): void { + this.textComponent.setText(this.renderText()); + this.requestRender(); + } + + private clearTimer(): void { + if (this.timer !== undefined) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + private renderText(): string { + if (this.backgrounded) { + return ` ${currentTheme.fg('textDim', 'Moved to background.')}`; + } + if (!this.running) { + return formatBashOutputForDisplay(this.finalStdout, this.finalStderr, this.finalIsError) + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + } + const elapsed = Math.floor((Date.now() - this.startedAt) / 1000); + const dim = (s: string): string => currentTheme.fg('textDim', s); + const trimmed = stripAnsi(this.combined).trimEnd(); + let body: string; + let extra = 0; + if (trimmed.length === 0) { + body = ` ${dim('Running…')}`; + } else { + const lines = trimmed.split('\n'); + const tail = lines.slice(-RUNNING_TAIL_LINES); + extra = Math.max(0, lines.length - RUNNING_TAIL_LINES); + body = tail.map((line) => ` ${dim(line)}`).join('\n'); + } + const timing = ` ${dim(`${extra > 0 ? `+${extra} lines ` : ''}(${elapsed}s)`)}`; + const hint = ` ${dim('(ctrl+b to run in background)')}`; + return `${body}\n${timing}\n${hint}`; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/status-message.ts b/apps/kimi-code/src/tui/components/messages/status-message.ts index 7377a3f52c..22c6eb073a 100644 --- a/apps/kimi-code/src/tui/components/messages/status-message.ts +++ b/apps/kimi-code/src/tui/components/messages/status-message.ts @@ -12,19 +12,30 @@ export class StatusMessageComponent extends Container { super(); this.content = content; this.color = color; - const text = color === undefined - ? currentTheme.fg('textDim', content) - : currentTheme.fg(color, content); - this.textComponent = new Text(` ${text}`, 0, 0); + this.textComponent = new Text(this.renderText(), 0, 0); this.addChild(this.textComponent); } + // Update the body in place (used for live-streamed `!` shell output) without + // remounting the component. + updateContent(content: string): void { + this.content = content; + this.textComponent.setText(this.renderText()); + } + override invalidate(): void { - const text = this.color === undefined + this.textComponent.setText(this.renderText()); + super.invalidate(); + } + + // Indent every line, not just the first. The `content` may be multi-line + // (e.g. `!` shell output); prefixing the whole string once would only indent + // the first line and leave the rest at column 0. + private renderText(): string { + const colored = this.color === undefined ? currentTheme.fg('textDim', this.content) : currentTheme.fg(this.color, this.content); - this.textComponent.setText(` ${text}`); - super.invalidate(); + return colored.split('\n').map((line) => ` ${line}`).join('\n'); } } diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index bc5b922b0e..a2c454aaab 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -33,9 +33,11 @@ export interface EditorKeyboardHost { toggleToolOutputExpansion(): void; toggleTodoPanelExpansion(): void; detachCurrentForegroundTask(): void; + cancelRunningShellCommand(): void; hideSessionPicker(): void; stop(exitCode?: number): Promise; handlePlanToggle(next: boolean): void; + handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; } @@ -147,6 +149,10 @@ export class EditorKeyboardController { host.handlePlanToggle(next); }; + editor.onInputModeChange = (mode) => { + host.handleInputModeChange(mode); + }; + editor.onOpenExternalEditor = () => { host.track('shortcut_editor'); void this.openExternalEditor(); @@ -194,6 +200,8 @@ export class EditorKeyboardController { }; editor.onCtrlB = (): boolean => { + // Shell command execution is treated as a streaming phase ('shell'), so + // this gate already covers it; only idle + not-compacting falls through. if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) { return false; } @@ -255,6 +263,9 @@ export class EditorKeyboardController { } private cancelCurrentStream(): void { + // Cancel any running `!` shell command (treated as a streaming phase) in + // addition to the agent turn, so Esc / Ctrl+C interrupts it too. + this.host.cancelRunningShellCommand(); void this.host.session?.cancel(); } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 075e87bca5..2adc00a4c0 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -106,6 +106,8 @@ export interface SessionEventHost { restoreEditor(): void; restoreInputText(text: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void; + handleShellStarted(event: { commandId: string; taskId: string }): void; sendNormalUserInput(text: string): void; updateTerminalTitle(): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; @@ -242,6 +244,8 @@ export class SessionEventHandler { case 'turn.step.completed': this.handleStepCompleted(event); break; case 'turn.step.retrying': break; case 'tool.progress': this.handleToolProgress(event); break; + case 'shell.output': this.host.handleShellOutput(event); break; + case 'shell.started': this.host.handleShellStarted(event); break; case 'assistant.delta': this.handleAssistantDelta(event); break; case 'hook.result': this.handleHookResult(event); break; case 'thinking.delta': this.handleThinkingDelta(event); break; diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 4a13fe3738..a87ae03109 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -10,6 +10,7 @@ import type { } from '@moonshot-ai/kimi-code-sdk'; import { ToolCallComponent } from '../components/messages/tool-call'; +import { currentTheme } from '../theme'; import type { TodoItem } from '../components/chrome/todo-panel'; import type { AppState, @@ -21,6 +22,7 @@ import { formatErrorMessage, isTodoItemShape } from '../utils/event-payload'; import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; import { buildGoalCompletionMessage } from '../utils/goal-completion'; +import { formatBashOutputForDisplay } from '../utils/shell-output'; import { appStateFromResumeAgent, backgroundOrigin, @@ -57,6 +59,14 @@ export interface SessionReplayHost { appendTranscriptEntry(entry: TranscriptEntry): void; } +function extractBashTag( + text: string, + tag: 'bash-input' | 'bash-stdout' | 'bash-stderr', +): string | undefined { + const match = new RegExp(`<${tag}>([\\s\\S]*?)`).exec(text); + return match?.[1]; +} + export class SessionReplayRenderer { constructor(private readonly host: SessionReplayHost) {} @@ -249,6 +259,26 @@ export class SessionReplayRenderer { if (message.origin?.kind === 'injection') { return; } + if (message.origin?.kind === 'shell_command') { + // A `!` command, replayed from records. Unwrap the XML tags back into the + // same `! cmd` + output view the live editor produced. (Must NOT fall into + // the `injection` branch above — that returns without rendering.) + this.flushAssistant(context); + const text = contentPartsToText(message.content); + if (message.origin.phase === 'input') { + const cmd = (extractBashTag(text, 'bash-input') ?? text).trim(); + this.advanceTurn(context); + this.host.appendTranscriptEntry( + replayEntry(context, 'user', currentTheme.fg('shellMode', `! ${cmd}`), 'plain'), + ); + } else { + const stdout = (extractBashTag(text, 'bash-stdout') ?? '').trim(); + const stderr = (extractBashTag(text, 'bash-stderr') ?? '').trim(); + const out = formatBashOutputForDisplay(stdout, stderr, message.origin.isError); + this.host.appendTranscriptEntry(replayEntry(context, 'status', out, 'plain')); + } + return; + } if (message.origin?.kind === 'cron_job') { this.renderCronJob(context, message); return; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 3a1023f050..60a7e02b8f 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -74,6 +74,7 @@ import { GoalSetMessageComponent, } from './components/messages/goal-panel'; import { SkillActivationComponent } from './components/messages/skill-activation'; +import { ShellRunComponent } from './components/messages/shell-run'; import { NoticeMessageComponent, StatusMessageComponent, @@ -136,6 +137,7 @@ import { notifyTerminalOnce } from './utils/terminal-notification'; import { installTerminalThemeTracking } from './utils/terminal-theme'; import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard'; import { markTranscriptComponent } from './utils/transcript-component-metadata'; +import { formatBashOutputForDisplay } from './utils/shell-output'; import { nextTranscriptId } from './utils/transcript-id'; export type { TUIState } from './tui-state'; @@ -189,6 +191,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { sessionId: '', permissionMode: startupPermission, planMode: input.cliOptions.plan, + inputMode: 'prompt', swarmMode: false, thinking: false, contextUsage: 0, @@ -251,6 +254,14 @@ export class KimiTUI { private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = undefined; private lastHistoryContent: string | undefined; + // Live `!` shell output entries, keyed by commandId so concurrent commands + // each update their own card and stale events are dropped. Mutated in place + // as `shell.output` events arrive; removed when the command completes. + // `taskId` (from `shell.started`) lets ctrl+b detach the exact task. + private readonly shellOutputStreams = new Map< + string, + { entry: TranscriptEntry; component: ShellRunComponent; taskId?: string } + >(); readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; readonly btwPanelController: BtwPanelController; @@ -817,16 +828,151 @@ export class KimiTUI { void slashCommands.handlePlanCommand(this, next ? 'on' : 'off'); } + handleInputModeChange(mode: 'prompt' | 'bash'): void { + this.setAppState({ inputMode: mode }); + this.updateEditorBorderHighlight(); + } + handleUserInput(text: string): void { + const wasBashMode = this.state.appState.inputMode === 'bash'; + if (wasBashMode) { + // A submit always exits bash mode (the `!` is consumed by this command). + this.state.editor.inputMode = 'prompt'; + this.handleInputModeChange('prompt'); + } if (text.trim().length === 0) return; if (this.state.appState.isReplaying) { this.showError('Cannot send input while session history is replaying.'); return; } void this.persistInputHistory(text); + if (wasBashMode) { + // Only one foreground action at a time: queue the shell command while + // another shell command is running or an agent turn is in progress. + if (this.state.appState.streamingPhase !== 'idle') { + this.enqueueMessage(text, undefined, 'bash'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + this.runShellCommandFromInput(text); + return; + } slashCommands.dispatchInput(this, text); } + private runShellCommandFromInput(command: string): void { + const session = this.session; + if (session === undefined) { + this.showError('No active session for shell command.'); + return; + } + // Echo the command locally (bash-input), like claude-code's `! cmd`. The + // agent also records it for resume; this is the live view. + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: currentTheme.fg('shellMode', `! ${command}`), + }); + // Create the live output entry up front. ShellRunComponent owns its own + // rendering (running card → final view) and is mutated in place as output + // streams in and on completion. + const commandId = nextTranscriptId(); + const outputEntry: TranscriptEntry = { + id: commandId, + kind: 'status', + turnId: undefined, + renderMode: 'plain', + content: '', + }; + const outputComponent = new ShellRunComponent(() => this.state.ui.requestRender()); + this.shellOutputStreams.set(commandId, { entry: outputEntry, component: outputComponent }); + this.state.transcriptEntries.push(outputEntry); + markTranscriptComponent(outputComponent, outputEntry); + this.state.transcriptContainer.addChild(outputComponent); + // Treat command execution as a streaming phase so input queues, the activity + // pane shows the moon spinner, and ctrl+b is enabled while it runs. + this.setAppState({ streamingPhase: 'shell' }); + this.state.ui.requestRender(); + + void session.runShellCommand(command, { commandId }).then( + ({ stdout, stderr, isError, backgrounded }) => { + this.finishShellOutput(commandId, stdout, stderr, isError, backgrounded); + }, + (error: unknown) => { + const message = formatErrorMessage(error); + this.finishShellOutput(commandId, '', message, true); + this.showError(`Shell command failed: ${message}`); + }, + ); + } + + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + const text = event.update.text ?? ''; + if (text.length === 0) return; + stream.component.append(text); + } + + handleShellStarted(event: { commandId: string; taskId: string }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + stream.taskId = event.taskId; + } + + cancelRunningShellCommand(): void { + const session = this.session; + if (session === undefined) return; + for (const commandId of this.shellOutputStreams.keys()) { + void session.cancelShellCommand(commandId).catch((error: unknown) => { + this.showError(`Failed to cancel shell command: ${formatErrorMessage(error)}`); + }); + } + } + + private finishShellOutput( + commandId: string, + stdout: string, + stderr: string, + isError?: boolean, + backgrounded?: boolean, + ): void { + const stream = this.shellOutputStreams.get(commandId); + if (stream === undefined) return; + if (backgrounded === true) { + // The command was moved to the background; detachRunningShellCommand owns + // the UI and the model notification, so there is nothing to render here. + return; + } + stream.component.finish(stdout, stderr, isError); + // Keep the transcript entry's metadata in sync for anything that reads it + // (export / copy). The component renders itself. + stream.entry.content = formatBashOutputForDisplay(stdout, stderr, isError); + this.shellOutputStreams.delete(commandId); + // When the last shell command finishes, leave the shell streaming phase, + // release one queued message (if any), and refresh the activity pane. + if (this.shellOutputStreams.size === 0) { + this.setAppState({ streamingPhase: 'idle' }); + this.drainOneQueuedMessage(); + } + } + + private drainOneQueuedMessage(): void { + const item = this.shiftQueuedMessage(); + if (item === undefined) return; + const session = this.session; + if (session === undefined) return; + if (item.mode === 'bash') { + this.runShellCommandFromInput(item.text); + } else { + this.sendQueuedMessage(session, item); + } + this.updateQueueDisplay(); + } + sendNormalUserInput(text: string): void { if (this.btwPanelController.sendUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { @@ -919,7 +1065,11 @@ export class KimiTUI { // Session Requests / Queues // ========================================================================= - private enqueueMessage(text: string, options?: SendMessageOptions): void { + private enqueueMessage( + text: string, + options?: SendMessageOptions, + mode?: 'prompt' | 'bash', + ): void { this.state.queuedMessages.push({ text, agentId: this.harness.interactiveAgentId, @@ -928,6 +1078,7 @@ export class KimiTUI { options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 ? options.imageAttachmentIds : undefined, + mode, }); this.track('input_queue'); } @@ -956,6 +1107,10 @@ export class KimiTUI { } sendQueuedMessage(session: Session, item: QueuedMessage): void { + if (item.mode === 'bash') { + this.runShellCommandFromInput(item.text); + return; + } this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => { this.sendMessageInternal(session, item.text, { parts: item.parts, @@ -1778,6 +1933,11 @@ export class KimiTUI { if (this.state.livePane.pendingQuestion !== null) return 'hidden'; const streamingPhase = this.state.appState.streamingPhase; + + // A running `!` shell command shows the moon spinner (same as `waiting`) + // until it finishes, signalling that input is busy / queued. + if (streamingPhase === 'shell') return 'waiting'; + if (this.state.livePane.mode === 'idle') { if (streamingPhase === 'thinking' || streamingPhase === 'composing') { return streamingPhase; @@ -1817,7 +1977,49 @@ export class KimiTUI { this.state.ui.requestRender(); } + private async detachRunningShellCommand(): Promise { + // Only one `!` command runs at a time (input is queued while busy). + const next = this.shellOutputStreams.entries().next(); + if (next.done) { + this.showDetachHint('No shell command running.'); + return; + } + const [commandId, stream] = next.value; + if (stream.taskId === undefined) { + this.showDetachHint('Command is still starting — try again.'); + return; + } + const session = this.session; + if (session === undefined) return; + try { + const info = await session.detachBackgroundTask(stream.taskId); + if (info === undefined) { + this.showDetachHint('Command already finished.'); + return; + } + } catch (error) { + this.showError(`Failed to move to background: ${formatErrorMessage(error)}`); + return; + } + // Finalize the card as backgrounded and drop the stream so the eventual + // runShellCommand resolution (which carries background metadata) is a no-op + // instead of overwriting this view. + stream.component.finishBackgrounded(); + stream.entry.content = 'Moved to background.'; + this.shellOutputStreams.delete(commandId); + // The backgrounded command's notification turn (started by agent-core via + // appendSystemReminderAndNotify) owns the streaming phase and drains the + // queue when it completes, so we intentionally leave both untouched here. + this.showDetachHint('Moved to background. /tasks to view.'); + } + async detachCurrentForegroundTask(): Promise { + // A running `!` shell command takes priority over agent foreground tasks. + if (this.shellOutputStreams.size > 0) { + await this.detachRunningShellCommand(); + return; + } + const session = this.session; if (session === undefined) { this.showError(NO_ACTIVE_SESSION_MESSAGE); @@ -1884,10 +2086,12 @@ export class KimiTUI { updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); - const highlighted = this.state.appState.planMode || trimmed.startsWith('/'); + const isBash = this.state.appState.inputMode === 'bash'; + const highlighted = this.state.appState.planMode || isBash || trimmed.startsWith('/'); this.state.editor.borderHighlighted = highlighted; - this.state.editor.borderColor = (s: string) => - currentTheme.fg(highlighted ? 'primary' : 'border', s); + // Shell mode gets its own hue; plan-mode and slash context stay primary. + const borderToken = isBash ? 'shellMode' : highlighted ? 'primary' : 'border'; + this.state.editor.borderColor = (s: string) => currentTheme.fg(borderToken, s); this.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts index 215c54bbb9..0fa11af844 100644 --- a/apps/kimi-code/src/tui/theme/colors.ts +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -71,6 +71,12 @@ export interface ColorPalette { /** User message: bullet & text, skill-activation name. The one role colour * with its own hue — assistant/thinking/status bullets reuse text/textDim. */ roleUser: string; + + // ── Shell mode ── + /** Shell mode (`!`): the `!` prompt symbol, bash-mode editor border, and the + * echoed `! command` line. Its own hue (magenta/rose), distinct from + * plan-mode (primary) and the user role (roleUser). */ + shellMode: string; } export const darkColors: ColorPalette = { @@ -97,6 +103,7 @@ export const darkColors: ColorPalette = { diffMeta: '#888888', roleUser: '#FFCB6B', + shellMode: '#FF79C6', }; export const lightColors: ColorPalette = { @@ -123,6 +130,7 @@ export const lightColors: ColorPalette = { diffMeta: '#5F5F5F', roleUser: '#9A4A00', + shellMode: '#C2185B', }; export type ResolvedTheme = 'dark' | 'light'; diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index b8249e92c6..72064c7c52 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -30,6 +30,8 @@ export interface AppState { sessionId: string; permissionMode: PermissionMode; planMode: boolean; + /** 'bash' when the editor is in `!` shell-command mode. */ + inputMode: 'prompt' | 'bash'; swarmMode: boolean; thinking: boolean; contextUsage: number; @@ -37,7 +39,7 @@ export interface AppState { maxContextTokens: number; isCompacting: boolean; isReplaying: boolean; - streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; + streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; streamingStartTime: number; theme: ThemeName; version: string; @@ -180,6 +182,9 @@ export interface QueuedMessage { readonly agentId?: string; readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; + /** `bash` for a `!` shell command queued while another command is running; + * undefined (=`prompt`) for a normal message. */ + readonly mode?: 'prompt' | 'bash'; } export const INITIAL_LIVE_PANE: LivePaneState = { diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index b1478697c1..b01ed19b60 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -250,6 +250,9 @@ function isReplayUserTurnRecord(record: AgentReplayRecord): boolean { return true; case 'skill_activation': return message.origin.trigger === 'user-slash'; + case 'shell_command': + // A `!` command's input is a user-turn anchor; its output is not. + return message.origin.phase === 'input'; case 'background_task': case 'compaction_summary': case 'cron_job': diff --git a/apps/kimi-code/src/tui/utils/shell-output.ts b/apps/kimi-code/src/tui/utils/shell-output.ts new file mode 100644 index 0000000000..610b9a59f8 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/shell-output.ts @@ -0,0 +1,23 @@ +import { currentTheme } from '#/tui/theme'; + +// Strip ANSI/CSI escape sequences (colours, cursor moves, …). Command output +// is run with NO_COLOR=1, but some tools ignore it; stripping keeps the live +// "running" view uniformly dim instead of leaking the tool's own colours. +const ANSI_PATTERN = /\u001B\[[0-9;]*[A-Za-z]/g; + +export function stripAnsi(text: string): string { + return text.replace(ANSI_PATTERN, ''); +} + +export function formatBashOutputForDisplay(stdout: string, stderr: string, isError?: boolean): string { + const dim = (s: string): string => currentTheme.fg('textDim', s); + const parts: string[] = []; + if (stdout.trimEnd().length > 0) parts.push(dim(stdout.trimEnd())); + if (stderr.trimEnd().length > 0) { + const err = stderr.trimEnd(); + // Dim grey normally; red only on actual failure (so warnings on a + // successful command are not mistaken for errors). + parts.push(isError ? currentTheme.fg('error', err) : dim(err)); + } + return parts.length > 0 ? parts.join('\n') : dim('(no output)'); +} diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 4045e3a067..6b43ab2f75 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -48,6 +48,7 @@ const appState: AppState = { streamingPhase: 'idle', streamingStartTime: 0, planMode: false, + inputMode: 'prompt', swarmMode: false, theme: 'dark', editorCommand: null, diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index a910c24813..1eb757e673 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -26,6 +26,7 @@ const appState: AppState = { streamingPhase: 'idle', streamingStartTime: 0, planMode: false, + inputMode: 'prompt', swarmMode: false, theme: 'dark', editorCommand: null, diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index a67715f2b4..7c4cbcebcd 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -12,6 +12,7 @@ function fakeInitialAppState(): AppState { sessionId: 'sess-1', permissionMode: 'manual', planMode: false, + inputMode: 'prompt', swarmMode: false, thinking: false, contextUsage: 0, diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 88edda53f9..01f5546c1b 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -65,6 +65,21 @@ export class ContextMemory { }); } + /** + * Inject a user-invisible message and immediately send it to the model by + * launching/steering a turn. The content is used as-is (no wrapper tag), so + * callers can pass raw tool-result-style text or wrap it themselves. The + * message is skipped on replay / transcript (so the user never sees it) but + * is included in the context sent to the model. Use this for events the + * model must react to right away without surfacing a user-visible message. + */ + injectAndNotify(content: string, origin?: PromptOrigin): void { + this.agent.turn.steer( + [{ type: 'text', text: content }], + origin ?? { kind: 'injection', variant: 'system_reminder' }, + ); + } + appendLocalCommandStdout(content: string): void { const text = `\n${content.trim()}\n`; this.appendMessage({ @@ -75,6 +90,33 @@ export class ContextMemory { }); } + // User-initiated `!` shell command. Unlike `injection` (which is skipped on + // replay), `shell_command` origin is replayed and rendered, so resumed + // sessions still show the command and its output. The XML tags carry the + // semantics to the model; the origin drives UI/replay routing. + appendBashInput(command: string): void { + const text = `\n${command}\n`; + this.appendMessage({ + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin: { kind: 'shell_command', phase: 'input' }, + }); + } + + appendBashOutput(stdout: string, stderr: string, isError?: boolean): void { + const text = `${stdout}${stderr}`; + this.appendMessage({ + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin: + isError === true + ? { kind: 'shell_command', phase: 'output', isError: true } + : { kind: 'shell_command', phase: 'output' }, + }); + } + popMatchedMessage(matcher: (origin: PromptOrigin | undefined) => boolean): boolean { const lastDeferred = this.deferredMessages.at(-1); const last = lastDeferred ?? this._history.at(-1); diff --git a/packages/agent-core/src/agent/context/types.ts b/packages/agent-core/src/agent/context/types.ts index d0a78b9756..3ebfc1bb37 100644 --- a/packages/agent-core/src/agent/context/types.ts +++ b/packages/agent-core/src/agent/context/types.ts @@ -25,6 +25,14 @@ export interface InjectionOrigin { readonly variant: string; } +export interface ShellCommandOrigin { + readonly kind: 'shell_command'; + readonly phase: 'input' | 'output'; + /** Only present on `phase: 'output'` — whether the command failed, so replay + * can colour stderr red only for actual failures (not warnings). */ + readonly isError?: boolean; +} + export interface CompactionSummaryOrigin { readonly kind: 'compaction_summary'; } @@ -73,6 +81,7 @@ export type PromptOrigin = | UserPromptOrigin | SkillActivationOrigin | InjectionOrigin + | ShellCommandOrigin | CompactionSummaryOrigin | SystemTriggerOrigin | BackgroundTaskOrigin diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index ad279b6e2e..ff49b36beb 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -281,6 +281,8 @@ export class Agent { prompt: (payload) => { this.turn.prompt(payload.input); }, + runShellCommand: (payload) => this.tools.runShellCommand(payload.command, payload.commandId), + cancelShellCommand: (payload) => this.tools.cancelShellCommand(payload.commandId), steer: (payload) => { this.telemetry.track('input_steer', { parts: payload.input.length }); this.turn.steer(payload.input); diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 064e7590cc..c20776055d 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -4,7 +4,7 @@ import picomatch from 'picomatch'; import type { Agent } from '..'; import { makeErrorPayload } from '../../errors'; -import type { ExecutableTool } from '../../loop'; +import type { ExecutableTool, ToolUpdate } from '../../loop'; import { createMcpAuthTool } from '../../mcp/auth-tool'; import type { McpConnectionManager, McpServerEntry } from '../../mcp'; import { mcpResultToExecutableOutput } from '../../mcp/output'; @@ -42,6 +42,10 @@ export class ToolManager { protected readonly store: Partial = {}; private mcpToolStatusUnsubscribe: (() => void) | undefined; + /** Abort controllers for in-flight `!` shell commands, keyed by commandId so + * the TUI can cancel (Esc / Ctrl+C) a running command. */ + private readonly shellCommandControllers = new Map(); + constructor(protected readonly agent: Agent) { this.attachMcpTools(); if (agent.config.hasProvider) { @@ -83,6 +87,85 @@ export class ToolManager { this.store[key] = value; } + /** + * Execute a user-initiated `!` shell command. Reuses the builtin Bash tool + * (same kaos / cwd / BackgroundManager as the agent), recording the command + * and its output as `shell_command`-origin messages. It does NOT start a turn + * — the model is not prompted (parity with claude-code's `shouldQuery: false`). + */ + async runShellCommand( + command: string, + commandId?: string, + ): Promise<{ stdout: string; stderr: string; isError?: boolean; backgrounded?: boolean }> { + this.agent.context.appendBashInput(command); + const bash = this.builtinTools.get('Bash'); + if (bash === undefined) { + const error = 'Bash tool is not available.'; + this.agent.context.appendBashOutput('', error); + return { stdout: '', stderr: error, isError: true }; + } + let stdout = ''; + let stderr = ''; + let isError: boolean | undefined; + const controller = new AbortController(); + if (commandId !== undefined) this.shellCommandControllers.set(commandId, controller); + try { + const execution = await bash.resolveExecution({ command }); + if (!('execute' in execution)) { + const output = + typeof execution.output === 'string' ? execution.output : 'Command failed.'; + this.agent.context.appendBashOutput('', output); + return { stdout: '', stderr: output, isError: true }; + } + const result = await execution.execute({ + turnId: '', + toolCallId: 'shell-command', + signal: controller.signal, + onUpdate: (update: ToolUpdate) => { + if (update.kind === 'stdout') stdout += update.text ?? ''; + else if (update.kind === 'stderr') stderr += update.text ?? ''; + else return; + // Stream the chunk live to the TUI. Transient event — the final + // output is still recorded once below for resume. + if (commandId !== undefined) { + this.agent.emitEvent({ type: 'shell.output', commandId, update }); + } + }, + onForegroundTaskStart: (taskId: string) => { + // Surface the background-task id so the TUI can detach (ctrl+b) it. + if (commandId !== undefined) { + this.agent.emitEvent({ type: 'shell.started', commandId, taskId }); + } + }, + }); + isError = result.isError === true; + + // Detached to background (ctrl+b): the BashTool returns the background + // metadata (task_id / status / output path) — the same payload a normal + // foreground Bash call returns as its tool result when backgrounded. + // Inject it as a user-invisible message and immediately send it to the + // model (mirrors the background-task completion notification, but hidden). + if (typeof result.output === 'string' && result.output.startsWith('task_id: ')) { + this.agent.context.injectAndNotify(result.output, { + kind: 'injection', + variant: 'shell_command_backgrounded', + }); + return { stdout: result.output, stderr: '', isError: false, backgrounded: true }; + } + } catch (error) { + stderr += error instanceof Error ? error.message : String(error); + isError = true; + } finally { + if (commandId !== undefined) this.shellCommandControllers.delete(commandId); + } + this.agent.context.appendBashOutput(stdout, stderr, isError); + return { stdout, stderr, isError }; + } + + cancelShellCommand(commandId: string): void { + this.shellCommandControllers.get(commandId)?.abort(); + } + registerUserTool(input: UserToolRegistration): void { this.agent.records.logRecord({ type: 'tools.register_user_tool', diff --git a/packages/agent-core/src/loop/types.ts b/packages/agent-core/src/loop/types.ts index 8459450efb..0538f225b0 100644 --- a/packages/agent-core/src/loop/types.ts +++ b/packages/agent-core/src/loop/types.ts @@ -110,6 +110,12 @@ export interface ExecutableToolContext { readonly metadata?: unknown; readonly signal: AbortSignal; readonly onUpdate?: ((update: ToolUpdate) => void) | undefined; + /** + * Fired once when a foreground (non-background) process task is registered, + * carrying its task id. Used by the `!` shell-command path so the TUI can + * later detach (ctrl+b) that exact task. Background runs skip it. + */ + readonly onForegroundTaskStart?: ((taskId: string) => void) | undefined; } export interface RunnableToolExecution { diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 2b7aaf54f5..4740134e33 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -161,6 +161,29 @@ export interface SessionSummary { export interface PromptPayload { readonly input: readonly ContentPart[]; } +export interface RunShellCommandPayload { + readonly command: string; + /** + * TUI-generated correlation id echoed back on every `shell.output` live event + * so the client can route chunks to the matching entry and drop stale events + * from a prior run. Optional for callers that don't stream. + */ + readonly commandId?: string; +} +export interface ShellCommandResult { + readonly stdout: string; + readonly stderr: string; + /** True when the command failed (non-zero exit / timeout / killed) — used by + * the TUI to render stderr in red only for actual failures, not warnings. */ + readonly isError?: boolean; + /** True when the command was detached to the background (ctrl+b) instead of + * completing in the foreground. The TUI uses this to skip the normal final + * render (the backgrounding path owns the UI + model notification). */ + readonly backgrounded?: boolean; +} +export interface CancelShellCommandPayload { + readonly commandId: string; +} export interface SteerPayload { readonly input: readonly ContentPart[]; } @@ -338,6 +361,8 @@ export interface RemoveKimiProviderPayload { export interface AgentAPI { prompt: (payload: PromptPayload) => void; + runShellCommand: (payload: RunShellCommandPayload) => Promise; + cancelShellCommand: (payload: CancelShellCommandPayload) => void; steer: (payload: SteerPayload) => void; cancel: (payload: CancelPayload) => void; undoHistory: (payload: UndoHistoryPayload) => void; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 54e07fd24d..a203777817 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -57,6 +57,7 @@ import type { BeginCompactionPayload, CancelPayload, CancelPlanPayload, + CancelShellCommandPayload, CloseSessionPayload, ConfigDiagnostics, CoreAPI, @@ -83,6 +84,7 @@ import type { PluginInfo, PluginSummary, PromptPayload, + RunShellCommandPayload, ReconnectMcpServerPayload, RegisterToolPayload, ReloadSessionPayload, @@ -575,6 +577,14 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).prompt(payload); } + runShellCommand({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).runShellCommand(payload); + } + + cancelShellCommand({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).cancelShellCommand(payload); + } + steer({ sessionId, ...payload }: SessionAgentPayload) { return this.sessionApi(sessionId).steer(payload); } diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index cddfe967b6..23187a6213 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -7,6 +7,7 @@ import type { BeginCompactionPayload, CancelPayload, CancelPlanPayload, + CancelShellCommandPayload, CreateGoalPayload, DetachBackgroundPayload, EmptyPayload, @@ -16,6 +17,7 @@ import type { McpServerInfo, McpStartupMetrics, PromptPayload, + RunShellCommandPayload, ReconnectMcpServerPayload, RenameSessionPayload, RegisterToolPayload, @@ -108,6 +110,14 @@ export class SessionAPIImpl implements PromisableMethods { return (await this.getAgent(agentId)).steer(payload); } + async runShellCommand({ agentId, ...payload }: AgentScopedPayload) { + return (await this.getAgent(agentId)).runShellCommand(payload); + } + + async cancelShellCommand({ agentId, ...payload }: AgentScopedPayload) { + return (await this.getAgent(agentId)).cancelShellCommand(payload); + } + async cancel({ agentId, ...payload }: AgentScopedPayload) { return (await this.getAgent(agentId)).cancel(payload); } diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 3e124c2b54..93f846fb5c 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -185,7 +185,8 @@ export class BashTool implements BuiltinTool { }, approvalRule: literalRulePattern(this.name, args.command), matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), - execute: ({ signal, onUpdate }) => this.execution(args, signal, onUpdate), + execute: ({ signal, onUpdate, onForegroundTaskStart }) => + this.execution(args, signal, onUpdate, onForegroundTaskStart), }; } @@ -220,6 +221,7 @@ export class BashTool implements BuiltinTool { args: BashInput, signal: AbortSignal, onUpdate?: ((update: ToolUpdate) => void) | undefined, + onForegroundTaskStart?: ((taskId: string) => void) | undefined, ): Promise { const validationError = this.validateRunRequest(args, signal); if (validationError !== undefined) return validationError; @@ -275,6 +277,10 @@ export class BashTool implements BuiltinTool { }; } + // Foreground `!` shell commands surface their task id so the TUI can detach + // (ctrl+b) this exact task. Background runs are already detached. + if (!startsInBackground) onForegroundTaskStart?.(taskId); + if (startsInBackground) { return this.backgroundStartedResult(taskId, proc, description, { title: 'Background task started', diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index ad36a56ca2..eca1799321 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -1,10 +1,14 @@ +import { Readable, type Writable } from 'node:stream'; + +import type { KaosProcess } from '@moonshot-ai/kaos'; import type { Message } from '@moonshot-ai/kosong'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { renderNotificationXml } from '../../src/agent/context/notification-xml'; import { project } from '../../src/agent/context/projector'; import type { ContextMessage } from '../../src/agent/context/types'; import { estimateTokensForMessages } from '../../src/utils/tokens'; +import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { testAgent } from './harness/agent'; describe('Agent context', () => { @@ -54,6 +58,65 @@ describe('Agent context', () => { expect(ctx.agent.context.messages.some((message) => 'origin' in message)).toBe(false); }); + it('records bash input/output as shell_command origin with tagged content', () => { + const ctx = testAgent(); + ctx.configure(); + + ctx.agent.context.appendBashInput('ls -la'); + ctx.agent.context.appendBashOutput('file1\nfile2', ''); + + expect(ctx.agent.context.history.map(({ role, origin }) => ({ role, origin }))).toEqual([ + { role: 'user', origin: { kind: 'shell_command', phase: 'input' } }, + { role: 'user', origin: { kind: 'shell_command', phase: 'output' } }, + ]); + + const textOf = (message: ContextMessage): string => + message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); + expect(textOf(ctx.agent.context.history[0]!)).toContain(''); + expect(textOf(ctx.agent.context.history[0]!)).toContain('ls -la'); + expect(textOf(ctx.agent.context.history[1]!)).toBe( + 'file1\nfile2', + ); + // origin must not leak into the LLM projection + expect(ctx.agent.context.messages.some((message) => 'origin' in message)).toBe(false); + }); + + it('runs a shell command via the Bash tool and records its output', async () => { + const fakeProcess = (stdout: string): KaosProcess => { + const out = Readable.from([stdout]); + const err = Readable.from([]); + return { + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout: out, + stderr: err, + pid: 1, + exitCode: 0, + wait: vi.fn(async () => 0), + kill: vi.fn(async () => {}), + dispose: vi.fn(async () => { + out.destroy(); + err.destroy(); + }), + }; + }; + const kaos = createFakeKaos({ + execWithEnv: vi.fn().mockImplementation(async () => fakeProcess('hello\n')), + }); + const ctx = testAgent({ kaos }); + ctx.configure(); + + await ctx.agent.tools.runShellCommand('echo hello'); + + expect(ctx.agent.context.history.map(({ role, origin }) => ({ role, origin }))).toEqual([ + { role: 'user', origin: { kind: 'shell_command', phase: 'input' } }, + { role: 'user', origin: { kind: 'shell_command', phase: 'output' } }, + ]); + const textOf = (message: ContextMessage): string => + message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); + expect(textOf(ctx.agent.context.history[0]!)).toContain('echo hello'); + expect(textOf(ctx.agent.context.history[1]!)).toContain('hello'); + }); + it('renders tool error and empty-output status as model-visible text', () => { const ctx = testAgent(); ctx.configure(); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 2b088df9ef..2c0bc3d7a9 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -230,6 +230,31 @@ export abstract class SDKRpcClientBase { }); } + async runShellCommand(input: { + sessionId: string; + command: string; + commandId?: string; + }): Promise<{ stdout: string; stderr: string; isError?: boolean; backgrounded?: boolean }> { + const agentId = this.interactiveAgentId; + const rpc = await this.getRpc(); + return rpc.runShellCommand({ + sessionId: input.sessionId, + agentId, + command: input.command, + commandId: input.commandId, + }); + } + + async cancelShellCommand(input: { sessionId: string; commandId: string }): Promise { + const agentId = this.interactiveAgentId; + const rpc = await this.getRpc(); + return rpc.cancelShellCommand({ + sessionId: input.sessionId, + agentId, + commandId: input.commandId, + }); + } + async steer(input: SessionPromptRpcInput): Promise { const agentId = this.interactiveAgentId; const rpc = await this.getRpc(); diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 8995297607..3215597f07 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -103,6 +103,27 @@ export class Session { }); } + /** Execute a user-initiated `!` shell command (silent — does not prompt the + * model). Resolves with the command's stdout/stderr for immediate display. + * Pass `commandId` to receive live `shell.output` events for this command. */ + async runShellCommand( + command: string, + options?: { commandId?: string }, + ): Promise<{ stdout: string; stderr: string; isError?: boolean; backgrounded?: boolean }> { + this.ensureOpen(); + return this.rpc.runShellCommand({ + sessionId: this.id, + command, + commandId: options?.commandId, + }); + } + + /** Cancel a running `!` shell command by its commandId (e.g. on Esc / Ctrl+C). */ + async cancelShellCommand(commandId: string): Promise { + this.ensureOpen(); + return this.rpc.cancelShellCommand({ sessionId: this.id, commandId }); + } + async steer(input: string | PromptInput): Promise { this.ensureOpen(); await this.rpc.steer({ diff --git a/packages/node-sdk/test/session-event-types.test.ts b/packages/node-sdk/test/session-event-types.test.ts index 7dc54e69a9..cedd15c284 100644 --- a/packages/node-sdk/test/session-event-types.test.ts +++ b/packages/node-sdk/test/session-event-types.test.ts @@ -78,6 +78,8 @@ describe('Event public types', () => { case 'tool.call.delta': case 'tool.call.started': case 'tool.progress': + case 'shell.output': + case 'shell.started': case 'tool.result': case 'tool.list.updated': case 'mcp.server.status': diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 52e9f5afb4..d0e22e0d64 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -52,6 +52,14 @@ export interface InjectionOrigin { readonly variant: string; } +export interface ShellCommandOrigin { + readonly kind: 'shell_command'; + readonly phase: 'input' | 'output'; + /** Only present on `phase: 'output'` — whether the command failed, so replay + * can colour stderr red only for actual failures (not warnings). */ + readonly isError?: boolean; +} + export interface CompactionSummaryOrigin { readonly kind: 'compaction_summary'; } @@ -105,6 +113,7 @@ export type PromptOrigin = | UserPromptOrigin | SkillActivationOrigin | InjectionOrigin + | ShellCommandOrigin | CompactionSummaryOrigin | SystemTriggerOrigin | BackgroundTaskOrigin @@ -474,6 +483,28 @@ export interface ToolProgressEvent { readonly update: ToolUpdate; } +/** + * Live stdout/stderr chunk from a user-initiated `!` shell command. Transient + * (never persisted, never replayed) — the final output is still recorded once + * via `context.append_message` on completion. `commandId` lets the TUI route + * chunks to the matching live entry and drop stale events from a prior run. + */ +export interface ShellOutputEvent { + readonly type: 'shell.output'; + readonly commandId: string; + readonly update: ToolUpdate; +} + +/** + * Fired once when a `!` shell command's foreground process task is registered, + * carrying the task id so the client can detach (ctrl+b) it. Transient. + */ +export interface ShellStartedEvent { + readonly type: 'shell.started'; + readonly commandId: string; + readonly taskId: string; +} + export interface ToolResultEvent { readonly type: 'tool.result'; readonly turnId: number; @@ -611,6 +642,8 @@ export type AgentEvent = | ToolCallDeltaEvent | ToolCallStartedEvent | ToolProgressEvent + | ShellOutputEvent + | ShellStartedEvent | ToolResultEvent | ToolListUpdatedEvent | McpServerStatusEvent @@ -676,6 +709,12 @@ export const injectionOriginSchema = z.object({ variant: z.string(), }) satisfies z.ZodType; +export const shellCommandOriginSchema = z.object({ + kind: z.literal('shell_command'), + phase: z.enum(['input', 'output']), + isError: z.boolean().optional(), +}) satisfies z.ZodType; + export const compactionSummaryOriginSchema = z.object({ kind: z.literal('compaction_summary'), }) satisfies z.ZodType; @@ -730,6 +769,7 @@ export const promptOriginSchema = z.discriminatedUnion('kind', [ userPromptOriginSchema, skillActivationOriginSchema, injectionOriginSchema, + shellCommandOriginSchema, compactionSummaryOriginSchema, systemTriggerOriginSchema, backgroundTaskOriginSchema, @@ -1101,6 +1141,18 @@ export const toolProgressEventSchema = z.object({ update: toolUpdateSchema, }) satisfies z.ZodType; +export const shellOutputEventSchema = z.object({ + type: z.literal('shell.output'), + commandId: z.string(), + update: toolUpdateSchema, +}) satisfies z.ZodType; + +export const shellStartedEventSchema = z.object({ + type: z.literal('shell.started'), + commandId: z.string(), + taskId: z.string(), +}) satisfies z.ZodType; + export const toolResultEventSchema = z.object({ type: z.literal('tool.result'), turnId: z.number(), @@ -1241,6 +1293,8 @@ export const agentEventSchema = z.discriminatedUnion('type', [ toolCallDeltaEventSchema, toolCallStartedEventSchema, toolProgressEventSchema, + shellOutputEventSchema, + shellStartedEventSchema, toolResultEventSchema, toolListUpdatedEventSchema, mcpServerStatusEventSchema, @@ -1283,6 +1337,8 @@ export const VOLATILE_EVENT_TYPES = [ 'thinking.delta', 'tool.call.delta', 'tool.progress', + 'shell.output', + 'shell.started', 'agent.status.updated', ] as const satisfies readonly AgentEvent['type'][]; From feb82a17505b22a8b4fc37e54ae5eec617afdf39 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 12:50:07 +0800 Subject: [PATCH 02/18] feat(kimi-code): show shell mode label on editor border and add tip Render a "! shell mode" label on the top-left of the editor border while the editor is in `!` bash mode, so the active mode is visible at a glance. Also add a rotating toolbar tip (`! to run a shell command`) to surface the feature. --- .../tui/components/editor/custom-editor.ts | 22 +++++++++++++-- apps/kimi-code/src/tui/constant/tips.ts | 1 + .../components/editor/custom-editor.test.ts | 28 +++++++++++++++++++ .../components/editor/side-borders.test.ts | 28 +++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 1ddb150b88..e8c2094af7 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -8,6 +8,7 @@ import { matchesKey, Key, SelectList, + visibleWidth, type SelectItem, type TUI, } from '@earendil-works/pi-tui'; @@ -230,6 +231,7 @@ export class CustomEditor extends Editor { const lines = super.render(width); if (lines.length < 3) return lines; const firstContentIdx = 1; + const isBash = this.inputMode === 'bash'; const text = this.getText().trimStart(); if (text.startsWith('/')) { // Paint only the FIRST editor content line; multi-line slash commands @@ -251,7 +253,6 @@ export class CustomEditor extends Editor { } const firstContent = lines[firstContentIdx]; if (firstContent !== undefined) { - const isBash = this.inputMode === 'bash'; const withPrompt = injectPromptSymbol( firstContent, isBash ? '!' : '>', @@ -267,6 +268,7 @@ export class CustomEditor extends Editor { // side bars through the same hook to stay in sync. return wrapWithSideBorders(lines, (s) => this.borderColor(s), { connectedAbove: this.connectedAbove && !this.borderHighlighted, + label: isBash ? ` ${currentTheme.boldFg('shellMode', '! shell mode')} ` : undefined, }); } @@ -648,21 +650,37 @@ export function injectPromptSymbol( * inner SGR intact; only column 0 and the last column are overlaid, and * only if they're literal spaces — that protects the cursor-overflow * case where the rightmost column is an SGR-tagged inverse cursor. + * + * When `options.label` is set, it is overlaid on the left of the top border + * (e.g. the `! shell mode` badge), replacing the leading dashes. It is only + * applied to a plain dash run, never to a `↑/↓ N more` scroll indicator. */ export function wrapWithSideBorders( lines: string[], paint: (s: string) => string, - options: { readonly connectedAbove?: boolean } = {}, + options: { readonly connectedAbove?: boolean; readonly label?: string } = {}, ): string[] { let seenTop = false; return lines.map((line) => { const plain = stripSgr(line); if (plain.length > 0 && plain[0] === '─') { + const isTop = !seenTop; const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭'; const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮'; seenTop = true; if (plain.length === 1) return paint(leftCorner); const middle = plain.slice(1, -1); + if (isTop && options.label !== undefined && /^─+$/.test(middle)) { + const labelWidth = visibleWidth(options.label); + if (labelWidth <= middle.length) { + return ( + paint(leftCorner) + + options.label + + paint('─'.repeat(middle.length - labelWidth)) + + paint(rightCorner) + ); + } + } return paint(leftCorner + middle + rightCorner); } if (line.length === 0) return line; diff --git a/apps/kimi-code/src/tui/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts index c04b5a763a..f9d0a7f27a 100644 --- a/apps/kimi-code/src/tui/constant/tips.ts +++ b/apps/kimi-code/src/tui/constant/tips.ts @@ -32,6 +32,7 @@ export const WORKING_TIPS: readonly ToolbarTip[] = [ { text: '/goal next to queue follow-up work while the current goal keeps running', solo: true }, { text: '/web: use the Web UI for a better experience', solo: true }, { text: '@: mention files', priority: 2 }, + { text: '! to run a shell command', priority: 2 }, ]; export const ALL_TIPS: readonly ToolbarTip[] = [ diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index e6129ffce5..e3c17e9897 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -552,3 +552,31 @@ describe('CustomEditor shortcut telemetry hooks', () => { expect(onToggleTodoExpand).toHaveBeenCalledOnce(); }); }); + +describe('CustomEditor bash mode border label', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + + it('shows "! shell mode" on the top border in bash mode', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + const top = stripAnsi(editor.render(90)[0] ?? ''); + expect(top.startsWith('╭')).toBe(true); + expect(top).toContain('! shell mode'); + expect(top.endsWith('╮')).toBe(true); + }); + + it('does not show the shell mode label in prompt mode', () => { + const editor = makeEditor(); + const top = stripAnsi(editor.render(90)[0] ?? ''); + expect(top).not.toContain('! shell mode'); + }); + + it('keeps the top border at full width when the label is present', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + const width = 90; + const top = stripAnsi(editor.render(width)[0] ?? ''); + expect(top).toHaveLength(width); + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts index d137558b99..6d3145b141 100644 --- a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts +++ b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts @@ -75,4 +75,32 @@ describe('wrapWithSideBorders', () => { // first column was a space → replaced with │; last column was 'c' → kept expect(out[1]).toBe('│ abc'); }); + + it('overlays a label on the top border, replacing leading dashes', () => { + const top = '─'.repeat(30); + const out = wrapWithSideBorders([top, ' x ', top], id, { label: ' ! shell mode ' }); + expect(out[0]).toBe(`╭ ! shell mode ${'─'.repeat(14)}╮`); + // width is preserved: corner + label + dashes + corner == input width + expect(out[0]).toHaveLength(top.length); + // bottom border is untouched + expect(out[2]).toBe(`╰${'─'.repeat(28)}╯`); + }); + + it('does not inject the label when it is wider than the top border', () => { + const out = wrapWithSideBorders(['──────', ' x ', '──────'], id, { + label: ' ! shell mode ', + }); + // falls back to a plain border — label must not leak or overflow + expect(out[0]).toBe('╭────╮'); + expect(out[0]).not.toContain('shell mode'); + }); + + it('does not inject the label onto a scroll-indicator top border', () => { + const top = '─── ↑ 5 more ────'; + const out = wrapWithSideBorders([top, ' x ', '─── ↓ 3 more ────'], id, { + label: ' ! shell mode ', + }); + expect(out[0]).toContain('↑ 5 more'); + expect(out[0]).not.toContain('shell mode'); + }); }); From 1615c9aecb0684710291c4f535f9bbea60521106 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 14:00:10 +0800 Subject: [PATCH 03/18] feat(kimi-code): refine shell mode queue, history, and display - Keep `!` commands out of input history so they never resurface as bare text stripped of their `!`. - Make `!` commands non-steerable: Ctrl-S skips them (they stay queued to run after the current task) and the steer hint is only shown when something is actually steerable. - Render queued `!` commands with a `$` prompt and the shell-mode hue so they read as commands, not as text to send to the model. - Echo executed shell commands with a `$` prompt instead of `!`. --- .../src/tui/components/panes/queue-pane.ts | 26 +++-- .../src/tui/controllers/editor-keyboard.ts | 17 ++-- .../src/tui/controllers/session-replay.ts | 4 +- apps/kimi-code/src/tui/kimi-tui.ts | 12 ++- apps/kimi-code/src/tui/theme/colors.ts | 2 +- .../tui/components/panes/queue-pane.test.ts | 37 +++++++ .../test/tui/kimi-tui-message-flow.test.ts | 96 +++++++++++++++++++ 7 files changed, 175 insertions(+), 19 deletions(-) diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index 77800b97e7..a3c959a6a7 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -22,26 +22,40 @@ export class QueuePaneComponent extends Container { this.messages = options.messages; if (options.messages.length > 0) { + // Bash commands (`! …`) are not steerable, so only advertise Ctrl-S when + // there is at least one plain-text item that steering would actually send. + const hasSteerable = options.messages.some((m) => m.mode !== 'bash'); + const canSteer = options.canSteerImmediately && hasSteerable; this.hint = options.isCompacting && !options.isStreaming ? ' ↑ to edit · will send after compaction' - : !options.canSteerImmediately - ? ' ↑ to edit · will send after current task' - : ' ↑ to edit · ctrl-s to steer immediately'; + : canSteer + ? ' ↑ to edit · ctrl-s to steer immediately' + : ' ↑ to edit · will send after current task'; } } override render(width: number): string[] { const accent = (text: string) => currentTheme.fg('accent', text); + const shell = (text: string) => currentTheme.fg('shellMode', text); const dim = (text: string) => currentTheme.fg('textDim', text); const lines: string[] = [currentTheme.fg('border', '─'.repeat(width))]; for (const item of this.messages) { const singleLine = item.text.replaceAll(/\s+/g, ' ').trim(); const prefix = ` ${SELECT_POINTER} `; - const availableWidth = Math.max(1, width - visibleWidth(prefix)); - const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); - lines.push(accent(prefix + truncated)); + if (item.mode === 'bash') { + // Shell commands get a `$ ` prompt and the shell-mode hue so they read + // as commands, not as plain text that would be sent to the model. + const prompt = '$ '; + const availableWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(prompt)); + const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); + lines.push(accent(prefix) + shell(prompt + truncated)); + } else { + const availableWidth = Math.max(1, width - visibleWidth(prefix)); + const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); + lines.push(accent(prefix + truncated)); + } } if (this.hint !== undefined) { diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index a2c454aaab..262afe2d6e 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -176,18 +176,23 @@ export class EditorKeyboardController { editor.onCtrlS = () => { if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) return; const text = editor.getText().trim(); - const queuedTexts = host.state.queuedMessages.map((m) => m.text); - host.clearQueuedMessages(); + const editorIsBash = editor.inputMode === 'bash'; + + // Bash commands (`! …`) are not steerable: keep them queued so they run + // after the current task instead of being injected into the turn as text. + const queued = host.state.queuedMessages; + const steerable = queued.filter((m) => m.mode !== 'bash'); + host.state.queuedMessages = queued.filter((m) => m.mode === 'bash'); const parts: string[] = []; - for (const q of queuedTexts) { - const trimmed = q.trim(); + for (const m of steerable) { + const trimmed = m.text.trim(); if (trimmed.length > 0) parts.push(trimmed); } - if (text.length > 0) parts.push(text); + if (!editorIsBash && text.length > 0) parts.push(text); if (parts.length > 0) { - editor.setText(''); + if (!editorIsBash) editor.setText(''); const session = host.session; if (host.state.appState.model.trim().length === 0 || session === undefined) { host.showError(LLM_NOT_SET_MESSAGE); diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index a87ae03109..ef8a6209e2 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -261,7 +261,7 @@ export class SessionReplayRenderer { } if (message.origin?.kind === 'shell_command') { // A `!` command, replayed from records. Unwrap the XML tags back into the - // same `! cmd` + output view the live editor produced. (Must NOT fall into + // same `$ cmd` + output view the live editor produced. (Must NOT fall into // the `injection` branch above — that returns without rendering.) this.flushAssistant(context); const text = contentPartsToText(message.content); @@ -269,7 +269,7 @@ export class SessionReplayRenderer { const cmd = (extractBashTag(text, 'bash-input') ?? text).trim(); this.advanceTurn(context); this.host.appendTranscriptEntry( - replayEntry(context, 'user', currentTheme.fg('shellMode', `! ${cmd}`), 'plain'), + replayEntry(context, 'user', currentTheme.fg('shellMode', `$ ${cmd}`), 'plain'), ); } else { const stdout = (extractBashTag(text, 'bash-stdout') ?? '').trim(); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 60a7e02b8f..2056cf4bd8 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -845,7 +845,11 @@ export class KimiTUI { this.showError('Cannot send input while session history is replaying.'); return; } - void this.persistInputHistory(text); + // Shell commands (`! …`) are not prompts — keep them out of input history + // so ↑ recall never surfaces a bare command stripped of its `!`. + if (!wasBashMode) { + void this.persistInputHistory(text); + } if (wasBashMode) { // Only one foreground action at a time: queue the shell command while // another shell command is running or an agent turn is in progress. @@ -867,14 +871,14 @@ export class KimiTUI { this.showError('No active session for shell command.'); return; } - // Echo the command locally (bash-input), like claude-code's `! cmd`. The - // agent also records it for resume; this is the live view. + // Echo the command locally (bash-input) with a `$` prompt. The agent also + // records it for resume; this is the live view. this.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'user', turnId: undefined, renderMode: 'plain', - content: currentTheme.fg('shellMode', `! ${command}`), + content: currentTheme.fg('shellMode', `$ ${command}`), }); // Create the live output entry up front. ShellRunComponent owns its own // rendering (running card → final view) and is mutated in place as output diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts index 0fa11af844..bf18a5e7da 100644 --- a/apps/kimi-code/src/tui/theme/colors.ts +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -74,7 +74,7 @@ export interface ColorPalette { // ── Shell mode ── /** Shell mode (`!`): the `!` prompt symbol, bash-mode editor border, and the - * echoed `! command` line. Its own hue (magenta/rose), distinct from + * echoed `$ command` line. Its own hue (magenta/rose), distinct from * plan-mode (primary) and the user role (roleUser). */ shellMode: string; } diff --git a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts index ca276a138e..0f9a011779 100644 --- a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts @@ -82,4 +82,41 @@ describe('QueuePaneComponent', () => { expect(messageLine).toContain('line one line two line three'); expect(messageLine).not.toContain('\n'); }); + + it('renders bash queued items with a $ prompt to distinguish them from text', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: false, + messages: [{ text: 'ls -la', mode: 'bash' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).toContain('❯ $ ls -la'); + }); + + it('omits the steer hint when every queued item is a bash command', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'ls', mode: 'bash' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).not.toContain('ctrl-s to steer immediately'); + expect(output).toContain('will send after current task'); + }); + + it('keeps the steer hint when at least one queued item is steerable', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'ls', mode: 'bash' }, { text: 'focus on tests' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).toContain('ctrl-s to steer immediately'); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 32ff744e80..4066d91d2e 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -64,6 +64,7 @@ interface MessageDriver { init(): Promise; handleUserInput(text: string): void; persistInputHistory(text: string): Promise; + sendQueuedMessage(session: unknown, item: QueuedMessage): void; getCurrentSessionId(): string; } @@ -1242,6 +1243,101 @@ command = "vim" } }); + it('queues bash input with mode bash while a turn is streaming', async () => { + const { driver, session } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); + + it('dispatches a queued bash item to runShellCommand instead of prompt', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); + const { driver } = await makeDriver(session); + + driver.sendQueuedMessage(session, { text: 'ls', mode: 'bash' }); + await Promise.resolve(); + + expect(runShellCommand).toHaveBeenCalledWith( + 'ls', + expect.objectContaining({ commandId: expect.any(String) }), + ); + expect(session.prompt).not.toHaveBeenCalled(); + }); + + it('does not persist bash input to input history', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(driver.persistInputHistory).not.toHaveBeenCalled(); + }); + + it('persists normal input to input history', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('hello'); + + expect(driver.persistInputHistory).toHaveBeenCalledWith('hello'); + }); + + it('does not steer queued bash commands, keeping them queued', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [ + { text: 'ls', agentId: 'main', mode: 'bash' }, + { text: 'focus on tests', agentId: 'main' }, + ]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).toHaveBeenCalledWith('focus on tests'); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); + + it('does not steer the editor draft while it is in bash mode', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.inputMode = 'bash'; + driver.state.editor.setText('ls'); + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.editor.getText()).toBe('ls'); + }); + + it('echoes a bash command with a $ prompt in the transcript', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); + const { driver } = await makeDriver(session); + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + await Promise.resolve(); + + const transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('$ ls'); + expect(transcript).not.toContain('! ls'); + }); + it('renders cron fired events as distinct transcript entries', async () => { const { driver } = await makeDriver(); From f9d44bd8e93a01c7e95bd65eb14722e173cb3be2 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 15:14:11 +0800 Subject: [PATCH 04/18] fix(kimi-code): sanitize shell output and harden rendering Captured shell command output can contain terminal control sequences (colours, cursor moves, alternate-screen switches, OSC hyperlinks, carriage-return spinners, bells). pi-tui's Text passes strings straight to the terminal, so any unhandled sequence was executed by the terminal and fought with pi-tui's own cursor control, producing the blank-screen-plus-leftover-characters mess after running commands like pnpm dev or a nested TUI. - Sanitize CSI (incl. private modes), OSC, single-char ESC and C0 control chars (keeping newline and tab) in both the finished/resume view (previously unsanitized) and the running tail. - Make the sanitize, format, and ShellRunComponent render paths never-throw, and cap the live running buffer, so a misbehaving command cannot crash the TUI. - Dispose transcript children on clear so ShellRunComponent's timer is released on /clear or session switch. --- .../src/tui/components/messages/shell-run.ts | 84 +++++++++----- apps/kimi-code/src/tui/kimi-tui.ts | 8 +- apps/kimi-code/src/tui/utils/shell-output.ts | 78 ++++++++++--- .../tui/components/messages/shell-run.test.ts | 70 ++++++++++++ .../test/tui/utils/shell-output.test.ts | 108 ++++++++++++++++++ 5 files changed, 302 insertions(+), 46 deletions(-) create mode 100644 apps/kimi-code/test/tui/components/messages/shell-run.test.ts create mode 100644 apps/kimi-code/test/tui/utils/shell-output.test.ts diff --git a/apps/kimi-code/src/tui/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts index 4e5d7f59ac..3c5fc20da5 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-run.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -2,10 +2,16 @@ import { Container, Text } from '@earendil-works/pi-tui'; import { currentTheme } from '#/tui/theme'; -import { formatBashOutputForDisplay, stripAnsi } from '#/tui/utils/shell-output'; +import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output'; const RUNNING_TAIL_LINES = 5; const TIMER_INTERVAL_MS = 1000; +// Cap the live running buffer so a command that spews output for minutes can't +// grow memory without bound or make every render re-strip a multi-MB string. +// Only affects the transient running tail; the final view uses the full +// captured stdout/stderr passed to finish(). +const MAX_COMBINED_CHARS = 256 * 1024; +const KEEP_COMBINED_CHARS = 64 * 1024; /** * Live view for a user-initiated `!` shell command. Two phases: @@ -16,12 +22,16 @@ const TIMER_INTERVAL_MS = 1000; * so warnings are grey rather than red while the command works. * - finished: the standard `formatBashOutputForDisplay` view (stderr red only * on failure), the timer stopped and the running chrome removed. + * + * Hardened so a misbehaving command can never crash the TUI: the running + * buffer is capped, and every render/render-request path swallows errors. */ export class ShellRunComponent extends Container { private readonly textComponent: Text; private combined = ''; private running = true; private backgrounded = false; + private disposed = false; private finalStdout = ''; private finalStderr = ''; private finalIsError?: boolean; @@ -36,13 +46,16 @@ export class ShellRunComponent extends Container { } append(text: string): void { - if (!this.running || text.length === 0) return; + if (this.disposed || !this.running || text.length === 0) return; this.combined += text; + if (this.combined.length > MAX_COMBINED_CHARS) { + this.combined = this.combined.slice(-KEEP_COMBINED_CHARS); + } this.flush(); } finish(stdout: string, stderr: string, isError?: boolean): void { - if (!this.running) return; + if (this.disposed || !this.running) return; this.running = false; this.finalStdout = stdout; this.finalStderr = stderr; @@ -52,7 +65,7 @@ export class ShellRunComponent extends Container { } finishBackgrounded(): void { - if (!this.running) return; + if (this.disposed || !this.running) return; this.running = false; this.backgrounded = true; this.clearTimer(); @@ -60,6 +73,7 @@ export class ShellRunComponent extends Container { } dispose(): void { + this.disposed = true; this.clearTimer(); } @@ -69,8 +83,14 @@ export class ShellRunComponent extends Container { } private flush(): void { - this.textComponent.setText(this.renderText()); - this.requestRender(); + if (this.disposed) return; + try { + this.textComponent.setText(this.renderText()); + this.requestRender(); + } catch { + // Never let a render/render-request error escape into a timer or event + // handler — an uncaught exception there can take down the whole TUI. + } } private clearTimer(): void { @@ -81,30 +101,34 @@ export class ShellRunComponent extends Container { } private renderText(): string { - if (this.backgrounded) { - return ` ${currentTheme.fg('textDim', 'Moved to background.')}`; - } - if (!this.running) { - return formatBashOutputForDisplay(this.finalStdout, this.finalStderr, this.finalIsError) - .split('\n') - .map((line) => ` ${line}`) - .join('\n'); - } - const elapsed = Math.floor((Date.now() - this.startedAt) / 1000); - const dim = (s: string): string => currentTheme.fg('textDim', s); - const trimmed = stripAnsi(this.combined).trimEnd(); - let body: string; - let extra = 0; - if (trimmed.length === 0) { - body = ` ${dim('Running…')}`; - } else { - const lines = trimmed.split('\n'); - const tail = lines.slice(-RUNNING_TAIL_LINES); - extra = Math.max(0, lines.length - RUNNING_TAIL_LINES); - body = tail.map((line) => ` ${dim(line)}`).join('\n'); + try { + if (this.backgrounded) { + return ` ${currentTheme.fg('textDim', 'Moved to background.')}`; + } + if (!this.running) { + return formatBashOutputForDisplay(this.finalStdout, this.finalStderr, this.finalIsError) + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + } + const elapsed = Math.floor((Date.now() - this.startedAt) / 1000); + const dim = (s: string): string => currentTheme.fg('textDim', s); + const trimmed = sanitizeShellOutput(this.combined).trimEnd(); + let body: string; + let extra = 0; + if (trimmed.length === 0) { + body = ` ${dim('Running…')}`; + } else { + const lines = trimmed.split('\n'); + const tail = lines.slice(-RUNNING_TAIL_LINES); + extra = Math.max(0, lines.length - RUNNING_TAIL_LINES); + body = tail.map((line) => ` ${dim(line)}`).join('\n'); + } + const timing = ` ${dim(`${extra > 0 ? `+${extra} lines ` : ''}(${elapsed}s)`)}`; + const hint = ` ${dim('(ctrl+b to run in background)')}`; + return `${body}\n${timing}\n${hint}`; + } catch { + return ' (output unavailable)'; } - const timing = ` ${dim(`${extra > 0 ? `+${extra} lines ` : ''}(${elapsed}s)`)}`; - const hint = ` ${dim('(ctrl+b to run in background)')}`; - return `${body}\n${timing}\n${hint}`; } } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 2056cf4bd8..4688130c47 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -123,7 +123,7 @@ import { type TUIStartupOptions, type TUIStartupState, } from './types'; -import { isExpandable } from './utils/component-capabilities'; +import { hasDispose, isExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; @@ -1771,6 +1771,12 @@ export class KimiTUI { this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + // Dispose disposable children (e.g. ShellRunComponent's 1s timer) before + // dropping them, so a /clear or session switch can't leak intervals that + // keep firing requestRender on a removed component. + for (const child of this.state.transcriptContainer.children) { + if (hasDispose(child)) child.dispose(); + } this.state.transcriptContainer.clear(); this.btwPanelController.clear(); this.clearTerminalInlineImages(); diff --git a/apps/kimi-code/src/tui/utils/shell-output.ts b/apps/kimi-code/src/tui/utils/shell-output.ts index 610b9a59f8..3a482feb73 100644 --- a/apps/kimi-code/src/tui/utils/shell-output.ts +++ b/apps/kimi-code/src/tui/utils/shell-output.ts @@ -1,23 +1,71 @@ import { currentTheme } from '#/tui/theme'; -// Strip ANSI/CSI escape sequences (colours, cursor moves, …). Command output -// is run with NO_COLOR=1, but some tools ignore it; stripping keeps the live -// "running" view uniformly dim instead of leaking the tool's own colours. -const ANSI_PATTERN = /\u001B\[[0-9;]*[A-Za-z]/g; +// Captured command output can contain terminal control sequences — colours, +// cursor moves, alternate-screen switches, hyperlinks, `\r` spinners, bells, … +// We render through pi-tui, which passes strings straight to the terminal, so +// any sequence left intact is executed by the terminal and fights with pi-tui's +// own cursor control (the "blank screen + leftover characters" symptom). Strip +// everything a terminal would interpret as a command rather than printable text, +// keeping only `\n` and `\t` (which the renderer understands). -export function stripAnsi(text: string): string { - return text.replace(ANSI_PATTERN, ''); +// ESC [ — colours, cursor moves, clear, and +// private modes such as ESC[?1049h (alt screen) / ESC[?25l (hide cursor). +const CSI_PATTERN = /\u001B\[[0-9:;<=>?]*[ -/]*[@-~]/g; +// ESC ] … or ESC ] … ESC \ — window titles and OSC 8 hyperlinks. +const OSC_PATTERN = /\u001B\][\s\S]*?(?:\u0007|\u001B\\)/g; +// ESC (and ESC ) — charset/keypad selection, +// save/restore cursor (ESC 7 / ESC 8), full reset (ESC c), etc. Runs after the +// CSI/OSC patterns, so it only catches sequences they didn't already consume. +const ESC_SINGLE_PATTERN = /\u001B(?:[ -/][0-~]|[0-~])/g; +// C0 control characters except \n (0x0A) and \t (0x09): NUL, BEL, \b, \r, … +// plus a lone ESC (0x1B) that wasn't part of a sequence recognised above. +const C0_CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u001B\u001C-\u001F]/g; + +/** + * Strip every terminal control sequence from captured command output so it is + * safe to render via pi-tui (which does not sanitize on its own). + * + * Never throws: a bad or pathological input falls back to stripping only the + * C0 control characters, so rendering can never crash the TUI. + */ +export function sanitizeShellOutput(text: string): string { + if (typeof text !== 'string') return ''; + if (text.length === 0) return text; + try { + return text + .replace(OSC_PATTERN, '') + .replace(CSI_PATTERN, '') + .replace(ESC_SINGLE_PATTERN, '') + .replace(C0_CONTROL_PATTERN, ''); + } catch { + return text.replace(C0_CONTROL_PATTERN, ''); + } } +/** + * Format captured stdout/stderr for the transcript. Sanitizes both streams and + * dims them; stderr is red only on actual failure. + * + * Never throws: if anything goes wrong (theme lookup, huge input, …) it falls + * back to a best-effort plain view so a render error can never crash the TUI. + */ export function formatBashOutputForDisplay(stdout: string, stderr: string, isError?: boolean): string { - const dim = (s: string): string => currentTheme.fg('textDim', s); - const parts: string[] = []; - if (stdout.trimEnd().length > 0) parts.push(dim(stdout.trimEnd())); - if (stderr.trimEnd().length > 0) { - const err = stderr.trimEnd(); - // Dim grey normally; red only on actual failure (so warnings on a - // successful command are not mistaken for errors). - parts.push(isError ? currentTheme.fg('error', err) : dim(err)); + try { + const dim = (s: string): string => currentTheme.fg('textDim', s); + const parts: string[] = []; + const cleanStdout = sanitizeShellOutput(stdout).trimEnd(); + if (cleanStdout.length > 0) parts.push(dim(cleanStdout)); + const cleanStderr = sanitizeShellOutput(stderr).trimEnd(); + if (cleanStderr.length > 0) { + // Dim grey normally; red only on actual failure (so warnings on a + // successful command are not mistaken for errors). + parts.push(isError ? currentTheme.fg('error', cleanStderr) : dim(cleanStderr)); + } + return parts.length > 0 ? parts.join('\n') : dim('(no output)'); + } catch { + const plain = [sanitizeShellOutput(String(stdout ?? '')), sanitizeShellOutput(String(stderr ?? ''))] + .filter((s) => s.length > 0) + .join('\n'); + return plain.length > 0 ? plain : '(no output)'; } - return parts.length > 0 ? parts.join('\n') : dim('(no output)'); } diff --git a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts new file mode 100644 index 0000000000..510da06bd6 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { ShellRunComponent } from '#/tui/components/messages/shell-run'; + +function stripTheme(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('ShellRunComponent hardening', () => { + let component: ShellRunComponent | undefined; + + afterEach(() => { + // Always clear the 1s timer so it can't keep the test process alive or + // fire requestRender after the test ends. + component?.dispose(); + component = undefined; + }); + + function create(): ShellRunComponent { + component = new ShellRunComponent(() => {}); + return component; + } + + it('caps the running buffer and never throws on huge streaming output', () => { + const c = create(); + const chunk = 'x'.repeat(50_000); + expect(() => { + for (let i = 0; i < 20; i++) c.append(chunk); + c.render(100); + }).not.toThrow(); + }); + + it('finish switches to the final view and ignores later appends', () => { + const c = create(); + c.finish('final output', '', false); + c.append('should be ignored'); + const rendered = stripTheme(c.render(100).join('\n')); + expect(rendered).toContain('final output'); + expect(rendered).not.toContain('should be ignored'); + }); + + it('finishBackgrounded renders the background hint', () => { + const c = create(); + c.finishBackgrounded(); + const rendered = stripTheme(c.render(100).join('\n')); + expect(rendered).toContain('Moved to background.'); + }); + + it('append / finish are no-ops after dispose', () => { + const c = create(); + c.dispose(); + expect(() => { + c.append('late'); + c.finish('late', '', false); + c.finishBackgrounded(); + c.render(100); + }).not.toThrow(); + }); + + it('does not throw when the render callback throws', () => { + const c = new ShellRunComponent(() => { + throw new Error('render failed'); + }); + component = c; + expect(() => { + c.append('output'); + c.render(100); + }).not.toThrow(); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/shell-output.test.ts b/apps/kimi-code/test/tui/utils/shell-output.test.ts new file mode 100644 index 0000000000..e7a724b43a --- /dev/null +++ b/apps/kimi-code/test/tui/utils/shell-output.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output'; + +const ESC = '\u001B'; +const BEL = '\u0007'; + +function stripTheme(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('sanitizeShellOutput', () => { + it('leaves plain text untouched', () => { + expect(sanitizeShellOutput('hello\nworld')).toBe('hello\nworld'); + }); + + it('strips SGR colour sequences', () => { + expect(sanitizeShellOutput(`${ESC}[31mred${ESC}[0m`)).toBe('red'); + expect(sanitizeShellOutput(`${ESC}[1;32mbold green${ESC}[0m`)).toBe('bold green'); + }); + + it('strips CSI private modes (alt screen, cursor visibility)', () => { + expect(sanitizeShellOutput(`${ESC}[?1049h${ESC}[?25l`)).toBe(''); + expect(sanitizeShellOutput(`before${ESC}[?2004hafter`)).toBe('beforeafter'); + }); + + it('strips clear-screen and cursor-movement sequences', () => { + expect(sanitizeShellOutput(`${ESC}[2J${ESC}[Hhello`)).toBe('hello'); + expect(sanitizeShellOutput(`${ESC}[10;5Hhi`)).toBe('hi'); + }); + + it('strips OSC window titles', () => { + expect(sanitizeShellOutput(`${ESC}]0;my title${BEL}text`)).toBe('text'); + }); + + it('strips OSC 8 hyperlinks but keeps the link text', () => { + const link = `${ESC}]8;;https://example.com${ESC}\\click here${ESC}]8;;${ESC}\\`; + expect(sanitizeShellOutput(link)).toBe('click here'); + }); + + it('strips carriage returns (spinner redraw)', () => { + expect(sanitizeShellOutput('frame1\rframe2\rframe3')).toBe('frame1frame2frame3'); + expect(sanitizeShellOutput('line\r\nnext')).toBe('line\nnext'); + }); + + it('strips backspace, bell and NUL', () => { + expect(sanitizeShellOutput(`a\u0008b${BEL}c\u0000d`)).toBe('abcd'); + }); + + it('preserves newlines and tabs', () => { + expect(sanitizeShellOutput('a\nb\tc')).toBe('a\nb\tc'); + }); + + it('strips single-char ESC commands (reset, save/restore cursor)', () => { + expect(sanitizeShellOutput(`${ESC}c${ESC}7${ESC}8text`)).toBe('text'); + }); + + it('never throws and returns "" for non-string input', () => { + expect(sanitizeShellOutput(undefined as unknown as string)).toBe(''); + expect(sanitizeShellOutput(null as unknown as string)).toBe(''); + expect(sanitizeShellOutput(42 as unknown as string)).toBe(''); + }); + + it('handles huge input without throwing', () => { + const huge = `${ESC}[31m${'x'.repeat(2_000_000)}\r${ESC}[0m`; + expect(() => sanitizeShellOutput(huge)).not.toThrow(); + }); + + it('cleans a realistic TUI/dev-server burst down to printable text', () => { + const messy = + `${ESC}[?1049h${ESC}[?25l${ESC}[2J${ESC}[H` + + `${ESC}[1m${ESC}[32mVITE${ESC}[0m ready in 120ms\r\n` + + `${ESC}]0;dev server${BEL}` + + ` Local: http://localhost:5173/`; + const result = sanitizeShellOutput(messy); + expect(result).not.toContain(ESC); + expect(result).not.toContain('\r'); + expect(result).toContain('VITE ready in 120ms'); + expect(result).toContain('Local: http://localhost:5173/'); + }); +}); + +describe('formatBashOutputForDisplay', () => { + it('shows "(no output)" when both streams are empty', () => { + expect(stripTheme(formatBashOutputForDisplay('', ''))).toBe('(no output)'); + }); + + it('strips control sequences from stdout before rendering', () => { + const result = stripTheme(formatBashOutputForDisplay(`${ESC}[?1049h${ESC}[31mhi${ESC}[0m\r`, '')); + expect(result).not.toContain(ESC); + expect(result).not.toContain('\r'); + expect(result).toContain('hi'); + }); + + it('strips control sequences from stderr before rendering', () => { + const result = stripTheme(formatBashOutputForDisplay('', `err${BEL}\r`, true)); + expect(result).not.toContain(ESC); + expect(result).not.toContain(BEL); + expect(result).not.toContain('\r'); + expect(result).toContain('err'); + }); + + it('never throws on malformed / non-string input', () => { + expect(() => + formatBashOutputForDisplay(undefined as unknown as string, null as unknown as string), + ).not.toThrow(); + }); +}); From 5899bab9a6d991ce4b79a51f7c9b4026ccb1bea6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 15:31:44 +0800 Subject: [PATCH 05/18] fix(kimi-code): render shell command echo with $ instead of sparkles The shell command echo is a 'user' transcript entry, so UserMessageComponent prefixed it with the USER_MESSAGE_BULLET (sparkles), producing 'sparkles $ command'. Add an optional bullet override to UserMessageComponent / TranscriptEntry and set it to an empty string for the shell echo (both live and resume), so the '$ command' content sits at the leading column where the sparkles marker used to be. Normal user messages keep the sparkles bullet. --- .../src/tui/components/messages/user-message.ts | 7 +++++-- .../src/tui/controllers/session-replay.ts | 4 +++- apps/kimi-code/src/tui/kimi-tui.ts | 3 ++- apps/kimi-code/src/tui/types.ts | 2 ++ apps/kimi-code/src/tui/utils/message-replay.ts | 3 ++- .../tui/components/messages/user-message.test.ts | 15 +++++++++++++++ 6 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index 43ed134593..7c590b26c0 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -11,11 +11,13 @@ import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; export class UserMessageComponent implements Component { private text: string; + private readonly bullet?: string; private spacerComponent: Spacer; private imageThumbnails: ImageThumbnail[]; - constructor(text: string, images?: ImageAttachment[]) { + constructor(text: string, images?: ImageAttachment[], bullet?: string) { this.text = text; + this.bullet = bullet; this.spacerComponent = new Spacer(1); this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? []; } @@ -30,7 +32,8 @@ export class UserMessageComponent implements Component { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; - const bullet = currentTheme.boldFg('roleUser', USER_MESSAGE_BULLET); + const marker = this.bullet ?? USER_MESSAGE_BULLET; + const bullet = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; const bulletWidth = visibleWidth(bullet); const contentWidth = Math.max(1, safeWidth - bulletWidth); diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index ef8a6209e2..9778fe3dc5 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -269,7 +269,9 @@ export class SessionReplayRenderer { const cmd = (extractBashTag(text, 'bash-input') ?? text).trim(); this.advanceTurn(context); this.host.appendTranscriptEntry( - replayEntry(context, 'user', currentTheme.fg('shellMode', `$ ${cmd}`), 'plain'), + replayEntry(context, 'user', currentTheme.fg('shellMode', `$ ${cmd}`), 'plain', { + bullet: '', + }), ); } else { const stdout = (extractBashTag(text, 'bash-stdout') ?? '').trim(); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 4688130c47..81b84569bc 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -879,6 +879,7 @@ export class KimiTUI { turnId: undefined, renderMode: 'plain', content: currentTheme.fg('shellMode', `$ ${command}`), + bullet: '', }); // Create the live output entry up front. ShellRunComponent owns its own // rendering (running card → final view) and is mutated in place as output @@ -1642,7 +1643,7 @@ export class KimiTUI { const images = entry.imageAttachmentIds ?.map((id) => this.imageStore.get(id)) .filter((a): a is ImageAttachment => a?.kind === 'image'); - return new UserMessageComponent(entry.content, images); + return new UserMessageComponent(entry.content, images, entry.bullet); } case 'skill_activation': return new SkillActivationComponent( diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 72064c7c52..985e576e46 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -152,6 +152,8 @@ export interface TranscriptEntry { content: string; color?: ColorToken; detail?: string; + /** Optional override for the leading bullet of a 'user' message entry. An empty string suppresses the bullet entirely (used by shell-command echoes so `$` replaces the sparkles marker). */ + bullet?: string; toolCallData?: ToolCallBlockData; backgroundAgentStatus?: BackgroundAgentStatusData; compactionData?: CompactionTranscriptData; diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index b01ed19b60..57d7e4b3df 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -135,7 +135,7 @@ export function replayEntry( kind: TranscriptEntry['kind'], content: string, renderMode: TranscriptEntry['renderMode'], - extras: { detail?: string } = {}, + extras: { detail?: string; bullet?: string } = {}, ): TranscriptEntry { return { id: nextTranscriptId(), @@ -144,6 +144,7 @@ export function replayEntry( renderMode, content, detail: extras.detail, + bullet: extras.bullet, }; } diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index 3d957eb5b7..23ec702aec 100644 --- a/apps/kimi-code/test/tui/components/messages/user-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -89,4 +89,19 @@ describe('UserMessageComponent', () => { expect(imageLine).not.toContain('…'); expect(imageLine).toContain('\u001B\\'); // intact Kitty terminator }); + + it('omits the sparkles bullet when an empty bullet is provided', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + + const withBullet = stripAnsi(new UserMessageComponent('hello', []).render(80).join('\n')); + expect(withBullet).toContain('✨'); + expect(withBullet).toContain('hello'); + + const lines = new UserMessageComponent('$ ls', [], '').render(80).map(stripAnsi); + const contentLine = lines.find((l) => l.includes('$ ls')); + expect(contentLine).toBeDefined(); + expect(stripAnsi(lines.join('\n'))).not.toContain('✨'); + // The `$` sits at the leading column where the bullet used to be. + expect(contentLine?.startsWith('$ ls')).toBe(true); + }); }); From 47278bd5538f0cdce6ade316ee66ccb7c41d9ac1 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 15:31:52 +0800 Subject: [PATCH 06/18] fix(kimi-code): enter shell mode when pasting a !-prefixed command The bash-mode trigger only handled the single ! keystroke, so a pasted !cmd was inserted as literal text in prompt mode and submitted as a normal message. After pi-tui inserts pasted content, detect an empty-prompt buffer that now starts with !, switch to bash mode, and strip the leading ! so the buffer holds only the command, matching the typed ! path. --- .../tui/components/editor/custom-editor.ts | 12 +++++ .../components/editor/custom-editor.test.ts | 51 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index e8c2094af7..c58e2b4d76 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -444,7 +444,19 @@ export class CustomEditor extends Editor { return; } + const emptyPromptBeforeInput = this.inputMode === 'prompt' && this.getText().length === 0; super.handleInput(normalized); + + // Enter bash mode when `!...` is pasted into an empty prompt. The typed path + // above handles the single `!` keystroke; this catches bracketed / Ctrl-V + // pastes whose content starts with `!`. Strip the leading `!` so the buffer + // holds only the command, exactly like the typed path. + if (emptyPromptBeforeInput && this.inputMode === 'prompt' && this.getText().startsWith('!')) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + this.setText(this.getText().slice(1)); + } + this.reopenAutocompleteAfterInput(); } diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index e3c17e9897..8b6a9d306c 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -580,3 +580,54 @@ describe('CustomEditor bash mode border label', () => { expect(top).toHaveLength(width); }); }); + +describe('CustomEditor bash mode via paste', () => { + const PASTE_START = '\u001B[200~'; + const PASTE_END = '\u001B[201~'; + + it('enters bash mode and strips the leading ! when !cmd is pasted into an empty prompt', () => { + const editor = makeEditor(); + const modes: Array<'prompt' | 'bash'> = []; + editor.onInputModeChange = (mode) => modes.push(mode); + + editor.handleInput(`${PASTE_START}!ls${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe('ls'); + expect(modes).toEqual(['bash']); + }); + + it('enters bash mode on a bare pasted ! with an empty buffer', () => { + const editor = makeEditor(); + editor.handleInput(`${PASTE_START}!${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); + + it('does not enter bash mode when pasting !cmd into a non-empty prompt', () => { + const editor = makeEditor(); + editor.handleInput('hello'); + editor.handleInput(`${PASTE_START}!ls${PASTE_END}`); + + expect(editor.inputMode).toBe('prompt'); + expect(editor.getText()).toContain('hello'); + expect(editor.getText()).toContain('!ls'); + }); + + it('does not enter bash mode for a pasted command without a leading !', () => { + const editor = makeEditor(); + editor.handleInput(`${PASTE_START}ls${PASTE_END}`); + + expect(editor.inputMode).toBe('prompt'); + expect(editor.getText()).toBe('ls'); + }); + + it('keeps the typed ! behaviour (bash mode, empty buffer)', () => { + const editor = makeEditor(); + editor.handleInput('!'); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); +}); From 0f343ef6003e032370da11f59d0b0ef66da62d1d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 16:01:00 +0800 Subject: [PATCH 07/18] fix(kimi-code): restore shell mode when recalling a queued command recallLastQueued() dropped the queued item's mode, and the Up-arrow recall only restored the text. A queued ! command (queued while another command runs, which resets the editor to prompt mode) therefore came back as a normal prompt and was submitted as a message instead of a shell command. Return the full QueuedMessage from recallLastQueued() and restore editor.inputMode (plus the onInputModeChange sync) from the recalled item's mode. --- .../src/tui/controllers/editor-keyboard.ts | 13 ++++++-- apps/kimi-code/src/tui/kimi-tui.ts | 4 +-- .../test/tui/kimi-tui-message-flow.test.ts | 33 +++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 262afe2d6e..ae30f49d79 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -13,7 +13,7 @@ import { } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; import type { ImageAttachmentStore } from '../utils/image-attachment-store'; -import type { PendingExit } from '../types'; +import type { PendingExit, QueuedMessage } from '../types'; import type { TUIState } from '../tui-state'; import type { BtwPanelController } from './btw-panel'; @@ -25,7 +25,7 @@ export interface EditorKeyboardHost { handleUserInput(text: string): void; readonly btwPanelController: BtwPanelController; steerMessage(session: Session, input: string[]): void; - recallLastQueued(): string | undefined; + recallLastQueued(): QueuedMessage | undefined; showError(msg: string): void; track(event: string, props?: Record): void; updateEditorBorderHighlight(text?: string): void; @@ -232,7 +232,14 @@ export class EditorKeyboardController { if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false; const recalled = host.recallLastQueued(); if (recalled !== undefined) { - editor.setText(recalled); + editor.setText(recalled.text); + // Restore the queued item's mode so a recalled `!` command runs as a + // shell command again instead of being submitted as a normal prompt. + const mode = recalled.mode ?? 'prompt'; + if (editor.inputMode !== mode) { + editor.inputMode = mode; + editor.onInputModeChange?.(mode); + } host.updateQueueDisplay(); host.state.ui.requestRender(); return true; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 81b84569bc..9edeeb5153 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1059,11 +1059,11 @@ export class KimiTUI { } } - recallLastQueued(): string | undefined { + recallLastQueued(): QueuedMessage | undefined { if (this.state.queuedMessages.length === 0) return undefined; const last = this.state.queuedMessages.at(-1)!; this.state.queuedMessages = this.state.queuedMessages.slice(0, -1); - return last.text; + return last; } // ========================================================================= diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 4066d91d2e..cfd461144d 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1323,6 +1323,39 @@ command = "vim" expect(driver.state.editor.getText()).toBe('ls'); }); + it('recalls a queued bash command back into bash mode on Up', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [{ text: 'ls', agentId: 'main', mode: 'bash' }]; + // After a bash command is queued the editor is reset to prompt mode. + driver.state.editor.inputMode = 'prompt'; + driver.state.appState.inputMode = 'prompt'; + + const handled = driver.state.editor.onUpArrowEmpty?.(); + + expect(handled).toBe(true); + expect(driver.state.editor.getText()).toBe('ls'); + expect(driver.state.editor.inputMode).toBe('bash'); + expect(driver.state.appState.inputMode).toBe('bash'); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it('recalls a queued prompt message in prompt mode on Up', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [{ text: 'hello', agentId: 'main' }]; + driver.state.editor.inputMode = 'bash'; + driver.state.appState.inputMode = 'bash'; + + const handled = driver.state.editor.onUpArrowEmpty?.(); + + expect(handled).toBe(true); + expect(driver.state.editor.getText()).toBe('hello'); + expect(driver.state.editor.inputMode).toBe('prompt'); + expect(driver.state.appState.inputMode).toBe('prompt'); + expect(driver.state.queuedMessages).toEqual([]); + }); + it('echoes a bash command with a $ prompt in the transcript', async () => { const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); const session = makeSession({ runShellCommand }); From 5dba11f0657e5d049c9c3756880be88357e9be99 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 16:01:08 +0800 Subject: [PATCH 08/18] feat(kimi-code): use violet as the shell mode color Replace the claude-code-style magenta/rose shellMode token with a violet that is distinct from plan-mode blue, the user role amber, success green, error red, and the teal accent. Custom themes that omit the token fall back to this new default via the base+overrides merge, so existing custom themes keep working unchanged. --- apps/kimi-code/src/tui/theme/colors.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts index bf18a5e7da..66f9819c3e 100644 --- a/apps/kimi-code/src/tui/theme/colors.ts +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -74,7 +74,7 @@ export interface ColorPalette { // ── Shell mode ── /** Shell mode (`!`): the `!` prompt symbol, bash-mode editor border, and the - * echoed `$ command` line. Its own hue (magenta/rose), distinct from + * echoed `$ command` line. Its own hue (violet), distinct from * plan-mode (primary) and the user role (roleUser). */ shellMode: string; } @@ -103,7 +103,7 @@ export const darkColors: ColorPalette = { diffMeta: '#888888', roleUser: '#FFCB6B', - shellMode: '#FF79C6', + shellMode: '#BD93F9', }; export const lightColors: ColorPalette = { @@ -130,7 +130,7 @@ export const lightColors: ColorPalette = { diffMeta: '#5F5F5F', roleUser: '#9A4A00', - shellMode: '#C2185B', + shellMode: '#7C3AED', }; export type ResolvedTheme = 'dark' | 'light'; From b9f275c8374185cdbabcb6c19f5d1bb5663dfa62 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 16:18:28 +0800 Subject: [PATCH 09/18] chore: refine the shell mode changeset --- .changeset/add-shell-mode.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/add-shell-mode.md b/.changeset/add-shell-mode.md index 205ad77e32..67e548d932 100644 --- a/.changeset/add-shell-mode.md +++ b/.changeset/add-shell-mode.md @@ -2,4 +2,8 @@ "@moonshot-ai/kimi-code": minor --- -Add shell mode (`!`) for running shell commands from the prompt with live output, background execution, and cancellation. +Add shell mode for running shell commands. +Type `!` in the input box to enable it. +The command output is visible to the AI. +For long-running commands, press Ctrl+B to move them to the background. +For example, you can run `!gh auth login` to sign in to the GitHub CLI without opening a new terminal, so Kimi can use `gh`. From 17c913ba73a423d4ee1b9ce8b6d066ffc8f261f7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 16:25:27 +0800 Subject: [PATCH 10/18] docs: document shell mode Add a Shell mode section to the interaction guide and list the ! and Ctrl+B shortcuts in the keyboard reference, in both English and Chinese. --- docs/en/guides/interaction.md | 10 ++++++++++ docs/en/reference/keyboard.md | 3 +++ docs/zh/guides/interaction.md | 10 ++++++++++ docs/zh/reference/keyboard.md | 3 +++ 4 files changed, 26 insertions(+) diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index d43448bad7..7bfe484301 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -64,6 +64,16 @@ After producing a plan the agent pauses for your review — you can approve it, YOLO mode skips confirmation for file writes and command execution. Only use it in working directories you trust. ::: +### Shell mode + +Shell mode lets you run terminal commands without leaving the conversation. The command output is written into the conversation context, so the agent can see the results in later turns. + +- Enter: type `!` in an empty input box, or paste a command that starts with `!`. +- Exit: press `Backspace` or `Esc` in an empty input box; submitting a command also returns you to normal mode automatically. +- Run in background: while a command is running, press `Ctrl+B` to move it to a background task. + +In shell mode the input box shows a `!` prompt on the left and the border turns violet. For example, you can run `!gh auth login` to sign in to the GitHub CLI without opening a new terminal, so Kimi can use `gh` afterward. + ## During streaming output The input box remains usable while the agent is thinking or calling tools, and supports the following extra actions: diff --git a/docs/en/reference/keyboard.md b/docs/en/reference/keyboard.md index 1ef344f3e9..128c956807 100644 --- a/docs/en/reference/keyboard.md +++ b/docs/en/reference/keyboard.md @@ -25,9 +25,12 @@ Pressing `Ctrl-C` **during streaming** cancels immediately — no second confirm | Shortcut | Function | | --- | --- | | `Shift-Tab` | Toggle Plan mode | +| `!` | Enter shell mode (in an empty input box) | Press `Shift-Tab` to enable or disable Plan mode. When enabled, the Agent prioritizes read-only tools for research and planning and can write to the current plan file; `Bash` is subject to the current permission mode and regular rules, without any additional separate approval triggered by Plan mode. Simply toggling does not create an empty plan file. Press `Shift-Tab` again to exit Plan mode. +Type `!` in an empty input box to enter shell mode and run terminal commands directly; while a command is running, press `Ctrl+B` to move it to a background task. See [Interaction and input](../guides/interaction.md#shell-mode). + ## Input & Editing | Shortcut | Function | diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 445f2c560a..d73aeb83c4 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -64,6 +64,16 @@ Agent 输出方案后会等待你审批——可批准执行、拒绝、或要 YOLO 模式会跳过文件写入和命令执行的确认,请只在受信任的工作目录下使用。 ::: +### Shell 模式 + +Shell 模式让你不离开对话就能运行终端命令,命令输出会写入对话上下文,AI 在后续轮次能够看到这些结果。 + +- 进入:在空输入框中键入 `!`,或粘贴以 `!` 开头的命令。 +- 退出:在空输入框中按 `Backspace` 或 `Esc`;提交命令后也会自动回到普通模式。 +- 后台运行:命令执行期间按 `Ctrl+B` 可将其转为后台任务。 + +进入 Shell 模式后,输入框左侧会显示 `!` 提示符,边框变为紫色。例如,无需新开终端就能运行 `!gh auth login` 登录 GitHub CLI,登录后 Kimi 就可以直接使用 `gh`。 + ## 流式输出期间 Agent 思考或调用工具时,输入框仍然可用,支持以下额外操作: diff --git a/docs/zh/reference/keyboard.md b/docs/zh/reference/keyboard.md index 36fe5a51c9..42aabcf9a2 100644 --- a/docs/zh/reference/keyboard.md +++ b/docs/zh/reference/keyboard.md @@ -25,9 +25,12 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用 | 快捷键 | 功能 | | --- | --- | | `Shift-Tab` | 切换 Plan 模式 | +| `!` | 在空输入框中进入 Shell 模式 | 按 `Shift-Tab` 可开启或关闭 Plan 模式。开启后,Agent 会优先使用只读工具进行研究和规划,并可写入当前计划文件;`Bash` 按当前权限模式和普通规则处理,不会因 Plan 模式额外发起独立审批。单纯切换模式不会创建空计划文件。再次按 `Shift-Tab` 退出 Plan 模式。 +在空输入框中键入 `!` 进入 Shell 模式,可直接运行终端命令;命令运行期间按 `Ctrl+B` 可将其转为后台任务。详见[交互与输入](../guides/interaction.md#shell-模式)。 + ## 输入与编辑 | 快捷键 | 功能 | From 04a62f8dddf8aaece68fc903c3afcbae9d3ae460 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 16:42:48 +0800 Subject: [PATCH 11/18] test(protocol): include shell events in volatile classification check shell.output and shell.started were added as volatile event types for shell mode; update the snapshot test's volatile-type list and count accordingly. --- packages/protocol/src/__tests__/snapshot.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/protocol/src/__tests__/snapshot.test.ts b/packages/protocol/src/__tests__/snapshot.test.ts index 7baa199e7f..017481c191 100644 --- a/packages/protocol/src/__tests__/snapshot.test.ts +++ b/packages/protocol/src/__tests__/snapshot.test.ts @@ -132,11 +132,13 @@ describe('events — volatile classification', () => { 'thinking.delta', 'tool.call.delta', 'tool.progress', + 'shell.output', + 'shell.started', 'agent.status.updated', ]) { expect(isVolatileEventType(type)).toBe(true); } - expect(VOLATILE_EVENT_TYPES).toHaveLength(5); + expect(VOLATILE_EVENT_TYPES).toHaveLength(7); }); it('keeps timeline-bearing events durable', () => { From 639c0491adcdf70404c1ca5678c404c202004165 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 17:02:56 +0800 Subject: [PATCH 12/18] fix(agent-core): surface shell command failure reason with no output When a ! shell command fails without producing stdout/stderr (non-zero exit with no output, timeout, spawn failure), the failure reason lived only in the tool result's output and the TUI showed '(no output)'. Fold it into stderr so the live view and replay show what went wrong. --- packages/agent-core/src/agent/tool/index.ts | 14 ++++++++ .../agent-core/test/agent/context.test.ts | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index c20776055d..d6386cf461 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -152,6 +152,20 @@ export class ToolManager { }); return { stdout: result.output, stderr: '', isError: false, backgrounded: true }; } + + // When the command fails with no captured stdout/stderr, the failure + // reason lives in result.output (non-zero exit with no output, timeout, + // spawn failure). Surface it as stderr so the TUI and replay show what + // went wrong instead of "(no output)". + if ( + isError && + stdout.length === 0 && + stderr.length === 0 && + typeof result.output === 'string' && + result.output.length > 0 + ) { + stderr = result.output; + } } catch (error) { stderr += error instanceof Error ? error.message : String(error); isError = true; diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index eca1799321..47dc670aa3 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -117,6 +117,41 @@ describe('Agent context', () => { expect(textOf(ctx.agent.context.history[1]!)).toContain('hello'); }); + it('surfaces the failure reason when a shell command fails with no output', async () => { + const fakeProcess = (exitCode: number): KaosProcess => { + const out = Readable.from([]); + const err = Readable.from([]); + return { + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout: out, + stderr: err, + pid: 1, + exitCode, + wait: vi.fn(async () => exitCode), + kill: vi.fn(async () => {}), + dispose: vi.fn(async () => { + out.destroy(); + err.destroy(); + }), + }; + }; + const kaos = createFakeKaos({ + execWithEnv: vi.fn().mockImplementation(async () => fakeProcess(1)), + }); + const ctx = testAgent({ kaos }); + ctx.configure(); + + const result = await ctx.agent.tools.runShellCommand('false'); + + expect(result.isError).toBe(true); + expect(result.stderr).toContain('exit code'); + const textOf = (message: ContextMessage): string => + message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); + const output = ctx.agent.context.history.at(-1)!; + expect(textOf(output)).toContain(''); + expect(textOf(output)).toContain('exit code'); + }); + it('renders tool error and empty-output status as model-visible text', () => { const ctx = testAgent(); ctx.configure(); From d1fc652429684d9a25abfb296b64853619af3bfe Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 17:03:04 +0800 Subject: [PATCH 13/18] fix(kimi-code): decode CSI-u ! to enter shell mode In terminals with the Kitty keyboard protocol (VSCode integrated terminal, Kitty), pressing ! arrives as a CSI-u sequence, so the raw normalized === '!' comparison never matched and shell mode could not be entered by typing !. Decode with printableChar before comparing, matching every other printable-key check in the TUI. --- apps/kimi-code/src/tui/components/editor/custom-editor.ts | 8 +++++++- .../test/tui/components/editor/custom-editor.test.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index c58e2b4d76..46f3a69651 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -16,6 +16,8 @@ import { import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; +import { printableChar } from '#/tui/utils/printable-key'; + import { extractAtPrefix } from './file-mention-provider'; import { WrappingSelectList } from './wrapping-select-list'; @@ -438,7 +440,11 @@ export class CustomEditor extends Editor { // Enter bash mode: typing `!` at the start of an empty prompt. The `!` is // not inserted into the buffer — it becomes the mode + prompt symbol, so the // cursor never has to skip over it and submit never has to strip it. - if (this.inputMode === 'prompt' && normalized === '!' && this.getText().length === 0) { + if ( + this.inputMode === 'prompt' && + printableChar(normalized) === '!' && + this.getText().length === 0 + ) { this.inputMode = 'bash'; this.onInputModeChange?.('bash'); return; diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 8b6a9d306c..976cdc770d 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -630,4 +630,12 @@ describe('CustomEditor bash mode via paste', () => { expect(editor.inputMode).toBe('bash'); expect(editor.getText()).toBe(''); }); + + it('enters bash mode on a CSI-u encoded ! keystroke (Kitty/VSCode terminals)', () => { + const editor = makeEditor(); + editor.handleInput('\u001B[33u'); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); }); From c6bba8082bae4df51e31237246191c18e227219f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 17:33:28 +0800 Subject: [PATCH 14/18] fix(kimi-code): do not steer while a shell command is running Ctrl-S steers queued input into the running turn, but a shell command is not an agent turn, so steering during streamingPhase === 'shell' would launch a turn before the command output is recorded. Keep Ctrl-S a no-op during shell runs; queued messages stay queued. --- .../src/tui/controllers/editor-keyboard.ts | 7 ++++++- .../test/tui/kimi-tui-message-flow.test.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index ae30f49d79..1e9c2a8d89 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -174,7 +174,12 @@ export class EditorKeyboardController { }; editor.onCtrlS = () => { - if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) return; + if ( + host.state.appState.streamingPhase === 'idle' || + host.state.appState.streamingPhase === 'shell' || + host.state.appState.isCompacting + ) + return; const text = editor.getText().trim(); const editorIsBash = editor.inputMode === 'bash'; diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index cfd461144d..73de9e66c7 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1309,6 +1309,21 @@ command = "vim" ]); }); + it('does not steer while a shell command is running', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'shell'; + driver.state.queuedMessages = [{ text: 'summarize the output', agentId: 'main' }]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'summarize the output', agentId: 'main' }, + ]); + }); + it('does not steer the editor draft while it is in bash mode', async () => { const session = makeSession(); const { driver } = await makeDriver(session); From ff67d9e34fafff5de4c49db016e8f1a9a2c42bd3 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 17:33:36 +0800 Subject: [PATCH 15/18] fix(agent-core): escape bash tag delimiters in shell output Shell command output is arbitrary text; if it contains a bash tag delimiter such as , the recorded pseudo-XML wrapper breaks and replay extracts the wrong slice. Escape the content when wrapping it in agent-core and unescape when extracting during replay, so output survives round-trip intact. --- .../src/tui/controllers/session-replay.ts | 10 +++++++++- apps/kimi-code/test/tui/message-replay.test.ts | 18 ++++++++++++++++++ packages/agent-core/src/agent/context/index.ts | 5 +++-- packages/agent-core/test/agent/context.test.ts | 16 ++++++++++++++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 9778fe3dc5..5f938c532d 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -64,7 +64,15 @@ function extractBashTag( tag: 'bash-input' | 'bash-stdout' | 'bash-stderr', ): string | undefined { const match = new RegExp(`<${tag}>([\\s\\S]*?)`).exec(text); - return match?.[1]; + return match?.[1] === undefined ? undefined : unescapeBashXml(match[1]); +} + +function unescapeBashXml(text: string): string { + return text + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('&', '&'); } export class SessionReplayRenderer { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index f54bac27b6..a915696817 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -307,6 +307,24 @@ describe('KimiTUI resume message replay', () => { expect(transcript).not.toContain('Goal complete'); }); + it('unescapes bash tag delimiters when replaying shell output', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: 'pre</bash-stdout>post', + }, + ], + { origin: { kind: 'shell_command', phase: 'output' } }, + ), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('prepost'); + }); + it('does not render neutral goal completion context reminders as transcript messages', async () => { const driver = await replayIntoDriver([ message( diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 01f5546c1b..cf4c88395e 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -4,6 +4,7 @@ import type { Agent } from '..'; import { ErrorCodes, KimiError } from '../../errors'; import type { ExecutableToolResult, LoopRecordedEvent } from '../../loop'; import { estimateTokensForMessages } from '../../utils/tokens'; +import { escapeXml } from '../../utils/xml-escape'; import type { CompactionResult } from '../compaction'; import { project, trimTrailingOpenToolExchange } from './projector'; import { @@ -95,7 +96,7 @@ export class ContextMemory { // sessions still show the command and its output. The XML tags carry the // semantics to the model; the origin drives UI/replay routing. appendBashInput(command: string): void { - const text = `\n${command}\n`; + const text = `\n${escapeXml(command)}\n`; this.appendMessage({ role: 'user', content: [{ type: 'text', text }], @@ -105,7 +106,7 @@ export class ContextMemory { } appendBashOutput(stdout: string, stderr: string, isError?: boolean): void { - const text = `${stdout}${stderr}`; + const text = `${escapeXml(stdout)}${escapeXml(stderr)}`; this.appendMessage({ role: 'user', content: [{ type: 'text', text }], diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index 47dc670aa3..84a0ba3b88 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -81,6 +81,22 @@ describe('Agent context', () => { expect(ctx.agent.context.messages.some((message) => 'origin' in message)).toBe(false); }); + it('escapes bash tag delimiters inside command output', () => { + const ctx = testAgent(); + ctx.configure(); + + ctx.agent.context.appendBashInput('printf x'); + ctx.agent.context.appendBashOutput('prepost', ''); + + const textOf = (message: ContextMessage): string => + message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); + const out = textOf(ctx.agent.context.history[1]!); + // The embedded delimiter is escaped so the wrapper stays well-formed. + expect(out).toContain('pre</bash-stdout>post'); + // Exactly one real closing tag. + expect(out.match(/<\/bash-stdout>/g)).toHaveLength(1); + }); + it('runs a shell command via the Bash tool and records its output', async () => { const fakeProcess = (stdout: string): KaosProcess => { const out = Readable.from([stdout]); From 20f0b34ada4f1daec22caf276af9ced4c9202841 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 20:14:37 +0800 Subject: [PATCH 16/18] docs: document the shellMode theme token The shellMode color token was added to the palette but not propagated to its mirrors. Add it to the custom-theme docs token table, the theme JSON schema, and the custom-theme skill token list. --- apps/kimi-code/src/tui/theme/theme-schema.json | 3 ++- docs/en/customization/themes.md | 1 + docs/zh/customization/themes.md | 1 + packages/agent-core/src/skill/builtin/custom-theme.md | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/theme/theme-schema.json b/apps/kimi-code/src/tui/theme/theme-schema.json index 5e320992d5..a411088f9c 100644 --- a/apps/kimi-code/src/tui/theme/theme-schema.json +++ b/apps/kimi-code/src/tui/theme/theme-schema.json @@ -45,7 +45,8 @@ "diffRemovedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines (strong)" }, "diffGutter": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff gutter color" }, "diffMeta": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff meta color" }, - "roleUser": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "User message accent" } + "roleUser": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "User message accent" }, + "shellMode": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Shell mode (`!`) prompt, editor border, and the echoed `$ command` line" } }, "additionalProperties": { "type": "string", diff --git a/docs/en/customization/themes.md b/docs/en/customization/themes.md index af3ddf2db3..82e0e55dfd 100644 --- a/docs/en/customization/themes.md +++ b/docs/en/customization/themes.md @@ -26,6 +26,7 @@ Custom themes can override the tokens below. The `dark` and `light` columns show | `diffGutter` | `#6B6B6B` | `#737373` | Diff line-number gutter | | `diffMeta` | `#888888` | `#5F5F5F` | Diff meta / hunk headers | | `roleUser` | `#FFCB6B` | `#9A4A00` | User message bullet and text, skill-activation name | +| `shellMode` | `#BD93F9` | `#7C3AED` | Shell mode (`!`) prompt, editor border, and the echoed `$ command` line | ## Use the custom-theme skill diff --git a/docs/zh/customization/themes.md b/docs/zh/customization/themes.md index dc983e7b38..778863bbfa 100644 --- a/docs/zh/customization/themes.md +++ b/docs/zh/customization/themes.md @@ -26,6 +26,7 @@ Kimi Code CLI 可以使用内置配色,也可以使用自定义 JSON 主题文 | `diffGutter` | `#6B6B6B` | `#737373` | diff 行号槽 | | `diffMeta` | `#888888` | `#5F5F5F` | diff 元信息 / hunk 头 | | `roleUser` | `#FFCB6B` | `#9A4A00` | 用户消息的子弹头与文字、技能激活名 | +| `shellMode` | `#BD93F9` | `#7C3AED` | Shell 模式(`!`)的提示符、编辑器边框,以及回显的 `$ 命令` 行 | ## 使用 custom-theme skill diff --git a/packages/agent-core/src/skill/builtin/custom-theme.md b/packages/agent-core/src/skill/builtin/custom-theme.md index 9e8dbc0d63..37a5bdfb4e 100644 --- a/packages/agent-core/src/skill/builtin/custom-theme.md +++ b/packages/agent-core/src/skill/builtin/custom-theme.md @@ -79,6 +79,7 @@ Only set tokens from this set — unknown keys are silently ignored at load. If | `diffGutter` | Diff line-number gutter | | `diffMeta` | Diff meta / hunk headers | | `roleUser` | User message bullet and text, skill-activation name (the one role color with its own hue) | +| `shellMode` | Shell mode (`!`) prompt, editor border, and the echoed `$ command` line | ## Workflow From 7c5f4e5c2249a54efc2dcec1ed20e47d02dd730e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 21:07:19 +0800 Subject: [PATCH 17/18] feat(agent-core): reset background task deadline on detach Add a resettable deadline timer to BackgroundManager and let tasks register a detach timeout; when a foreground task is moved to the background, its deadline resets to the background default counted from the detach moment. Wire this into shell mode so ! commands run with a 3-minute foreground timeout and get 10 minutes once detached to the background, instead of staying bounded by the original 60-second foreground deadline. --- .../agent-core/src/agent/background/index.ts | 22 +++++++++-- packages/agent-core/src/agent/tool/index.ts | 5 ++- .../src/tools/builtin/shell/bash.ts | 4 ++ packages/agent-core/src/utils/promise.ts | 38 +++++++++++++++++++ .../test/agent/background/manager.test.ts | 28 ++++++++++++++ 5 files changed, 93 insertions(+), 4 deletions(-) diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index a61a4c3b94..6f7aaed3e8 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -17,7 +17,7 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '../..'; import { errorMessage } from '../../loop/errors'; -import { timeoutOutcome } from '../../utils/promise'; +import { resettableTimeoutOutcome, timeoutOutcome, type ResettableTimeoutPromise } from '../../utils/promise'; import { escapeXml, escapeXmlAttr } from '../../utils/xml-escape'; import type { BackgroundTaskOrigin } from '../context'; import { renderNotificationXml } from '../context/notification-xml'; @@ -67,6 +67,8 @@ interface ManagedTask { endedAt: number | null; /** Foreground tool call release signal, present only for non-detached starts. */ foregroundRelease?: ControlledPromise; + /** Resettable deadline timer; reset on detach to apply `detachTimeoutMs`. */ + timeoutHandle?: ResettableTimeoutPromise; /** User/tool stop request. */ readonly stop: ControlledPromise; /** Resolved once manager has finalized the task. */ @@ -176,6 +178,12 @@ export interface RegisterBackgroundTaskOptions { readonly detached?: boolean; /** Deadline owned by BackgroundManager. `0` and `undefined` do not arm a timer. */ readonly timeoutMs?: number; + /** + * When set, detaching a foreground task resets its deadline to this value + * (counted from the detach moment). Lets a command started with a short + * foreground timeout run longer once it is moved to the background. + */ + readonly detachTimeoutMs?: number; /** Foreground caller signal. Ignored for tasks created already detached. */ readonly signal?: AbortSignal; } @@ -264,6 +272,7 @@ export class BackgroundManager { const entryOptions: RegisterBackgroundTaskOptions = { detached, timeoutMs, + detachTimeoutMs: options.detachTimeoutMs, signal: detached ? undefined : options.signal, }; this.assertCanRegister(detached); @@ -413,6 +422,9 @@ export class BackgroundManager { if (foregroundRelease === undefined) return this.toInfo(entry); entry.foregroundRelease = undefined; + if (entry.options.detachTimeoutMs !== undefined) { + entry.timeoutHandle?.reset(entry.options.detachTimeoutMs); + } try { entry.task.onDetach?.(); } catch { @@ -744,13 +756,17 @@ export class BackgroundManager { }); }); - const timeout = timeoutOutcome(entry.options.timeoutMs, { kind: 'timeout' as const }); + const timeout = resettableTimeoutOutcome(entry.options.timeoutMs, { kind: 'timeout' as const }); + entry.timeoutHandle = timeout; const outcome = await Promise.race([ worker.then((settlement): TerminalOutcome => ({ kind: 'worker', settlement })), timeout, entry.stop.then((request): TerminalOutcome => ({ kind: 'stop', request })), this.signalOutcome(entry), - ]).finally(() => timeout.clear()); + ]).finally(() => { + timeout.clear(); + entry.timeoutHandle = undefined; + }); const settlement = await this.settlementForOutcome(entry, outcome, worker); await this.finalizeTask(entry, settlement); } diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index d6386cf461..838c304bb5 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -24,6 +24,9 @@ import type { export * from './types'; +/** Foreground timeout (seconds) for a user-initiated `!` shell command. */ +const SHELL_FOREGROUND_TIMEOUT_S = 3 * 60; + interface McpToolEntry { readonly tool: ExecutableTool; readonly serverName: string; @@ -110,7 +113,7 @@ export class ToolManager { const controller = new AbortController(); if (commandId !== undefined) this.shellCommandControllers.set(commandId, controller); try { - const execution = await bash.resolveExecution({ command }); + const execution = await bash.resolveExecution({ command, timeout: SHELL_FOREGROUND_TIMEOUT_S }); if (!('execute' in execution)) { const output = typeof execution.output === 'string' ? execution.output : 'Command failed.'; diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 09a1d6c675..473035e4a7 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -274,6 +274,10 @@ export class BashTool implements BuiltinTool { { detached: startsInBackground, timeoutMs, + // Detaching (ctrl+b) moves a foreground command to the background; + // give it the background timeout so it is not still bounded by the + // shorter foreground deadline. + detachTimeoutMs: DEFAULT_BACKGROUND_TIMEOUT_S * MS_PER_SECOND, signal: startsInBackground ? undefined : signal, }, ); diff --git a/packages/agent-core/src/utils/promise.ts b/packages/agent-core/src/utils/promise.ts index 03182d3d2f..e030e33c0c 100644 --- a/packages/agent-core/src/utils/promise.ts +++ b/packages/agent-core/src/utils/promise.ts @@ -27,3 +27,41 @@ export function timeoutOutcome( }, }); } + +export type ResettableTimeoutPromise = Promise & { + /** Restart the timer from now with a new duration; the same promise resolves when it fires. */ + reset(timeoutMs: number | undefined): void; + clear(): void; +}; + +/** + * Like `timeoutOutcome`, but the timer can be restarted via `reset()` while the + * returned promise stays the same — so a `Promise.race` that already captured it + * observes the new deadline. Used to extend a task's timeout (e.g. when a + * foreground command is detached to the background). + */ +export function resettableTimeoutOutcome( + initialMs: number | undefined, + outcome: Outcome, +): ResettableTimeoutPromise { + let timer: ReturnType | undefined; + let resolvePromise!: (value: Outcome) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + const clear = (): void => { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + }; + const reset = (timeoutMs: number | undefined): void => { + clear(); + if (timeoutMs === undefined || timeoutMs <= 0) return; + timer = setTimeout(() => { + timer = undefined; + resolvePromise(outcome); + }, timeoutMs); + }; + reset(initialMs); + return Object.assign(promise, { reset, clear }); +} diff --git a/packages/agent-core/test/agent/background/manager.test.ts b/packages/agent-core/test/agent/background/manager.test.ts index 1ce88fd979..7f74cefacf 100644 --- a/packages/agent-core/test/agent/background/manager.test.ts +++ b/packages/agent-core/test/agent/background/manager.test.ts @@ -706,6 +706,34 @@ describe('BackgroundManager', () => { expect(vi.getTimerCount()).toBe(0); }); + it('resets the deadline to detachTimeoutMs when a foreground task is detached', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const { manager } = createBackgroundManager(); + const { proc } = pendingProcess(); + const taskId = manager.registerTask(new ProcessBackgroundTask(proc, 'sleep 60', 'detach timeout'), { + detached: false, + timeoutMs: 1_000, + detachTimeoutMs: 5_000, + }); + + // Let the lifecycle arm its foreground timer, then detach at 500ms. + await vi.advanceTimersByTimeAsync(500); + expect(manager.detach(taskId)?.detached).toBe(true); + + // Past the original 1s deadline; the task is still running because detach + // reset the timer to 5s counted from the detach moment. + await vi.advanceTimersByTimeAsync(1_000); + expect(manager.getTask(taskId)?.status).toBe('running'); + + // Past the 5s detach deadline (500 + 5000 = 5500ms). + await vi.advanceTimersByTimeAsync(4_500); + expect(manager.getTask(taskId)?.status).toBe('timed_out'); + } finally { + vi.useRealTimers(); + } + }); + it('returns undefined or empty output for unknown task ids', async () => { const { manager } = createBackgroundManager(); From 0482e23af03b59f110dc5f01c022a44719fc28fc Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 21:18:29 +0800 Subject: [PATCH 18/18] feat(agent-core): lower shell mode foreground timeout to 2 minutes --- packages/agent-core/src/agent/tool/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 838c304bb5..f165772538 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -25,7 +25,7 @@ import type { export * from './types'; /** Foreground timeout (seconds) for a user-initiated `!` shell command. */ -const SHELL_FOREGROUND_TIMEOUT_S = 3 * 60; +const SHELL_FOREGROUND_TIMEOUT_S = 2 * 60; interface McpToolEntry { readonly tool: ExecutableTool;