Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/pi-tui-history-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---

Add history hooks to the editor so hosts can filter entries (`setHistoryFilter`), decorate recalled entries (`onRecall`), and save and restore their own state alongside the history draft (`onHistoryDraftSave` / `onHistoryDraftRestore`).
5 changes: 5 additions & 0 deletions .changeset/shell-input-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Save shell commands to input history and recall them in bash mode. Press Up on an empty `!` prompt to browse previous shell commands.
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ export class CustomEditor extends Editor {
};
}

public setInputMode(mode: 'prompt' | 'bash'): void {
if (this.inputMode === mode) return;
this.inputMode = mode;
this.onInputModeChange?.(mode);
}

private expandPasteMarkerAtCursor(): boolean {
const { line, col } = this.getCursor();
const lines = this.getLines();
Expand Down
37 changes: 37 additions & 0 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,43 @@ export class EditorKeyboardController {
host.updateEditorBorderHighlight(text);
};

// bash mode recalls only shell (`!`-prefixed) history entries; prompt mode
// recalls everything. The filter is locked to the mode captured when the
// user first enters history browsing (see onHistoryDraftSave), so landing on
// a shell entry mid-browse doesn't switch the filter to shell-only.
let browseMode: 'prompt' | 'bash' | null = null;
editor.setHistoryFilter((entry: string) => {
const mode = browseMode ?? editor.inputMode;
return mode === 'bash' ? entry.startsWith('!') : true;
});

// Recalling a `!`-prefixed entry strips the marker and returns to bash
// mode; recalling a plain entry returns to prompt mode. The filter above
// guarantees bash mode only ever lands on `!` entries, so this never
// misfires on commands typed in bash mode.
editor.onRecall = (entry: string) => {
if (entry.startsWith('!')) {
editor.setInputMode('bash');
Comment thread
liruifengv marked this conversation as resolved.
return entry.slice(1);
Comment thread
liruifengv marked this conversation as resolved.
Comment thread
liruifengv marked this conversation as resolved.
}
editor.setInputMode('prompt');
return undefined;
};

// Save/restore the input mode alongside pi-tui's history draft. Without
// this, recalling a shell entry and then pressing Down back to an empty
// draft would leave the editor stuck in bash mode, so the next typed
// message would be submitted as a shell command. Also locks the history
// filter (browseMode) for the duration of the browse session.
editor.onHistoryDraftSave = () => {
browseMode = editor.inputMode;
return editor.inputMode;
};
editor.onHistoryDraftRestore = (state: unknown) => {
editor.setInputMode(state as 'prompt' | 'bash');
browseMode = null;
};

editor.onNonEscapeInput = () => {
this.clearPendingUndoEsc();
};
Expand Down
10 changes: 5 additions & 5 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,11 +930,11 @@ export class KimiTUI {
this.showError('Cannot send input while session history is replaying.');
return;
}
// 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);
}
// Shell commands are stored with a leading `!` so ↑ recall can tell them
// apart from prompts and restore bash mode (see CustomEditor's mode-aware
// history navigation). The `!` is stripped again when the entry is recalled.
const historyText = wasBashMode ? `!${text}` : text;
void this.persistInputHistory(historyText);
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.
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/utils/history/input-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
*
* Semantics:
* - One JSON object per line (`InputHistoryEntry { content }`)
* - `content` is the raw input. Shell commands are stored with a leading `!`
* (e.g. `!ls -la`) so ↑ recall can distinguish them from prompts and restore
* bash mode; the `!` is stripped again when the entry is recalled. Plain
* prompts (and legacy entries without a leading `!`) are normal prompts.
* - Append-only writes
* - Skip empty entries
* - Skip when same as last entry (consecutive deduplication)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ interface PasteHarness {
}

function createPasteHarness(): PasteHarness {
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {};
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
};
const store = new ImageAttachmentStore();
const host = {
state: {
Expand Down
82 changes: 81 additions & 1 deletion apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ interface Harness {
}

function createHarness(options: { streamingPhase?: string; isCompacting?: boolean } = {}): Harness {
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {};
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
setInputMode: vi.fn() as unknown as (...args: never[]) => unknown,
};
const openUndoSelector = vi.fn();
const cancelRunningShellCommand = vi.fn();
const session = { cancel: vi.fn(async () => {}) };
Expand Down Expand Up @@ -119,3 +122,80 @@ describe('EditorKeyboardController double-Esc undo', () => {
expect(session.cancel).toHaveBeenCalled();
});
});

describe('EditorKeyboardController shell history recall', () => {
type Recall = (entry: string, direction: 1 | -1) => string | undefined;
type Mock = ReturnType<typeof vi.fn>;

it('installs a filter that allows shell entries only in bash mode', () => {
const { editor } = createHarness();
const setHistoryFilter = editor['setHistoryFilter'] as unknown as Mock;
expect(setHistoryFilter).toHaveBeenCalledOnce();
const [filter] = setHistoryFilter.mock.calls[0] as [(entry: string) => boolean];

(editor as unknown as { inputMode: string }).inputMode = 'prompt';
expect(filter('!cmd')).toBe(true);
expect(filter('hello')).toBe(true);

(editor as unknown as { inputMode: string }).inputMode = 'bash';
expect(filter('!cmd')).toBe(true);
expect(filter('hello')).toBe(false);
});

it('locks the filter to the browse-entry mode once browsing starts', () => {
const { editor } = createHarness();
const setHistoryFilter = editor['setHistoryFilter'] as unknown as Mock;
const [filter] = setHistoryFilter.mock.calls[0] as [(entry: string) => boolean];
const save = editor['onHistoryDraftSave'] as unknown as () => unknown;

// Enter browse from prompt mode, then simulate landing on a shell entry
// (which flips inputMode to bash). The filter should stay locked to prompt
// and keep allowing plain entries.
(editor as unknown as { inputMode: string }).inputMode = 'prompt';
save();
(editor as unknown as { inputMode: string }).inputMode = 'bash';

expect(filter('hello')).toBe(true);
expect(filter('!cmd')).toBe(true);
});

it('strips the leading ! and switches to bash mode when recalling a shell entry', () => {
const { editor } = createHarness();
const onRecall = editor['onRecall'] as unknown as Recall;

const result = onRecall('!cmd', -1);

expect(result).toBe('cmd');
expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('bash');
});

it('keeps plain entries as-is and switches to prompt mode', () => {
const { editor } = createHarness();
const onRecall = editor['onRecall'] as unknown as Recall;

const result = onRecall('hello', -1);

expect(result).toBeUndefined();
expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('prompt');
});

it('saves the current input mode as the history draft host state', () => {
const { editor } = createHarness();
const save = editor['onHistoryDraftSave'] as unknown as () => unknown;

(editor as unknown as { inputMode: string }).inputMode = 'prompt';
expect(save()).toBe('prompt');

(editor as unknown as { inputMode: string }).inputMode = 'bash';
expect(save()).toBe('bash');
});

it('restores the input mode from the saved draft host state', () => {
const { editor } = createHarness();
const restore = editor['onHistoryDraftRestore'] as unknown as (state: unknown) => void;

restore('prompt');

expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('prompt');
});
});
4 changes: 2 additions & 2 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1664,15 +1664,15 @@ command = "vim"
expect(session.prompt).not.toHaveBeenCalled();
});

it('does not persist bash input to input history', async () => {
it('persists bash input to input history with a leading !', 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();
expect(driver.persistInputHistory).toHaveBeenCalledWith('!ls');
});

it('persists normal input to input history', async () => {
Expand Down
3 changes: 2 additions & 1 deletion docs/en/guides/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Kimi Code CLI runs as an interactive TUI (terminal user interface) built around

## Input box basics

The input box accepts free-form text. Press `Enter` to send, or `Shift-Enter` / `Ctrl-J` to insert a newline. When the input box is empty, press `↑` / `↓` to browse the input history for the current working directory.
The input box accepts free-form text. Press `Enter` to send, or `Shift-Enter` / `Ctrl-J` to insert a newline. When the input box is empty, press `↑` / `↓` to browse the input history for the current working directory, including previous shell commands.

**Exiting the CLI**: press `Ctrl-D` with the input box empty, press `Ctrl-C` twice while idle, or type `/exit`. Pressing `Ctrl-C` or `Esc` during streaming output interrupts the current turn — it does not exit the program.

Expand Down Expand Up @@ -71,6 +71,7 @@ Shell mode lets you run terminal commands without leaving the conversation. The
- 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.
- Recall previous commands: with the input box empty in shell mode, press `↑` to browse earlier shell commands; recalling one keeps you in shell mode so it runs as a command again.

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.

Expand Down
3 changes: 2 additions & 1 deletion docs/zh/guides/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Kimi Code CLI 以交互式 TUI 运行,核心由输入框、对话视图和状

## 输入框基本操作

输入框接受自由文本:`Enter` 发送,`Shift-Enter` 或 `Ctrl-J` 插入换行。输入框为空时按 `↑` / `↓` 浏览当前工作目录的历史输入。
输入框接受自由文本:`Enter` 发送,`Shift-Enter` 或 `Ctrl-J` 插入换行。输入框为空时按 `↑` / `↓` 浏览当前工作目录的历史输入,包括此前运行过的 Shell 命令

**退出 CLI**:输入框为空时按 `Ctrl-D`,或空闲状态下连按 `Ctrl-C` 两次,或输入 `/exit`。流式输出期间按 `Ctrl-C` 或 `Esc` 是中断当前轮次,不会退出程序。

Expand Down Expand Up @@ -71,6 +71,7 @@ Shell 模式让你不离开对话就能运行终端命令,命令输出会写
- 进入:在空输入框中键入 `!`,或粘贴以 `!` 开头的命令。
- 退出:在空输入框中按 `Backspace` 或 `Esc`;提交命令后也会自动回到普通模式。
- 后台运行:命令执行期间按 `Ctrl+B` 可将其转为后台任务。
- 召回历史命令:在 Shell 模式的空输入框中按 `↑` 浏览此前运行过的 Shell 命令,召回后仍处于 Shell 模式,可再次作为命令执行。

进入 Shell 模式后,输入框左侧会显示 `!` 提示符,边框变为紫色。例如,无需新开终端就能运行 `!gh auth login` 登录 GitHub CLI,登录后 Kimi 就可以直接使用 `gh`。

Expand Down
69 changes: 65 additions & 4 deletions packages/pi-tui/src/components/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ export class Editor implements Component, Focusable {
private history: string[] = [];
private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
private historyDraft: EditorState | null = null;
private hostHistoryDraft: unknown = undefined;
private historyFilter: ((entry: string) => boolean) | null = null;

// Kill ring for Emacs-style kill/yank operations
private killRing = new KillRing();
Expand All @@ -333,6 +335,22 @@ export class Editor implements Component, Focusable {

public onSubmit?: (text: string) => void;
public onChange?: (text: string) => void;
/**
* Called when a history entry is recalled, before it is put into the buffer.
* Return the text to display, or `undefined` to use the entry as-is. Lets the
* host decorate entries (e.g. strip a marker) and react to recalls (e.g.
* switch input mode) without touching editor internals.
*/
public onRecall?: (entry: string, direction: 1 | -1) => string | undefined;
/**
* Called when entering history browsing, to capture host state that should be
* saved alongside the editor draft. The returned value is passed to
* `onHistoryDraftRestore` when the user navigates back to the draft, so the
* host can restore state the editor does not own (e.g. an input mode).
*/
public onHistoryDraftSave?: () => unknown;
/** Called with the value from `onHistoryDraftSave` when the draft is restored. */
public onHistoryDraftRestore?: (state: unknown) => void;
public disableSubmit: boolean = false;

constructor(tui: TUI, theme: EditorTheme, options: EditorOptions = {}) {
Expand Down Expand Up @@ -385,6 +403,14 @@ export class Editor implements Component, Focusable {
this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []);
}

/**
* Limit which history entries ↑/↓ navigate. `null` (default) visits every
* entry. The filter is evaluated against each stored entry as-is.
*/
setHistoryFilter(filter: ((entry: string) => boolean) | null): void {
this.historyFilter = filter;
}

/**
* Add a prompt to history for up/down arrow navigation.
* Called after successful submission.
Expand Down Expand Up @@ -421,13 +447,41 @@ export class Editor implements Component, Focusable {
this.lastAction = null;
if (this.history.length === 0) return;

const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
if (newIndex < -1 || newIndex >= this.history.length) return;
// When entering browse, capture host state up front — before the filter
// runs — so the host's filter can read the browse-entry mode rather than a
// mode that changes as entries are recalled. The captured value is only
// committed to hostHistoryDraft once a matching entry is actually found.
const entering = this.historyIndex === -1;
const pendingHostDraft = entering ? this.onHistoryDraftSave?.() : undefined;

// Find the next index that passes the filter. Up(-1) increases index,
// Down(1) decreases. The draft (-1) is always reachable; stepping past
// either end is a no-op.
let newIndex = this.historyIndex;
let found = false;
while (true) {
newIndex = newIndex - direction;
if (newIndex === -1) {
found = true;
break;
}
if (newIndex < -1 || newIndex >= this.history.length) {
found = false;
break;
}
const candidate = this.history[newIndex];
if (!this.historyFilter || (candidate !== undefined && this.historyFilter(candidate))) {
found = true;
break;
}
}
if (!found) return;

// Capture state when first entering history browsing mode
if (this.historyIndex === -1 && newIndex >= 0) {
if (entering && newIndex >= 0) {
this.pushUndoSnapshot();
this.historyDraft = structuredClone(this.state);
this.hostHistoryDraft = pendingHostDraft;
}

this.historyIndex = newIndex;
Expand All @@ -440,18 +494,25 @@ export class Editor implements Component, Focusable {
this.preferredVisualCol = null;
this.snappedFromCursorCol = null;
this.scrollOffset = 0;
if (this.hostHistoryDraft !== undefined) {
this.onHistoryDraftRestore?.(this.hostHistoryDraft);
this.hostHistoryDraft = undefined;
}
if (this.onChange) this.onChange(this.getText());
} else {
this.setTextInternal("");
}
} else {
this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end");
const rawEntry = this.history[this.historyIndex] || "";
const entry = this.onRecall ? this.onRecall(rawEntry, direction) ?? rawEntry : rawEntry;
this.setTextInternal(entry, direction === -1 ? "start" : "end");
}
}

private exitHistoryBrowsing(): void {
this.historyIndex = -1;
this.historyDraft = null;
this.hostHistoryDraft = undefined;
}

/** Internal setText that doesn't reset history state - used by navigateHistory */
Expand Down
Loading
Loading