From 2db5fc20ecdf3212afd47e7c26195e428f8eddd5 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 21:24:53 +0800 Subject: [PATCH] feat: add shell mode (`!`) to the CLI (#1079) * feat: add shell mode (`!`) to the CLI Add shell mode, letting users run shell commands directly from the prompt with `!`. Output streams live into the transcript, supports backgrounding (ctrl+b), cancellation (Esc / Ctrl+C), input queuing while running, and enters the conversation context with resume support. * feat(kimi-code): show shell mode label on editor border and add tip Render a "! shell mode" label on the top-left of the editor border while the editor is in `!` bash mode, so the active mode is visible at a glance. Also add a rotating toolbar tip (`! to run a shell command`) to surface the feature. * feat(kimi-code): refine shell mode queue, history, and display - Keep `!` commands out of input history so they never resurface as bare text stripped of their `!`. - Make `!` commands non-steerable: Ctrl-S skips them (they stay queued to run after the current task) and the steer hint is only shown when something is actually steerable. - Render queued `!` commands with a `$` prompt and the shell-mode hue so they read as commands, not as text to send to the model. - Echo executed shell commands with a `$` prompt instead of `!`. * fix(kimi-code): sanitize shell output and harden rendering Captured shell command output can contain terminal control sequences (colours, cursor moves, alternate-screen switches, OSC hyperlinks, carriage-return spinners, bells). pi-tui's Text passes strings straight to the terminal, so any unhandled sequence was executed by the terminal and fought with pi-tui's own cursor control, producing the blank-screen-plus-leftover-characters mess after running commands like pnpm dev or a nested TUI. - Sanitize CSI (incl. private modes), OSC, single-char ESC and C0 control chars (keeping newline and tab) in both the finished/resume view (previously unsanitized) and the running tail. - Make the sanitize, format, and ShellRunComponent render paths never-throw, and cap the live running buffer, so a misbehaving command cannot crash the TUI. - Dispose transcript children on clear so ShellRunComponent's timer is released on /clear or session switch. * fix(kimi-code): render shell command echo with $ instead of sparkles The shell command echo is a 'user' transcript entry, so UserMessageComponent prefixed it with the USER_MESSAGE_BULLET (sparkles), producing 'sparkles $ command'. Add an optional bullet override to UserMessageComponent / TranscriptEntry and set it to an empty string for the shell echo (both live and resume), so the '$ command' content sits at the leading column where the sparkles marker used to be. Normal user messages keep the sparkles bullet. * fix(kimi-code): enter shell mode when pasting a !-prefixed command The bash-mode trigger only handled the single ! keystroke, so a pasted !cmd was inserted as literal text in prompt mode and submitted as a normal message. After pi-tui inserts pasted content, detect an empty-prompt buffer that now starts with !, switch to bash mode, and strip the leading ! so the buffer holds only the command, matching the typed ! path. * fix(kimi-code): restore shell mode when recalling a queued command recallLastQueued() dropped the queued item's mode, and the Up-arrow recall only restored the text. A queued ! command (queued while another command runs, which resets the editor to prompt mode) therefore came back as a normal prompt and was submitted as a message instead of a shell command. Return the full QueuedMessage from recallLastQueued() and restore editor.inputMode (plus the onInputModeChange sync) from the recalled item's mode. * feat(kimi-code): use violet as the shell mode color Replace the claude-code-style magenta/rose shellMode token with a violet that is distinct from plan-mode blue, the user role amber, success green, error red, and the teal accent. Custom themes that omit the token fall back to this new default via the base+overrides merge, so existing custom themes keep working unchanged. * chore: refine the shell mode changeset * docs: document shell mode Add a Shell mode section to the interaction guide and list the ! and Ctrl+B shortcuts in the keyboard reference, in both English and Chinese. * test(protocol): include shell events in volatile classification check shell.output and shell.started were added as volatile event types for shell mode; update the snapshot test's volatile-type list and count accordingly. * fix(agent-core): surface shell command failure reason with no output When a ! shell command fails without producing stdout/stderr (non-zero exit with no output, timeout, spawn failure), the failure reason lived only in the tool result's output and the TUI showed '(no output)'. Fold it into stderr so the live view and replay show what went wrong. * fix(kimi-code): decode CSI-u ! to enter shell mode In terminals with the Kitty keyboard protocol (VSCode integrated terminal, Kitty), pressing ! arrives as a CSI-u sequence, so the raw normalized === '!' comparison never matched and shell mode could not be entered by typing !. Decode with printableChar before comparing, matching every other printable-key check in the TUI. * fix(kimi-code): do not steer while a shell command is running Ctrl-S steers queued input into the running turn, but a shell command is not an agent turn, so steering during streamingPhase === 'shell' would launch a turn before the command output is recorded. Keep Ctrl-S a no-op during shell runs; queued messages stay queued. * fix(agent-core): escape bash tag delimiters in shell output Shell command output is arbitrary text; if it contains a bash tag delimiter such as , the recorded pseudo-XML wrapper breaks and replay extracts the wrong slice. Escape the content when wrapping it in agent-core and unescape when extracting during replay, so output survives round-trip intact. * docs: document the shellMode theme token The shellMode color token was added to the palette but not propagated to its mirrors. Add it to the custom-theme docs token table, the theme JSON schema, and the custom-theme skill token list. * feat(agent-core): reset background task deadline on detach Add a resettable deadline timer to BackgroundManager and let tasks register a detach timeout; when a foreground task is moved to the background, its deadline resets to the background default counted from the detach moment. Wire this into shell mode so ! commands run with a 3-minute foreground timeout and get 10 minutes once detached to the background, instead of staying bounded by the original 60-second foreground deadline. * feat(agent-core): lower shell mode foreground timeout to 2 minutes --- .changeset/add-shell-mode.md | 9 + .../tui/components/editor/custom-editor.ts | 80 +++++- .../src/tui/components/messages/shell-run.ts | 134 ++++++++++ .../tui/components/messages/status-message.ts | 25 +- .../tui/components/messages/user-message.ts | 7 +- .../src/tui/components/panes/queue-pane.ts | 26 +- apps/kimi-code/src/tui/constant/tips.ts | 1 + .../src/tui/controllers/editor-keyboard.ts | 48 +++- .../tui/controllers/session-event-handler.ts | 4 + .../src/tui/controllers/session-replay.ts | 40 +++ apps/kimi-code/src/tui/kimi-tui.ts | 233 +++++++++++++++++- apps/kimi-code/src/tui/theme/colors.ts | 8 + .../kimi-code/src/tui/theme/theme-schema.json | 3 +- apps/kimi-code/src/tui/types.ts | 9 +- .../kimi-code/src/tui/utils/message-replay.ts | 6 +- apps/kimi-code/src/tui/utils/shell-output.ts | 71 ++++++ .../test/tui/components/chrome/footer.test.ts | 1 + .../tui/components/chrome/welcome.test.ts | 1 + .../components/editor/custom-editor.test.ts | 87 +++++++ .../components/editor/side-borders.test.ts | 28 +++ .../tui/components/messages/shell-run.test.ts | 70 ++++++ .../components/messages/user-message.test.ts | 15 ++ .../tui/components/panes/queue-pane.test.ts | 37 +++ .../test/tui/create-tui-state.test.ts | 1 + .../test/tui/kimi-tui-message-flow.test.ts | 144 +++++++++++ .../kimi-code/test/tui/message-replay.test.ts | 18 ++ .../test/tui/utils/shell-output.test.ts | 108 ++++++++ docs/en/customization/themes.md | 1 + docs/en/guides/interaction.md | 10 + docs/en/reference/keyboard.md | 3 + docs/zh/customization/themes.md | 1 + docs/zh/guides/interaction.md | 10 + docs/zh/reference/keyboard.md | 3 + .../agent-core/src/agent/background/index.ts | 22 +- .../agent-core/src/agent/context/index.ts | 43 ++++ .../agent-core/src/agent/context/types.ts | 9 + packages/agent-core/src/agent/index.ts | 2 + packages/agent-core/src/agent/tool/index.ts | 102 +++++++- packages/agent-core/src/loop/types.ts | 6 + packages/agent-core/src/rpc/core-api.ts | 25 ++ packages/agent-core/src/rpc/core-impl.ts | 10 + packages/agent-core/src/session/rpc.ts | 10 + .../src/skill/builtin/custom-theme.md | 1 + .../src/tools/builtin/shell/bash.ts | 12 +- packages/agent-core/src/utils/promise.ts | 38 +++ .../test/agent/background/manager.test.ts | 28 +++ .../agent-core/test/agent/context.test.ts | 116 ++++++++- packages/node-sdk/src/rpc.ts | 25 ++ packages/node-sdk/src/session.ts | 21 ++ .../node-sdk/test/session-event-types.test.ts | 2 + .../protocol/src/__tests__/snapshot.test.ts | 4 +- packages/protocol/src/events.ts | 56 +++++ 52 files changed, 1726 insertions(+), 48 deletions(-) create mode 100644 .changeset/add-shell-mode.md create mode 100644 apps/kimi-code/src/tui/components/messages/shell-run.ts create mode 100644 apps/kimi-code/src/tui/utils/shell-output.ts create mode 100644 apps/kimi-code/test/tui/components/messages/shell-run.test.ts create mode 100644 apps/kimi-code/test/tui/utils/shell-output.test.ts diff --git a/.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'][];