From 0863e0520a0de4051d0e0a7cce710e06be318626 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 03:25:28 +0000 Subject: [PATCH 1/2] fix(tui): enter prompt history with Up only when the input is empty --- .changeset/up-history-empty-draft-only.md | 5 ++ packages/pi-tui/AGENTS.md | 1 + packages/pi-tui/src/components/editor.ts | 15 +++-- .../test/editor-history-keybindings.test.ts | 27 +++++++-- packages/pi-tui/test/editor.test.ts | 58 ++++++++++++++----- 5 files changed, 79 insertions(+), 27 deletions(-) create mode 100644 .changeset/up-history-empty-draft-only.md diff --git a/.changeset/up-history-empty-draft-only.md b/.changeset/up-history-empty-draft-only.md new file mode 100644 index 0000000000..7262ad4628 --- /dev/null +++ b/.changeset/up-history-empty-draft-only.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +The Up arrow recalls prompt history only when the input box is empty. diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 3ee3f9a003..7e87cc4085 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -14,6 +14,7 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`. 7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`. 8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts` — `data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — history recall only from an empty draft**: the Up-arrow path enters history browsing only when the editor is completely empty or already browsing (`isEditorEmpty() || this.historyIndex > -1`); upstream also enters whenever the cursor sits at column 0 of the first visual line, which sweeps a non-empty draft into history. With a draft present, Up stays pure cursor movement (first visual line → jump to line start). The dedicated `tui.editor.historyPrevious` action applies the same empty-draft guard (`historyNext` needs none — it is already a no-op outside browsing). A whitespace-only draft counts as content. Guarding tests: "never enters history from a non-empty draft", "treats a whitespace-only draft as content for history recall", and "does not enter history at the start of the first line of a multi-line draft" in `test/editor.test.ts`, plus "does not enter history from a non-empty draft" in `test/editor-history-keybindings.test.ts`. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 276cac7e7a..4886d8983d 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -873,10 +873,14 @@ export class Editor implements Component, Focusable { return; } - // Dedicated history actions always browse entries instead of moving the cursor. + // Dedicated history actions browse entries instead of moving the cursor. + // Like the Up-arrow path, entering history from the draft requires an empty + // editor so a draft in progress is never swept into history browsing. if (kb.matches(data, "tui.editor.historyPrevious")) { this.cancelAutocomplete(); - this.navigateHistory(-1); + if (this.isEditorEmpty() || this.historyIndex > -1) { + this.navigateHistory(-1); + } return; } if (kb.matches(data, "tui.editor.historyNext")) { @@ -946,10 +950,9 @@ export class Editor implements Component, Focusable { // Arrow key navigation (with history support) if (kb.matches(data, "tui.editor.cursorUp")) { - if ( - this.isOnFirstVisualLine() && - (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0) - ) { + // History recall is only entered from an empty editor (or while already + // browsing); with a draft in progress, Up stays pure cursor movement. + if (this.isOnFirstVisualLine() && (this.isEditorEmpty() || this.historyIndex > -1)) { this.navigateHistory(-1); } else if (this.isOnFirstVisualLine()) { // Already at top - jump to start of line diff --git a/packages/pi-tui/test/editor-history-keybindings.test.ts b/packages/pi-tui/test/editor-history-keybindings.test.ts index c26a4cf26c..a1805845a1 100644 --- a/packages/pi-tui/test/editor-history-keybindings.test.ts +++ b/packages/pi-tui/test/editor-history-keybindings.test.ts @@ -21,9 +21,6 @@ describe("Editor prompt history keybindings", () => { const editor = new Editor(new TuiMainScreen(new VirtualTerminal()), defaultEditorTheme); editor.addToHistory("older prompt"); editor.addToHistory("newer\nmultiline prompt"); - editor.setText("draft"); - editor.handleInput("\x1b[D"); - editor.handleInput("\x1b[D"); editor.handleInput("\x10"); // Ctrl+P assert.strictEqual(editor.getText(), "newer\nmultiline prompt"); @@ -36,8 +33,28 @@ describe("Editor prompt history keybindings", () => { assert.strictEqual(editor.getText(), "newer\nmultiline prompt"); assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 16 }); - editor.handleInput("\x0e"); // Ctrl+N + editor.handleInput("\x0e"); // Ctrl+N - restores the (empty) draft + assert.strictEqual(editor.getText(), ""); + }); + + it("does not enter history from a non-empty draft", () => { + setKeybindings( + new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.editor.historyPrevious": "ctrl+p", + "tui.editor.historyNext": "ctrl+n", + }), + ); + const editor = new Editor(new TuiMainScreen(new VirtualTerminal()), defaultEditorTheme); + editor.addToHistory("older prompt"); + editor.setText("draft"); + + editor.handleInput("\x10"); // Ctrl+P with a draft - blocked, draft untouched assert.strictEqual(editor.getText(), "draft"); - assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); + + // Clearing the draft re-enables recall + editor.setText(""); + editor.handleInput("\x10"); // Ctrl+P on empty editor + assert.strictEqual(editor.getText(), "older prompt"); }); }); diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 7ed25e0241..9d13933b0b 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -135,7 +135,7 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "first"); }); - it("jumps to start before entering history from a non-empty draft", () => { + it("never enters history from a non-empty draft", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("prompt"); @@ -143,15 +143,47 @@ describe("Editor component", () => { editor.handleInput("\x1b[D"); editor.handleInput("\x1b[D"); - editor.handleInput("\x1b[A"); // Up - jumps to start before history browsing + editor.handleInput("\x1b[A"); // Up - jumps to start of line, no history assert.strictEqual(editor.getText(), "draft"); assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); - editor.handleInput("\x1b[A"); // Up at start - shows "prompt" + editor.handleInput("\x1b[A"); // Up at start with content - still no history + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Clearing the draft re-enables history recall + editor.setText(""); + editor.handleInput("\x1b[A"); // Up on empty editor - shows "prompt" assert.strictEqual(editor.getText(), "prompt"); - editor.handleInput("\x1b[B"); // Down - restores draft - assert.strictEqual(editor.getText(), "draft"); + editor.handleInput("\x1b[B"); // Down - restores empty draft + assert.strictEqual(editor.getText(), ""); + }); + + it("treats a whitespace-only draft as content for history recall", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("prompt"); + editor.setText(" "); + + editor.handleInput("\x1b[A"); // Up - whitespace counts as content, no history + assert.strictEqual(editor.getText(), " "); + }); + + it("does not enter history at the start of the first line of a multi-line draft", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("prompt"); + editor.setText("ab\ncd"); + + editor.handleInput("\x1b[A"); // Up - moves to first line + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 }); + + editor.handleInput("\x1b[D"); + editor.handleInput("\x1b[D"); // cursor to column 0 of the first line + + editor.handleInput("\x1b[A"); // Up at (0, 0) with content - no history + assert.strictEqual(editor.getText(), "ab\ncd"); assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); }); @@ -161,10 +193,8 @@ describe("Editor component", () => { editor.addToHistory("first"); editor.addToHistory("second"); editor.addToHistory("third"); - editor.setText("draft"); // Go to oldest - editor.handleInput("\x1b[A"); // start of draft editor.handleInput("\x1b[A"); // third editor.handleInput("\x1b[A"); // second editor.handleInput("\x1b[A"); // first @@ -176,8 +206,8 @@ describe("Editor component", () => { editor.handleInput("\x1b[B"); // third assert.strictEqual(editor.getText(), "third"); - editor.handleInput("\x1b[B"); // draft - assert.strictEqual(editor.getText(), "draft"); + editor.handleInput("\x1b[B"); // back to the (empty) draft + assert.strictEqual(editor.getText(), ""); }); it("exits history mode when typing a character", () => { @@ -397,16 +427,12 @@ describe("Editor component", () => { 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" + editor.handleInput("\x1b[A"); // recall "!cmd" from the empty editor assert.strictEqual(editor.getText(), "!cmd"); - editor.handleInput("\x1b[B"); // restore draft - assert.strictEqual(editor.getText(), "draft"); + editor.handleInput("\x1b[B"); // restore the (empty) draft + assert.strictEqual(editor.getText(), ""); }); }); From 6dfd807695dff5ec79082148741f1bc90c6f2606 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 03:56:08 +0000 Subject: [PATCH 2/2] fix(tui): let guarded historyPrevious fall through to a shared cursorUp binding --- packages/pi-tui/AGENTS.md | 2 +- packages/pi-tui/src/components/editor.ts | 8 +++++- .../test/editor-history-keybindings.test.ts | 26 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 7e87cc4085..2a58efc9ed 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -14,7 +14,7 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`. 7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`. 8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts` — `data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`. -9. **`src/components/editor.ts` — history recall only from an empty draft**: the Up-arrow path enters history browsing only when the editor is completely empty or already browsing (`isEditorEmpty() || this.historyIndex > -1`); upstream also enters whenever the cursor sits at column 0 of the first visual line, which sweeps a non-empty draft into history. With a draft present, Up stays pure cursor movement (first visual line → jump to line start). The dedicated `tui.editor.historyPrevious` action applies the same empty-draft guard (`historyNext` needs none — it is already a no-op outside browsing). A whitespace-only draft counts as content. Guarding tests: "never enters history from a non-empty draft", "treats a whitespace-only draft as content for history recall", and "does not enter history at the start of the first line of a multi-line draft" in `test/editor.test.ts`, plus "does not enter history from a non-empty draft" in `test/editor-history-keybindings.test.ts`. +9. **`src/components/editor.ts` — history recall only from an empty draft**: the Up-arrow path enters history browsing only when the editor is completely empty or already browsing (`isEditorEmpty() || this.historyIndex > -1`); upstream also enters whenever the cursor sits at column 0 of the first visual line, which sweeps a non-empty draft into history. With a draft present, Up stays pure cursor movement (first visual line → jump to line start). The dedicated `tui.editor.historyPrevious` action applies the same empty-draft guard (`historyNext` needs none — it is already a no-op outside browsing); when the guard rejects a press whose key also matches `cursorUp` (default bindings survive key reuse), the event falls through to the cursor branch so the shared key still moves the cursor instead of dead-ending. A whitespace-only draft counts as content. Guarding tests: "never enters history from a non-empty draft", "treats a whitespace-only draft as content for history recall", and "does not enter history at the start of the first line of a multi-line draft" in `test/editor.test.ts`, plus "does not enter history from a non-empty draft" and "falls through to cursor movement when the guard blocks a shared Up binding" in `test/editor-history-keybindings.test.ts`. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 4886d8983d..f0f4574f28 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -880,8 +880,14 @@ export class Editor implements Component, Focusable { this.cancelAutocomplete(); if (this.isEditorEmpty() || this.historyIndex > -1) { this.navigateHistory(-1); + return; + } + // Guard rejected the entry: if this key also drives cursorUp (defaults + // survive key reuse), fall through so the press still moves the cursor + // instead of dead-ending. + if (!kb.matches(data, "tui.editor.cursorUp")) { + return; } - return; } if (kb.matches(data, "tui.editor.historyNext")) { this.cancelAutocomplete(); diff --git a/packages/pi-tui/test/editor-history-keybindings.test.ts b/packages/pi-tui/test/editor-history-keybindings.test.ts index a1805845a1..e3d6877f60 100644 --- a/packages/pi-tui/test/editor-history-keybindings.test.ts +++ b/packages/pi-tui/test/editor-history-keybindings.test.ts @@ -57,4 +57,30 @@ describe("Editor prompt history keybindings", () => { editor.handleInput("\x10"); // Ctrl+P on empty editor assert.strictEqual(editor.getText(), "older prompt"); }); + + it("falls through to cursor movement when the guard blocks a shared Up binding", () => { + setKeybindings( + new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.editor.historyPrevious": "up", + }), + ); + const editor = new Editor(new TuiMainScreen(new VirtualTerminal()), defaultEditorTheme); + editor.addToHistory("prompt"); + editor.setText("ab\ncd"); + + // Guard blocks history entry, but the shared default cursorUp binding must + // still move the cursor instead of dead-ending. + editor.handleInput("\x1b[A"); // Up with a draft - cursor to the first line + assert.strictEqual(editor.getText(), "ab\ncd"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 }); + + editor.handleInput("\x1b[A"); // Up on the first line - jump to line start, no history + assert.strictEqual(editor.getText(), "ab\ncd"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Empty editor: the same key enters history via historyPrevious + editor.setText(""); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "prompt"); + }); });