diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2d03e8e91..0178c3445 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index d48790708..bb675120b 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -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 diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index cac56387d..168090187 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -239,14 +239,14 @@ pi --no-extensions -e ./my-extension.ts |--------|-------------| | `--system-prompt ` | Replace default prompt; context files and skills are still appended | | `--append-system-prompt ` | 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 diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f9462d1a9..e10ac8e23 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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(); diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index 5e2e3f543..e191cbc4e 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -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); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9bc425ae0..46d5eae7d 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/README.md b/packages/tui/README.md index acec9fc20..5fee7d642 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -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. diff --git a/packages/tui/src/TuiAltScreen.ts b/packages/tui/src/TuiAltScreen.ts index d284647ac..47ebe45bb 100644 --- a/packages/tui/src/TuiAltScreen.ts +++ b/packages/tui/src/TuiAltScreen.ts @@ -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(); + 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; } } diff --git a/packages/tui/src/components/h-stack.ts b/packages/tui/src/components/h-stack.ts new file mode 100644 index 000000000..07f8f8ffd --- /dev/null +++ b/packages/tui/src/components/h-stack.ts @@ -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; + } +} diff --git a/packages/tui/src/components/scroll-view.ts b/packages/tui/src/components/scroll-view.ts new file mode 100644 index 000000000..2af9da353 --- /dev/null +++ b/packages/tui/src/components/scroll-view.ts @@ -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 }; + } +} diff --git a/packages/tui/src/components/stack.ts b/packages/tui/src/components/stack.ts new file mode 100644 index 000000000..68bde0368 --- /dev/null +++ b/packages/tui/src/components/stack.ts @@ -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; +} diff --git a/packages/tui/src/components/v-stack.ts b/packages/tui/src/components/v-stack.ts new file mode 100644 index 000000000..8e13b07c1 --- /dev/null +++ b/packages/tui/src/components/v-stack.ts @@ -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"; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index e0923e464..cd8ecc292 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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 { diff --git a/packages/tui/src/layout-node.ts b/packages/tui/src/layout-node.ts new file mode 100644 index 000000000..f66a4d18d --- /dev/null +++ b/packages/tui/src/layout-node.ts @@ -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; + return typeof candidate[LAYOUT_NODE] === "function" ? candidate[LAYOUT_NODE]() : undefined; +} diff --git a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts new file mode 100644 index 000000000..7f29896ef --- /dev/null +++ b/packages/tui/src/layout.ts @@ -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>; + 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(); + 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); +} diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 50cde5ce3..eb807000e 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -308,6 +308,17 @@ export interface TUI extends Component { queryTerminalColorScheme(options: { timeoutMs: number }): Promise; } +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)[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 { diff --git a/packages/tui/test/layout.test.ts b/packages/tui/test/layout.test.ts new file mode 100644 index 000000000..2aabc0419 --- /dev/null +++ b/packages/tui/test/layout.test.ts @@ -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); + }); +}); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 8ca07bb34..acdc15fa5 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -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); diff --git a/tui-plan.md b/tui-plan.md new file mode 100644 index 000000000..fe76f8c9f --- /dev/null +++ b/tui-plan.md @@ -0,0 +1,1001 @@ +# Alternate-Screen Layout System Plan + +## Purpose + +Implement a constrained layout system for `TuiAltScreen` and use it to keep the coding-agent transcript scrollable while the pending/status/widget/editor/footer area remains fixed at the bottom. + +This document is an implementation handoff. It records the decisions made during design discussion and should be treated as the intended scope unless implementation findings require revisiting a decision. + +## Core decisions + +1. The constrained layout system is an alternate-screen feature. +2. `TuiMainScreen` keeps its existing terminal-scrollback rendering model. +3. Interactive mode uses two different compositions, but shares the same component instances and behavior. +4. The public layout primitives are initially: + - `VStack` + - `HStack` + - `ScrollView` + - existing overlays +5. The frame-specific layout tree is internal. API users construct a component tree and never manipulate layout boxes, rectangles, hit-test nodes, or scroll ancestry. +6. Rebuild the internal layout tree on each requested render. Do not rebuild component state. +7. Rely on existing leaf render caches, especially `Markdown`, `Text`, `Image`, and `Box`. Do not introduce a second framework-level render cache initially. +8. `Editor` does not currently cache its rendered lines, but it is small and active; this is not expected to be the dominant cost. +9. Keep `interactive-mode.ts` changes declarative and minimal. Layout, clipping, scrolling, hit testing, and event routing belong in `packages/tui`. +10. Mouse wheel support is an enhancement. Configurable keyboard scrolling must always remain available. + +## Why main-screen and alternate-screen layouts differ + +The terminal owns scrolling in main-screen mode. The application cannot reliably provide: + +- sticky rows +- independently scrollable nested regions +- full-height side-by-side panes +- reliable mouse hit testing for content moved into terminal scrollback +- arbitrary repainting of off-screen regions without replaying or clearing scrollback + +Therefore, do not pretend the same constrained viewport semantics exist in `TuiMainScreen`. + +Main-screen interactive mode remains a vertically rendered document: + +```text +header +loaded resources +chat +pending messages +status +widgets above +editor / replacement UI +widgets below +footer +``` + +Alternate-screen interactive mode becomes: + +```text +┌─────────────────────────────────────────────┐ +│ scrollable transcript │ +│ │ +│ header │ +│ loaded resources │ +│ chat/messages/tool output │ +│ │ +├─────────────────────────────────────────────┤ +│ pending messages │ +│ working/retry/compaction status │ +│ widgets above editor │ +│ editor or temporary replacement UI │ +│ widgets below editor │ +│ footer │ +└─────────────────────────────────────────────┘ +``` + +Pending messages and status belong in the fixed region. Hiding active queue/working state while the user reads older output would be surprising. + +## Goals + +### Required for the first implementation + +- Constrained root layout in `TuiAltScreen`. +- Vertical and horizontal stack layout. +- Vertical scrolling with follow-end behavior. +- Sticky coding-agent dock. +- Existing mouse-wheel and keyboard transcript scrolling. +- Wheel routing based on the region under the pointer. +- Scroll chaining for nested scroll views. +- Existing overlay rendering must continue to work. +- Existing cursor positioning and IME support must continue to work. +- Existing hyperlink clicking and mouse text selection must continue to work. +- Existing Kitty image behavior must not regress for the transcript use case. +- `TuiMainScreen` behavior and output order must remain unchanged. +- Leaving alt mode must still print a complete logical final document. + +### Future uses enabled by the design + +- Wide-terminal sidebars. +- Independently scrollable transcript and sidebar. +- Sticky top regions. +- Layout-aware overlays. +- Scrollbars and unread-line indicators. +- Transcript virtualization. + +## Non-goals for the first implementation + +- CSS-compatible flexbox. +- Grid layout. +- Wrapped flex rows. +- Arbitrary absolute positioning; overlays already cover this need. +- Percentage sizing unless it falls out naturally from existing size utilities. +- Virtualized transcript rendering. +- Incremental layout-tree mutation. +- A public API for custom components to create or mutate internal layout nodes. +- Reworking every existing component to understand height constraints. +- Giving main-screen mode fake sticky or nested-scroll semantics. + +## Public API + +### Stack entries + +Use one axis-neutral entry type for both vertical and horizontal stacks. + +```ts +export interface StackEntryOptions { + /** Initial size on the stack's main axis. Defaults to "auto". */ + basis?: number | "auto"; + /** Share of positive remaining space. Defaults to 0. */ + grow?: number; + /** Relative willingness to shrink when content overflows. Defaults to 1. */ + shrink?: number; + /** Minimum allocated size on the main axis. Defaults to 0. */ + minSize?: number; + /** Maximum allocated size on the main axis. */ + maxSize?: number; + /** Conditionally omit this entry for a viewport size. */ + visible?: (viewport: { width: number; height: number }) => boolean; +} + +export interface StackEntry extends StackEntryOptions { + component: Component; +} + +export type StackChild = Component | StackEntry; + +export interface StackOptions { + gap?: number; + align?: "stretch" | "start" | "center" | "end"; +} +``` + +Use explicit fields in implementations. Do not use TypeScript parameter properties because root-configured source must remain erasable in Node strip-only mode. + +### `VStack` + +```ts +export class VStack implements Component { + constructor(children?: StackChild[], options?: StackOptions); + + addChild(component: Component, options?: StackEntryOptions): void; + removeChild(component: Component): void; + clear(): void; + invalidate(): void; + render(width: number): string[]; +} +``` + +Behavior: + +- Public `render(width)` provides an unbounded-height rendering for compatibility and debugging. +- Constrained behavior is invoked internally by `TuiAltScreen` through the internal layout engine. +- Children are arranged from top to bottom. +- `gap` rows appear only between visible children. +- The cross axis defaults to `stretch`. + +### `HStack` + +```ts +export class HStack implements Component { + constructor(children?: StackChild[], options?: StackOptions); + + addChild(component: Component, options?: StackEntryOptions): void; + removeChild(component: Component): void; + clear(): void; + invalidate(): void; + render(width: number): string[]; +} +``` + +Behavior: + +- Children are arranged from left to right. +- Child widths are allocated from `basis`, `grow`, `shrink`, `minSize`, and `maxSize`. +- Shorter children are padded according to `align`. +- Compose ANSI lines using existing ANSI-aware slicing/compositing utilities. Never use plain string length or raw substring for terminal columns. +- Initial image support only needs to preserve current vertical transcript behavior. See the image section for horizontal limitations. + +### `ScrollView` + +```ts +export interface ScrollViewOptions { + axis?: "vertical"; + /** Follow content growth while positioned at the end. */ + follow?: "none" | "end"; + /** Designate this view as the fallback target for global scroll actions. */ + primary?: boolean; + /** Bubble unused wheel delta to an outer scroll view. */ + overscroll?: "chain" | "contain"; + /** Reserved for a later visible scrollbar implementation. */ + scrollbar?: "hidden" | "auto" | "always"; +} + +export class ScrollView implements Component { + constructor(component: Component, options?: ScrollViewOptions); + + get scrollTop(): number; + get isFollowingEnd(): boolean; + + scrollBy(lines: number): number; + scrollToStart(): void; + scrollToEnd(): void; + invalidate(): void; + render(width: number): string[]; +} +``` + +`scrollBy()` returns unused delta so nested scrolling can chain: + +```ts +const remaining = scrollView.scrollBy(delta); +``` + +Examples: + +- Requested `+3`, moved `+3`: return `0`. +- Requested `+3`, only one row remained: move one and return `+2`. +- Requested `-3`, already at the top: return `-3`. + +Behavior: + +- In constrained layout, the child is measured/rendered at unbounded height and clipped to the allocated viewport. +- In public unbounded `render(width)`, render the complete child. This is needed for final-document output and debugging, not to emulate viewport behavior in main-screen mode. +- `follow: "end"` behaves like current `TuiAltScreen.stickToBottom`: + - start in follow mode + - content growth keeps the view at the end + - scrolling away from the end disables follow mode + - reaching or explicitly scrolling to the end enables follow mode +- Scrolling must request a render. +- Preserve `scrollTop` when viewport height changes, unless following the end. + +### Viewport capability + +Do not add constrained layout methods to every `TUI` implementation as though main-screen mode supports them. + +Add an explicit capability: + +```ts +export interface ViewportTUI extends TUI { + setLayoutRoot(component: Component | undefined): void; +} + +export function isViewportTUI(tui: TUI): tui is ViewportTUI; +``` + +`TuiAltScreen` implements `ViewportTUI`. `TuiMainScreen` does not. + +The type guard should test a stable capability, not rely on application-level `instanceof`. The concrete implementation may use a symbol or method-presence check. + +`TuiAltScreen` behavior when no explicit layout root is set must remain compatible with current users of `addChild()`. Treat its existing children as an implicit vertically stacked document in an implicit primary `ScrollView`. + +## Internal layout API + +Do not export these types from `packages/tui/src/index.ts`. + +Suggested module: `packages/tui/src/layout.ts`. + +```ts +interface LayoutConstraints { + width: number; + /** Undefined means unbounded height. */ + height: number | undefined; +} + +interface LayoutRect { + x: number; + y: number; + width: number; + height: number; +} + +interface LayoutBox { + component: Component; + rect: LayoutRect; + clip: LayoutRect; + children: LayoutBox[]; + parent?: LayoutBox; + /** Leaf-rendered lines. Keep the returned array by reference. */ + lines?: readonly string[]; + /** Present when this box represents a ScrollView viewport. */ + scrollView?: ScrollView; + /** Z/layer ordering for hit testing when needed. */ + layer: number; +} + +interface LayoutFrame { + root: LayoutBox; + width: number; + height: number; + lines: string[]; + primaryScrollView?: ScrollView; +} +``` + +The exact shape may change during implementation, but it must support: + +- painting visible terminal rows +- clipping nested children +- hit testing from terminal coordinates +- translating to component-local coordinates +- walking ancestors +- identifying scroll ancestors +- locating cursor markers +- retaining enough mapping for selection and hyperlinks + +### Component tree versus layout tree + +The public component tree is long-lived and stateful: + +```text +VStack +├─ ScrollView +│ └─ chat container +└─ dock VStack + ├─ editor container + └─ footer container +``` + +The internal layout tree is a transient frame snapshot: + +```text +box root rect 0,0,120,40 +├─ scroll box rect 0,0,120,31 clip 0,0,120,31 +│ └─ content rect 0,-85,120,116 +└─ dock box rect 0,31,120,9 +``` + +Rebuild the layout tree for every requested frame. Replace the committed frame atomically after successful painting so input is always routed against the last displayed geometry. + +Do not mutate component state merely to generate layout geometry, except for intentional `ScrollView` clamping/follow state. + +## Rendering and caching strategy + +### Rebuild geometry, reuse leaf lines + +A fresh frame performs: + +```ts +const nextLayout = layout(root, terminalBounds); +const nextScreen = paint(nextLayout); +writeScreenDiff(previousScreen, nextScreen); +currentLayout = nextLayout; +``` + +For a leaf component: + +```ts +const lines = component.render(width); +``` + +Keep `lines` by reference in the layout box. Most expensive leaves already cache by content and width: + +- `Markdown` caches text, width, and rendered lines. +- `Text` caches text, width, and rendered lines. +- `Image` caches width and rendered lines. +- `Box` caches based on width/background/child output. +- several coding-agent animation and tool components have their own caches. + +`Editor`, `Input`, selectors, footer, and some small leaves recompute. This is acceptable initially. + +Do not add a separate `WeakMap` in the layout engine until profiling shows a need. A second cache risks becoming stale because existing components own their invalidation semantics. + +### Avoid unnecessary flattening where practical + +The first correct implementation may call existing `Container.render(width)`, which flattens child arrays. Markdown parsing/highlighting will still be cached, so this is acceptable for the initial implementation. + +If easy and safe, optimize exact base `Container` instances as structural vertical stacks so layout can retain child line arrays and heights without flattening the whole transcript. Do not bypass overridden rendering in `Container` subclasses such as message/tool components. Treat subclasses as leaves unless they explicitly opt into internal structural layout. + +Do not make this optimization a prerequisite for correctness. + +### No render means no layout + +Only rebuild a layout frame after `requestRender()` schedules a render. There is no independent layout loop. + +## Stack layout algorithm + +The implementation should use one shared stack allocator parameterized by axis. + +### Visibility + +1. Evaluate `visible` against the terminal viewport dimensions. +2. Remove invisible entries before calculating gaps or size distribution. + +### Intrinsic sizes + +- `basis: "auto"` uses the child's intrinsic size on the main axis. +- Numeric `basis` uses the given cell count. +- Clamp basis to `minSize`/`maxSize`. +- For wrapped leaves, width allocation must happen before intrinsic height is known. +- `HStack` therefore allocates widths before measuring child heights. +- `VStack` renders/measures auto-height children at the allocated width before distributing remaining height. + +### Positive remaining space + +Distribute positive remaining space among entries with `grow > 0`, proportional to `grow`, respecting `maxSize`. + +Use deterministic integer rounding. Allocate leftover cells in child order so layouts do not jitter frame to frame. + +### Overflow + +When total basis exceeds available size: + +1. Compute shrinkable entries (`shrink > 0` and current size above `minSize`). +2. Distribute required shrink proportional to `shrink` and current basis, or another deterministic documented policy. +3. Repeat if an entry reaches `minSize` before overflow is resolved. +4. If constraints still cannot be satisfied, clip at the parent boundary. + +A focused cursor must not disappear merely because a leaf is clipped. When clipping a leaf vertically and its lines contain `CURSOR_MARKER`, choose a visible line window containing the marker where possible. + +### Initial interactive layout sizing + +The transcript should be flexible and the dock should prefer intrinsic height: + +```ts +new VStack([ + { + component: transcriptScrollView, + basis: 0, + grow: 1, + shrink: 1, + minSize: 1, + }, + { + component: dock, + basis: "auto", + grow: 0, + shrink: 1, + minSize: 1, + }, +]); +``` + +The implementation must define sensible behavior for very small terminals and oversized custom widgets. Preferred priority: + +1. Preserve at least one transcript row when terminal height permits. +2. Preserve the focused editor/selector cursor. +3. Preserve at least one footer row when possible. +4. Clip/truncate widgets and pending/status content before hiding the focused editor. + +This may require coding-agent-specific stack entry `minSize`/`shrink` settings rather than adding domain-specific priority rules to generic TUI layout. + +## Painting + +### Frame surface + +The layout engine may continue using ANSI strings per terminal row rather than introducing a full cell object model. + +Painting must: + +- create exactly `terminal.rows` base rows in constrained alt mode +- respect each box's rectangle and accumulated clip +- use ANSI-aware column slicing +- reset styles between independently painted regions +- preserve `CURSOR_MARKER` until cursor extraction +- compose horizontal children without style leakage +- produce lines no wider than terminal width + +Reuse: + +- `sliceByColumn()` +- `compositeTuiLine()` +- `visibleWidth()` +- existing line-reset normalization + +### Vertical stacks + +Paint each child at its allocated `y`. Skip children and line ranges that do not intersect the accumulated clip. + +### Horizontal stacks + +Paint each child at its allocated `x`. Pad short lines to the allocated width before composing adjacent children. Apply reset boundaries so one child's style or OSC 8 hyperlink does not leak into another. + +### Scroll views + +- Child content is laid out at its full natural height. +- The child's painted origin is translated by `-scrollTop`. +- Accumulate the scroll view's rectangle into the clip. +- Only paint child rows intersecting the viewport. +- Record the scroll box in the layout tree for hit testing and ancestor walking. + +## Input and event routing + +### Normalized mouse events + +Keep terminal mouse parsing in `TuiAltScreen`, but convert parsed sequences into normalized events before routing: + +```ts +interface TuiMouseEvent { + type: "press" | "release" | "move" | "wheel"; + x: number; + y: number; + button: number; + deltaX: number; + deltaY: number; +} +``` + +The exact public visibility of this type is optional. The initial wheel router can remain internal. + +### Hit testing + +Hit test the committed layout frame, not the frame currently being constructed. + +1. Reject boxes outside their clip. +2. Traverse higher layers/frontmost children first. +3. Return the deepest visible box containing the terminal coordinate. +4. Preserve the ancestor chain for event bubbling. + +### Wheel routing + +For a wheel event: + +1. Hit test at the pointer. +2. Starting at the deepest box, walk toward the root. +3. Offer the delta to each encountered `ScrollView`. +4. If `overscroll` is `"chain"`, pass unused delta to the next scroll ancestor. +5. If `overscroll` is `"contain"`, stop even when delta remains. +6. If no hit ancestor consumes the delta, offer it to the frame's primary scroll view. +7. Consume recognized mouse sequences so raw mouse bytes never reach the editor. + +Expected behavior: + +- Wheel over transcript: scroll transcript. +- Wheel over a future sidebar: scroll sidebar. +- Wheel over a nested scroll view: scroll inner view, then chain at its boundary. +- Wheel over non-scrollable dock/footer: scroll primary transcript. +- Wheel interaction must not steal keyboard focus from the editor. + +### Trackpads + +Preserve current behavior of ignoring horizontal wheel events for a vertical-only scroll view. If an event contains both axes, consume only the supported vertical portion and document the policy. + +### Mouse-disabled fallback + +Do not depend on detecting mouse support. Terminals do not provide a sufficiently reliable universal capability signal. + +Keyboard navigation is always available through existing configurable actions: + +- `tui.altScreen.pageUp` +- `tui.altScreen.pageDown` +- `tui.altScreen.top` +- `tui.altScreen.bottom` + +Route these actions to: + +1. an explicitly active scroll region, if future multi-pane navigation sets one +2. otherwise the primary scroll view + +For the first coding-agent layout there is only one scroll view, so the transcript is always the keyboard target. + +If future layouts introduce multiple keyboard-selectable scroll regions, add configurable actions to `TUI_KEYBINDINGS`; never hardcode key checks. + +## Focus and cursor behavior + +- Existing `TUI.setFocus(component)` remains the public keyboard-focus API. +- Keyboard focus and wheel-scroll target are separate. Scrolling a sidebar must not move focus away from the editor unless explicitly requested. +- During paint, find `CURSOR_MARKER` in the final composited frame. +- Cursor row/column must include stack offsets, scroll translations, overlay offsets, and horizontal-pane offsets. +- Only show the hardware cursor according to existing `showHardwareCursor` behavior. +- Layout containment checks used by overlay focus restoration must understand layout roots and nested layout components. + +## Selection and hyperlinks + +The current alt renderer maps selection rows directly into one global logical document. That assumption no longer holds once fixed and horizontal regions exist. + +For the first implementation, preserve visible-screen selection semantics: + +- Anchor and focus begin from terminal-screen coordinates. +- Apply highlight against the current committed visible frame. +- Copy text from the selected visible rows/columns using ANSI-aware slicing and `stripTerminalSequences()`. +- Blank/padded areas contribute no text beyond required line separation. +- Continue snapping selection columns to grapheme boundaries. + +If maintaining selection across frame changes is required, store enough source mapping in painted rows to translate screen rows into leaf line references. Do not map fixed dock rows to unrelated transcript rows. + +Hyperlink clicking can continue reading OSC 8 metadata from the committed screen line at the clicked column. Ensure the final composed line, rather than an unshifted child line, is used. + +Maintain current behavior: + +- click without drag may call `openUrl` +- dragging does not activate a URL +- release after drag copies via OSC 52 + +## Images + +The initial required image case is the existing vertically scrolling transcript. + +Preserve: + +- Kitty image metadata and reserved rows +- cropping when the top of a Kitty image is above the scroll viewport +- deletion/redraw when image-containing rows change +- iTerm2 fallback to text in alt mode + +Horizontal composition of image protocol lines is not required to become fully general in the first implementation. Terminal image placements do not behave like ordinary ANSI text. Document and defensively handle the limitation: + +- an image-bearing component in an `HStack` may be required to occupy the full row/width +- do not silently corrupt adjacent pane output +- add a focused test for whatever fallback policy is chosen + +Do not regress existing vertical image tests. + +## Overlays + +Keep the current overlay stack and positioning API. + +Initial integration: + +1. Paint the base constrained layout into terminal-height lines. +2. Composite existing overlays over those lines using current overlay logic. +3. Extract the cursor from the final result. +4. Apply differential rendering. + +Existing overlays are not required to become nested `ScrollView` layout roots in the first implementation. However, base-layout hit testing must not break overlay focus or input ownership. + +A later phase can give each overlay its own constrained layout tree and include overlay boxes as higher hit-test layers. + +## `TuiAltScreen` refactor + +Suggested state after the change: + +```ts +private layoutRoot?: Component; +private currentLayout?: LayoutFrame; +private implicitScrollView?: ScrollView; +``` + +Move these responsibilities out of `TuiAltScreen` global fields and into `ScrollView` where applicable: + +- `scrollTop` +- `contentLineCount` +- `stickToBottom` + +Compatibility getters/methods such as `viewportTop`, `isFollowingOutput`, `scrollBy()`, `scrollToTop()`, and `scrollToBottom()` may delegate to the primary/implicit scroll view so existing tests and consumers continue to work. Do not preserve backward compatibility if it materially complicates the implementation unless tests/public API indicate these methods are relied upon; check exports and usage before removal. + +`doRender()` becomes conceptually: + +```ts +const root = this.layoutRoot ?? this.getImplicitLegacyRoot(); +const nextLayout = layoutConstrained(root, width, height); +let screen = paint(nextLayout); +screen = this.compositeOverlays(screen, width, height); +screen = this.applySelection(screen); +const cursor = this.extractCursorPosition(screen, height); +// Normalize, crop defensive overflow, diff, write. +this.currentLayout = nextLayout; +``` + +### Legacy implicit root + +When callers only use `tui.addChild()`: + +```text +implicit ScrollView(primary, follow=end) +└─ implicit vertical document of TuiAltScreen.children +``` + +This preserves the current standalone `TuiAltScreen` API and tests. + +The implicit root must observe subsequent `addChild()`, `removeChild()`, and `clear()` mutations. + +### Final document on stop + +When leaving alt mode, render the explicit or implicit root with unbounded height: + +- `ScrollView` emits its complete child rather than a clipped viewport. +- The coding-agent transcript appears first and the dock appears once after it. +- Do not print terminal-height padding rows. +- Strip cursor markers. +- Preserve existing line resets and image cleanup. + +Do not use only the last visible frame as the exit document. + +## Interactive-mode changes + +File: `packages/coding-agent/src/modes/interactive/interactive-mode.ts` + +Changes should remain small. + +### Add stable grouping containers + +```ts +private documentContainer: Container; +private footerContainer: Container; +``` + +The existing component containers remain unchanged: + +- `headerContainer` +- `loadedResourcesContainer` +- `chatContainer` +- `pendingMessagesContainer` +- `statusContainer` +- `widgetContainerAbove` +- `editorContainer` +- `widgetContainerBelow` + +Build the transcript group once: + +```ts +this.documentContainer.addChild(this.headerContainer); +this.documentContainer.addChild(this.loadedResourcesContainer); +this.documentContainer.addChild(this.chatContainer); +``` + +Build the footer slot once: + +```ts +this.footerContainer.addChild(this.footer); +``` + +### Main-screen composition + +Preserve exact current ordering: + +```ts +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); +``` + +Because `documentContainer` is visually transparent, its three children render exactly where they do today. + +### Alternate-screen composition + +```ts +const transcript = new ScrollView(this.documentContainer, { + follow: "end", + primary: true, + overscroll: "chain", +}); + +const dock = new 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 }, +]); + +const root = new VStack([ + { component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 }, + { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 }, +]); + +viewportTui.setLayoutRoot(root); +``` + +Use `isViewportTUI(this.ui)` for narrowing. Since `options.alt` selected the renderer, failure to obtain the capability is an internal programming error rather than a silent fallback. + +### Custom footer replacement + +Refactor `setExtensionFooter()` so it never removes/adds root TUI children: + +```ts +this.footerContainer.clear(); +this.footerContainer.addChild(this.customFooter ?? this.footer); +this.ui.requestRender(); +``` + +Continue disposing replaced custom footers. + +### Features that should require no logic changes + +- message rendering +- streaming updates +- tool updates +- widget APIs +- editor replacement +- extension selectors/input/editor +- built-in selectors +- queue rendering +- status indicators +- focus changes +- overlays +- theme invalidation + +These features mutate existing stable containers and should automatically appear in the correct layout. + +### Existing alt-specific status workaround + +Revisit this code: + +```ts +if (hadActiveStatusIndicator && !this.options.alt && this.ui.getClearOnShrink()) { + this.statusContainer.addChild(this.idleStatus); +} +``` + +The main-screen workaround should remain main-screen-only. Constrained alt layout should naturally clear released rows. + +## Suggested files + +Likely new files: + +- `packages/tui/src/layout.ts` — internal constraints, boxes, layout, paint, hit testing +- `packages/tui/src/components/v-stack.ts` +- `packages/tui/src/components/h-stack.ts` +- `packages/tui/src/components/scroll-view.ts` + +Likely modified files: + +- `packages/tui/src/tui.ts` +- `packages/tui/src/TuiAltScreen.ts` +- `packages/tui/src/index.ts` +- `packages/tui/src/keybindings.ts` only if new configurable actions are required +- `packages/coding-agent/src/modes/interactive/interactive-mode.ts` +- `packages/tui/test/tui-alt-screen.test.ts` +- new focused layout tests under `packages/tui/test/` +- `packages/coding-agent/test/interactive-tui.test.ts` +- `packages/tui/README.md` +- `packages/coding-agent/docs/usage.md` +- `packages/coding-agent/docs/keybindings.md` if keyboard behavior changes +- `packages/tui/CHANGELOG.md` +- `packages/coding-agent/CHANGELOG.md` + +Do not modify released changelog sections. Add entries under the existing `## [Unreleased]` subsections. + +## Test plan + +### Stack allocation tests + +Add focused unit tests for both axes: + +- auto-sized children +- numeric basis +- positive grow distribution +- shrink distribution +- min/max clamping +- deterministic odd-cell rounding +- gaps only between visible children +- conditional visibility +- cross-axis alignment +- child output wider than allocation is clipped safely +- ANSI styles/hyperlinks do not leak between horizontal children +- CJK, emoji, and combining-character boundaries in horizontal clipping + +### ScrollView tests + +- initial `follow: "end"` position +- content growth while following +- manual upward scroll disables follow +- reaching bottom reenables follow +- explicit `scrollToEnd()` reenables follow +- viewport growth/shrink while following +- viewport growth/shrink while manually positioned +- `scrollBy()` returns unused positive/negative delta +- nested scroll chaining +- `overscroll: "contain"` +- child shorter than viewport +- empty child +- child width change +- cursor marker remains visible when focused content is clipped + +### Layout frame tests + +- generated rectangles for nested V/H stacks +- accumulated clipping +- hit testing returns deepest visible box +- clipped boxes are not hit-testable +- local coordinate translation +- layer ordering +- only visible rows are painted from a scroll view +- each frame uses fresh geometry after resize/content change +- cached leaf line arrays are accepted by reference and not mutated + +### Alternate-screen renderer tests + +Extend `packages/tui/test/tui-alt-screen.test.ts`: + +- legacy `addChild()` path still behaves as current implicit scrolling +- explicit layout root renders terminal-height frame +- fixed dock remains unchanged while transcript scrolls +- transcript viewport height accounts for dock height +- dock growth/shrink while following +- dock growth/shrink while manually scrolled +- Shift+PageUp/Down targets primary ScrollView +- Ctrl+Home/End targets primary ScrollView +- wheel over transcript scrolls transcript +- wheel over non-scrollable dock falls back to primary transcript +- nested scroll consumes first and bubbles unused delta +- mouse-disabled mode still supports keyboard scrolling +- cursor row is correct inside dock +- cursor row is correct inside scrolled content +- overlay compositing remains screen-relative +- overlay focus behavior remains correct +- OSC 8 click remains correct after horizontal/vertical offsets +- selection/copy works in transcript +- selection/copy works in dock without mapping to transcript rows +- terminal resize recomputes layout +- oversized dock does not lose the focused cursor +- stopping prints complete transcript plus dock exactly once +- no terminal padding rows in final output + +Retain and pass all existing image tests: + +- Kitty cropping at viewport top +- image deletion/redraw +- iTerm2 fallback +- no stale image placements + +### Main-screen regression tests + +- existing main-screen tests pass unchanged +- interactive main-screen child order/rendered output is unchanged +- custom footer remains at the bottom in flow +- no layout root or application scrolling is installed in main-screen mode + +### Coding-agent integration tests + +In `packages/coding-agent/test/interactive-tui.test.ts` or a focused new test: + +- renderer capability is exposed only for alt mode +- main mode mounts flow composition +- alt mode mounts transcript ScrollView plus dock +- pending/status/widgets/editor/footer are in the dock +- custom footer replacement updates `footerContainer` +- editor replacement does not rebuild the root layout +- widget updates do not rebuild the public component composition + +Prefer inspecting component composition or using `VirtualTerminal`; do not use real provider APIs. + +## Verification commands + +After implementation changes: + +1. Run each modified/new focused test from the relevant package root using the repository-prescribed Vitest invocation. +2. Run `npm run check` from the repository root and fix all errors, warnings, and infos. +3. Do not run `npm test` or the full Vitest suite. +4. Optionally use the repository's `./test.sh` for all non-e2e tests if broader validation is warranted. +5. Manually exercise alt mode in tmux using the procedure in `AGENTS.md`: + - long transcript + - wheel/trackpad scrolling + - Shift+PageUp/Down + - streaming while manually scrolled + - return to bottom/follow + - multiline editor + - autocomplete open + - settings/model/tree selectors replacing editor + - extension widget above and below editor + - custom footer + - terminal resize + - hyperlink click + - mouse selection/copy + - Kitty image where available +6. Manually smoke-test main-screen mode to ensure terminal scrollback behavior is unchanged. + +## Recommended implementation order + +1. Add stack allocation unit tests and shared axis allocator. +2. Implement `VStack` unbounded rendering and constrained internal layout. +3. Implement `HStack` with ANSI-safe composition. +4. Implement `ScrollView` state and unit tests independent of terminal ANSI output. +5. Implement internal layout frame generation and painting. +6. Add hit testing and scroll-ancestor traversal. +7. Integrate explicit and implicit layout roots into `TuiAltScreen`. +8. Move current global alt scrolling behavior behind the implicit primary `ScrollView` compatibility path. +9. Preserve selection, hyperlinks, cursor, overlays, and image handling one subsystem at a time, running existing tests after each step. +10. Add coding-agent grouping containers and the two small composition branches. +11. Refactor custom footer replacement to use `footerContainer`. +12. Add integration tests, docs, and changelog entries. +13. Run focused tests and `npm run check`. +14. Perform tmux/manual smoke tests in both modes. + +## Acceptance criteria + +The implementation is complete when: + +- Main-screen mode behaves as before and preserves terminal scrollback. +- Alt-screen mode has a scrollable transcript and fixed bottom dock. +- Streaming follows the transcript end only while follow mode is active. +- Manual scrolling remains stable while new output arrives. +- Mouse wheel routes to the appropriate scroll view and chains at boundaries. +- Keyboard navigation works with mouse disabled. +- Editor/selector focus and IME cursor placement remain correct. +- Widgets and custom footers remain extension-compatible and fixed in alt mode. +- Hyperlinks, selection, overlays, and Kitty transcript images do not regress. +- Leaving alt mode prints the complete logical document once. +- Layout boxes are internal and rebuilt per requested frame. +- Expensive leaf rendering continues to use existing component caches. +- All focused tests and `npm run check` pass.