From 94507d60ccc366efeba1fba9bfd4de2803d2e5be Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 13:58:55 +0800 Subject: [PATCH 1/8] 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. --- .changeset/shell-input-history.md | 5 + .../tui/components/editor/custom-editor.ts | 84 ++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 10 +- .../src/utils/history/input-history.ts | 4 + .../components/editor/custom-editor.test.ts | 98 +++++++++++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 4 +- 6 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 .changeset/shell-input-history.md diff --git a/.changeset/shell-input-history.md b/.changeset/shell-input-history.md new file mode 100644 index 0000000000..d80a45f91a --- /dev/null +++ b/.changeset/shell-input-history.md @@ -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. 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 ee005950c4..6144d5cb6d 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -54,6 +54,15 @@ interface AutocompleteTriggerInternals { requestAutocomplete: (options: { force: boolean; explicitTab: boolean }) => void; } +interface HistoryNavigationInternals { + history: string[]; + historyIndex: number; + lastAction: unknown; + navigateHistory(direction: number): void; + setTextInternal(text: string): void; + pushUndoSnapshot(): void; +} + // Mirror pi-tui's private SLASH_COMMAND_SELECT_LIST_LAYOUT // (dist/components/editor.js); keep in sync when bumping pi-tui. const SLASH_COMMAND_SELECT_LIST_LAYOUT = { @@ -209,6 +218,81 @@ export class CustomEditor extends Editor { triggerInternals.tryTriggerAutocomplete = (explicitTab = false) => { triggerInternals.requestAutocomplete({ force: this.inputMode === 'bash', explicitTab }); }; + + // pi-tui's built-in history navigation walks every entry and stores only + // bare strings, so it can neither keep shell commands out of prompt recall + // (and vice versa) nor restore bash mode when a `!cmd` entry is recalled. + // Shadow it with a mode-aware version: bash mode only visits `!`-prefixed + // entries (shell commands); prompt mode visits everything. Recalling a + // `!`-prefixed entry strips the `!` and switches back to bash mode so the + // command runs as a shell command again instead of being sent as a prompt. + // Mirrors pi-tui internals (dist/components/editor.js navigateHistory); + // keep in sync when bumping pi-tui. + const navInternals = this as unknown as HistoryNavigationInternals; + let browseStartMode: 'prompt' | 'bash' = 'prompt'; + navInternals.navigateHistory = (direction: number) => { + navInternals.lastAction = null; + if (navInternals.history.length === 0) return; + + // The filter is fixed for the whole browse session by the mode we were in + // when we first pressed ↑: bash → only shell entries, prompt → all. + const entering = navInternals.historyIndex === -1; + if (entering) browseStartMode = this.inputMode; + const wantShell = browseStartMode === 'bash'; + const matches = (entry: string): boolean => + wantShell ? entry.startsWith('!') : true; + + // Step from the current position in `direction`, skipping entries that + // don't match. pi-tui convention: Up passes -1 (older), Down passes 1. + let idx = navInternals.historyIndex; + let target: number | 'draft' | 'none' = 'none'; + while (true) { + idx = idx - direction; + if (idx === -1) { + target = 'draft'; + break; + } + if (idx < -1 || idx >= navInternals.history.length) { + target = 'none'; + break; + } + const candidate = navInternals.history[idx]; + if (candidate !== undefined && matches(candidate)) { + target = idx; + break; + } + } + + if (target === 'none') return; + + // First step into history: save the draft so undo can restore it. + if (entering && typeof target === 'number') { + navInternals.pushUndoSnapshot(); + } + + if (target === 'draft') { + navInternals.historyIndex = -1; + navInternals.setTextInternal(''); + this.setInputMode(browseStartMode); + return; + } + + navInternals.historyIndex = target; + const entry = navInternals.history[target] ?? ''; + if (entry.startsWith('!')) { + this.setInputMode('bash'); + navInternals.setTextInternal(entry.slice(1)); + } else { + this.setInputMode('prompt'); + navInternals.setTextInternal(entry); + } + }; + } + + private setInputMode(mode: 'prompt' | 'bash'): void { + if (this.inputMode === mode) return; + this.inputMode = mode; + this.onInputModeChange?.(mode); } private expandPasteMarkerAtCursor(): boolean { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 90d4c35e87..2d92ec2e42 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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. diff --git a/apps/kimi-code/src/utils/history/input-history.ts b/apps/kimi-code/src/utils/history/input-history.ts index 9505425637..cade00bec2 100644 --- a/apps/kimi-code/src/utils/history/input-history.ts +++ b/apps/kimi-code/src/utils/history/input-history.ts @@ -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) 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 6ef871208e..28bd77de32 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 @@ -769,3 +769,101 @@ describe('CustomEditor bash mode file completion', () => { expect(calls.every((call) => call.force === true)).toBe(true); }); }); + +describe('CustomEditor mode-aware history navigation', () => { + const UP = '\u001B[A'; + const DOWN = '\u001B[B'; + + it('in bash mode, Up only recalls shell (!-prefixed) entries', () => { + const editor = makeEditor(); + editor.addToHistory('prompt1'); // older + editor.addToHistory('!cmd1'); // newer → history index 0 + editor.inputMode = 'bash'; + + editor.handleInput(UP); + expect(editor.getText()).toBe('cmd1'); + expect(editor.inputMode).toBe('bash'); + + // No more shell entries beyond index 0, so a second Up is a no-op. + editor.handleInput(UP); + expect(editor.getText()).toBe('cmd1'); + expect(editor.inputMode).toBe('bash'); + }); + + it('in prompt mode, Up recalls all entries and switches to bash for shell entries', () => { + const editor = makeEditor(); + editor.addToHistory('!cmd1'); // older + editor.addToHistory('prompt1'); // newer → history index 0 + + editor.handleInput(UP); + expect(editor.getText()).toBe('prompt1'); + expect(editor.inputMode).toBe('prompt'); + + editor.handleInput(UP); + expect(editor.getText()).toBe('cmd1'); + expect(editor.inputMode).toBe('bash'); + + editor.handleInput(DOWN); + expect(editor.getText()).toBe('prompt1'); + expect(editor.inputMode).toBe('prompt'); + }); + + it('notifies onInputModeChange when a recalled entry changes the mode', () => { + const editor = makeEditor(); + const modes: Array<'prompt' | 'bash'> = []; + editor.onInputModeChange = (mode) => modes.push(mode); + editor.addToHistory('!cmd1'); + + editor.handleInput(UP); + + expect(editor.inputMode).toBe('bash'); + expect(modes).toEqual(['bash']); + }); + + it('does nothing on Up when no history entry matches the current mode', () => { + const editor = makeEditor(); + editor.addToHistory('prompt1'); + editor.addToHistory('prompt2'); + editor.inputMode = 'bash'; + + editor.handleInput(UP); + + expect(editor.getText()).toBe(''); + expect(editor.inputMode).toBe('bash'); + }); + + it('restores the pre-browse mode when returning to an empty draft', () => { + const editor = makeEditor(); + editor.addToHistory('!cmd1'); + editor.inputMode = 'bash'; + + editor.handleInput(UP); + expect(editor.getText()).toBe('cmd1'); + + editor.handleInput(DOWN); + expect(editor.getText()).toBe(''); + expect(editor.inputMode).toBe('bash'); + }); + + it('strips only the leading ! from a multi-line shell command', () => { + const editor = makeEditor(); + editor.addToHistory('!echo hi\necho bye'); + editor.inputMode = 'bash'; + + editor.handleInput(UP); + + expect(editor.getText()).toBe('echo hi\necho bye'); + expect(editor.inputMode).toBe('bash'); + }); + + it('strips only the marker ! from a command that itself starts with !', () => { + const editor = makeEditor(); + editor.addToHistory('!!ls'); // marker '!' + command '!ls' + editor.inputMode = 'bash'; + + editor.handleInput(UP); + + expect(editor.getText()).toBe('!ls'); + expect(editor.inputMode).toBe('bash'); + }); +}); 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 b5243e894e..550122428a 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 @@ -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 () => { From 8df8965117a6104acc6aca0026f486e0edb811de Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 14:01:39 +0800 Subject: [PATCH 2/8] 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. --- docs/en/guides/interaction.md | 3 ++- docs/zh/guides/interaction.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 7bfe484301..d905a8e122 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -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. diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index d73aeb83c4..66749766ee 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -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`。 From 53ed0b157788f06ab364ddb54619fd123e8b3a23 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 14:57:06 +0800 Subject: [PATCH 3/8] 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. --- .changeset/pi-tui-history-filter.md | 5 ++ packages/pi-tui/src/components/editor.ts | 44 +++++++++- packages/pi-tui/test/editor.test.ts | 106 +++++++++++++++++++++++ 3 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 .changeset/pi-tui-history-filter.md diff --git a/.changeset/pi-tui-history-filter.md b/.changeset/pi-tui-history-filter.md new file mode 100644 index 0000000000..f96885637e --- /dev/null +++ b/.changeset/pi-tui-history-filter.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Add `setHistoryFilter` and `onRecall` to the editor so hosts can filter and decorate history entries. diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index f7b7fbcdfe..b4d1692a24 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -299,6 +299,7 @@ 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 historyFilter: ((entry: string) => boolean) | null = null; // Kill ring for Emacs-style kill/yank operations private killRing = new KillRing(); @@ -322,6 +323,13 @@ 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; public disableSubmit: boolean = false; constructor(tui: TUI, theme: EditorTheme, options: EditorOptions = {}) { @@ -374,6 +382,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. @@ -410,8 +426,28 @@ 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; + // 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) { @@ -434,7 +470,9 @@ export class Editor implements Component, Focusable { 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"); } } diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 0f33370e10..1983fc3eaa 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -284,6 +284,112 @@ 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("public state accessors", () => { it("returns cursor position", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); From 839a855dc3c8e9eee2b31faeeb9b863507d328ec Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 14:57:16 +0800 Subject: [PATCH 4/8] 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. --- .../tui/components/editor/custom-editor.ts | 80 +-------------- .../src/tui/controllers/editor-keyboard.ts | 19 ++++ .../components/editor/custom-editor.test.ts | 98 ------------------- .../editor-keyboard-image-paste.test.ts | 4 +- .../tui/controllers/editor-keyboard.test.ts | 45 ++++++++- 5 files changed, 67 insertions(+), 179 deletions(-) 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 6144d5cb6d..1fb28a0060 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -54,15 +54,6 @@ interface AutocompleteTriggerInternals { requestAutocomplete: (options: { force: boolean; explicitTab: boolean }) => void; } -interface HistoryNavigationInternals { - history: string[]; - historyIndex: number; - lastAction: unknown; - navigateHistory(direction: number): void; - setTextInternal(text: string): void; - pushUndoSnapshot(): void; -} - // Mirror pi-tui's private SLASH_COMMAND_SELECT_LIST_LAYOUT // (dist/components/editor.js); keep in sync when bumping pi-tui. const SLASH_COMMAND_SELECT_LIST_LAYOUT = { @@ -218,78 +209,9 @@ export class CustomEditor extends Editor { triggerInternals.tryTriggerAutocomplete = (explicitTab = false) => { triggerInternals.requestAutocomplete({ force: this.inputMode === 'bash', explicitTab }); }; - - // pi-tui's built-in history navigation walks every entry and stores only - // bare strings, so it can neither keep shell commands out of prompt recall - // (and vice versa) nor restore bash mode when a `!cmd` entry is recalled. - // Shadow it with a mode-aware version: bash mode only visits `!`-prefixed - // entries (shell commands); prompt mode visits everything. Recalling a - // `!`-prefixed entry strips the `!` and switches back to bash mode so the - // command runs as a shell command again instead of being sent as a prompt. - // Mirrors pi-tui internals (dist/components/editor.js navigateHistory); - // keep in sync when bumping pi-tui. - const navInternals = this as unknown as HistoryNavigationInternals; - let browseStartMode: 'prompt' | 'bash' = 'prompt'; - navInternals.navigateHistory = (direction: number) => { - navInternals.lastAction = null; - if (navInternals.history.length === 0) return; - - // The filter is fixed for the whole browse session by the mode we were in - // when we first pressed ↑: bash → only shell entries, prompt → all. - const entering = navInternals.historyIndex === -1; - if (entering) browseStartMode = this.inputMode; - const wantShell = browseStartMode === 'bash'; - const matches = (entry: string): boolean => - wantShell ? entry.startsWith('!') : true; - - // Step from the current position in `direction`, skipping entries that - // don't match. pi-tui convention: Up passes -1 (older), Down passes 1. - let idx = navInternals.historyIndex; - let target: number | 'draft' | 'none' = 'none'; - while (true) { - idx = idx - direction; - if (idx === -1) { - target = 'draft'; - break; - } - if (idx < -1 || idx >= navInternals.history.length) { - target = 'none'; - break; - } - const candidate = navInternals.history[idx]; - if (candidate !== undefined && matches(candidate)) { - target = idx; - break; - } - } - - if (target === 'none') return; - - // First step into history: save the draft so undo can restore it. - if (entering && typeof target === 'number') { - navInternals.pushUndoSnapshot(); - } - - if (target === 'draft') { - navInternals.historyIndex = -1; - navInternals.setTextInternal(''); - this.setInputMode(browseStartMode); - return; - } - - navInternals.historyIndex = target; - const entry = navInternals.history[target] ?? ''; - if (entry.startsWith('!')) { - this.setInputMode('bash'); - navInternals.setTextInternal(entry.slice(1)); - } else { - this.setInputMode('prompt'); - navInternals.setTextInternal(entry); - } - }; } - private setInputMode(mode: 'prompt' | 'bash'): void { + public setInputMode(mode: 'prompt' | 'bash'): void { if (this.inputMode === mode) return; this.inputMode = mode; this.onInputModeChange?.(mode); diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index f3539d39e6..96e274944d 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -67,6 +67,25 @@ export class EditorKeyboardController { host.updateEditorBorderHighlight(text); }; + // bash mode recalls only shell (`!`-prefixed) history entries; prompt mode + // recalls everything. The closure reads inputMode live at navigation time. + editor.setHistoryFilter((entry: string) => + editor.inputMode === '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; + }; + editor.onNonEscapeInput = () => { this.clearPendingUndoEsc(); }; 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 28bd77de32..6ef871208e 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 @@ -769,101 +769,3 @@ describe('CustomEditor bash mode file completion', () => { expect(calls.every((call) => call.force === true)).toBe(true); }); }); - -describe('CustomEditor mode-aware history navigation', () => { - const UP = '\u001B[A'; - const DOWN = '\u001B[B'; - - it('in bash mode, Up only recalls shell (!-prefixed) entries', () => { - const editor = makeEditor(); - editor.addToHistory('prompt1'); // older - editor.addToHistory('!cmd1'); // newer → history index 0 - editor.inputMode = 'bash'; - - editor.handleInput(UP); - expect(editor.getText()).toBe('cmd1'); - expect(editor.inputMode).toBe('bash'); - - // No more shell entries beyond index 0, so a second Up is a no-op. - editor.handleInput(UP); - expect(editor.getText()).toBe('cmd1'); - expect(editor.inputMode).toBe('bash'); - }); - - it('in prompt mode, Up recalls all entries and switches to bash for shell entries', () => { - const editor = makeEditor(); - editor.addToHistory('!cmd1'); // older - editor.addToHistory('prompt1'); // newer → history index 0 - - editor.handleInput(UP); - expect(editor.getText()).toBe('prompt1'); - expect(editor.inputMode).toBe('prompt'); - - editor.handleInput(UP); - expect(editor.getText()).toBe('cmd1'); - expect(editor.inputMode).toBe('bash'); - - editor.handleInput(DOWN); - expect(editor.getText()).toBe('prompt1'); - expect(editor.inputMode).toBe('prompt'); - }); - - it('notifies onInputModeChange when a recalled entry changes the mode', () => { - const editor = makeEditor(); - const modes: Array<'prompt' | 'bash'> = []; - editor.onInputModeChange = (mode) => modes.push(mode); - editor.addToHistory('!cmd1'); - - editor.handleInput(UP); - - expect(editor.inputMode).toBe('bash'); - expect(modes).toEqual(['bash']); - }); - - it('does nothing on Up when no history entry matches the current mode', () => { - const editor = makeEditor(); - editor.addToHistory('prompt1'); - editor.addToHistory('prompt2'); - editor.inputMode = 'bash'; - - editor.handleInput(UP); - - expect(editor.getText()).toBe(''); - expect(editor.inputMode).toBe('bash'); - }); - - it('restores the pre-browse mode when returning to an empty draft', () => { - const editor = makeEditor(); - editor.addToHistory('!cmd1'); - editor.inputMode = 'bash'; - - editor.handleInput(UP); - expect(editor.getText()).toBe('cmd1'); - - editor.handleInput(DOWN); - expect(editor.getText()).toBe(''); - expect(editor.inputMode).toBe('bash'); - }); - - it('strips only the leading ! from a multi-line shell command', () => { - const editor = makeEditor(); - editor.addToHistory('!echo hi\necho bye'); - editor.inputMode = 'bash'; - - editor.handleInput(UP); - - expect(editor.getText()).toBe('echo hi\necho bye'); - expect(editor.inputMode).toBe('bash'); - }); - - it('strips only the marker ! from a command that itself starts with !', () => { - const editor = makeEditor(); - editor.addToHistory('!!ls'); // marker '!' + command '!ls' - editor.inputMode = 'bash'; - - editor.handleInput(UP); - - expect(editor.getText()).toBe('!ls'); - expect(editor.inputMode).toBe('bash'); - }); -}); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts index bbead65ec4..3651bb81bb 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -33,7 +33,9 @@ interface PasteHarness { } function createPasteHarness(): PasteHarness { - const editor: Record unknown) | undefined> = {}; + const editor: Record unknown) | undefined> = { + setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, + }; const store = new ImageAttachmentStore(); const host = { state: { diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts index 5bc96cd743..3d7c02dd97 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -15,7 +15,10 @@ interface Harness { } function createHarness(options: { streamingPhase?: string; isCompacting?: boolean } = {}): Harness { - const editor: Record unknown) | undefined> = {}; + const editor: Record 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,43 @@ 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; + + 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('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'); + }); +}); From 86021ff267a1c19d71f07a781a606ded76ab7e31 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 16:17:06 +0800 Subject: [PATCH 5/8] 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. --- .changeset/pi-tui-history-filter.md | 2 +- packages/pi-tui/src/components/editor.ts | 16 ++++++++ packages/pi-tui/test/editor.test.ts | 50 ++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/.changeset/pi-tui-history-filter.md b/.changeset/pi-tui-history-filter.md index f96885637e..62f3df09b5 100644 --- a/.changeset/pi-tui-history-filter.md +++ b/.changeset/pi-tui-history-filter.md @@ -2,4 +2,4 @@ "@moonshot-ai/pi-tui": patch --- -Add `setHistoryFilter` and `onRecall` to the editor so hosts can filter and decorate history entries. +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`). diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index b4d1692a24..21dd7fbd3b 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -299,6 +299,7 @@ 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 @@ -330,6 +331,15 @@ export class Editor implements Component, Focusable { * 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 = {}) { @@ -453,6 +463,7 @@ export class Editor implements Component, Focusable { if (this.historyIndex === -1 && newIndex >= 0) { this.pushUndoSnapshot(); this.historyDraft = structuredClone(this.state); + this.hostHistoryDraft = this.onHistoryDraftSave?.(); } this.historyIndex = newIndex; @@ -465,6 +476,10 @@ 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(""); @@ -479,6 +494,7 @@ export class Editor implements Component, Focusable { private exitHistoryBrowsing(): void { this.historyIndex = -1; this.historyDraft = null; + this.hostHistoryDraft = undefined; } /** Internal setText that doesn't reset history state - used by navigateHistory */ diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 1983fc3eaa..51c531e10d 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -390,6 +390,56 @@ describe("Editor component", () => { }); }); + 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); From f6f14ad56dfc8fe95adde571666847f963f088aa Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 16:17:12 +0800 Subject: [PATCH 6/8] 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. --- .../src/tui/controllers/editor-keyboard.ts | 9 +++++++++ .../tui/controllers/editor-keyboard.test.ts | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 96e274944d..9f4a922b12 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -86,6 +86,15 @@ export class EditorKeyboardController { 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. + editor.onHistoryDraftSave = () => editor.inputMode; + editor.onHistoryDraftRestore = (state: unknown) => { + editor.setInputMode(state as 'prompt' | 'bash'); + }; + editor.onNonEscapeInput = () => { this.clearPendingUndoEsc(); }; diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts index 3d7c02dd97..c959e256d5 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -161,4 +161,24 @@ describe('EditorKeyboardController shell history recall', () => { 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'); + }); }); From b2d6db6576b11591f017516d2fa03c79a7862f5e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 16:48:45 +0800 Subject: [PATCH 7/8] 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. --- packages/pi-tui/src/components/editor.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 21dd7fbd3b..dfea4ff014 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -436,6 +436,13 @@ export class Editor implements Component, Focusable { this.lastAction = null; if (this.history.length === 0) 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. @@ -460,10 +467,10 @@ export class Editor implements Component, Focusable { 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 = this.onHistoryDraftSave?.(); + this.hostHistoryDraft = pendingHostDraft; } this.historyIndex = newIndex; From fcaececa213a0f946e3e973c62b188a9bfc8705b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 16:48:52 +0800 Subject: [PATCH 8/8] 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. --- .../src/tui/controllers/editor-keyboard.ts | 21 +++++++++++++------ .../tui/controllers/editor-keyboard.test.ts | 17 +++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 9f4a922b12..27ec948485 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -68,10 +68,14 @@ export class EditorKeyboardController { }; // bash mode recalls only shell (`!`-prefixed) history entries; prompt mode - // recalls everything. The closure reads inputMode live at navigation time. - editor.setHistoryFilter((entry: string) => - editor.inputMode === 'bash' ? entry.startsWith('!') : true, - ); + // 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 @@ -89,10 +93,15 @@ export class EditorKeyboardController { // 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. - editor.onHistoryDraftSave = () => editor.inputMode; + // 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 = () => { diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts index c959e256d5..090d47d503 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -142,6 +142,23 @@ describe('EditorKeyboardController shell history recall', () => { 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;