feat(tui): include shell commands in input history (#1295)

* feat(tui): include shell commands in input history

Shell commands entered through the `!` prompt are now saved to input history. Recalling one restores bash mode, and in bash mode Up only cycles through previous shell commands while a normal prompt browses all history.

* docs(interaction): document shell command recall in input history

Note that shell commands are now saved to input history and can be recalled in Shell mode, in both the English and Chinese interaction guides.

* feat(pi-tui): add setHistoryFilter and onRecall to editor history

Add two first-class hooks to the editor's history navigation: setHistoryFilter to limit which entries Up/Down visit, and onRecall to decorate a recalled entry before it is shown. Draft restore, direction-aware cursor placement, and undo behavior are unchanged.

* refactor(tui): use pi-tui history filter for shell command recall

Replace the CustomEditor navigateHistory shadow with pi-tui's setHistoryFilter + onRecall hooks, wired in the editor-keyboard controller. This keeps pi-tui's draft-restore and direction-aware cursor behavior intact (the shadow dropped both) and moves the shell/prompt filtering and mode-restore logic into the business layer.

* feat(pi-tui): save and restore host state with the history draft

Add onHistoryDraftSave/onHistoryDraftRestore hooks so hosts can stash their own state when entering history browsing and restore it when the user navigates back to the draft. The saved host state is discarded when browsing ends any other way (typing, submit), mirroring the editor draft lifecycle.

* fix(tui): restore input mode when returning to the history draft

Wire pi-tui's history draft save/restore hooks to the editor input mode. Without this, recalling a shell entry and then pressing Down back to an empty draft left the editor in bash mode, so the next typed message was submitted as a shell command.

* fix(pi-tui): capture host draft state before running the history filter

Fire onHistoryDraftSave before the history filter runs when entering browse, so the host's filter can read the browse-entry mode rather than a mode that changes as entries are recalled. The captured state is still only committed once a matching entry is found.

* fix(tui): lock history filter to the browse-entry mode

Lock the history filter to the input mode captured when entering browse. Previously the filter read inputMode live, so after recalling a shell entry (which flips to bash mode) a second Up would only show shell commands.
This commit is contained in:
liruifengv 2026-07-02 17:59:26 +08:00 committed by GitHub
parent 2639786ce5
commit 77eb3a9fe4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 373 additions and 15 deletions

View file

@ -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`).

View file

@ -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.

View file

@ -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();

View file

@ -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');
return entry.slice(1);
}
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();
};

View file

@ -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.

View file

@ -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)

View file

@ -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: {

View file

@ -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 () => {}) };
@ -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');
});
});

View file

@ -1664,7 +1664,7 @@ 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';
@ -1672,7 +1672,7 @@ command = "vim"
driver.handleUserInput('ls');
expect(driver.persistInputHistory).not.toHaveBeenCalled();
expect(driver.persistInputHistory).toHaveBeenCalledWith('!ls');
});
it('persists normal input to input history', async () => {

View file

@ -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.
@ -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.

View file

@ -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` 是中断当前轮次,不会退出程序。
@ -71,6 +71,7 @@ Shell 模式让你不离开对话就能运行终端命令,命令输出会写
- 进入:在空输入框中键入 `!`,或粘贴以 `!` 开头的命令。
- 退出:在空输入框中按 `Backspace``Esc`;提交命令后也会自动回到普通模式。
- 后台运行:命令执行期间按 `Ctrl+B` 可将其转为后台任务。
- 召回历史命令:在 Shell 模式的空输入框中按 `↑` 浏览此前运行过的 Shell 命令,召回后仍处于 Shell 模式,可再次作为命令执行。
进入 Shell 模式后,输入框左侧会显示 `!` 提示符,边框变为紫色。例如,无需新开终端就能运行 `!gh auth login` 登录 GitHub CLI登录后 Kimi 就可以直接使用 `gh`

View file

@ -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();
@ -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 = {}) {
@ -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.
@ -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;
@ -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 */

View file

@ -284,6 +284,162 @@ describe("Editor component", () => {
});
});
describe("history filter", () => {
it("visits all entries when no filter is set", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("first");
editor.addToHistory("second");
editor.handleInput("\x1b[A"); // Up - "second"
assert.strictEqual(editor.getText(), "second");
editor.handleInput("\x1b[A"); // Up - "first"
assert.strictEqual(editor.getText(), "first");
});
it("skips entries that do not pass the filter on Up", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("prompt-a");
editor.addToHistory("!cmd-b");
editor.addToHistory("prompt-c");
// history: ["prompt-c", "!cmd-b", "prompt-a"]
editor.setHistoryFilter((entry) => entry.startsWith("!"));
editor.handleInput("\x1b[A"); // Up - "!cmd-b" (skips "prompt-c")
assert.strictEqual(editor.getText(), "!cmd-b");
editor.handleInput("\x1b[A"); // Up - no more shell entries, stays
assert.strictEqual(editor.getText(), "!cmd-b");
});
it("skips entries that do not pass the filter on Down", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("!cmd-a");
editor.addToHistory("prompt-b");
editor.addToHistory("!cmd-c");
// history: ["!cmd-c", "prompt-b", "!cmd-a"]
editor.setHistoryFilter((entry) => entry.startsWith("!"));
editor.handleInput("\x1b[A"); // Up - "!cmd-c"
assert.strictEqual(editor.getText(), "!cmd-c");
editor.handleInput("\x1b[A"); // Up - "!cmd-a" (skips "prompt-b")
assert.strictEqual(editor.getText(), "!cmd-a");
editor.handleInput("\x1b[B"); // Down - "!cmd-c" (skips "prompt-b")
assert.strictEqual(editor.getText(), "!cmd-c");
});
it("does nothing on Up when no entry passes the filter", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("prompt-a");
editor.addToHistory("prompt-b");
editor.setHistoryFilter((entry) => entry.startsWith("!"));
editor.handleInput("\x1b[A");
assert.strictEqual(editor.getText(), "");
});
it("still restores the draft with a filter active", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("!cmd");
editor.setHistoryFilter((entry) => entry.startsWith("!"));
editor.setText("draft");
editor.handleInput("\x1b[D");
editor.handleInput("\x1b[D");
editor.handleInput("\x1b[A"); // to line start
editor.handleInput("\x1b[A"); // recall "!cmd"
assert.strictEqual(editor.getText(), "!cmd");
editor.handleInput("\x1b[B"); // restore draft
assert.strictEqual(editor.getText(), "draft");
});
});
describe("onRecall", () => {
it("uses the returned text instead of the stored entry", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("!cmd");
editor.onRecall = (entry) => (entry.startsWith("!") ? entry.slice(1) : undefined);
editor.handleInput("\x1b[A");
assert.strictEqual(editor.getText(), "cmd");
});
it("uses the stored entry when onRecall returns undefined", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("prompt");
editor.onRecall = () => undefined;
editor.handleInput("\x1b[A");
assert.strictEqual(editor.getText(), "prompt");
});
it("passes the navigation direction to onRecall", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("a");
editor.addToHistory("b");
const directions: Array<1 | -1> = [];
editor.onRecall = (_entry, direction) => {
directions.push(direction);
return undefined;
};
editor.handleInput("\x1b[A"); // Up -> "b" (-1)
editor.handleInput("\x1b[A"); // Up -> "a" (-1)
editor.handleInput("\x1b[B"); // Down -> "b" (1)
assert.deepStrictEqual(directions, [-1, -1, 1]);
});
});
describe("history draft host state", () => {
it("saves host state on entering browse and restores it on draft return", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("entry");
let restored: unknown;
editor.onHistoryDraftSave = () => "prompt-mode";
editor.onHistoryDraftRestore = (state) => {
restored = state;
};
editor.handleInput("\x1b[A"); // Up - recall "entry", saves host state
assert.strictEqual(editor.getText(), "entry");
assert.strictEqual(restored, undefined);
editor.handleInput("\x1b[B"); // Down - restore draft
assert.strictEqual(editor.getText(), "");
assert.strictEqual(restored, "prompt-mode");
});
it("does not restore host state when leaving browse by typing", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("entry");
let restored = false;
editor.onHistoryDraftSave = () => "state";
editor.onHistoryDraftRestore = () => {
restored = true;
};
editor.handleInput("\x1b[A"); // recall "entry"
editor.handleInput("x"); // type - exits browse without restoring draft
assert.strictEqual(restored, false);
});
it("saves and restores host state across multiple browse sessions", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("entry");
let count = 0;
editor.onHistoryDraftSave = () => "state";
editor.onHistoryDraftRestore = () => {
count++;
};
editor.handleInput("\x1b[A"); // recall
editor.handleInput("\x1b[B"); // restore draft (count=1)
editor.handleInput("\x1b[A"); // recall again
editor.handleInput("\x1b[B"); // restore draft again (count=2)
assert.strictEqual(count, 2);
});
});
describe("public state accessors", () => {
it("returns cursor position", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);