diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 17b6d9f0a..a27467f06 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ - Added an experimental fullscreen UI mode, selectable through `--ui-mode fullscreen` or `/settings` ([#7304](https://github.com/earendil-works/pi/issues/7304)). - Added a sticky editor, status, widget, and footer dock to fullscreen mode while keeping the transcript independently scrollable. - Added a transient draggable transcript scrollbar to fullscreen mode. +- Added page scrolling and marked-message navigation shortcuts to fullscreen mode. ### Fixed diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 64e173580..8fa41074d 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -84,10 +84,12 @@ These actions apply when interactive mode uses `--ui-mode fullscreen` and target | Keybinding id | Default | Description | |--------|---------|-------------| -| `tui.altScreen.pageUp` | `shift+pageUp` | Scroll the transcript up by one page | -| `tui.altScreen.pageDown` | `shift+pageDown` | Scroll the transcript down by one page | -| `tui.altScreen.top` | `ctrl+home` | Scroll to the beginning of the transcript | -| `tui.altScreen.bottom` | `ctrl+end` | Scroll to the transcript end and follow new output | +| `tui.altScreen.pageUp` | `pageUp` | Scroll the transcript up by one page | +| `tui.altScreen.pageDown` | `pageDown` | Scroll the transcript down by one page | +| `tui.altScreen.previousPrompt` | `ctrl+shift+up` | Jump to the previous marked message | +| `tui.altScreen.nextPrompt` | `ctrl+shift+down` | Jump to the next marked message | +| `tui.altScreen.top` | `home` | Scroll to the beginning of the transcript | +| `tui.altScreen.bottom` | `end` | Scroll to the transcript end and follow new output | ### Application diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 730d8c5ec..39982a6f5 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -15,6 +15,7 @@ - Added alternate-screen `VStack`, `HStack`, and nested `ScrollView` layouts with constrained sizing, sticky regions, and pointer-targeted scrolling. - Added edge auto-scrolling for alternate-screen drag selection across off-screen scroll-view content. - Added transient proportional scrollbars with mouse dragging and Home/End document navigation to alternate-screen `ScrollView`s. +- Added page scrolling and OSC 133 semantic prompt navigation to the alternate-screen viewport. ## [0.83.0] - 2026-07-29 diff --git a/packages/tui/README.md b/packages/tui/README.md index 5fee7d642..dfcfc9ea5 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -121,7 +121,7 @@ if (isViewportTUI(tui)) { } ``` -Stack entries support `basis`, `grow`, `shrink`, `minSize`, `maxSize`, and responsive `visible` callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. +Stack entries support `basis`, `grow`, `shrink`, `minSize`, `maxSize`, and responsive `visible` callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. It can also jump between OSC 133 semantic prompt markers, matching common terminal prompt-navigation shortcuts. Layout geometry is rebuilt for each requested frame. Stateful components are retained, and their existing rendered-line caches remain effective. Calling `render(width)` directly on these layout components produces an unbounded document, which is also used when alt mode restores the main screen. @@ -657,7 +657,7 @@ if (matchesKey(data, Key.enter)) { 2. **Width Changed or Change Above Viewport**: Clear screen and fully re-render 3. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines -`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting. +`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting. Both renderers wrap updates in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering. diff --git a/packages/tui/src/TuiAltScreen.ts b/packages/tui/src/TuiAltScreen.ts index ec86f067d..2548a3d74 100644 --- a/packages/tui/src/TuiAltScreen.ts +++ b/packages/tui/src/TuiAltScreen.ts @@ -1,6 +1,7 @@ import { AltScreenFlashContainer } from "./components/alt-screen-flash.ts"; import { ScrollView } from "./components/scroll-view.ts"; import { getKeybindings } from "./keybindings.ts"; +import { isKeyRelease } from "./keys.ts"; import { getScrollbarGeometry, getScrollViewBox, @@ -39,6 +40,8 @@ const FOCUS_OUT = "\x1b[O"; const BEGIN_SYNCHRONIZED_OUTPUT = "\x1b[?2026h"; const END_SYNCHRONIZED_OUTPUT = "\x1b[?2026l"; const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/; +const OSC133_PROMPT_START = /^\x1b\]133;A(?:\x07|\x1b\\)/; +const PAGE_SCROLL_OVERLAP = 4; interface SelectionPoint { row: number; @@ -244,6 +247,20 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.requestRender(); } + private scrollToPrompt(direction: -1 | 1): void { + if (!this.currentLayout) return; + const scrollView = this.getPrimaryScrollView(); + const lines = getScrollViewBox(this.currentLayout, scrollView)?.scrollContentLines; + if (!lines) return; + + for (let row = scrollView.scrollTop + direction; row >= 0 && row < lines.length; row += direction) { + if (!OSC133_PROMPT_START.test(lines[row] ?? "")) continue; + scrollView.scrollTo(row); + this.requestRender(); + return; + } + } + /** Show a transient message in the alternate-screen flash stack. */ flash(message: string, durationMs?: number): void { this.flashes.flash(message, durationMs); @@ -282,20 +299,33 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (this.isMouseSequence(data)) return { consume: true }; const keybindings = getKeybindings(); + const isRelease = isKeyRelease(data); if (keybindings.matches(data, "tui.altScreen.pageUp")) { - this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - 1)); + if (!isRelease) { + this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP)); + } return { consume: true }; } if (keybindings.matches(data, "tui.altScreen.pageDown")) { - this.scrollBy(Math.max(1, this.getPrimaryScrollView().viewportHeight - 1)); + if (!isRelease) { + this.scrollBy(Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP)); + } + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.previousPrompt")) { + if (!isRelease) this.scrollToPrompt(-1); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.nextPrompt")) { + if (!isRelease) this.scrollToPrompt(1); return { consume: true }; } if (keybindings.matches(data, "tui.altScreen.top")) { - this.scrollToTop(); + if (!isRelease) this.scrollToTop(); return { consume: true }; } if (keybindings.matches(data, "tui.altScreen.bottom")) { - this.scrollToBottom(); + if (!isRelease) this.scrollToBottom(); return { consume: true }; } return undefined; diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index ebe4e96c4..2cc644724 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -42,6 +42,8 @@ export interface Keybindings { // Alternate-screen viewport navigation "tui.altScreen.pageUp": true; "tui.altScreen.pageDown": true; + "tui.altScreen.previousPrompt": true; + "tui.altScreen.nextPrompt": true; "tui.altScreen.top": true; "tui.altScreen.bottom": true; } @@ -136,8 +138,22 @@ export const TUI_KEYBINDINGS = { defaultKeys: ["escape", "ctrl+c"], description: "Cancel selection", }, - "tui.altScreen.pageUp": { defaultKeys: "shift+pageUp", description: "Scroll viewport up one page" }, - "tui.altScreen.pageDown": { defaultKeys: "shift+pageDown", description: "Scroll viewport down one page" }, + "tui.altScreen.pageUp": { + defaultKeys: "pageUp", + description: "Scroll viewport up one page", + }, + "tui.altScreen.pageDown": { + defaultKeys: "pageDown", + description: "Scroll viewport down one page", + }, + "tui.altScreen.previousPrompt": { + defaultKeys: "ctrl+shift+up", + description: "Jump to previous semantic prompt", + }, + "tui.altScreen.nextPrompt": { + defaultKeys: "ctrl+shift+down", + description: "Jump to next semantic prompt", + }, "tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" }, "tui.altScreen.bottom": { defaultKeys: "end", description: "Scroll viewport to bottom" }, } as const satisfies KeybindingDefinitions; diff --git a/packages/tui/test/keybindings.test.ts b/packages/tui/test/keybindings.test.ts index b756f2a0a..52d8e3d17 100644 --- a/packages/tui/test/keybindings.test.ts +++ b/packages/tui/test/keybindings.test.ts @@ -11,9 +11,13 @@ describe("KeybindingsManager", () => { assert.strictEqual(keybindings.matches("\x1b[106;5u", "tui.input.newLine"), true); }); - it("binds Home and End to alternate-screen document navigation", () => { + it("binds terminal viewport shortcuts to alternate-screen navigation", () => { const keybindings = new KeybindingsManager(TUI_KEYBINDINGS); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageUp"), ["pageUp"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageDown"), ["pageDown"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end"]); }); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 359b24788..166c6b155 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -15,6 +15,8 @@ import { } from "../src/terminal-image.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; +const OSC133_ZONE_START = "\x1b]133;A\x07"; + class RecordingTerminal extends VirtualTerminal { readonly events: Array<{ type: "write"; data: string } | { type: "start" } | { type: "stop" }> = []; @@ -249,37 +251,86 @@ describe("TuiAltScreen", () => { tui.stop(); }); - it("supports configurable keyboard viewport navigation", async () => { - const terminal = new VirtualTerminal(20, 4); + it("supports configurable keyboard viewport navigation with four rows of page overlap", async () => { + const terminal = new VirtualTerminal(20, 8); const tui = new TuiAltScreen(terminal); - tui.addChild(new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); tui.start(); await terminal.waitForRender(); - terminal.sendInput("\x1b[5$"); + terminal.sendInput("\x1b[57421u"); + terminal.sendInput("\x1b[57421;1:3u"); await terminal.waitForRender(); assert.deepStrictEqual( terminal.getViewport().map((line) => line.trimEnd()), - ["line 2", "line 3", "line 4", "line 5"], + ["line 1", "line 2", "line 3", "line 4", "line 5", "line 6", "line 7", "line 8"], + ); + + terminal.sendInput("\x1b[57422u"); + terminal.sendInput("\x1b[57422;1:3u"); + await terminal.waitForRender(); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 5", "line 6", "line 7", "line 8", "line 9", "line 10", "line 11", "line 12"], ); terminal.sendInput("\x1bOH"); await terminal.waitForRender(); assert.deepStrictEqual( terminal.getViewport().map((line) => line.trimEnd()), - ["line 1", "line 2", "line 3", "line 4"], + ["line 1", "line 2", "line 3", "line 4", "line 5", "line 6", "line 7", "line 8"], ); terminal.sendInput("\x1bOF"); await terminal.waitForRender(); assert.deepStrictEqual( terminal.getViewport().map((line) => line.trimEnd()), - ["line 5", "line 6", "line 7", "line 8"], + ["line 5", "line 6", "line 7", "line 8", "line 9", "line 10", "line 11", "line 12"], ); tui.stop(); }); + it("jumps between OSC 133 semantic prompt markers", async () => { + const terminal = new VirtualTerminal(20, 3); + const tui = new TuiAltScreen(terminal); + tui.addChild( + new Text( + [1, 2, 3, 4].flatMap((message) => [`${OSC133_ZONE_START}message ${message}`, "detail"]).join("\n"), + 0, + 0, + ), + ); + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 5); + + terminal.sendInput("\x1b[57419;6u"); + terminal.sendInput("\x1b[57419;6:3u"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 4); + assert.strictEqual(terminal.getViewport()[0]?.trimEnd(), "message 3"); + + terminal.sendInput("\x1b[1;6A"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 2); + assert.strictEqual(terminal.getViewport()[0]?.trimEnd(), "message 2"); + + terminal.sendInput("\x1b[57420;6u"); + terminal.sendInput("\x1b[57420;6:3u"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 4); + assert.strictEqual(terminal.getViewport()[0]?.trimEnd(), "message 3"); + + terminal.sendInput("\x1b[1;6B"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 5); + assert.strictEqual(terminal.getViewport()[1]?.trimEnd(), "message 4"); + assert.strictEqual(tui.isFollowingOutput, true); + + tui.stop(); + }); + it("does not emit Kitty graphics commands or OSC 133 zones in iTerm2", async () => { setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true }); try {