From 77eb3a9fe40c93fa32e335f07160b8128355bab6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 17:59:26 +0800 Subject: [PATCH] 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. --- .changeset/pi-tui-history-filter.md | 5 + .changeset/shell-input-history.md | 5 + .../tui/components/editor/custom-editor.ts | 6 + .../src/tui/controllers/editor-keyboard.ts | 37 +++++ apps/kimi-code/src/tui/kimi-tui.ts | 10 +- .../src/utils/history/input-history.ts | 4 + .../editor-keyboard-image-paste.test.ts | 4 +- .../tui/controllers/editor-keyboard.test.ts | 82 ++++++++- .../test/tui/kimi-tui-message-flow.test.ts | 4 +- docs/en/guides/interaction.md | 3 +- docs/zh/guides/interaction.md | 3 +- packages/pi-tui/src/components/editor.ts | 69 +++++++- packages/pi-tui/test/editor.test.ts | 156 ++++++++++++++++++ 13 files changed, 373 insertions(+), 15 deletions(-) create mode 100644 .changeset/pi-tui-history-filter.md create mode 100644 .changeset/shell-input-history.md diff --git a/.changeset/pi-tui-history-filter.md b/.changeset/pi-tui-history-filter.md new file mode 100644 index 000000000..62f3df09b --- /dev/null +++ b/.changeset/pi-tui-history-filter.md @@ -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`). diff --git a/.changeset/shell-input-history.md b/.changeset/shell-input-history.md new file mode 100644 index 000000000..d80a45f91 --- /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 ee005950c..1fb28a006 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -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(); diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index f3539d39e..27ec94848 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -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(); }; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 90d4c35e8..2d92ec2e4 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 950542563..cade00bec 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/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts index bbead65ec..3651bb81b 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 5bc96cd74..090d47d50 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,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; + + 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'); + }); +}); 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 b5243e894..550122428 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 () => { diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 7bfe48430..d905a8e12 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 d73aeb83c..66749766e 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`。 diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 45f64ba90..75d1c68eb 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -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 */ diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 35463df8a..577bccd18 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -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);