fix(ui): unbind history recall from arrow keys; use Ctrl+P / Ctrl+N

Mouse wheel on Windows Terminal + ConPTY translates to up/down
arrow key events for raw-mode child processes. Indistinguishable
from keyboard arrows at the byte level. With history recall bound
to up/down on empty buffer, every wheel-scroll on the prompt was
clobbering it with a recalled prior message.

Fix: rebind history recall to Ctrl+P (prev) / Ctrl+N (next), the
bash readline convention. Up/down arrows now no-op on a single-
line buffer and only move the cursor across lines in a multi-line
draft. Wheel scrolls again belong entirely to the terminal.

Tests updated to reflect the new binding. Placeholder hint mentions
Ctrl+P/N alongside the Ctrl+J newline hint.
This commit is contained in:
esengine committed 2026-04-25 23:36:24 -07:00
1 parent 1d3b9e9c77
commit 9254d3ae87
4 files changed
+69 -46

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.7.9",
"version": "0.7.10",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
+1 -1
View File
@@ -176,7 +176,7 @@ export function PromptInput({
const placeholderActive = narrow
? "type a message, or /command"
: "type a message, or /command · [Ctrl+J] newline (Shift+Enter where supported)";
: "type a message, or /command · [Ctrl+J] newline · [Ctrl+P/N] history";
const effectivePlaceholder = disabled
? (placeholder ?? "…waiting for response…")
: (placeholder ?? placeholderActive);
+15 -11
View File
@@ -107,12 +107,21 @@ export function processMultilineKey(
return cursor === value.length ? NOOP : { next: null, cursor: value.length, submit: false };
}
// Empty buffer + ↑/↓ → parent handles history recall.
if (value.length === 0 && (key.upArrow || key.downArrow)) {
return { ...NOOP, historyHandoff: key.upArrow ? "prev" : "next" };
// History recall is bound to Ctrl+P / Ctrl+N (bash readline
// convention) rather than ↑/↓. Reason: Windows Terminal + ConPTY
// translates mouse wheel events to ↑/↓ keystrokes for raw-mode
// child processes — indistinguishable at the byte level. If ↑/↓
// recalled history, every wheel-scroll would corrupt the prompt
// with a prior message. Ctrl+P/N is wheel-immune.
if (key.ctrl && key.input === "p") {
return { ...NOOP, historyHandoff: "prev" };
}
if (key.ctrl && key.input === "n") {
return { ...NOOP, historyHandoff: "next" };
}
// Cursor motion.
// Cursor motion. Empty single-line buffer + ↑/↓ is now a no-op
// (used to recall history; see Ctrl+P/N comment above).
if (key.leftArrow) {
return { next: null, cursor: Math.max(0, cursor - 1), submit: false };
}
@@ -121,16 +130,11 @@ export function processMultilineKey(
}
if (key.upArrow) {
const moved = moveCursorUp(value, cursor);
// Cursor already on the first line — hand off to history recall
// instead of sitting stuck. Users can now escape into history
// from the middle of a multi-line draft.
if (moved === cursor) return { ...NOOP, historyHandoff: "prev" };
return { next: null, cursor: moved, submit: false };
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
if (key.downArrow) {
const moved = moveCursorDown(value, cursor);
if (moved === cursor) return { ...NOOP, historyHandoff: "next" };
return { next: null, cursor: moved, submit: false };
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
// Emacs-style line jumps (universal across terminals; Home/End aren't
+52 -33
View File
@@ -148,64 +148,76 @@ describe("processMultilineKey — cursor motion", () => {
expect(processMultilineKey("abc", 3, key({ rightArrow: true })).cursor).toBe(3);
});
it("↑/↓ on a single-line non-empty buffer hands off to history recall (nowhere to move)", () => {
// 0.7.1: previously this was a pure NOOP, which left the user
// feeling stuck when they wanted to recall a prior prompt from
// a non-empty buffer. Now we emit `historyHandoff` so the
// parent can decide — typically swap the buffer for the
// previous prompt.
it("↑/↓ on a single-line non-empty buffer is a NOOP (no history recall)", () => {
// 0.7.10: history recall moved to Ctrl+P/N because mouse wheel
// on Windows ConPTY translates to ↑/↓ events, indistinguishable
// from keyboard. Wheel scrolling was clobbering the prompt with
// recalled history. Now ↑/↓ does nothing on a single-line
// buffer; users press Ctrl+P / Ctrl+N for history.
const up = processMultilineKey("hello", 3, key({ upArrow: true }));
expect(up).toEqual({ next: null, cursor: null, submit: false, historyHandoff: "prev" });
expect(up).toEqual({ next: null, cursor: null, submit: false });
const down = processMultilineKey("hello", 3, key({ downArrow: true }));
expect(down).toEqual({ next: null, cursor: null, submit: false, historyHandoff: "next" });
expect(down).toEqual({ next: null, cursor: null, submit: false });
});
it("↑/↓ on an empty buffer hands off to the parent (history recall)", () => {
it("↑/↓ on an empty buffer is a NOOP", () => {
expect(processMultilineKey("", 0, key({ upArrow: true }))).toEqual({
next: null,
cursor: null,
submit: false,
historyHandoff: "prev",
});
expect(processMultilineKey("", 0, key({ downArrow: true }))).toEqual({
next: null,
cursor: null,
submit: false,
historyHandoff: "next",
});
});
it("↑ at line 0 of a multi-line buffer hands off to history (escape hatch from a draft)", () => {
// Cursor on first line, buffer has a second line below — ↑ has
// nowhere to move, so we emit the handoff. Previously this was
// a silent NOOP that trapped the user.
const v = "first\nsecond";
const handoff = processMultilineKey(v, 3, key({ upArrow: true }));
expect(handoff).toEqual({
it("Ctrl+P / Ctrl+N hand off to history recall (bash readline binding)", () => {
expect(processMultilineKey("", 0, key({ ctrl: true, input: "p" }))).toEqual({
next: null,
cursor: null,
submit: false,
historyHandoff: "prev",
});
expect(processMultilineKey("", 0, key({ ctrl: true, input: "n" }))).toEqual({
next: null,
cursor: null,
submit: false,
historyHandoff: "next",
});
// Also from a non-empty buffer — Ctrl+P/N is unambiguous, no
// need for buffer-state gating like the old ↑/↓ binding.
expect(processMultilineKey("hello", 3, key({ ctrl: true, input: "p" })).historyHandoff).toBe(
"prev",
);
});
it("↓ at last line hands off similarly", () => {
// cursor at pos 8 = col 2 on line 1 ("second")
it("↑ at line 0 of a multi-line buffer is a NOOP (no auto history handoff)", () => {
const v = "first\nsecond";
const handoff = processMultilineKey(v, 8, key({ downArrow: true }));
expect(handoff.historyHandoff).toBe("next");
const result = processMultilineKey(v, 3, key({ upArrow: true }));
expect(result).toEqual({ next: null, cursor: null, submit: false });
});
it("raw `\\x1b[A` escape sequence is rewritten into upArrow (Windows-terminal fallback)", () => {
// Some Windows consoles don't set key.upArrow; the raw CSI leaks
// through as `input` and would otherwise be inserted verbatim
// into the buffer. Rewrite into structured key instead.
const handoff = processMultilineKey("", 0, { input: "\x1b[A" });
expect(handoff.historyHandoff).toBe("prev");
it("↓ at last line is a NOOP", () => {
const v = "first\nsecond";
const result = processMultilineKey(v, 8, key({ downArrow: true }));
expect(result).toEqual({ next: null, cursor: null, submit: false });
});
it("raw `\\x1b[A` escape sequence is rewritten into upArrow (no history handoff)", () => {
// Recovery still runs (so wheel-as-↑ doesn't insert literal
// `[A` into the buffer), but ↑ now no-ops instead of recalling.
const result = processMultilineKey("", 0, { input: "\x1b[A" });
expect(result).toEqual({ next: null, cursor: null, submit: false });
});
it("raw `\\x1b[B` becomes downArrow; `\\x1b[C` rightArrow; `\\x1b[D` leftArrow", () => {
expect(processMultilineKey("", 0, { input: "\x1b[B" }).historyHandoff).toBe("next");
expect(processMultilineKey("", 0, { input: "\x1b[B" })).toEqual({
next: null,
cursor: null,
submit: false,
});
expect(processMultilineKey("abc", 0, { input: "\x1b[C" }).cursor).toBe(1);
expect(processMultilineKey("abc", 2, { input: "\x1b[D" }).cursor).toBe(1);
});
@@ -215,12 +227,19 @@ describe("processMultilineKey — cursor motion", () => {
// remaining `[C` through useInput as plain text. Without the
// ESC-less fallback, pressing right-arrow at end of a line would
// insert literal `[C` instead of moving the cursor across the
// newline boundary — which surfaced as "right arrow can't reach
// line 2" on Windows.
// newline boundary.
expect(processMultilineKey("ab\ncd", 2, { input: "[C" }).cursor).toBe(3);
expect(processMultilineKey("ab\ncd", 3, { input: "[D" }).cursor).toBe(2);
expect(processMultilineKey("", 0, { input: "[A" }).historyHandoff).toBe("prev");
expect(processMultilineKey("", 0, { input: "[B" }).historyHandoff).toBe("next");
expect(processMultilineKey("", 0, { input: "[A" })).toEqual({
next: null,
cursor: null,
submit: false,
});
expect(processMultilineKey("", 0, { input: "[B" })).toEqual({
next: null,
cursor: null,
submit: false,
});
});
it("↑ moves cursor to the previous line, preserving column when possible", () => {