Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/held-up-key-history-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep a held Up key from scrolling the prompt draft into history.
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions apps/kimi-code/test/tui/components/editor/custom-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
1 change: 1 addition & 0 deletions packages/pi-tui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`. 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` (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

Expand Down
94 changes: 92 additions & 2 deletions packages/pi-tui/src/components/editor.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,6 +25,20 @@ 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;
Comment thread
bj456736 marked this conversation as resolved.

/**
* 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);
Expand Down Expand Up @@ -344,6 +358,14 @@ 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;
/**
* 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();
Expand Down Expand Up @@ -484,6 +506,34 @@ export class Editor implements Component, Focusable {
return currentVisualLine === visualLines.length - 1;
}

/**
* 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 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)
: gap < UP_ARROW_REPEAT_THRESHOLD_MS;
Comment thread
bj456736 marked this conversation as resolved.
Comment thread
bj456736 marked this conversation as resolved.
Comment on lines +522 to +524

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back unless Kitty reports event types

When a terminal negotiates only a subset of the Kitty protocol flags, such as flag 1 without flag 2, terminal.ts still marks Kitty active for any nonzero flag set, while isKeyRepeat() is meaningful only with the report-event-types flag. In that environment every autorepeat arrives without :2 and is classified as a discrete press here, completely bypassing the held-Up history guard. Track whether negotiated flag 2 is enabled and use the timing heuristic when it is not.

AGENTS.md reference: packages/pi-tui/AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

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;
Expand Down Expand Up @@ -698,6 +748,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.resetUpArrowRepeatChain();
}

// Handle character jump mode (awaiting next character to jump to)
if (this.jumpMode !== null) {
// Cancel if the hotkey is pressed again
Expand Down Expand Up @@ -946,10 +1002,43 @@ export class Editor implements Component, Focusable {

// Arrow key navigation (with history support)
if (kb.matches(data, "tui.editor.cursorUp")) {
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)
(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.
!(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.
// 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;
}
this.navigateHistory(-1);
} else if (this.isOnFirstVisualLine()) {
// Already at top - jump to start of line
Expand Down Expand Up @@ -1144,6 +1233,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) {
Expand Down
Loading
Loading