Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
94bc5b5
feat: add shell mode (`!`) to the CLI
liruifengv Jun 24, 2026
feb82a1
feat(kimi-code): show shell mode label on editor border and add tip
liruifengv Jun 25, 2026
1615c9a
feat(kimi-code): refine shell mode queue, history, and display
liruifengv Jun 25, 2026
f9d44bd
fix(kimi-code): sanitize shell output and harden rendering
liruifengv Jun 25, 2026
5899bab
fix(kimi-code): render shell command echo with $ instead of sparkles
liruifengv Jun 25, 2026
47278bd
fix(kimi-code): enter shell mode when pasting a !-prefixed command
liruifengv Jun 25, 2026
0f343ef
fix(kimi-code): restore shell mode when recalling a queued command
liruifengv Jun 25, 2026
5dba11f
feat(kimi-code): use violet as the shell mode color
liruifengv Jun 25, 2026
b9f275c
chore: refine the shell mode changeset
liruifengv Jun 25, 2026
17c913b
docs: document shell mode
liruifengv Jun 25, 2026
04a62f8
test(protocol): include shell events in volatile classification check
liruifengv Jun 25, 2026
639c049
fix(agent-core): surface shell command failure reason with no output
liruifengv Jun 25, 2026
d1fc652
fix(kimi-code): decode CSI-u ! to enter shell mode
liruifengv Jun 25, 2026
c6bba80
fix(kimi-code): do not steer while a shell command is running
liruifengv Jun 25, 2026
ff67d9e
fix(agent-core): escape bash tag delimiters in shell output
liruifengv Jun 25, 2026
eb285c8
Merge branch 'main' into feat/shell-mode
liruifengv Jun 25, 2026
20f0b34
docs: document the shellMode theme token
liruifengv Jun 25, 2026
d546ac5
Merge branch 'feat/shell-mode' of https://github.com/MoonshotAI/kimi-…
liruifengv Jun 25, 2026
7c5f4e5
feat(agent-core): reset background task deadline on detach
liruifengv Jun 25, 2026
0482e23
feat(agent-core): lower shell mode foreground timeout to 2 minutes
liruifengv Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/add-shell-mode.md
Original file line number Diff line number Diff line change
@@ -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`.
80 changes: 76 additions & 4 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import {
matchesKey,
Key,
SelectList,
visibleWidth,
type SelectItem,
type TUI,
} from '@earendil-works/pi-tui';

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';

Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand All @@ -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,
});
}

Expand Down Expand Up @@ -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?.();
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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;
Expand Down
134 changes: 134 additions & 0 deletions apps/kimi-code/src/tui/components/messages/shell-run.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | 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)';
}
}
}
25 changes: 18 additions & 7 deletions apps/kimi-code/src/tui/components/messages/status-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}

Expand Down
7 changes: 5 additions & 2 deletions apps/kimi-code/src/tui/components/messages/user-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) ?? [];
}
Expand All @@ -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);

Expand Down
Loading
Loading