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