feat(tui): add fullscreen transcript navigation

This commit is contained in:
Armin Ronacher 2026-07-31 16:23:58 +02:00
parent f074efd921
commit 3c717842e8
8 changed files with 125 additions and 20 deletions

View file

@ -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

View file

@ -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.

View file

@ -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;

View file

@ -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;

View file

@ -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"]);
});

View file

@ -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 {