diff --git a/.changeset/add-shell-mode.md b/.changeset/add-shell-mode.md new file mode 100644 index 000000000..67e548d93 --- /dev/null +++ b/.changeset/add-shell-mode.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +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`. 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 600817547..46f3a6965 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'; @@ -15,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'; @@ -134,6 +137,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; /** @@ -226,6 +233,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 @@ -247,7 +255,11 @@ export class CustomEditor extends Editor { } const firstContent = lines[firstContentIdx]; if (firstContent !== undefined) { - const withPrompt = injectPromptSymbol(firstContent); + const withPrompt = injectPromptSymbol( + firstContent, + isBash ? '!' : '>', + isBash ? (s) => this.borderColor(s) : undefined, + ); if (withPrompt !== undefined) { lines[firstContentIdx] = withPrompt; } @@ -258,6 +270,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, }); } @@ -375,6 +388,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,7 +437,32 @@ 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' && + printableChar(normalized) === '!' && + this.getText().length === 0 + ) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + 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(); } @@ -593,12 +644,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); } /** @@ -612,21 +668,37 @@ export function injectPromptSymbol(line: string): string | undefined { * 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/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts new file mode 100644 index 000000000..3c5fc20da --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -0,0 +1,134 @@ +import { Container, Text } from '@earendil-works/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +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: + * + * - 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. + * + * 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; + 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.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.disposed || !this.running) return; + this.running = false; + this.finalStdout = stdout; + this.finalStderr = stderr; + this.finalIsError = isError; + this.clearTimer(); + this.flush(); + } + + finishBackgrounded(): void { + if (this.disposed || !this.running) return; + this.running = false; + this.backgrounded = true; + this.clearTimer(); + this.flush(); + } + + dispose(): void { + this.disposed = true; + this.clearTimer(); + } + + private tick(): void { + if (!this.running) return; + this.flush(); + } + + private flush(): void { + 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 { + if (this.timer !== undefined) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + private renderText(): string { + 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)'; + } + } +} 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 7377a3f52..22c6eb073 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/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index 43ed13459..7c590b26c 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/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index 77800b97e..a3c959a6a 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/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts index c04b5a763..f9d0a7f27 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/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 42871d5dc..a4cba595f 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; @@ -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(); @@ -168,20 +174,30 @@ 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 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); @@ -194,6 +210,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; } @@ -219,7 +237,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; @@ -262,6 +287,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 075e87bca..2adc00a4c 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 4a13fe373..5f938c532 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,22 @@ 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] === undefined ? undefined : unescapeBashXml(match[1]); +} + +function unescapeBashXml(text: string): string { + return text + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('&', '&'); +} + export class SessionReplayRenderer { constructor(private readonly host: SessionReplayHost) {} @@ -249,6 +267,28 @@ 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', { + bullet: '', + }), + ); + } 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 24a72043f..409a9eb2b 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, @@ -122,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'; @@ -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; @@ -831,16 +842,156 @@ 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); + // 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. + 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) 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}`), + bullet: '', + }); + // 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) { @@ -922,18 +1073,22 @@ 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; } // ========================================================================= // 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, @@ -942,6 +1097,7 @@ export class KimiTUI { options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 ? options.imageAttachmentIds : undefined, + mode, }); this.track('input_queue'); } @@ -970,6 +1126,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, @@ -1500,7 +1660,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( @@ -1629,6 +1789,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(); @@ -1795,6 +1961,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; @@ -1834,7 +2005,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); @@ -1901,10 +2114,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 215c54bbb..66f9819c3 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 (violet), 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: '#BD93F9', }; export const lightColors: ColorPalette = { @@ -123,6 +130,7 @@ export const lightColors: ColorPalette = { diffMeta: '#5F5F5F', roleUser: '#9A4A00', + shellMode: '#7C3AED', }; export type ResolvedTheme = 'dark' | 'light'; diff --git a/apps/kimi-code/src/tui/theme/theme-schema.json b/apps/kimi-code/src/tui/theme/theme-schema.json index 5e320992d..a411088f9 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/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index b8249e92c..985e576e4 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; @@ -150,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; @@ -180,6 +184,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 b1478697c..57d7e4b3d 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, }; } @@ -250,6 +251,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 000000000..3a482feb7 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/shell-output.ts @@ -0,0 +1,71 @@ +import { currentTheme } from '#/tui/theme'; + +// 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). + +// 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 { + 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)'; + } +} 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 4045e3a06..6b43ab2f7 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 a910c2481..1eb757e67 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/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index e6129ffce..976cdc770 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,90 @@ 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); + }); +}); + +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(''); + }); + + 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(''); + }); +}); 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 d137558b9..6d3145b14 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'); + }); }); 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 000000000..510da06bd --- /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/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index 3d957eb5b..23ec702ae 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); + }); }); 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 ca276a138..0f9a01177 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/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index a67715f2b..7c4cbcebc 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/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 3cc7f5140..3ab58ac14 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 @@ -62,6 +62,7 @@ interface MessageDriver { init(): Promise; handleUserInput(text: string): void; persistInputHistory(text: string): Promise; + sendQueuedMessage(session: unknown, item: QueuedMessage): void; getCurrentSessionId(): string; } @@ -1242,6 +1243,149 @@ 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 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); + 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('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 }); + 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(); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index f54bac27b..a91569681 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/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 000000000..e7a724b43 --- /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(); + }); +}); diff --git a/docs/en/customization/themes.md b/docs/en/customization/themes.md index af3ddf2db..82e0e55df 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/en/guides/interaction.md b/docs/en/guides/interaction.md index d43448bad..7bfe48430 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 1ef344f3e..128c95680 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/customization/themes.md b/docs/zh/customization/themes.md index dc983e7b3..778863bbf 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/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 445f2c560..d73aeb83c 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 36fe5a51c..42aabcf9a 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-模式)。 + ## 输入与编辑 | 快捷键 | 功能 | diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index a61a4c3b9..6f7aaed3e 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/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 88edda53f..cf4c88395 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 { @@ -65,6 +66,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 +91,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${escapeXml(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 = `${escapeXml(stdout)}${escapeXml(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 d0a78b975..3ebfc1bb3 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 ad279b6e2..ff49b36be 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 064e7590c..f16577253 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'; @@ -24,6 +24,9 @@ import type { export * from './types'; +/** Foreground timeout (seconds) for a user-initiated `!` shell command. */ +const SHELL_FOREGROUND_TIMEOUT_S = 2 * 60; + interface McpToolEntry { readonly tool: ExecutableTool; readonly serverName: string; @@ -42,6 +45,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 +90,99 @@ 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, timeout: SHELL_FOREGROUND_TIMEOUT_S }); + 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 }; + } + + // 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; + } 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 4d954c076..9d290f235 100644 --- a/packages/agent-core/src/loop/types.ts +++ b/packages/agent-core/src/loop/types.ts @@ -118,6 +118,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 61b19e569..5c10a4897 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -162,6 +162,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[]; } @@ -339,6 +362,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 7088fba23..e18575446 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, @@ -576,6 +578,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 05d6c5277..297182d5e 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -8,6 +8,7 @@ import type { BeginCompactionPayload, CancelPayload, CancelPlanPayload, + CancelShellCommandPayload, CreateGoalPayload, DetachBackgroundPayload, EmptyPayload, @@ -17,6 +18,7 @@ import type { McpServerInfo, McpStartupMetrics, PromptPayload, + RunShellCommandPayload, ReconnectMcpServerPayload, RenameSessionPayload, RegisterToolPayload, @@ -113,6 +115,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/skill/builtin/custom-theme.md b/packages/agent-core/src/skill/builtin/custom-theme.md index 9e8dbc0d6..37a5bdfb4 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 diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index df6c4deb8..473035e4a 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -188,7 +188,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), }; } @@ -223,6 +224,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; @@ -272,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, }, ); @@ -285,6 +291,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/src/utils/promise.ts b/packages/agent-core/src/utils/promise.ts index 03182d3d2..e030e33c0 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 1ce88fd97..7f74cefac 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(); diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index 7efa1f066..580bda69c 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,116 @@ 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('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]); + 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('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(); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 7582bec64..093e05218 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 b6b2eb9ee..c29c612ef 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 7dc54e69a..cedd15c28 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/__tests__/snapshot.test.ts b/packages/protocol/src/__tests__/snapshot.test.ts index 7baa199e7..017481c19 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', () => { diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 52e9f5afb..d0e22e0d6 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'][];