Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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.
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` 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

Expand Down
79 changes: 77 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,25 @@ 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 };
}

private navigateHistory(direction: 1 | -1): void {
this.lastAction = null;
if (this.history.length === 0) return;
Expand Down Expand Up @@ -698,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;
Comment thread
bj456736 marked this conversation as resolved.
Outdated
}

// 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 +993,38 @@ 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.
this.pendingHeldUpCrossingAt = gap < UP_ARROW_INITIAL_DELAY_MAX_MS ? now : 0;
Comment thread
bj456736 marked this conversation as resolved.
Outdated
} 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
Loading
Loading