From b0d382e25afd7bdf0bbce4300ec8b26ec78f4ca4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 4 Aug 2026 21:25:39 +0200 Subject: [PATCH] fix(tui): add editor viewport key aliases (closes #7574) --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/keybindings.md | 19 +++++++--- packages/tui/CHANGELOG.md | 1 + packages/tui/src/keybindings.ts | 9 ++--- packages/tui/test/keybindings.test.ts | 11 +++++- packages/tui/test/keys.test.ts | 11 ++++++ packages/tui/test/tui-alt-screen.test.ts | 44 +++++++++++++++++++++++ 7 files changed, 87 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 158a387a6..4d82a69f8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -112,6 +112,7 @@ - Updated `undici` to 8.9.0 and the packaged `brace-expansion` to 5.0.9 to address GHSA-8xcm-r25x-g524, GHSA-4cwx-7wf7-3272, GHSA-m8rv-5g2x-5cg5, GHSA-jr45-8vmc-qm54, GHSA-v3r7-h72x-cjcm, and GHSA-rgw5-rvv9-x895. - Fixed GitHub Copilot compaction and branch summaries using the Individual endpoint instead of the credential-resolved Business or Enterprise endpoint ([#6768](https://github.com/earendil-works/pi/issues/6768)). - Fixed extension model calls dropping credential-resolved endpoints when forwarding request authentication, including custom compaction with GitHub Copilot Business and Enterprise accounts ([#7579](https://github.com/earendil-works/pi/issues/7579)). +- Fixed fullscreen transcript navigation leaving no editor-accessible `Home`, `End`, `PageUp`, or `PageDown` variants by adding Ctrl-modified editor bindings ([#7574](https://github.com/earendil-works/pi/issues/7574)). ## [0.83.0] - 2026-07-29 diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 8fa41074d..7dc2ed687 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -32,12 +32,12 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1 | `tui.editor.cursorRight` | `right`, `ctrl+f` | Move cursor right | | `tui.editor.cursorWordLeft` | `alt+left`, `ctrl+left`, `alt+b` | Move cursor word left | | `tui.editor.cursorWordRight` | `alt+right`, `ctrl+right`, `alt+f` | Move cursor word right | -| `tui.editor.cursorLineStart` | `home`, `ctrl+a` | Move to line start | -| `tui.editor.cursorLineEnd` | `end`, `ctrl+e` | Move to line end | +| `tui.editor.cursorLineStart` | `home`, `ctrl+home`, `ctrl+a` | Move to line start | +| `tui.editor.cursorLineEnd` | `end`, `ctrl+end`, `ctrl+e` | Move to line end | | `tui.editor.jumpForward` | `ctrl+]` | Jump forward to character | | `tui.editor.jumpBackward` | `ctrl+alt+]` | Jump backward to character | -| `tui.editor.pageUp` | `pageUp` | Scroll up by page | -| `tui.editor.pageDown` | `pageDown` | Scroll down by page | +| `tui.editor.pageUp` | `pageUp`, `ctrl+pageUp` | Scroll up by page | +| `tui.editor.pageDown` | `pageDown`, `ctrl+pageDown` | Scroll down by page | ### TUI Editor Deletion @@ -82,6 +82,17 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1 These actions apply when interactive mode uses `--ui-mode fullscreen` and target the primary transcript scroll region. Two-finger trackpad and mouse-wheel input scroll the region under the pointer, falling back to the transcript over the fixed editor/status/footer dock. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects text and copies it to the clipboard; holding at the transcript's top or bottom edge auto-scrolls into off-screen content. +Fullscreen transcript bindings take precedence over editor bindings. The default unmodified navigation keys therefore control the transcript in fullscreen mode, while their `ctrl` variants continue to control the editor. Outside fullscreen mode, both variants control the editor. + +| Key | Default mode | Fullscreen mode | +|-----|--------------|-----------------| +| `home`, `end` | Editor | Transcript | +| `ctrl+home`, `ctrl+end` | Editor | Editor | +| `pageUp`, `pageDown` | Editor | Transcript | +| `ctrl+pageUp`, `ctrl+pageDown` | Editor | Editor | + +This routing remains configurable through the ordinary action bindings. For example, `"tui.altScreen.pageUp": "ctrl+pageUp"` makes `pageUp` control the editor and `ctrl+pageUp` control the transcript in fullscreen mode. Setting `"tui.altScreen.pageUp": []` disables that transcript shortcut entirely. User bindings replace the defaults for that action. + | Keybinding id | Default | Description | |--------|---------|-------------| | `tui.altScreen.pageUp` | `pageUp` | Scroll the transcript up by one page | diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index fef007ab6..bcdf65a24 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed spaces in searchable settings queries changing the selected value instead of filtering multi-word labels. - Fixed alternate-screen Kitty images crossing vertical layout clip boundaries and overlapping sticky regions while scrolling. - Fixed alternate-screen redraws retransmitting Kitty image data when placements move or recently offscreen images return, dropping adjacent row content when reusing placements, rendering fixed-basis scroll content twice per frame, and scanning clipped transcript rows while painting. +- Fixed fullscreen transcript navigation leaving no editor-accessible `Home`, `End`, `PageUp`, or `PageDown` variants by adding Ctrl-modified editor bindings ([#7574](https://github.com/earendil-works/pi/issues/7574)). ### Added diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index 2cc644724..d2e7416ac 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -78,11 +78,11 @@ export const TUI_KEYBINDINGS = { description: "Move cursor word right", }, "tui.editor.cursorLineStart": { - defaultKeys: ["home", "ctrl+a"], + defaultKeys: ["home", "ctrl+home", "ctrl+a"], description: "Move to line start", }, "tui.editor.cursorLineEnd": { - defaultKeys: ["end", "ctrl+e"], + defaultKeys: ["end", "ctrl+end", "ctrl+e"], description: "Move to line end", }, "tui.editor.jumpForward": { @@ -93,8 +93,8 @@ export const TUI_KEYBINDINGS = { defaultKeys: "ctrl+alt+]", description: "Jump backward to character", }, - "tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" }, - "tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" }, + "tui.editor.pageUp": { defaultKeys: ["pageUp", "ctrl+pageUp"], description: "Page up" }, + "tui.editor.pageDown": { defaultKeys: ["pageDown", "ctrl+pageDown"], description: "Page down" }, "tui.editor.deleteCharBackward": { defaultKeys: "backspace", description: "Delete character backward", @@ -138,6 +138,7 @@ export const TUI_KEYBINDINGS = { defaultKeys: ["escape", "ctrl+c"], description: "Cancel selection", }, + // These intentionally shadow the unmodified editor bindings in fullscreen mode. "tui.altScreen.pageUp": { defaultKeys: "pageUp", description: "Scroll viewport up one page", diff --git a/packages/tui/test/keybindings.test.ts b/packages/tui/test/keybindings.test.ts index 52d8e3d17..24a52f638 100644 --- a/packages/tui/test/keybindings.test.ts +++ b/packages/tui/test/keybindings.test.ts @@ -11,7 +11,16 @@ describe("KeybindingsManager", () => { assert.strictEqual(keybindings.matches("\x1b[106;5u", "tui.input.newLine"), true); }); - it("binds terminal viewport shortcuts to alternate-screen navigation", () => { + it("binds modified and unmodified editor viewport navigation", () => { + const keybindings = new KeybindingsManager(TUI_KEYBINDINGS); + + assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineStart"), ["home", "ctrl+home", "ctrl+a"]); + assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineEnd"), ["end", "ctrl+end", "ctrl+e"]); + assert.deepStrictEqual(keybindings.getKeys("tui.editor.pageUp"), ["pageUp", "ctrl+pageUp"]); + assert.deepStrictEqual(keybindings.getKeys("tui.editor.pageDown"), ["pageDown", "ctrl+pageDown"]); + }); + + it("binds unmodified terminal viewport shortcuts to alternate-screen navigation", () => { const keybindings = new KeybindingsManager(TUI_KEYBINDINGS); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageUp"), ["pageUp"]); diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index a5b893f26..68f594359 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -480,6 +480,17 @@ describe("matchesKey", () => { assert.strictEqual(matchesKey("\x1bOF", "end"), true); }); + it("should match xterm Ctrl-modified viewport navigation", () => { + assert.strictEqual(matchesKey("\x1b[1;5H", "ctrl+home"), true); + assert.strictEqual(matchesKey("\x1b[1;5F", "ctrl+end"), true); + assert.strictEqual(matchesKey("\x1b[5;5~", "ctrl+pageUp"), true); + assert.strictEqual(matchesKey("\x1b[6;5~", "ctrl+pageDown"), true); + assert.strictEqual(parseKey("\x1b[1;5H"), "ctrl+home"); + assert.strictEqual(parseKey("\x1b[1;5F"), "ctrl+end"); + assert.strictEqual(parseKey("\x1b[5;5~"), "ctrl+pageUp"); + assert.strictEqual(parseKey("\x1b[6;5~"), "ctrl+pageDown"); + }); + it("should match legacy function keys and clear", () => { assert.strictEqual(matchesKey("\x1bOP", "f1"), true); assert.strictEqual(matchesKey("\x1b[24~", "f12"), true); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index fccb13a46..5b2dabf48 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -307,6 +307,50 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("routes Ctrl-modified viewport navigation to the focused component", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TuiAltScreen(terminal); + const transcript = new ScrollView( + new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0), + { follow: "end", primary: true }, + ); + const editorInputs: string[] = []; + const editor = { + focused: false, + render: () => ["editor"], + invalidate: () => {}, + handleInput: (data: string) => editorInputs.push(data), + }; + tui.setLayoutRoot( + new VStack([ + { component: transcript, basis: 0, grow: 1, minSize: 1 }, + { component: editor, basis: 1, shrink: 0 }, + ]), + ); + tui.setFocus(editor); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1bOH"); + await terminal.waitForRender(); + assert.strictEqual(transcript.scrollTop, 0); + assert.deepStrictEqual(editorInputs, []); + + const modifiedInputs = ["\x1b[1;5H", "\x1b[1;5F", "\x1b[5;5~", "\x1b[6;5~", "\x1b[57423;5u"]; + for (const input of modifiedInputs) terminal.sendInput(input); + terminal.sendInput("\x1b[57423;5:3u"); + await terminal.waitForRender(); + assert.strictEqual(transcript.scrollTop, 0); + assert.deepStrictEqual(editorInputs, modifiedInputs); + + terminal.sendInput("\x1b[6~"); + await terminal.waitForRender(); + assert.strictEqual(transcript.scrollTop, 1); + assert.deepStrictEqual(editorInputs, modifiedInputs); + + tui.stop(); + }); + it("jumps between OSC 133 semantic prompt markers", async () => { const terminal = new VirtualTerminal(20, 3); const tui = new TuiAltScreen(terminal);