From 31c757600a921bcb38b035c0fb22283a47925e64 Mon Sep 17 00:00:00 2001 From: bj456736 Date: Tue, 18 Aug 2026 09:52:46 +0000 Subject: [PATCH 1/6] fix(tui): keep a held Up key from crossing into prompt history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A held Up key fires key-repeat events that walk the cursor to the top of a multi-line draft and then, without any deliberate keypress, carry the editor into prompt history — where a single stray edit silently drops the unsent draft. Detect held-key repeats (Kitty keyboard protocol event types where available, a 100ms inter-press heuristic elsewhere) and bar them from crossing from the draft into history; discrete presses keep the existing behavior, and once history is entered deliberately, repeats may keep browsing. --- .changeset/held-up-key-history-guard.md | 5 + packages/pi-tui/src/components/editor.ts | 35 +++- packages/pi-tui/test/editor.test.ts | 231 ++++++++++++++++------- 3 files changed, 206 insertions(+), 65 deletions(-) create mode 100644 .changeset/held-up-key-history-guard.md diff --git a/.changeset/held-up-key-history-guard.md b/.changeset/held-up-key-history-guard.md new file mode 100644 index 0000000000..99824dbcf5 --- /dev/null +++ b/.changeset/held-up-key-history-guard.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep a held Up key from scrolling the prompt draft into history. diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 276cac7e7a..be9de13b84 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -1,6 +1,6 @@ import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocomplete.ts"; import { getKeybindings } from "../keybindings.ts"; -import { decodePrintableKey, matchesKey } from "../keys.ts"; +import { decodePrintableKey, isKeyRepeat, isKittyProtocolActive, matchesKey } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { PasteBurst } from "../paste-burst.ts"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts"; @@ -25,6 +25,13 @@ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g; /** Non-global version for single-segment testing. */ const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/; +/** + * Two ↑ events arriving closer together than this are treated as a held key + * (key repeat) rather than discrete presses. Terminals emit held-key repeats + * every ~25-40ms; humans rarely re-press faster than ~100ms. + */ +const UP_ARROW_REPEAT_THRESHOLD_MS = 100; + /** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */ function isPasteMarker(segment: string): boolean { return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment); @@ -344,6 +351,8 @@ export class Editor implements Component, Focusable { private historyDraft: EditorState | null = null; private hostHistoryDraft: unknown = undefined; private historyFilter: ((entry: string) => boolean) | null = null; + /** Timestamp of the previous ↑ key event, for held-key repeat detection. */ + private lastUpArrowAt = 0; // Kill ring for Emacs-style kill/yank operations private killRing = new KillRing(); @@ -484,6 +493,24 @@ export class Editor implements Component, Focusable { return currentVisualLine === visualLines.length - 1; } + /** + * Whether this ↑ event is a held-key repeat: the terminal reported a + * repeat event (Kitty keyboard protocol), or — without that protocol — + * the key arrived faster after the previous ↑ than a human re-presses. + * A user holding ↑ to reach the top of a long draft expects to stop + * there, so repeats must not carry the editor from the draft into + * history browsing; once history was entered by a discrete press, + * repeats may keep browsing. + */ + private isUpArrowRepeat(data: string): boolean { + const now = Date.now(); + const repeat = isKittyProtocolActive() + ? isKeyRepeat(data) + : now - this.lastUpArrowAt < UP_ARROW_REPEAT_THRESHOLD_MS; + this.lastUpArrowAt = now; + return repeat; + } + private navigateHistory(direction: 1 | -1): void { this.lastAction = null; if (this.history.length === 0) return; @@ -946,9 +973,13 @@ export class Editor implements Component, Focusable { // Arrow key navigation (with history support) if (kb.matches(data, "tui.editor.cursorUp")) { + const upArrowRepeat = this.isUpArrowRepeat(data); if ( this.isOnFirstVisualLine() && - (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0) + (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0) && + // A held ↑ must not cross from the draft into history; a discrete + // press still enters, and once browsing, repeats keep browsing. + !(upArrowRepeat && this.historyIndex === -1 && this.history.length > 0) ) { this.navigateHistory(-1); } else if (this.isOnFirstVisualLine()) { diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 7ed25e0241..e6190b17a1 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -3,6 +3,7 @@ import { describe, it, mock } from "node:test"; import { stripVTControlCharacters } from "node:util"; import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts"; import { Editor, wordWrapLine } from "../src/components/editor.ts"; +import { setKittyProtocolActive } from "../src/keys.ts"; import { PasteBurst } from "../src/paste-burst.ts"; import type { TUI } from "../src/tui.ts"; import { TuiMainScreen } from "../src/tui-main-screen.ts"; @@ -136,48 +137,130 @@ describe("Editor component", () => { }); it("jumps to start before entering history from a non-empty draft", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); + // Mocked clock: the second Up must read as a discrete press, not a + // held-key repeat (repeats are barred from entering history). + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); - editor.addToHistory("prompt"); - editor.setText("draft"); - editor.handleInput("\x1b[D"); - editor.handleInput("\x1b[D"); + editor.addToHistory("prompt"); + editor.setText("draft"); + editor.handleInput("\x1b[D"); + editor.handleInput("\x1b[D"); - editor.handleInput("\x1b[A"); // Up - jumps to start before history browsing - assert.strictEqual(editor.getText(), "draft"); - assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + editor.handleInput("\x1b[A"); // Up - jumps to start before history browsing + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); - editor.handleInput("\x1b[A"); // Up at start - shows "prompt" - assert.strictEqual(editor.getText(), "prompt"); + mock.timers.tick(200); + editor.handleInput("\x1b[A"); // Up at start - shows "prompt" + assert.strictEqual(editor.getText(), "prompt"); - editor.handleInput("\x1b[B"); // Down - restores draft - assert.strictEqual(editor.getText(), "draft"); - assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + editor.handleInput("\x1b[B"); // Down - restores draft + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + } finally { + mock.timers.reset(); + } + }); + + it("keeps a held Up key from crossing into history (repeat guard)", () => { + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("older"); + editor.addToHistory("newer"); + editor.setText("line one\nline two\nline three"); + + // Held key: repeats arrive every ~30ms. The cursor climbs to the + // top and stops there instead of entering history. + for (let i = 0; i < 8; i++) { + editor.handleInput("\x1b[A"); + mock.timers.tick(30); + } + assert.strictEqual(editor.getText(), "line one\nline two\nline three"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Releasing and pressing again is a discrete press: it enters history. + mock.timers.tick(200); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "newer"); + } finally { + mock.timers.reset(); + } + }); + + it("keeps browsing history while Up is held once history was entered discretely", () => { + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("first"); + editor.addToHistory("second"); + + editor.handleInput("\x1b[A"); // discrete press - shows "second" + assert.strictEqual(editor.getText(), "second"); + + mock.timers.tick(30); // held-key repeat keeps browsing + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "first"); + } finally { + mock.timers.reset(); + } + }); + + it("bars Kitty protocol repeat events from entering history", () => { + setKittyProtocolActive(true); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("prompt"); + editor.setText("draft"); + + editor.handleInput("\x1b[A"); // discrete press - jumps to line start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1b[1;1:2A"); // held-key repeat - stays on the draft + assert.strictEqual(editor.getText(), "draft"); + + editor.handleInput("\x1b[A"); // discrete press - enters history + assert.strictEqual(editor.getText(), "prompt"); + } finally { + setKittyProtocolActive(false); + } }); it("navigates forward through history with Down arrow", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); - editor.addToHistory("first"); - editor.addToHistory("second"); - editor.addToHistory("third"); - editor.setText("draft"); + 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 + // Go to oldest (each Up spaced out so it reads as a discrete press) + editor.handleInput("\x1b[A"); // start of draft + mock.timers.tick(200); + editor.handleInput("\x1b[A"); // third + editor.handleInput("\x1b[A"); // second + editor.handleInput("\x1b[A"); // first - // Navigate back - editor.handleInput("\x1b[B"); // second - assert.strictEqual(editor.getText(), "second"); + // Navigate back + editor.handleInput("\x1b[B"); // second + assert.strictEqual(editor.getText(), "second"); - editor.handleInput("\x1b[B"); // third - assert.strictEqual(editor.getText(), "third"); + editor.handleInput("\x1b[B"); // third + assert.strictEqual(editor.getText(), "third"); - editor.handleInput("\x1b[B"); // draft - assert.strictEqual(editor.getText(), "draft"); + editor.handleInput("\x1b[B"); // draft + assert.strictEqual(editor.getText(), "draft"); + } finally { + mock.timers.reset(); + } }); it("exits history mode when typing a character", () => { @@ -192,17 +275,25 @@ describe("Editor component", () => { }); it("exits history mode on setText", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); - editor.addToHistory("first"); - editor.addToHistory("second"); + editor.addToHistory("first"); + editor.addToHistory("second"); - editor.handleInput("\x1b[A"); // Up - shows "second" - editor.setText(""); // External clear + editor.handleInput("\x1b[A"); // Up - shows "second" + editor.setText(""); // External clear - // Up should start fresh from most recent - editor.handleInput("\x1b[A"); - assert.strictEqual(editor.getText(), "second"); + // Up should start fresh from most recent (spaced out so it reads + // as a discrete press, not a held-key repeat) + mock.timers.tick(200); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "second"); + } finally { + mock.timers.reset(); + } }); it("does not add empty strings to history", () => { @@ -394,19 +485,26 @@ describe("Editor component", () => { }); 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"); + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + 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 + mock.timers.tick(200); // discrete press, not a held-key repeat + editor.handleInput("\x1b[A"); // recall "!cmd" + assert.strictEqual(editor.getText(), "!cmd"); + + editor.handleInput("\x1b[B"); // restore draft + assert.strictEqual(editor.getText(), "draft"); + } finally { + mock.timers.reset(); + } }); }); @@ -480,19 +578,26 @@ describe("Editor component", () => { }); 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++; - }; + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + 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); + editor.handleInput("\x1b[A"); // recall + editor.handleInput("\x1b[B"); // restore draft (count=1) + mock.timers.tick(200); // discrete press, not a held-key repeat + editor.handleInput("\x1b[A"); // recall again + editor.handleInput("\x1b[B"); // restore draft again (count=2) + assert.strictEqual(count, 2); + } finally { + mock.timers.reset(); + } }); }); From 72049b60cca6e73d4b898f2934f19605acb31868 Mon Sep 17 00:00:00 2001 From: bj456736 Date: Tue, 18 Aug 2026 10:38:34 +0000 Subject: [PATCH 2/6] fix(tui): snap back when a held Up key's initial repeat delay outruns the guard On legacy terminals the first autorepeat arrives only after the keyboard's initial repeat delay (X11 defaults to 660ms, macOS/Windows up to ~1s), so it outruns the 100ms inter-press heuristic and still crosses from the draft into history. Arm a snap-back on such crossings: a repeat-classified Up arriving within the initial-delay window proves the hold and navigates back out to the draft. Crossings followed by deliberate navigation past the first entry are disarmed. Also register the held-Up guard as divergence #9 in the pi-tui AGENTS.md local-divergence list so re-vendoring preserves it. --- packages/pi-tui/AGENTS.md | 1 + packages/pi-tui/src/components/editor.ts | 56 ++++++++++++++++++++---- packages/pi-tui/test/editor.test.ts | 54 ++++++++++++++++++++++- 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 3ee3f9a003..c7eb145b7d 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` — held-↑ repeat guard at the history boundary**: a held Up key must not carry the editor from the draft into prompt history. Repeats are detected via Kitty keyboard protocol event types when the protocol is active, otherwise via a 100ms inter-press heuristic (`UP_ARROW_REPEAT_THRESHOLD_MS`); once history was entered by a discrete press, repeats may keep browsing. Because a legacy keyboard's initial repeat delay (X11 defaults to 660ms; macOS/Windows up to ~1s) outruns that threshold and lets the first autorepeat cross anyway, a crossing armed within `UP_ARROW_INITIAL_DELAY_MAX_MS` that is followed by a repeat-classified ↑ snaps back out to the draft. Guarding tests: "keeps a held Up key from crossing into history (repeat guard)", "snaps back out of history when a held Up key's initial repeat delay outruns the threshold", "keeps browsing history while Up is held once history was entered discretely", and "bars Kitty protocol repeat events from entering history" in `test/editor.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 be9de13b84..f64a6b0100 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -32,6 +32,13 @@ const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/; */ const UP_ARROW_REPEAT_THRESHOLD_MS = 100; +/** + * Upper bound of a keyboard's initial repeat delay (how long a key must be + * held before autorepeat starts) that the history snap-back accounts for. + * X11 defaults to 660ms; macOS and Windows repeat delays top out around 1s. + */ +const UP_ARROW_INITIAL_DELAY_MAX_MS = 1200; + /** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */ function isPasteMarker(segment: string): boolean { return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment); @@ -353,6 +360,12 @@ export class Editor implements Component, Focusable { private historyFilter: ((entry: string) => boolean) | null = null; /** Timestamp of the previous ↑ key event, for held-key repeat detection. */ private lastUpArrowAt = 0; + /** + * Set when ↑ crosses from the draft into history soon after a previous ↑: + * the crossing may still prove to be a held key's first autorepeat (its + * initial delay outran the repeat threshold), arming the snap-back. + */ + private pendingHeldUpCrossingAt = 0; // Kill ring for Emacs-style kill/yank operations private killRing = new KillRing(); @@ -494,21 +507,22 @@ export class Editor implements Component, Focusable { } /** - * Whether this ↑ event is a held-key repeat: the terminal reported a - * repeat event (Kitty keyboard protocol), or — without that protocol — - * the key arrived faster after the previous ↑ than a human re-presses. + * Classify this ↑ event: a held-key repeat when the terminal reported a + * repeat event (Kitty keyboard protocol) or — without that protocol — + * when it arrived faster after the previous ↑ than a human re-presses. * A user holding ↑ to reach the top of a long draft expects to stop * there, so repeats must not carry the editor from the draft into * history browsing; once history was entered by a discrete press, * repeats may keep browsing. */ - private isUpArrowRepeat(data: string): boolean { + private upArrowRepeatInfo(data: string): { now: number; gap: number; repeat: boolean } { const now = Date.now(); + const gap = now - this.lastUpArrowAt; + this.lastUpArrowAt = now; const repeat = isKittyProtocolActive() ? isKeyRepeat(data) - : now - this.lastUpArrowAt < UP_ARROW_REPEAT_THRESHOLD_MS; - this.lastUpArrowAt = now; - return repeat; + : gap < UP_ARROW_REPEAT_THRESHOLD_MS; + return { now, gap, repeat }; } private navigateHistory(direction: 1 | -1): void { @@ -973,14 +987,38 @@ export class Editor implements Component, Focusable { // Arrow key navigation (with history support) if (kb.matches(data, "tui.editor.cursorUp")) { - const upArrowRepeat = this.isUpArrowRepeat(data); + const { now, gap, repeat } = this.upArrowRepeatInfo(data); + + // Snap back: a repeat-classified ↑ right after a recent crossing + // proves the "discrete" press that crossed was really a held key's + // first autorepeat — the keyboard's initial repeat delay outran + // the repeat threshold. Return to the draft and stay there. + if ( + repeat && + this.pendingHeldUpCrossingAt > 0 && + now - this.pendingHeldUpCrossingAt < UP_ARROW_INITIAL_DELAY_MAX_MS && + this.historyIndex > -1 + ) { + this.pendingHeldUpCrossingAt = 0; + this.navigateHistory(1); + return; + } + if ( this.isOnFirstVisualLine() && (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0) && // A held ↑ must not cross from the draft into history; a discrete // press still enters, and once browsing, repeats keep browsing. - !(upArrowRepeat && this.historyIndex === -1 && this.history.length > 0) + !(repeat && this.historyIndex === -1 && this.history.length > 0) ) { + if (this.historyIndex === -1) { + // A crossing soon after a previous ↑ may still prove to be a + // held key's first autorepeat — arm the snap-back above. + this.pendingHeldUpCrossingAt = gap < UP_ARROW_INITIAL_DELAY_MAX_MS ? now : 0; + } else { + // Browsing past the first entry is deliberate navigation. + this.pendingHeldUpCrossingAt = 0; + } this.navigateHistory(-1); } else if (this.isOnFirstVisualLine()) { // Already at top - jump to start of line diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index e6190b17a1..c06ba16cc8 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -199,13 +199,63 @@ describe("Editor component", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("first"); editor.addToHistory("second"); + editor.addToHistory("third"); + + editor.handleInput("\x1b[A"); // discrete press - shows "third" + assert.strictEqual(editor.getText(), "third"); + mock.timers.tick(200); editor.handleInput("\x1b[A"); // discrete press - shows "second" assert.strictEqual(editor.getText(), "second"); - mock.timers.tick(30); // held-key repeat keeps browsing + // The user then holds the key: the first autorepeat outruns the + // repeat threshold (initial delay), but browsing continues — + // navigation past the first entry is already deliberate. + mock.timers.tick(500); editor.handleInput("\x1b[A"); assert.strictEqual(editor.getText(), "first"); + + mock.timers.tick(30); // held-key repeat - no snap-back, keeps browsing + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "first"); + } finally { + mock.timers.reset(); + } + }); + + it("snaps back out of history when a held Up key's initial repeat delay outruns the threshold", () => { + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("older"); + editor.addToHistory("newer"); + editor.setText("one line draft"); + + editor.handleInput("\x1b[A"); // discrete press - jumps to line start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // First autorepeat after the keyboard's initial repeat delay: + // too slow for the repeat threshold, so it still crosses... + mock.timers.tick(500); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "newer"); + + // ...but the next repeat arrives fast and proves the hold: + // snap back out to the draft. + mock.timers.tick(30); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "one line draft"); + + // Still held: further repeats stay on the draft. + mock.timers.tick(30); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "one line draft"); + + // Released and pressed again: a discrete press enters history. + mock.timers.tick(200); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "newer"); } finally { mock.timers.reset(); } @@ -246,7 +296,9 @@ describe("Editor component", () => { editor.handleInput("\x1b[A"); // start of draft mock.timers.tick(200); editor.handleInput("\x1b[A"); // third + mock.timers.tick(200); editor.handleInput("\x1b[A"); // second + mock.timers.tick(200); editor.handleInput("\x1b[A"); // first // Navigate back From a2c8686d3c0c7d5acda88c1705b4db31cfd61ae7 Mon Sep 17 00:00:00 2001 From: bj456736 Date: Tue, 18 Aug 2026 10:50:43 +0000 Subject: [PATCH 3/6] docs(pi-tui): note the sub-10Hz autorepeat limit of the held-Up guard --- packages/pi-tui/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index c7eb145b7d..e462be1ca8 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` — held-↑ repeat guard at the history boundary**: a held Up key must not carry the editor from the draft into prompt history. Repeats are detected via Kitty keyboard protocol event types when the protocol is active, otherwise via a 100ms inter-press heuristic (`UP_ARROW_REPEAT_THRESHOLD_MS`); once history was entered by a discrete press, repeats may keep browsing. Because a legacy keyboard's initial repeat delay (X11 defaults to 660ms; macOS/Windows up to ~1s) outruns that threshold and lets the first autorepeat cross anyway, a crossing armed within `UP_ARROW_INITIAL_DELAY_MAX_MS` that is followed by a repeat-classified ↑ snaps back out to the draft. Guarding tests: "keeps a held Up key from crossing into history (repeat guard)", "snaps back out of history when a held Up key's initial repeat delay outruns the threshold", "keeps browsing history while Up is held once history was entered discretely", and "bars Kitty protocol repeat events from entering history" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — held-↑ repeat guard at the history boundary**: a held Up key must not carry the editor from the draft into prompt history. Repeats are detected via Kitty keyboard protocol event types when the protocol is active, otherwise via a 100ms inter-press heuristic (`UP_ARROW_REPEAT_THRESHOLD_MS`); once history was entered by a discrete press, repeats may keep browsing. Because a legacy keyboard's initial repeat delay (X11 defaults to 660ms; macOS/Windows up to ~1s) outruns that threshold and lets the first autorepeat cross anyway, a crossing armed within `UP_ARROW_INITIAL_DELAY_MAX_MS` that is followed by a repeat-classified ↑ snaps back out to the draft. Known limit: on legacy terminals an autorepeat stream configured slower than ~10Hz produces uniform ≥100ms gaps that are indistinguishable from deliberate rapid tapping, so it is intentionally left unguarded rather than eating the double-tap-to-enter gesture (the draft snapshot still protects the content, and Kitty-protocol terminals are exact at any rate). Guarding tests: "keeps a held Up key from crossing into history (repeat guard)", "snaps back out of history when a held Up key's initial repeat delay outruns the threshold", "keeps browsing history while Up is held once history was entered discretely", and "bars Kitty protocol repeat events from entering history" in `test/editor.test.ts`. ## Acceptance after syncing from upstream From ec2c3e613c5c4b98b1271d13b51406ec440cfcb2 Mon Sep 17 00:00:00 2001 From: bj456736 Date: Tue, 18 Aug 2026 11:04:17 +0000 Subject: [PATCH 4/6] fix(tui): break the Up repeat chain on any intervening key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-Up key between two Up presses proves the stream was interrupted, so the next Up must read as a fresh press rather than an autorepeat of the earlier one. Without the reset, Up → Down → quick Up failed to re-enter history. The multiple-browse-session test now exercises the reset path instead of masking it with a clock advance, and a dedicated regression test covers the interrupted-stream case. --- packages/pi-tui/AGENTS.md | 2 +- packages/pi-tui/src/components/editor.ts | 6 ++++++ packages/pi-tui/test/editor.test.ts | 24 +++++++++++++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index e462be1ca8..491493edae 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` — held-↑ repeat guard at the history boundary**: a held Up key must not carry the editor from the draft into prompt history. Repeats are detected via Kitty keyboard protocol event types when the protocol is active, otherwise via a 100ms inter-press heuristic (`UP_ARROW_REPEAT_THRESHOLD_MS`); once history was entered by a discrete press, repeats may keep browsing. Because a legacy keyboard's initial repeat delay (X11 defaults to 660ms; macOS/Windows up to ~1s) outruns that threshold and lets the first autorepeat cross anyway, a crossing armed within `UP_ARROW_INITIAL_DELAY_MAX_MS` that is followed by a repeat-classified ↑ snaps back out to the draft. Known limit: on legacy terminals an autorepeat stream configured slower than ~10Hz produces uniform ≥100ms gaps that are indistinguishable from deliberate rapid tapping, so it is intentionally left unguarded rather than eating the double-tap-to-enter gesture (the draft snapshot still protects the content, and Kitty-protocol terminals are exact at any rate). Guarding tests: "keeps a held Up key from crossing into history (repeat guard)", "snaps back out of history when a held Up key's initial repeat delay outruns the threshold", "keeps browsing history while Up is held once history was entered discretely", and "bars Kitty protocol repeat events from entering history" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — held-↑ repeat guard at the history boundary**: a held Up key must not carry the editor from the draft into prompt history. Repeats are detected via Kitty keyboard protocol event types when the protocol is active, otherwise via a 100ms inter-press heuristic (`UP_ARROW_REPEAT_THRESHOLD_MS`); once history was entered by a discrete press, repeats may keep browsing. Because a legacy keyboard's initial repeat delay (X11 defaults to 660ms; macOS/Windows up to ~1s) outruns that threshold and lets the first autorepeat cross anyway, a crossing armed within `UP_ARROW_INITIAL_DELAY_MAX_MS` that is followed by a repeat-classified ↑ snaps back out to the draft; any non-↑ key resets the timing chain so an interrupted stream's next ↑ reads as a fresh press. Known limit: on legacy terminals an autorepeat stream configured slower than ~10Hz produces uniform ≥100ms gaps that are indistinguishable from deliberate rapid tapping, so it is intentionally left unguarded rather than eating the double-tap-to-enter gesture (the draft snapshot still protects the content, and Kitty-protocol terminals are exact at any rate). Guarding tests: "keeps a held Up key from crossing into history (repeat guard)", "snaps back out of history when a held Up key's initial repeat delay outruns the threshold", "keeps browsing history while Up is held once history was entered discretely", "treats an Up after an intervening key as a fresh press", and "bars Kitty protocol repeat events from entering history" in `test/editor.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 f64a6b0100..4b67dd1e53 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -739,6 +739,12 @@ export class Editor implements Component, Focusable { handleInput(data: string): void { const kb = getKeybindings(); + // A non-↑ key between two ↑ presses breaks the repeat stream — the + // next ↑ is a fresh press, not an autorepeat of the earlier one. + if (!kb.matches(data, "tui.editor.cursorUp")) { + this.lastUpArrowAt = 0; + } + // Handle character jump mode (awaiting next character to jump to) if (this.jumpMode !== null) { // Cancel if the hotkey is pressed again diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index c06ba16cc8..a4c4208171 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -261,6 +261,27 @@ describe("Editor component", () => { } }); + it("treats an Up after an intervening key as a fresh press", () => { + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("entry"); + + editor.handleInput("\x1b[A"); // discrete press - enters history + assert.strictEqual(editor.getText(), "entry"); + + mock.timers.tick(30); + editor.handleInput("\x1b[B"); // Down restores the empty draft + + mock.timers.tick(30); // quick, but the Down broke the repeat stream + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "entry"); + } finally { + mock.timers.reset(); + } + }); + it("bars Kitty protocol repeat events from entering history", () => { setKittyProtocolActive(true); try { @@ -643,7 +664,8 @@ describe("Editor component", () => { editor.handleInput("\x1b[A"); // recall editor.handleInput("\x1b[B"); // restore draft (count=1) - mock.timers.tick(200); // discrete press, not a held-key repeat + // No clock advance needed: the intervening Down broke the ↑ + // repeat stream, so the next Up reads as a fresh press. editor.handleInput("\x1b[A"); // recall again editor.handleInput("\x1b[B"); // restore draft again (count=2) assert.strictEqual(count, 2); From 95f55050659d571807b51c3b0f4729d3d70d334f Mon Sep 17 00:00:00 2001 From: bj456736 Date: Tue, 18 Aug 2026 11:17:14 +0000 Subject: [PATCH 5/6] fix(tui): reset the Up repeat chain on programmatic and intercepted input The chain reset lived in the base Editor's handleInput, but hosts also break the stream outside it: programmatic setText ('') clears (e.g. the Ctrl+C clear-draft path) and CustomEditor consumes shortcuts like Ctrl+C before they ever reach super.handleInput. Expose resetUpArrowRepeatChain(), call it from setText, and reset in CustomEditor for intercepted non-Up keys. The setText history test now exercises the reset instead of masking the stale timestamp with a clock advance; a new CustomEditor test covers the intercepted-shortcut path. --- .../tui/components/editor/custom-editor.ts | 7 +++++ .../components/editor/custom-editor.test.ts | 30 +++++++++++++++++++ packages/pi-tui/AGENTS.md | 2 +- packages/pi-tui/src/components/editor.ts | 12 +++++++- packages/pi-tui/test/editor.test.ts | 7 ++--- 5 files changed, 52 insertions(+), 6 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 2a28020932..310764ebf2 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -366,6 +366,13 @@ export class CustomEditor extends Editor { return; } + // A non-↑ key breaks any held-↑ repeat stream. The base Editor resets + // on keys it sees, but this class consumes some (Ctrl+C, Escape, …) + // before they ever reach super.handleInput — reset here too. + if (!matchesKey(normalized, Key.up)) { + this.resetUpArrowRepeatChain(); + } + // Clipboard reads are asynchronous. Queue every key received while a // paste callback is in flight and replay it once the callback settles // (clipboard read + placeholder insert — compression and the daemon 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 6b68d83e19..255f975b13 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 @@ -815,3 +815,33 @@ describe('CustomEditor bash mode file completion', () => { expect(calls.every((call) => call.force === true)).toBe(true); }); }); + +describe('CustomEditor held-Up repeat guard', () => { + it('treats an Up after an intercepted shortcut as a fresh press', () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(1_000); + const editor = makeEditor(); + editor.addToHistory('entry'); + editor.setText('ab'); + editor.render(90); // establish layout width for visual-line math + + editor.handleInput('\u001B[A'); // Up: cursor jumps to line start + expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); + + vi.setSystemTime(1_030); + const onCtrlC = vi.fn(); + editor.onCtrlC = onCtrlC; + editor.handleInput('\x03'); // Ctrl+C: intercepted, never reaches super.handleInput + expect(onCtrlC).toHaveBeenCalled(); + + vi.setSystemTime(1_060); + // 60ms after the previous Up, but the intercepted Ctrl+C broke the + // repeat stream — this is a fresh press and must recall history. + editor.handleInput('\u001B[A'); + expect(editor.getText()).toBe('entry'); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 491493edae..99569e9fd9 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` — held-↑ repeat guard at the history boundary**: a held Up key must not carry the editor from the draft into prompt history. Repeats are detected via Kitty keyboard protocol event types when the protocol is active, otherwise via a 100ms inter-press heuristic (`UP_ARROW_REPEAT_THRESHOLD_MS`); once history was entered by a discrete press, repeats may keep browsing. Because a legacy keyboard's initial repeat delay (X11 defaults to 660ms; macOS/Windows up to ~1s) outruns that threshold and lets the first autorepeat cross anyway, a crossing armed within `UP_ARROW_INITIAL_DELAY_MAX_MS` that is followed by a repeat-classified ↑ snaps back out to the draft; any non-↑ key resets the timing chain so an interrupted stream's next ↑ reads as a fresh press. Known limit: on legacy terminals an autorepeat stream configured slower than ~10Hz produces uniform ≥100ms gaps that are indistinguishable from deliberate rapid tapping, so it is intentionally left unguarded rather than eating the double-tap-to-enter gesture (the draft snapshot still protects the content, and Kitty-protocol terminals are exact at any rate). Guarding tests: "keeps a held Up key from crossing into history (repeat guard)", "snaps back out of history when a held Up key's initial repeat delay outruns the threshold", "keeps browsing history while Up is held once history was entered discretely", "treats an Up after an intervening key as a fresh press", and "bars Kitty protocol repeat events from entering history" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — held-↑ repeat guard at the history boundary**: a held Up key must not carry the editor from the draft into prompt history. Repeats are detected via Kitty keyboard protocol event types when the protocol is active, otherwise via a 100ms inter-press heuristic (`UP_ARROW_REPEAT_THRESHOLD_MS`); once history was entered by a discrete press, repeats may keep browsing. Because a legacy keyboard's initial repeat delay (X11 defaults to 660ms; macOS/Windows up to ~1s) outruns that threshold and lets the first autorepeat cross anyway, a crossing armed within `UP_ARROW_INITIAL_DELAY_MAX_MS` that is followed by a repeat-classified ↑ snaps back out to the draft; any non-↑ key resets the timing chain (via `resetUpArrowRepeatChain()`, which `setText` and subclass-intercepted shortcuts such as CustomEditor's Ctrl+C also call) so an interrupted stream's next ↑ reads as a fresh press. Known limit: on legacy terminals an autorepeat stream configured slower than ~10Hz produces uniform ≥100ms gaps that are indistinguishable from deliberate rapid tapping, so it is intentionally left unguarded rather than eating the double-tap-to-enter gesture (the draft snapshot still protects the content, and Kitty-protocol terminals are exact at any rate). Guarding tests: "keeps a held Up key from crossing into history (repeat guard)", "snaps back out of history when a held Up key's initial repeat delay outruns the threshold", "keeps browsing history while Up is held once history was entered discretely", "treats an Up after an intervening key as a fresh press", and "bars Kitty protocol repeat events from entering history" in `test/editor.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 4b67dd1e53..bcc1af2847 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -525,6 +525,15 @@ export class Editor implements Component, Focusable { return { now, gap, repeat }; } + /** + * Break the held-↑ repeat stream: the next ↑ reads as a fresh press. + * Called for non-↑ keys here, and by hosts for programmatic text changes + * or subclass-intercepted shortcuts the base class never sees. + */ + resetUpArrowRepeatChain(): void { + this.lastUpArrowAt = 0; + } + private navigateHistory(direction: 1 | -1): void { this.lastAction = null; if (this.history.length === 0) return; @@ -742,7 +751,7 @@ export class Editor implements Component, Focusable { // A non-↑ key between two ↑ presses breaks the repeat stream — the // next ↑ is a fresh press, not an autorepeat of the earlier one. if (!kb.matches(data, "tui.editor.cursorUp")) { - this.lastUpArrowAt = 0; + this.resetUpArrowRepeatChain(); } // Handle character jump mode (awaiting next character to jump to) @@ -1219,6 +1228,7 @@ export class Editor implements Component, Focusable { this.cancelAutocomplete(); this.lastAction = null; this.exitHistoryBrowsing(); + this.resetUpArrowRepeatChain(); const normalized = this.normalizeText(text); // Push undo snapshot if content differs (makes programmatic changes undoable) if (this.getText() !== normalized) { diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index a4c4208171..ed30f71d02 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -357,11 +357,10 @@ describe("Editor component", () => { editor.addToHistory("second"); editor.handleInput("\x1b[A"); // Up - shows "second" - editor.setText(""); // External clear + editor.setText(""); // External clear - also breaks the ↑ repeat stream - // Up should start fresh from most recent (spaced out so it reads - // as a discrete press, not a held-key repeat) - mock.timers.tick(200); + // Up should start fresh from most recent; no clock advance is + // needed because setText reset the repeat chain. editor.handleInput("\x1b[A"); assert.strictEqual(editor.getText(), "second"); } finally { From 087fb7f08f67f6b2df114984f647b576afe74a80 Mon Sep 17 00:00:00 2001 From: bj456736 Date: Tue, 18 Aug 2026 11:44:21 +0000 Subject: [PATCH 6/6] fix(tui): arm the legacy snap-back only for legacy input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the Kitty protocol the crossing event's press/repeat type is exact, so a press crossing is deliberate — arming the timing-based snap-back anyway made the first repeat of that same held key yank the editor back to the draft instead of continuing through history. Only arm when the Kitty protocol is inactive. --- packages/pi-tui/AGENTS.md | 2 +- packages/pi-tui/src/components/editor.ts | 7 ++++++- packages/pi-tui/test/editor.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 99569e9fd9..0f014f71f7 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` — held-↑ repeat guard at the history boundary**: a held Up key must not carry the editor from the draft into prompt history. Repeats are detected via Kitty keyboard protocol event types when the protocol is active, otherwise via a 100ms inter-press heuristic (`UP_ARROW_REPEAT_THRESHOLD_MS`); once history was entered by a discrete press, repeats may keep browsing. Because a legacy keyboard's initial repeat delay (X11 defaults to 660ms; macOS/Windows up to ~1s) outruns that threshold and lets the first autorepeat cross anyway, a crossing armed within `UP_ARROW_INITIAL_DELAY_MAX_MS` that is followed by a repeat-classified ↑ snaps back out to the draft; any non-↑ key resets the timing chain (via `resetUpArrowRepeatChain()`, which `setText` and subclass-intercepted shortcuts such as CustomEditor's Ctrl+C also call) so an interrupted stream's next ↑ reads as a fresh press. Known limit: on legacy terminals an autorepeat stream configured slower than ~10Hz produces uniform ≥100ms gaps that are indistinguishable from deliberate rapid tapping, so it is intentionally left unguarded rather than eating the double-tap-to-enter gesture (the draft snapshot still protects the content, and Kitty-protocol terminals are exact at any rate). Guarding tests: "keeps a held Up key from crossing into history (repeat guard)", "snaps back out of history when a held Up key's initial repeat delay outruns the threshold", "keeps browsing history while Up is held once history was entered discretely", "treats an Up after an intervening key as a fresh press", and "bars Kitty protocol repeat events from entering history" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — held-↑ repeat guard at the history boundary**: a held Up key must not carry the editor from the draft into prompt history. Repeats are detected via Kitty keyboard protocol event types when the protocol is active, otherwise via a 100ms inter-press heuristic (`UP_ARROW_REPEAT_THRESHOLD_MS`); once history was entered by a discrete press, repeats may keep browsing. Because a legacy keyboard's initial repeat delay (X11 defaults to 660ms; macOS/Windows up to ~1s) outruns that threshold and lets the first autorepeat cross anyway, a crossing armed within `UP_ARROW_INITIAL_DELAY_MAX_MS` (armed on legacy input only — Kitty press/repeat events are exact and never arm it) that is followed by a repeat-classified ↑ snaps back out to the draft; any non-↑ key resets the timing chain (via `resetUpArrowRepeatChain()`, which `setText` and subclass-intercepted shortcuts such as CustomEditor's Ctrl+C also call) so an interrupted stream's next ↑ reads as a fresh press. Known limit: on legacy terminals an autorepeat stream configured slower than ~10Hz produces uniform ≥100ms gaps that are indistinguishable from deliberate rapid tapping, so it is intentionally left unguarded rather than eating the double-tap-to-enter gesture (the draft snapshot still protects the content, and Kitty-protocol terminals are exact at any rate). Guarding tests: "keeps a held Up key from crossing into history (repeat guard)", "snaps back out of history when a held Up key's initial repeat delay outruns the threshold", "keeps browsing history while Up is held once history was entered discretely", "treats an Up after an intervening key as a fresh press", "lets a held key keep browsing after a deliberate Kitty press crossing", and "bars Kitty protocol repeat events from entering history" in `test/editor.test.ts` (plus "treats an Up after an intercepted shortcut as a fresh press" in `apps/kimi-code/test/tui/components/editor/custom-editor.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 bcc1af2847..b9dfea3db8 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -1029,7 +1029,12 @@ export class Editor implements Component, Focusable { if (this.historyIndex === -1) { // A crossing soon after a previous ↑ may still prove to be a // held key's first autorepeat — arm the snap-back above. - this.pendingHeldUpCrossingAt = gap < UP_ARROW_INITIAL_DELAY_MAX_MS ? now : 0; + // Legacy input only: with the Kitty protocol the crossing + // event's press/repeat type is exact, so a press crossing + // is deliberate and holding that key afterwards must be + // free to keep browsing. + this.pendingHeldUpCrossingAt = + !isKittyProtocolActive() && gap < UP_ARROW_INITIAL_DELAY_MAX_MS ? now : 0; } else { // Browsing past the first entry is deliberate navigation. this.pendingHeldUpCrossingAt = 0; diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index ed30f71d02..a5916c1bd6 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -302,6 +302,28 @@ describe("Editor component", () => { } }); + it("lets a held key keep browsing after a deliberate Kitty press crossing", () => { + setKittyProtocolActive(true); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("older"); + editor.addToHistory("newer"); + editor.setText("draft"); + + editor.handleInput("\x1b[A"); // press - jumps to line start + editor.handleInput("\x1b[A"); // deliberate press - enters history + assert.strictEqual(editor.getText(), "newer"); + + // Holding that same key sends explicit repeat events: they must + // keep browsing, not snap back — the press crossing was exact, + // so no legacy snap-back is ever armed under Kitty. + editor.handleInput("\x1b[1;1:2A"); + assert.strictEqual(editor.getText(), "older"); + } finally { + setKittyProtocolActive(false); + } + }); + it("navigates forward through history with Down arrow", () => { mock.timers.enable({ apis: ["Date"] }); mock.timers.setTime(1000);