feat(tui): add alternate-screen viewport layouts

This commit is contained in:
Mario Zechner 2026-07-31 09:21:42 +02:00
parent 471c3390fe
commit ea1e77e2db
19 changed files with 2362 additions and 118 deletions

View file

@ -6,10 +6,12 @@
- Added chainable `pi.registerMarkdownTransformer()` hooks for display-only transformation of user and assistant Markdown.
- Added `--alt` to run interactive mode with the alternate-screen TUI ([#7304](https://github.com/earendil-works/pi/issues/7304)).
- Added a sticky editor, status, widget, and footer dock to `--alt` mode while keeping the transcript independently scrollable.
### Fixed
- Fixed malformed resource arrays in package manifests crashing session startup ([#7187](https://github.com/earendil-works/pi/issues/7187)).
- Fixed the DOOM overlay example downloading its shareware WAD from a dead URL.
- Fixed `setToolsExpanded(false)` to be a no-op when tool output is already collapsed, avoiding redundant `Tool output: collapsed` startup notices from extensions ([#7292](https://github.com/earendil-works/pi/issues/7292)).
## [0.83.0] - 2026-07-29

View file

@ -80,14 +80,14 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1
### TUI Alternate-Screen Viewport
These actions apply when interactive mode is started with `--alt`. Two-finger trackpad and mouse-wheel input also scroll the viewport. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects visible text and copies it to the clipboard.
These actions apply when interactive mode is started with `--alt` 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.
| Keybinding id | Default | Description |
|--------|---------|-------------|
| `tui.altScreen.pageUp` | `shift+pageUp` | Scroll the viewport up by one page |
| `tui.altScreen.pageDown` | `shift+pageDown` | Scroll the viewport down by one page |
| `tui.altScreen.top` | `ctrl+home` | Scroll to the beginning of the document |
| `tui.altScreen.bottom` | `ctrl+end` | Scroll to the end and follow new output |
| `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 |
### Application

View file

@ -239,14 +239,14 @@ pi --no-extensions -e ./my-extension.ts
|--------|-------------|
| `--system-prompt <text>` | Replace default prompt; context files and skills are still appended |
| `--append-system-prompt <text>` | Append to system prompt |
| `--alt` | Use the alternate-screen TUI with application-owned scrolling in interactive mode |
| `--alt` | Use the alternate-screen TUI with a scrollable transcript and fixed editor/status/footer dock |
| `--verbose` | Force verbose startup |
| `-a`, `--approve` | Trust project-local files for this run |
| `-na`, `--no-approve` | Ignore project-local files for this run |
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show version |
When `--alt` is active, inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. Without `--alt`, iTerm2 inline images continue to render normally.
When `--alt` is active, the transcript scrolls inside the terminal viewport while queued messages, working status, extension widgets, editor, and footer remain fixed at the bottom. Mouse/trackpad input scrolls the region under the pointer; keyboard viewport actions always remain available. Inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. Without `--alt`, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally.
### File Arguments

View file

@ -22,6 +22,7 @@ import type {
SlashCommand,
Terminal,
} from "@earendil-works/pi-tui";
import * as TuiLayouts from "@earendil-works/pi-tui";
import {
CombinedAutocompleteProvider,
type Component,
@ -348,6 +349,7 @@ export class InteractiveMode {
private ui: TUI;
private loadedResourcesContainer: Container;
private chatContainer: Container;
private documentContainer: Container;
private pendingMessagesContainer: Container;
private statusContainer: Container;
private defaultEditor: CustomEditor;
@ -358,6 +360,7 @@ export class InteractiveMode {
private fdPath: string | undefined;
private editorContainer: Container;
private footer: FooterComponent;
private footerContainer: Container;
private footerDataProvider: FooterDataProvider;
// Stored so the same manager can be injected into custom editors, selectors, and extension UI.
private keybindings: KeybindingsManager;
@ -488,6 +491,10 @@ export class InteractiveMode {
this.headerContainer = new Container();
this.loadedResourcesContainer = new Container();
this.chatContainer = new Container();
this.documentContainer = new Container();
this.documentContainer.addChild(this.headerContainer);
this.documentContainer.addChild(this.loadedResourcesContainer);
this.documentContainer.addChild(this.chatContainer);
this.pendingMessagesContainer = new Container();
this.statusContainer = new Container();
this.widgetContainerAbove = new Container();
@ -506,6 +513,8 @@ export class InteractiveMode {
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
this.footer = new FooterComponent(this.session, this.footerDataProvider);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
this.footerContainer = new Container();
this.footerContainer.addChild(this.footer);
// Load hide thinking block setting
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
@ -731,19 +740,37 @@ export class InteractiveMode {
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
}
// Add header container as first child. Populate it after applying theme settings.
// Keep loaded resources before chat so restored session messages never precede them.
this.ui.addChild(this.headerContainer);
this.ui.addChild(this.loadedResourcesContainer);
this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
// Populate stable regions before selecting the renderer-specific composition.
this.renderWidgets(); // Initialize with default spacer
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footer);
if (TuiLayouts.isViewportTUI(this.ui)) {
const transcript = new TuiLayouts.ScrollView(this.documentContainer, {
follow: "end",
primary: true,
overscroll: "chain",
});
const dock = new TuiLayouts.VStack([
{ component: this.pendingMessagesContainer, shrink: 1, minSize: 0 },
{ component: this.statusContainer, shrink: 1, minSize: 0 },
{ component: this.widgetContainerAbove, shrink: 1, minSize: 0 },
{ component: this.editorContainer, shrink: 1, minSize: 3 },
{ component: this.widgetContainerBelow, shrink: 1, minSize: 0 },
{ component: this.footerContainer, shrink: 1, minSize: 1 },
]);
this.ui.setLayoutRoot(
new TuiLayouts.VStack([
{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
]),
);
} else {
this.ui.addChild(this.documentContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footerContainer);
}
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
@ -2084,21 +2111,15 @@ export class InteractiveMode {
this.customFooter.dispose();
}
// Remove current footer from UI
if (this.customFooter) {
this.ui.removeChild(this.customFooter);
} else {
this.ui.removeChild(this.footer);
}
this.footerContainer.clear();
if (factory) {
// Create and add custom footer, passing the data provider
this.customFooter = factory(this.ui, theme, this.footerDataProvider);
this.ui.addChild(this.customFooter);
this.footerContainer.addChild(this.customFooter);
} else {
// Restore built-in footer
this.customFooter = undefined;
this.ui.addChild(this.footer);
this.footerContainer.addChild(this.footer);
}
this.ui.requestRender();

View file

@ -1,5 +1,5 @@
import type { Component, Terminal } from "@earendil-works/pi-tui";
import { Container, Text } from "@earendil-works/pi-tui";
import { Container, isViewportTUI, Text } from "@earendil-works/pi-tui";
import { describe, expect, it, vi } from "vitest";
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
import { createInteractiveTui, InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
@ -22,6 +22,7 @@ describe("createInteractiveTui", () => {
logDirectory: "/tmp",
terminal: mainTerminal,
});
expect(isViewportTUI(mainTui)).toBe(false);
mainTui.start();
await mainTerminal.waitForRender();
expect(mainTerminal.writes.some((write) => write.includes("\x1b[?1049h"))).toBe(false);
@ -34,6 +35,7 @@ describe("createInteractiveTui", () => {
logDirectory: "/tmp",
terminal: altTerminal,
});
expect(isViewportTUI(altTui)).toBe(true);
altTui.start();
await altTerminal.waitForRender();
expect(altTerminal.writes.some((write) => write.includes("\x1b[?1049h"))).toBe(true);

View file

@ -11,6 +11,8 @@
- Exported the bundled `Marked` parser and token types.
- Added width-aware source transforms to the `Markdown` component.
- Added interface-compatible main-screen and alternate-screen TUI renderers with application-owned scrolling ([#7304](https://github.com/earendil-works/pi/issues/7304)).
- 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.
## [0.83.0] - 2026-07-29

View file

@ -11,7 +11,7 @@ Minimal terminal UI framework with differential rendering and synchronized outpu
- **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
- **Component-based**: Simple Component interface with render() method
- **Theme Support**: Components accept theme interfaces for customizable styling
- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container
- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container, VStack, HStack, ScrollView
- **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols
- **Autocomplete Support**: File paths and slash commands
@ -78,6 +78,53 @@ tui.requestRender(); // Request a re-render
tui.onDebug = () => console.log("Debug triggered");
```
### Alternate-screen viewport layouts
`TuiAltScreen` can render an explicit terminal-height layout. `VStack` and `HStack` allocate constrained regions, while `ScrollView` owns scrolling for one region. These semantics are intentionally unavailable on `TuiMainScreen`, where the terminal owns scrollback.
```typescript
import {
Container,
isViewportTUI,
ScrollView,
Text,
VStack,
} from "@earendil-works/pi-tui";
const transcript = new Container();
transcript.addChild(new Text("History"));
const editorAndFooter = new VStack([
editor,
new Text("status"),
]);
if (isViewportTUI(tui)) {
tui.setLayoutRoot(new VStack([
{
component: new ScrollView(transcript, {
follow: "end",
primary: true,
overscroll: "chain",
}),
basis: 0,
grow: 1,
minSize: 1,
},
{
component: editorAndFooter,
basis: "auto",
shrink: 1,
minSize: 1,
},
]));
}
```
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.
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.
### Overlays
Overlays render components on top of existing content without replacing it. Useful for dialogs, menus, and modal UI.
@ -610,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. 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 the document without modifying terminal scrollback. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects visible text and copies it to the clipboard with OSC 52. Kitty images support 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. 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,17 +1,18 @@
import { ScrollView } from "./components/scroll-view.ts";
import { getKeybindings } from "./keybindings.ts";
import { getScrollViewBox, getScrollViewsAt, type LayoutFrame, renderLayoutFrame } from "./layout.ts";
import type { Terminal } from "./terminal.ts";
import {
cropKittyImageLine,
deleteAllKittyImages,
getCapabilities,
getKittyImageMetadata,
type ImageProtocol,
isImageLine,
setCapabilities,
type TerminalCapabilities,
} from "./terminal-image.ts";
import { CURSOR_MARKER, type TUI, TuiBase } from "./tui.ts";
import { type Component, CURSOR_MARKER, TuiBase, VIEWPORT_TUI, type ViewportTUI } from "./tui.ts";
import {
extractAnsiCode,
getGraphemeCellRange,
getOsc8LinkAtColumn,
sliceByColumn,
@ -32,6 +33,7 @@ const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/;
interface SelectionPoint {
row: number;
col: number;
scrollView?: ScrollView;
}
interface SgrMouseEvent {
@ -41,6 +43,12 @@ interface SgrMouseEvent {
release: boolean;
}
interface WheelEvent {
direction: -1 | 1;
x: number;
y: number;
}
export interface TuiAltScreenOptions {
/** Number of logical lines moved for each mouse-wheel event. */
wheelScrollLines?: number;
@ -51,19 +59,24 @@ export interface TuiAltScreenOptions {
}
/** Alternate-screen TUI with a scrollable, application-owned viewport. */
export class TuiAltScreen extends TuiBase implements TUI {
export class TuiAltScreen extends TuiBase implements ViewportTUI {
readonly [VIEWPORT_TUI] = true as const;
private previousScreen: string[] = [];
private lastDocument: string[] = [];
private previousScreenWidth = 0;
private previousScreenHeight = 0;
private scrollTop = 0;
private contentLineCount = 0;
private stickToBottom = true;
private layoutRoot: Component | undefined;
private currentLayout: LayoutFrame | undefined;
private readonly implicitDocument: Component;
private readonly implicitScrollView: ScrollView;
private altScreenActive = false;
private imageProtocol: ImageProtocol = null;
private savedCapabilities?: TerminalCapabilities;
private selectionAnchor?: SelectionPoint;
private selectionFocus?: SelectionPoint;
private selectionDragPointer?: { x: number; y: number };
private selectionAutoScrollDirection: -1 | 0 | 1 = 0;
private selectionAutoScrollTimer?: NodeJS.Timeout;
private pressedUrl?: string;
private selectionDragged = false;
private readonly wheelScrollLines: number;
@ -77,6 +90,13 @@ export class TuiAltScreen extends TuiBase implements TUI {
options: TuiAltScreenOptions = {},
) {
super(terminal, showHardwareCursor, logDirectory);
this.implicitDocument = {
render: (width) => super.render(width),
invalidate: () => {
for (const child of this.children) child.invalidate();
},
};
this.implicitScrollView = new ScrollView(this.implicitDocument, { follow: "end", primary: true });
this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 3));
this.mouseEnabled = options.mouse ?? true;
this.openUrl = options.openUrl;
@ -84,14 +104,39 @@ export class TuiAltScreen extends TuiBase implements TUI {
}
get viewportTop(): number {
return this.scrollTop;
return this.getPrimaryScrollView().scrollTop;
}
get isFollowingOutput(): boolean {
return this.stickToBottom;
return this.getPrimaryScrollView().isFollowingEnd;
}
setLayoutRoot(component: Component | undefined): void {
if (this.layoutRoot === component) return;
this.layoutRoot = component;
this.currentLayout = undefined;
this.requestRender();
}
override render(width: number): string[] {
return this.layoutRoot?.render(width) ?? super.render(width);
}
override invalidate(): void {
super.invalidate();
this.layoutRoot?.invalidate();
}
protected override getMountedRoots(): readonly Component[] {
return this.layoutRoot ? [this.layoutRoot] : this.children;
}
private getPrimaryScrollView(): ScrollView {
return this.currentLayout?.primaryScrollView ?? this.implicitScrollView;
}
protected override beforeTerminalStart(): void {
this.stopSelectionAutoScroll();
this.altScreenActive = true;
const capabilities = getCapabilities();
this.imageProtocol = capabilities.images;
@ -112,6 +157,7 @@ export class TuiAltScreen extends TuiBase implements TUI {
}
protected override beforeTerminalStop(): void {
this.stopSelectionAutoScroll();
if (!this.altScreenActive) return;
this.terminal.write(
`${BEGIN_SYNCHRONIZED_OUTPUT}${this.deleteKittyImages()}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${END_SYNCHRONIZED_OUTPUT}`,
@ -121,6 +167,11 @@ export class TuiAltScreen extends TuiBase implements TUI {
protected override afterTerminalStop(): void {
if (!this.altScreenActive) return;
this.altScreenActive = false;
const width = Math.max(1, this.terminal.columns);
const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map(
(line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)),
);
let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`;
for (let row = 0; row < this.lastDocument.length; row++) {
if (row > 0) buffer += "\r\n";
@ -142,34 +193,28 @@ export class TuiAltScreen extends TuiBase implements TUI {
this.previousScreen = [];
this.previousScreenWidth = 0;
this.previousScreenHeight = 0;
this.currentLayout = undefined;
}
scrollBy(lines: number): void {
if (lines === 0) return;
const height = Math.max(1, this.terminal.rows);
const maxScrollTop = Math.max(0, this.contentLineCount - height);
const currentTop = this.stickToBottom ? maxScrollTop : this.scrollTop;
this.scrollTop = Math.max(0, Math.min(maxScrollTop, currentTop + lines));
this.stickToBottom = this.scrollTop === maxScrollTop;
this.getPrimaryScrollView().scrollBy(lines);
this.requestRender();
}
scrollToTop(): void {
this.scrollTop = 0;
this.stickToBottom = this.contentLineCount <= this.terminal.rows;
this.getPrimaryScrollView().scrollToStart();
this.requestRender();
}
scrollToBottom(): void {
this.stickToBottom = true;
this.scrollTop = Math.max(0, this.contentLineCount - this.terminal.rows);
this.getPrimaryScrollView().scrollToEnd();
this.requestRender();
}
private handleViewportInput(data: string): { consume?: boolean } | undefined {
const wheelDirection = this.parseWheelDirection(data);
if (wheelDirection !== undefined) {
this.scrollBy(wheelDirection * this.wheelScrollLines);
const wheelEvent = this.parseWheelEvent(data);
if (wheelEvent) {
this.routeWheel(wheelEvent);
return { consume: true };
}
const mouseEvent = this.parseSgrMouseEvent(data);
@ -181,11 +226,11 @@ export class TuiAltScreen extends TuiBase implements TUI {
const keybindings = getKeybindings();
if (keybindings.matches(data, "tui.altScreen.pageUp")) {
this.scrollBy(-Math.max(1, this.terminal.rows - 1));
this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - 1));
return { consume: true };
}
if (keybindings.matches(data, "tui.altScreen.pageDown")) {
this.scrollBy(Math.max(1, this.terminal.rows - 1));
this.scrollBy(Math.max(1, this.getPrimaryScrollView().viewportHeight - 1));
return { consume: true };
}
if (keybindings.matches(data, "tui.altScreen.top")) {
@ -199,27 +244,46 @@ export class TuiAltScreen extends TuiBase implements TUI {
return undefined;
}
private parseWheelDirection(data: string): -1 | 1 | undefined {
const sgr = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
private parseWheelEvent(data: string): WheelEvent | undefined {
const sgr = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
if (sgr) {
const button = Number.parseInt(sgr[1], 10);
if ((button & 64) === 0) return undefined;
const direction = button & 3;
if (direction === 0) return -1;
if (direction === 1) return 1;
return undefined;
if (direction !== 0 && direction !== 1) return undefined;
return {
direction: direction === 0 ? -1 : 1,
x: Number.parseInt(sgr[2], 10) - 1,
y: Number.parseInt(sgr[3], 10) - 1,
};
}
if (data.length === 6 && data.startsWith("\x1b[M")) {
const button = data.charCodeAt(3) - 32;
if ((button & 64) === 0) return undefined;
const direction = button & 3;
if (direction === 0) return -1;
if (direction === 1) return 1;
return undefined;
if (direction !== 0 && direction !== 1) return undefined;
return {
direction: direction === 0 ? -1 : 1,
x: data.charCodeAt(4) - 33,
y: data.charCodeAt(5) - 33,
};
}
return undefined;
}
private routeWheel(event: WheelEvent): void {
let remaining = event.direction * this.wheelScrollLines;
const seen = new Set<ScrollView>();
for (const scrollView of this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y) : []) {
seen.add(scrollView);
remaining = scrollView.scrollBy(remaining);
if (remaining === 0 || scrollView.overscroll === "contain") break;
}
const primary = this.getPrimaryScrollView();
if (remaining !== 0 && !seen.has(primary)) primary.scrollBy(remaining);
this.requestRender();
}
private parseSgrMouseEvent(data: string): SgrMouseEvent | undefined {
const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
if (!match) return undefined;
@ -231,18 +295,105 @@ export class TuiAltScreen extends TuiBase implements TUI {
};
}
private handleSelectionMouseEvent(event: SgrMouseEvent): void {
if ((event.button & 3) !== 0) return;
const viewportRow = Math.max(0, Math.min(this.terminal.rows - 1, event.y));
const point = {
row: this.scrollTop + viewportRow,
private getScrollSelectionPoint(scrollView: ScrollView, x: number, y: number): SelectionPoint | undefined {
if (!this.currentLayout) return undefined;
const box = getScrollViewBox(this.currentLayout, scrollView);
if (!box || box.rect.height <= 0 || box.clip.height <= 0) return undefined;
const visibleTop = Math.max(0, box.rect.y, box.clip.y);
const visibleBottom = Math.min(
this.terminal.rows - 1,
box.rect.y + box.rect.height - 1,
box.clip.y + box.clip.height - 1,
);
if (visibleBottom < visibleTop) return undefined;
const pointerRow = Math.max(visibleTop, Math.min(visibleBottom, y));
const maxContentRow = Math.max(0, (box.scrollContentLines?.length ?? 1) - 1);
return {
row: Math.max(0, Math.min(maxContentRow, scrollView.scrollTop + pointerRow - box.rect.y)),
col: Math.max(0, Math.min(box.rect.width - 1, x - box.rect.x)),
scrollView,
};
}
private getSelectionPoint(event: SgrMouseEvent, scrollView?: ScrollView): SelectionPoint {
if (scrollView) {
const point = this.getScrollSelectionPoint(scrollView, event.x, event.y);
if (point) return point;
}
return {
row: Math.max(0, Math.min(this.terminal.rows - 1, event.y)),
col: Math.max(0, Math.min(this.terminal.columns - 1, event.x)),
};
}
private updateSelectionAutoScroll(event: SgrMouseEvent): void {
const scrollView = this.selectionAnchor?.scrollView;
if (!scrollView || !this.currentLayout) {
this.stopSelectionAutoScroll();
return;
}
const box = getScrollViewBox(this.currentLayout, scrollView);
if (!box || box.rect.height <= 0 || box.clip.height <= 0) {
this.stopSelectionAutoScroll();
return;
}
const visibleTop = Math.max(0, box.rect.y, box.clip.y);
const visibleBottom = Math.min(
this.terminal.rows - 1,
box.rect.y + box.rect.height - 1,
box.clip.y + box.clip.height - 1,
);
this.selectionDragPointer = { x: event.x, y: event.y };
this.selectionAutoScrollDirection = event.y <= visibleTop ? -1 : event.y >= visibleBottom ? 1 : 0;
if (this.selectionAutoScrollDirection === 0) {
this.stopSelectionAutoScroll();
return;
}
if (this.selectionAutoScrollTimer) return;
this.selectionAutoScrollTimer = setInterval(() => this.autoScrollSelection(), 50);
this.selectionAutoScrollTimer.unref();
}
private autoScrollSelection(): void {
const scrollView = this.selectionAnchor?.scrollView;
const pointer = this.selectionDragPointer;
const direction = this.selectionAutoScrollDirection;
if (!scrollView || !pointer || direction === 0) {
this.stopSelectionAutoScroll();
return;
}
const remaining = scrollView.scrollBy(direction);
if (remaining === direction) {
this.stopSelectionAutoScroll();
return;
}
const point = this.getScrollSelectionPoint(scrollView, pointer.x, pointer.y);
if (point) this.selectionFocus = point;
this.requestRender();
}
private stopSelectionAutoScroll(): void {
if (this.selectionAutoScrollTimer) {
clearInterval(this.selectionAutoScrollTimer);
this.selectionAutoScrollTimer = undefined;
}
this.selectionAutoScrollDirection = 0;
this.selectionDragPointer = undefined;
}
private handleSelectionMouseEvent(event: SgrMouseEvent): void {
if ((event.button & 3) !== 0) return;
const anchorScrollView = this.selectionAnchor?.scrollView;
const point = this.getSelectionPoint(event, anchorScrollView);
if (event.release) {
this.stopSelectionAutoScroll();
if (!this.selectionAnchor) return;
this.selectionFocus = point;
const clickedUrl =
!this.selectionDragged && this.selectionAnchor.row === point.row && this.selectionAnchor.col === point.col
!this.selectionDragged &&
this.selectionAnchor.scrollView === point.scrollView &&
this.selectionAnchor.row === point.row &&
this.selectionAnchor.col === point.col
? this.pressedUrl
: undefined;
this.pressedUrl = undefined;
@ -266,18 +417,29 @@ export class TuiAltScreen extends TuiBase implements TUI {
this.selectionDragged = true;
this.pressedUrl = undefined;
this.selectionFocus = point;
this.updateSelectionAutoScroll(event);
this.requestRender();
return;
}
this.selectionAnchor = point;
this.selectionFocus = point;
this.stopSelectionAutoScroll();
const scrollView =
!this.hasOverlay() && this.currentLayout
? getScrollViewsAt(this.currentLayout, event.x, event.y)[0]
: undefined;
const anchor = this.getSelectionPoint(event, scrollView);
this.selectionAnchor = anchor;
this.selectionFocus = anchor;
this.selectionDragged = false;
this.pressedUrl = getOsc8LinkAtColumn(this.previousScreen[viewportRow] ?? "", point.col);
this.pressedUrl = getOsc8LinkAtColumn(
this.previousScreen[Math.max(0, Math.min(this.terminal.rows - 1, event.y))] ?? "",
Math.max(0, Math.min(this.terminal.columns - 1, event.x)),
);
this.requestRender();
}
private getSelectionBounds(): { start: SelectionPoint; end: SelectionPoint } | undefined {
if (!this.selectionAnchor || !this.selectionFocus) return undefined;
if (this.selectionAnchor.scrollView !== this.selectionFocus.scrollView) return undefined;
const anchorBeforeFocus =
this.selectionAnchor.row < this.selectionFocus.row ||
(this.selectionAnchor.row === this.selectionFocus.row && this.selectionAnchor.col < this.selectionFocus.col);
@ -296,25 +458,34 @@ export class TuiAltScreen extends TuiBase implements TUI {
line: string,
row: number,
selection: { start: SelectionPoint; end: SelectionPoint },
minColumn = 0,
maxColumn = visibleWidth(line),
): { start: number; end: number } {
const lineWidth = visibleWidth(line);
let start = 0;
let end = lineWidth;
let start = Math.max(0, minColumn);
let end = Math.min(lineWidth, maxColumn);
if (row === selection.start.row) {
start = getGraphemeCellRange(line, selection.start.col)?.start ?? Math.min(selection.start.col, lineWidth);
}
if (row === selection.end.row) {
end = getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth);
}
return { start, end };
return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) };
}
private copySelectionToClipboard(): void {
const selection = this.getSelectionBounds();
if (!selection) return;
let sourceLines: readonly string[] = this.previousScreen;
if (selection.start.scrollView) {
if (!this.currentLayout) return;
const box = getScrollViewBox(this.currentLayout, selection.start.scrollView);
if (!box?.scrollContentLines) return;
sourceLines = box.scrollContentLines;
}
const lines: string[] = [];
for (let row = selection.start.row; row <= selection.end.row; row++) {
const line = this.lastDocument[row] ?? "";
const line = sourceLines[row] ?? "";
const columns = this.getSelectionColumns(line, row, selection);
lines.push(
stripTerminalSequences(
@ -327,19 +498,69 @@ export class TuiAltScreen extends TuiBase implements TUI {
this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
}
private applySelection(screen: string[]): string[] {
private applySelectionHighlight(text: string): string {
let result = "\x1b[7m";
let index = 0;
while (index < text.length) {
const ansi = extractAnsiCode(text, index);
if (!ansi) {
result += text[index];
index += 1;
continue;
}
result += ansi.code;
if (ansi.code.endsWith("m")) result += "\x1b[7m";
index += ansi.length;
}
return `${result}\x1b[27m`;
}
private applySelection(screen: string[], layout = this.currentLayout): string[] {
const selection = this.getSelectionBounds();
if (!selection) return screen;
return screen.map((line, viewportRow) => {
const row = this.scrollTop + viewportRow;
if (row < selection.start.row || row > selection.end.row || isImageLine(line)) return line;
let screenSelection = selection;
let minRow = 0;
let maxRow = screen.length - 1;
let minColumn = 0;
let maxColumn = this.terminal.columns;
if (selection.start.scrollView) {
if (!layout) return screen;
const box = getScrollViewBox(layout, selection.start.scrollView);
if (!box) return screen;
minRow = Math.max(0, box.rect.y, box.clip.y);
maxRow = Math.min(screen.length - 1, box.rect.y + box.rect.height - 1, box.clip.y + box.clip.height - 1);
minColumn = Math.max(0, box.rect.x, box.clip.x);
maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width);
screenSelection = {
start: {
row: box.rect.y + selection.start.row - selection.start.scrollView.scrollTop,
col: box.rect.x + selection.start.col,
scrollView: selection.start.scrollView,
},
end: {
row: box.rect.y + selection.end.row - selection.start.scrollView.scrollTop,
col: box.rect.x + selection.end.col,
scrollView: selection.start.scrollView,
},
};
}
return screen.map((line, row) => {
if (
row < minRow ||
row > maxRow ||
row < screenSelection.start.row ||
row > screenSelection.end.row ||
isImageLine(line)
) {
return line;
}
const lineWidth = visibleWidth(line);
const columns = this.getSelectionColumns(line, row, selection);
const columns = this.getSelectionColumns(line, row, screenSelection, minColumn, maxColumn);
if (columns.end <= columns.start) return line;
const before = sliceByColumn(line, 0, columns.start, true);
const selected = sliceByColumn(line, columns.start, columns.end - columns.start, true);
const after = sliceByColumn(line, columns.end, Math.max(0, lineWidth - columns.end), true);
return `${before}\x1b[7m${selected}\x1b[27m${after}`;
return `${before}${this.applySelectionHighlight(selected)}${after}`;
});
}
@ -351,43 +572,12 @@ export class TuiAltScreen extends TuiBase implements TUI {
if (this.stopped || !this.altScreenActive) return;
const width = Math.max(1, this.terminal.columns);
const height = Math.max(1, this.terminal.rows);
const contentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
this.contentLineCount = contentLines.length;
this.lastDocument = this.applyLineResets(contentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map(
(line) => {
if (isImageLine(line) || visibleWidth(line) <= width) return line;
return sliceByColumn(line, 0, width, true);
},
);
const maxScrollTop = Math.max(0, contentLines.length - height);
if (this.stickToBottom) {
this.scrollTop = maxScrollTop;
} else {
this.scrollTop = Math.max(0, Math.min(this.scrollTop, maxScrollTop));
this.stickToBottom = this.scrollTop === maxScrollTop;
}
let screen = contentLines.slice(this.scrollTop, this.scrollTop + height);
if (this.scrollTop > 0) {
for (let imageRow = this.scrollTop - 1; imageRow >= 0; imageRow--) {
const imageLine = contentLines[imageRow] ?? "";
const metadata = getKittyImageMetadata(imageLine);
if (metadata) {
const hiddenRows = this.scrollTop - imageRow;
if (hiddenRows < metadata.rows) {
const visibleRows = Math.min(height, metadata.rows - hiddenRows);
screen[0] = cropKittyImageLine(imageLine, hiddenRows, visibleRows);
}
break;
}
if (imageLine !== "") break;
}
}
while (screen.length < height) screen.push("");
const root = this.layoutRoot ?? this.implicitScrollView;
const nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
screen = this.compositeOverlays(screen, width, height);
if (screen.length > height) screen = screen.slice(screen.length - height);
screen = this.applySelection(screen);
screen = this.applySelection(screen, nextLayout);
const cursorPos = this.extractCursorPosition(screen, height);
screen = this.applyLineResets(screen).map((line) => {
@ -427,5 +617,6 @@ export class TuiAltScreen extends TuiBase implements TUI {
this.previousScreen = screen;
this.previousScreenWidth = width;
this.previousScreenHeight = height;
this.currentLayout = nextLayout;
}
}

View file

@ -0,0 +1,44 @@
import { compositeTuiLine } from "../tui.ts";
import { visibleWidth } from "../utils.ts";
import { allocateStackSizes, Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts";
export class HStack extends Stack {
protected readonly layoutType = "hstack" as const;
constructor(children: StackChild[] = [], options: StackOptions = {}) {
super(children, options);
}
override render(width: number): string[] {
const safeWidth = Math.max(1, width);
const viewport = { width: safeWidth, height: Number.MAX_SAFE_INTEGER };
const entries = visibleStackEntries(this.entries, viewport);
if (entries.length === 0) return [];
const intrinsicWidths = entries.map((entry) => {
const lines = entry.component.render(safeWidth);
return lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
});
const widths = allocateStackSizes(entries, intrinsicWidths, safeWidth, this.gap);
const rendered = entries.map((entry, index) =>
widths[index] === 0 ? [] : entry.component.render(widths[index]!),
);
const height = rendered.reduce((max, lines) => Math.max(max, lines.length), 0);
const result = Array.from({ length: height }, () => "");
let x = 0;
for (let index = 0; index < rendered.length; index++) {
const lines = rendered[index]!;
const childWidth = widths[index]!;
let offset = 0;
if (this.align === "center") offset = Math.floor((height - lines.length) / 2);
else if (this.align === "end") offset = height - lines.length;
for (let row = 0; row < lines.length; row++) {
const target = row + offset;
if (target < 0 || target >= result.length) continue;
result[target] = compositeTuiLine(result[target]!, lines[row]!, x, childWidth, safeWidth);
}
x += childWidth + this.gap;
}
return result;
}
}

View file

@ -0,0 +1,109 @@
import { LAYOUT_NODE, type ScrollLayoutNode } from "../layout-node.ts";
import { type Component, Container } from "../tui.ts";
export interface ScrollViewOptions {
axis?: "vertical";
follow?: "none" | "end";
primary?: boolean;
overscroll?: "chain" | "contain";
scrollbar?: "hidden" | "auto" | "always";
}
export class ScrollView extends Container {
private readonly child: Component;
private readonly followEnd: boolean;
readonly primary: boolean;
readonly overscroll: "chain" | "contain";
readonly scrollbar: "hidden" | "auto" | "always";
private currentScrollTop = 0;
private contentHeight = 0;
private currentViewportHeight = 0;
private followingEnd: boolean;
private requestRenderCallback: (() => void) | undefined;
constructor(component: Component, options: ScrollViewOptions = {}) {
super();
if (options.axis !== undefined && options.axis !== "vertical") {
throw new Error(`Unsupported ScrollView axis: ${options.axis}`);
}
this.child = component;
this.children.push(component);
this.followEnd = (options.follow ?? "none") === "end";
this.followingEnd = this.followEnd;
this.primary = options.primary ?? false;
this.overscroll = options.overscroll ?? "chain";
this.scrollbar = options.scrollbar ?? "hidden";
}
get scrollTop(): number {
return this.currentScrollTop;
}
get isFollowingEnd(): boolean {
return this.followingEnd;
}
get viewportHeight(): number {
return this.currentViewportHeight;
}
scrollBy(lines: number): number {
const requested = Number.isFinite(lines) ? Math.trunc(lines) : 0;
if (requested === 0) return 0;
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
const start = this.followingEnd ? maxScrollTop : this.currentScrollTop;
const next = Math.max(0, Math.min(maxScrollTop, start + requested));
const moved = next - start;
this.currentScrollTop = next;
this.followingEnd = this.followEnd && next === maxScrollTop;
if (moved !== 0) this.requestRenderCallback?.();
return requested - moved;
}
scrollToStart(): void {
const changed =
this.currentScrollTop !== 0 ||
this.followingEnd !== (this.followEnd && this.contentHeight <= this.currentViewportHeight);
this.currentScrollTop = 0;
this.followingEnd = this.followEnd && this.contentHeight <= this.currentViewportHeight;
if (changed) this.requestRenderCallback?.();
}
scrollToEnd(): void {
const next = Math.max(0, this.contentHeight - this.currentViewportHeight);
const changed = this.currentScrollTop !== next || this.followingEnd !== this.followEnd;
this.currentScrollTop = next;
this.followingEnd = this.followEnd;
if (changed) this.requestRenderCallback?.();
}
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void {
this.contentHeight = Math.max(0, Math.floor(contentHeight));
this.currentViewportHeight = Math.max(0, Math.floor(viewportHeight));
this.requestRenderCallback = requestRender;
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
if (this.followingEnd) this.currentScrollTop = maxScrollTop;
else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop));
if (this.followEnd && this.currentScrollTop === maxScrollTop) this.followingEnd = true;
}
override addChild(_component: Component): void {
throw new Error("ScrollView has exactly one child");
}
override removeChild(_component: Component): void {
throw new Error("ScrollView child cannot be removed");
}
override clear(): void {
throw new Error("ScrollView child cannot be cleared");
}
override render(width: number): string[] {
return this.child.render(width);
}
[LAYOUT_NODE](): ScrollLayoutNode {
return { type: "scroll", component: this.child, state: this };
}
}

View file

@ -0,0 +1,154 @@
import { LAYOUT_NODE, type LayoutViewport, type StackLayoutEntry, type StackLayoutNode } from "../layout-node.ts";
import { type Component, Container } from "../tui.ts";
export interface StackEntryOptions {
basis?: number | "auto";
grow?: number;
shrink?: number;
minSize?: number;
maxSize?: number;
visible?: (viewport: LayoutViewport) => boolean;
}
export interface StackEntry extends StackEntryOptions {
component: Component;
}
export type StackChild = Component | StackEntry;
export interface StackOptions {
gap?: number;
align?: "stretch" | "start" | "center" | "end";
}
function isStackEntry(child: StackChild): child is StackEntry {
return !("render" in child);
}
function normalizeSize(value: number | undefined, fallback: number): number {
return value === undefined || !Number.isFinite(value) ? fallback : Math.max(0, Math.floor(value));
}
export abstract class Stack extends Container {
protected readonly entries: StackLayoutEntry[] = [];
protected readonly gap: number;
protected readonly align: "stretch" | "start" | "center" | "end";
protected abstract readonly layoutType: "vstack" | "hstack";
constructor(children: StackChild[] = [], options: StackOptions = {}) {
super();
this.gap = normalizeSize(options.gap, 0);
this.align = options.align ?? "stretch";
for (const child of children) {
if (isStackEntry(child)) this.addChild(child.component, child);
else this.addChild(child);
}
}
override addChild(component: Component, options: StackEntryOptions = {}): void {
super.addChild(component);
this.entries.push({
component,
...(options.basis === undefined ? {} : { basis: options.basis }),
...(options.grow === undefined ? {} : { grow: normalizeSize(options.grow, 0) }),
...(options.shrink === undefined ? {} : { shrink: normalizeSize(options.shrink, 1) }),
...(options.minSize === undefined ? {} : { minSize: normalizeSize(options.minSize, 0) }),
...(options.maxSize === undefined ? {} : { maxSize: normalizeSize(options.maxSize, Number.MAX_SAFE_INTEGER) }),
...(options.visible === undefined ? {} : { visible: options.visible }),
});
}
override removeChild(component: Component): void {
super.removeChild(component);
const index = this.entries.findIndex((entry) => entry.component === component);
if (index !== -1) this.entries.splice(index, 1);
}
override clear(): void {
super.clear();
this.entries.length = 0;
}
[LAYOUT_NODE](): StackLayoutNode {
return {
type: this.layoutType,
entries: this.entries,
gap: this.gap,
align: this.align,
};
}
}
export function visibleStackEntries(
entries: readonly StackLayoutEntry[],
viewport: LayoutViewport,
): StackLayoutEntry[] {
return entries.filter((entry) => entry.visible?.(viewport) ?? true);
}
function clampSize(size: number, entry: StackLayoutEntry): number {
const min = Math.max(0, Math.floor(entry.minSize ?? 0));
const max = Math.max(min, Math.floor(entry.maxSize ?? Number.MAX_SAFE_INTEGER));
return Math.max(min, Math.min(max, Math.max(0, Math.floor(size))));
}
function distribute(
sizes: number[],
entries: readonly StackLayoutEntry[],
amount: number,
mode: "grow" | "shrink",
): void {
let remaining = amount;
while (remaining > 0) {
const candidates = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry, index }) => {
if (mode === "grow") {
return (entry.grow ?? 0) > 0 && sizes[index]! < (entry.maxSize ?? Number.MAX_SAFE_INTEGER);
}
return (entry.shrink ?? 1) > 0 && sizes[index]! > (entry.minSize ?? 0);
});
if (candidates.length === 0) return;
const totalWeight = candidates.reduce((sum, { entry, index }) => {
return sum + (mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!));
}, 0);
let distributed = 0;
for (const { entry, index } of candidates) {
if (remaining <= 0) break;
const weight = mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!);
const proposed = Math.max(1, Math.floor((remaining * weight) / totalWeight));
const capacity =
mode === "grow"
? (entry.maxSize ?? Number.MAX_SAFE_INTEGER) - sizes[index]!
: sizes[index]! - (entry.minSize ?? 0);
const delta = Math.min(remaining, proposed, capacity);
if (delta <= 0) continue;
sizes[index] = sizes[index]! + (mode === "grow" ? delta : -delta);
remaining -= delta;
distributed += delta;
}
if (distributed === 0) return;
}
}
export function allocateStackSizes(
entries: readonly StackLayoutEntry[],
intrinsicSizes: readonly number[],
availableSize: number | undefined,
gap: number,
): number[] {
const sizes = entries.map((entry, index) =>
clampSize(
entry.basis === undefined || entry.basis === "auto" ? (intrinsicSizes[index] ?? 0) : entry.basis,
entry,
),
);
if (availableSize === undefined) return sizes;
const contentSize = Math.max(0, Math.floor(availableSize) - Math.max(0, entries.length - 1) * gap);
const total = sizes.reduce((sum, size) => sum + size, 0);
if (total < contentSize) distribute(sizes, entries, contentSize - total, "grow");
else if (total > contentSize) distribute(sizes, entries, total - contentSize, "shrink");
return sizes;
}

View file

@ -0,0 +1,24 @@
import { Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts";
export class VStack extends Stack {
protected readonly layoutType = "vstack" as const;
constructor(children: StackChild[] = [], options: StackOptions = {}) {
super(children, options);
}
override render(width: number): string[] {
const viewport = { width: Math.max(1, width), height: Number.MAX_SAFE_INTEGER };
const entries = visibleStackEntries(this.entries, viewport);
const lines: string[] = [];
for (let index = 0; index < entries.length; index++) {
if (index > 0) {
for (let gap = 0; gap < this.gap; gap++) lines.push("");
}
lines.push(...entries[index]!.component.render(viewport.width));
}
return lines;
}
}
export type { StackChild, StackEntry, StackEntryOptions, StackOptions } from "./stack.ts";

View file

@ -13,10 +13,12 @@ export {
export { Box } from "./components/box.ts";
export { CancellableLoader } from "./components/cancellable-loader.ts";
export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.ts";
export { HStack } from "./components/h-stack.ts";
export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts";
export { Input } from "./components/input.ts";
export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts";
export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts";
export { ScrollView, type ScrollViewOptions } from "./components/scroll-view.ts";
export {
type SelectItem,
SelectList,
@ -28,6 +30,13 @@ export { type SettingItem, SettingsList, type SettingsListTheme } from "./compon
export { Spacer } from "./components/spacer.ts";
export { Text } from "./components/text.ts";
export { TruncatedText } from "./components/truncated-text.ts";
export {
type StackChild,
type StackEntry,
type StackEntryOptions,
type StackOptions,
VStack,
} from "./components/v-stack.ts";
// Editor component interface (for custom editors)
export type { EditorComponent } from "./editor-component.ts";
// Fuzzy matching
@ -106,6 +115,7 @@ export {
compositeTuiLine,
type Focusable,
isFocusable,
isViewportTUI,
type OverlayAnchor,
type OverlayHandle,
type OverlayMargin,
@ -115,6 +125,7 @@ export {
type TUI,
type TuiInputListener,
type TuiInputListenerResult,
type ViewportTUI,
} from "./tui.ts";
// Utilities
export {

View file

@ -0,0 +1,50 @@
import type { Component } from "./tui.ts";
export const LAYOUT_NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
export interface LayoutViewport {
width: number;
height: number;
}
export interface StackLayoutEntry {
component: Component;
basis?: number | "auto";
grow?: number;
shrink?: number;
minSize?: number;
maxSize?: number;
visible?: (viewport: LayoutViewport) => boolean;
}
export interface StackLayoutNode {
type: "vstack" | "hstack";
entries: readonly StackLayoutEntry[];
gap: number;
align: "stretch" | "start" | "center" | "end";
}
export interface ScrollLayoutState {
readonly scrollTop: number;
readonly primary: boolean;
readonly overscroll: "chain" | "contain";
readonly viewportHeight: number;
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void;
}
export interface ScrollLayoutNode {
type: "scroll";
component: Component;
state: ScrollLayoutState;
}
export type LayoutNode = StackLayoutNode | ScrollLayoutNode;
export interface LayoutComponent extends Component {
[LAYOUT_NODE](): LayoutNode;
}
export function getLayoutNode(component: Component): LayoutNode | undefined {
const candidate = component as Partial<LayoutComponent>;
return typeof candidate[LAYOUT_NODE] === "function" ? candidate[LAYOUT_NODE]() : undefined;
}

311
packages/tui/src/layout.ts Normal file
View file

@ -0,0 +1,311 @@
import type { ScrollView } from "./components/scroll-view.ts";
import { allocateStackSizes, visibleStackEntries } from "./components/stack.ts";
import { getLayoutNode } from "./layout-node.ts";
import { cropKittyImageLine, getKittyImageMetadata, isImageLine } from "./terminal-image.ts";
import { type Component, CURSOR_MARKER, compositeTuiLine } from "./tui.ts";
import { visibleWidth } from "./utils.ts";
const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/;
export interface LayoutRect {
x: number;
y: number;
width: number;
height: number;
}
export interface LayoutBox {
component: Component;
rect: LayoutRect;
clip: LayoutRect;
children: LayoutBox[];
parent?: LayoutBox;
lines?: readonly string[];
lineOffset?: number;
scrollView?: ScrollView;
scrollContentLines?: readonly string[];
layer: number;
}
export interface LayoutFrame {
root: LayoutBox;
width: number;
height: number;
lines: string[];
primaryScrollView?: ScrollView;
}
interface LayoutContext {
viewport: { width: number; height: number };
renderCache: Map<Component, Map<number, string[]>>;
requestRender: () => void;
primaryScrollView: ScrollView | undefined;
}
function intersect(a: LayoutRect, b: LayoutRect): LayoutRect {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
const right = Math.min(a.x + a.width, b.x + b.width);
const bottom = Math.min(a.y + a.height, b.y + b.height);
return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
}
function renderCached(context: LayoutContext, component: Component, width: number): string[] {
const safeWidth = Math.max(1, Math.floor(width));
let widths = context.renderCache.get(component);
if (!widths) {
widths = new Map<number, string[]>();
context.renderCache.set(component, widths);
}
let lines = widths.get(safeWidth);
if (!lines) {
lines = component.render(safeWidth);
widths.set(safeWidth, lines);
}
return lines;
}
function measureHeight(context: LayoutContext, component: Component, width: number): number {
return renderCached(context, component, width).length;
}
function measureWidth(context: LayoutContext, component: Component, width: number): number {
return renderCached(context, component, width).reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
}
function withParent(box: LayoutBox, parent: LayoutBox): LayoutBox {
box.parent = parent;
return box;
}
function translateBox(box: LayoutBox, deltaY: number): void {
box.rect.y += deltaY;
for (const child of box.children) translateBox(child, deltaY);
}
function updateClips(box: LayoutBox, parentClip: LayoutRect): void {
box.clip = intersect(parentClip, box.rect);
for (const child of box.children) updateClips(child, box.clip);
}
function layoutComponent(
context: LayoutContext,
component: Component,
x: number,
y: number,
width: number,
height: number | undefined,
clip: LayoutRect,
): LayoutBox {
const safeWidth = Math.max(1, Math.floor(width));
const node = getLayoutNode(component);
if (!node) {
const lines = renderCached(context, component, safeWidth);
const allocatedHeight = height === undefined ? lines.length : Math.max(0, Math.floor(height));
let lineOffset = 0;
if (lines.length > allocatedHeight && allocatedHeight > 0) {
const cursorLine = lines.findIndex((line) => line.includes(CURSOR_MARKER));
if (cursorLine >= allocatedHeight) lineOffset = cursorLine - allocatedHeight + 1;
}
return {
component,
rect: { x, y, width: safeWidth, height: allocatedHeight },
clip: intersect(clip, { x, y, width: safeWidth, height: allocatedHeight }),
children: [],
lines,
lineOffset,
layer: 0,
};
}
if (node.type === "scroll") {
const previousScrollTop = node.state.scrollTop;
const childBox = layoutComponent(context, node.component, x, y - previousScrollTop, safeWidth, undefined, clip);
const contentHeight = childBox.rect.height;
const viewportHeight = height === undefined ? contentHeight : Math.max(0, Math.floor(height));
node.state.updateLayout(contentHeight, viewportHeight, context.requestRender);
translateBox(childBox, previousScrollTop - node.state.scrollTop);
const scrollView = node.state as ScrollView;
if (node.state.primary || !context.primaryScrollView) context.primaryScrollView = scrollView;
const rect = { x, y, width: safeWidth, height: viewportHeight };
const childClip = intersect(clip, rect);
const box: LayoutBox = {
component,
rect,
clip: childClip,
children: [childBox],
scrollView,
scrollContentLines: renderCached(context, node.component, safeWidth),
layer: 0,
};
childBox.parent = box;
updateClips(childBox, childClip);
return box;
}
const entries = visibleStackEntries(node.entries, context.viewport);
const gapTotal = Math.max(0, entries.length - 1) * node.gap;
if (node.type === "vstack") {
const intrinsicHeights = entries.map((entry) => measureHeight(context, entry.component, safeWidth));
const sizes = allocateStackSizes(entries, intrinsicHeights, height, node.gap);
const naturalHeight = sizes.reduce((sum, size) => sum + size, 0) + gapTotal;
const allocatedHeight = height === undefined ? naturalHeight : Math.max(0, Math.floor(height));
const rect = { x, y, width: safeWidth, height: allocatedHeight };
const box: LayoutBox = {
component,
rect,
clip: intersect(clip, rect),
children: [],
layer: 0,
};
let childY = y;
for (let index = 0; index < entries.length; index++) {
box.children.push(
withParent(
layoutComponent(context, entries[index]!.component, x, childY, safeWidth, sizes[index]!, box.clip),
box,
),
);
childY += sizes[index]! + node.gap;
}
return box;
}
const intrinsicWidths = entries.map((entry) => measureWidth(context, entry.component, safeWidth));
const widths = allocateStackSizes(entries, intrinsicWidths, safeWidth, node.gap);
const intrinsicHeights = entries.map((entry, index) =>
measureHeight(context, entry.component, Math.max(1, widths[index]!)),
);
const allocatedHeight =
height === undefined
? intrinsicHeights.reduce((max, childHeight) => Math.max(max, childHeight), 0)
: Math.max(0, height);
const rect = { x, y, width: safeWidth, height: allocatedHeight };
const box: LayoutBox = {
component,
rect,
clip: intersect(clip, rect),
children: [],
layer: 0,
};
let childX = x;
for (let index = 0; index < entries.length; index++) {
const naturalChildHeight = intrinsicHeights[index]!;
const childHeight = node.align === "stretch" ? allocatedHeight : Math.min(allocatedHeight, naturalChildHeight);
let childY = y;
if (node.align === "center") childY += Math.floor((allocatedHeight - childHeight) / 2);
else if (node.align === "end") childY += allocatedHeight - childHeight;
const childWidth = widths[index]!;
if (childWidth === 0) {
box.children.push({
component: entries[index]!.component,
rect: { x: childX, y: childY, width: 0, height: childHeight },
clip: { x: childX, y: childY, width: 0, height: 0 },
children: [],
parent: box,
layer: 0,
});
} else {
box.children.push(
withParent(
layoutComponent(context, entries[index]!.component, childX, childY, childWidth, childHeight, box.clip),
box,
),
);
}
childX += childWidth + node.gap;
}
return box;
}
function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void {
if (box.lines) {
const offset = box.lineOffset ?? 0;
for (let localRow = 0; localRow < box.rect.height; localRow++) {
const row = box.rect.y + localRow;
if (row < box.clip.y || row >= box.clip.y + box.clip.height || row < 0 || row >= screen.length) continue;
const sourceLine = box.lines[offset + localRow];
if (sourceLine === undefined) continue;
const line = sourceLine.replace(OSC133_ZONE_PREFIX, "");
if (isImageLine(line) && box.rect.x === 0 && box.rect.width >= totalWidth) screen[row] = line;
else screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth);
}
}
for (const child of box.children) paintBox(child, screen, totalWidth);
if (box.scrollView && box.scrollContentLines && box.scrollView.scrollTop > 0 && box.rect.height > 0) {
for (let imageRow = box.scrollView.scrollTop - 1; imageRow >= 0; imageRow--) {
const imageLine = box.scrollContentLines[imageRow] ?? "";
const metadata = getKittyImageMetadata(imageLine);
if (metadata) {
const hiddenRows = box.scrollView.scrollTop - imageRow;
if (hiddenRows < metadata.rows) {
const visibleRows = Math.min(box.rect.height, metadata.rows - hiddenRows);
const cropped = cropKittyImageLine(imageLine, hiddenRows, visibleRows);
if (box.rect.x === 0 && box.rect.width >= totalWidth) screen[box.rect.y] = cropped;
}
break;
}
if (imageLine !== "") break;
}
}
}
export function renderLayoutFrame(
root: Component,
width: number,
height: number,
requestRender: () => void,
): LayoutFrame {
const safeWidth = Math.max(1, Math.floor(width));
const safeHeight = Math.max(1, Math.floor(height));
const context: LayoutContext = {
viewport: { width: safeWidth, height: safeHeight },
renderCache: new Map(),
requestRender,
primaryScrollView: undefined,
};
const rootBox = layoutComponent(context, root, 0, 0, safeWidth, safeHeight, {
x: 0,
y: 0,
width: safeWidth,
height: safeHeight,
});
const lines = Array.from({ length: safeHeight }, () => "");
paintBox(rootBox, lines, safeWidth);
return {
root: rootBox,
width: safeWidth,
height: safeHeight,
lines,
...(context.primaryScrollView === undefined ? {} : { primaryScrollView: context.primaryScrollView }),
};
}
function containsPoint(rect: LayoutRect, x: number, y: number): boolean {
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
}
export function getScrollViewBox(frame: LayoutFrame, scrollView: ScrollView): LayoutBox | undefined {
const visit = (box: LayoutBox): LayoutBox | undefined => {
if (box.scrollView === scrollView) return box;
for (const child of box.children) {
const match = visit(child);
if (match) return match;
}
return undefined;
};
return visit(frame.root);
}
export function getScrollViewsAt(frame: LayoutFrame, x: number, y: number): ScrollView[] {
const result: Array<{ scrollView: ScrollView; depth: number }> = [];
const visit = (box: LayoutBox, depth: number): void => {
if (!containsPoint(box.clip, x, y)) return;
if (box.scrollView && containsPoint(box.rect, x, y)) result.push({ scrollView: box.scrollView, depth });
for (const child of box.children) visit(child, depth + 1);
};
visit(frame.root, 0);
result.sort((a, b) => b.depth - a.depth);
return result.map((entry) => entry.scrollView);
}

View file

@ -308,6 +308,17 @@ export interface TUI extends Component {
queryTerminalColorScheme(options: { timeoutMs: number }): Promise<TerminalColorScheme | undefined>;
}
export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport");
export interface ViewportTUI extends TUI {
readonly [VIEWPORT_TUI]: true;
setLayoutRoot(component: Component | undefined): void;
}
export function isViewportTUI(tui: TUI): tui is ViewportTUI {
return (tui as Partial<ViewportTUI>)[VIEWPORT_TUI] === true;
}
export abstract class TuiBase extends Container implements TUI {
public terminal: Terminal;
private focusedComponent: Component | null = null;
@ -502,8 +513,12 @@ export abstract class TuiBase extends Container implements TUI {
}
}
protected getMountedRoots(): readonly Component[] {
return this.children;
}
private isComponentMounted(component: Component): boolean {
return this.children.some((child) => this.containsComponent(child, component));
return this.getMountedRoots().some((child) => this.containsComponent(child, component));
}
private containsComponent(root: Component, target: Component): boolean {

View file

@ -0,0 +1,127 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { HStack } from "../src/components/h-stack.ts";
import { ScrollView } from "../src/components/scroll-view.ts";
import { Text } from "../src/components/text.ts";
import { VStack } from "../src/components/v-stack.ts";
import { renderLayoutFrame } from "../src/layout.ts";
import { stripTerminalSequences } from "../src/utils.ts";
function visibleLines(lines: string[]): string[] {
return lines.map((line) => stripTerminalSequences(line).trimEnd());
}
describe("viewport layout", () => {
it("allocates vertical grow space deterministically", () => {
const frame = renderLayoutFrame(
new VStack([
{ component: new Text("top", 0, 0), basis: 1, shrink: 0 },
{ component: new Text("body", 0, 0), basis: 0, grow: 1 },
]),
10,
4,
() => {},
);
assert.deepStrictEqual(
frame.root.children.map((child) => child.rect.height),
[1, 3],
);
assert.deepStrictEqual(visibleLines(frame.lines), ["top", "body", "", ""]);
});
it("shrinks entries to their minimum sizes", () => {
const frame = renderLayoutFrame(
new VStack([
{ component: new Text("a1\na2\na3", 0, 0), shrink: 1, minSize: 1 },
{ component: new Text("b1\nb2\nb3", 0, 0), shrink: 0 },
]),
10,
4,
() => {},
);
assert.deepStrictEqual(
frame.root.children.map((child) => child.rect.height),
[1, 3],
);
assert.deepStrictEqual(visibleLines(frame.lines), ["a1", "b1", "b2", "b3"]);
});
it("omits gaps around invisible entries", () => {
const stack = new VStack(
[new Text("one", 0, 0), { component: new Text("hidden", 0, 0), visible: () => false }, new Text("two", 0, 0)],
{ gap: 1 },
);
assert.deepStrictEqual(
stack.render(10).map((line) => line.trimEnd()),
["one", "", "two"],
);
});
it("composes horizontal children at allocated widths", () => {
const frame = renderLayoutFrame(
new HStack([
{ component: new Text("left", 0, 0), basis: 6, shrink: 0 },
{ component: new Text("right", 0, 0), basis: 6, shrink: 0 },
]),
12,
1,
() => {},
);
assert.deepStrictEqual(visibleLines(frame.lines), ["left right"]);
});
it("does not paint zero-width horizontal children", () => {
const frame = renderLayoutFrame(
new HStack([
{ component: new Text("hidden", 0, 0), basis: 0, shrink: 0 },
{ component: new Text("shown", 0, 0), basis: 0, grow: 1 },
]),
5,
1,
() => {},
);
assert.deepStrictEqual(visibleLines(frame.lines), ["shown"]);
});
it("tracks follow-end state and returns unused scroll delta", () => {
const scrollView = new ScrollView(new Text("1\n2\n3\n4\n5\n6", 0, 0), {
follow: "end",
primary: true,
});
renderLayoutFrame(scrollView, 10, 3, () => {});
assert.strictEqual(scrollView.scrollTop, 3);
assert.strictEqual(scrollView.isFollowingEnd, true);
assert.strictEqual(scrollView.scrollBy(-2), 0);
assert.strictEqual(scrollView.scrollTop, 1);
assert.strictEqual(scrollView.isFollowingEnd, false);
assert.strictEqual(scrollView.scrollBy(-3), -2);
assert.strictEqual(scrollView.scrollTop, 0);
assert.strictEqual(scrollView.scrollBy(10), 7);
assert.strictEqual(scrollView.scrollTop, 3);
assert.strictEqual(scrollView.isFollowingEnd, true);
});
it("measures nested scroll content from constrained child geometry", () => {
const inner = new ScrollView(new Text("1\n2\n3\n4\n5\n6", 0, 0));
const outer = new ScrollView(new VStack([{ component: inner, basis: 2 }, new Text("tail", 0, 0)]));
renderLayoutFrame(outer, 10, 2, () => {});
assert.strictEqual(inner.viewportHeight, 2);
assert.strictEqual(outer.scrollBy(10), 9);
assert.strictEqual(outer.scrollTop, 1);
});
it("rebuilds geometry after content changes", () => {
const text = new Text("one", 0, 0);
const root = new VStack([text]);
const first = renderLayoutFrame(root, 10, 4, () => {});
text.setText("one\ntwo\nthree");
const second = renderLayoutFrame(root, 10, 4, () => {});
assert.strictEqual(first.root.children[0]?.lines?.length, 1);
assert.strictEqual(second.root.children[0]?.lines?.length, 3);
});
});

View file

@ -1,7 +1,10 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { HStack } from "../src/components/h-stack.ts";
import { Image } from "../src/components/image.ts";
import { ScrollView } from "../src/components/scroll-view.ts";
import { Text } from "../src/components/text.ts";
import { VStack } from "../src/components/v-stack.ts";
import { TuiAltScreen } from "../src/TuiAltScreen.ts";
import {
encodeKitty,
@ -66,6 +69,104 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("keeps an explicit dock fixed while the transcript scrolls", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TuiAltScreen(terminal);
const transcriptText = new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0);
const transcript = new ScrollView(transcriptText, { follow: "end", primary: true });
const dock = new VStack([new Text("editor", 0, 0), new Text("footer", 0, 0)]);
tui.setLayoutRoot(
new VStack([
{ component: transcript, basis: 0, grow: 1, minSize: 1 },
{ component: dock, basis: "auto", minSize: 1 },
]),
);
tui.start();
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 5", "line 6", "line 7", "line 8", "editor", "footer"],
);
// Wheel over the dock falls back to the primary transcript scroll view.
terminal.sendInput("\x1b[<64;1;6M");
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 2", "line 3", "line 4", "line 5", "editor", "footer"],
);
assert.strictEqual(transcript.isFollowingEnd, false);
transcriptText.setText(Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n"));
tui.requestRender();
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 2", "line 3", "line 4", "line 5", "editor", "footer"],
);
tui.scrollToBottom();
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 7", "line 8", "line 9", "line 10", "editor", "footer"],
);
tui.stop();
});
it("routes wheel input to the scroll view under the pointer", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
const left = new ScrollView(new Text("a1\na2\na3\na4\na5\na6\na7", 0, 0), {
follow: "end",
primary: true,
});
const right = new ScrollView(new Text("b1\nb2\nb3\nb4\nb5\nb6\nb7", 0, 0), { follow: "end" });
tui.setLayoutRoot(
new HStack([
{ component: left, basis: 10, shrink: 0 },
{ component: right, basis: 10, shrink: 0 },
]),
);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<64;15;1M");
await terminal.waitForRender();
assert.strictEqual(left.scrollTop, 3);
assert.strictEqual(right.scrollTop, 0);
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["a4 b1", "a5 b2", "a6 b3", "a7 b4"],
);
tui.stop();
});
it("chains unused wheel delta to an outer scroll view", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
const inner = new ScrollView(new Text("i1\ni2\ni3\ni4\ni5\ni6", 0, 0));
const outer = new ScrollView(
new VStack([{ component: inner, basis: 2 }, new Text("tail1\ntail2\ntail3\ntail4\ntail5", 0, 0)]),
{ primary: true },
);
tui.setLayoutRoot(outer);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<65;1;1M");
await terminal.waitForRender();
assert.strictEqual(inner.scrollTop, 3);
assert.strictEqual(outer.scrollTop, 0);
terminal.sendInput("\x1b[<65;1;1M");
await terminal.waitForRender();
assert.strictEqual(inner.scrollTop, 4);
assert.strictEqual(outer.scrollTop, 2);
tui.stop();
});
it("supports configurable keyboard viewport navigation", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
@ -234,10 +335,42 @@ describe("TuiAltScreen", () => {
JSON.stringify(clipboardWrites),
);
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m")));
assert.ok(
terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m\x1b[0m\x1b[7m")),
"selection inverse must be reapplied after layout segment resets",
);
tui.stop();
});
it("auto-scrolls and extends a drag selection held at the viewport edge", async () => {
const terminal = new RecordingTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text(Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0));
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 6);
terminal.sendInput("\x1b[<0;1;3M");
terminal.sendInput("\x1b[<32;1;1M");
await new Promise((resolve) => setTimeout(resolve, 130));
await terminal.waitForRender();
const selectionTop = tui.viewportTop;
assert.ok(selectionTop < 6, `expected auto-scroll above row 6, got ${selectionTop}`);
terminal.sendInput("\x1b[<0;1;1m");
await terminal.waitForRender();
const selectedLines = Array.from({ length: 8 - selectionTop }, (_, index) => `line ${selectionTop + index + 1}`);
selectedLines.push("l");
const expectedClipboardSequence = `\x1b]52;c;${Buffer.from(selectedLines.join("\n")).toString("base64")}\x07`;
assert.ok(
terminal.events.some((event) => event.type === "write" && event.data.includes(expectedClipboardSequence)),
JSON.stringify(terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;"))),
);
tui.stop();
});
it("snaps mouse selection to CJK, emoji, and combining grapheme boundaries", async () => {
const terminal = new RecordingTerminal(20, 2);
const tui = new TuiAltScreen(terminal);

1001
tui-plan.md Normal file

File diff suppressed because it is too large Load diff