mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(tui): add fullscreen transcript navigation
This commit is contained in:
parent
f074efd921
commit
3c717842e8
8 changed files with 125 additions and 20 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue