Component Library: Box/Input/Markdown/SelectList
The components/ directory in @mariozechner/pi-tui holds the reusable components. They all implement the Component interface (render(width): string[] + invalidate()). The components fall into three buckets: layout (Box, Text, TruncatedText, Spacer), interactive (Input, SelectList, SettingsList, Editor), and display (Markdown, Image, Loader). TUI itself does not implement any concrete components; it only schedules the component tree.
Responsibilities
components/ does three things:
- Unified interface: every component implements
Component.render(width)returns an array of lines with ANSI escapes, andinvalidate()clears caches on theme change. Seepackages/tui/src/tui.ts:39-63. - Cached rendering:
Text,Box, andMarkdownall cachecachedLines. If the text and width have not changed, they return the previous result, avoiding a re-word-wrap on every frame. Seepackages/tui/src/components/text.ts:45-58. - Focusable protocol:
InputandEditorimplementFocusable(thefocusedfield). WhenTUIcallssetFocus, the focused component emits aCURSOR_MARKERat the cursor position during render. Seepackages/tui/src/tui.ts:74-82.
Design Motivation
Why do all components return string[] instead of writing directly? Because TUI needs to diff: it has to see the final content of each line, ANSI included, to compare against the previous frame. Components only produce lines; they do not care how those lines get written to the terminal. That keeps components testable (a pure function render(width) -> string[]) and composable (Container just concatenates children's lines). When Box applies a background to a whole block via bgFn, it uses applyBackgroundToLine to splice the background color into the existing ANSI rather than wrapping it, so visibleWidth still computes correctly.
Key Files
Under packages/tui/src/components/:
packages/tui/src/components/box.ts:14-60—Boxcontainer:paddingX/Y+bgFnpaint a background over the whole block of children, with a render cache.packages/tui/src/components/text.ts:7-58—Text: multiline text + word-wrap + a custom background function.packages/tui/src/components/truncated-text.ts:7-50—TruncatedText: takes only the first line and truncates to width, used in the status bar.packages/tui/src/components/input.ts:18-50—Input: single-line input with horizontal scrolling; manages its own paste/kill-ring/undo (same machinery asEditor).packages/tui/src/components/select-list.ts:40-100—SelectList: up/down selectable list with filter, two columns (primary + description), and a scroll indicator.packages/tui/src/components/settings-list.ts:34-83—SettingsList: settings panel that embeds anInputsearch field and recurses into submenus.packages/tui/src/components/markdown.ts:78-159—Markdown: marked lexer + custom inline styles turn markdown into ANSI-styled lines.packages/tui/src/components/loader.ts/packages/tui/src/components/cancellable-loader.ts— spinners; each frame only touches one line, which hits the diff fast path.
Text.render returns the cache on a hit, otherwise re-wraps:
// packages/tui/src/components/text.ts:45-58
render(width: number): string[] {
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
return this.cachedLines;
}
if (!this.text || this.text.trim() === "") {
const result: string[] = [];
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result;
}
// Replace tabs with 3 spacesSelectList.render uses startIndex as a scroll window and only draws maxVisible items; the selected item gets a highlight prefix:
// packages/tui/src/components/select-list.ts:74-100
render(width: number): string[] {
const lines: string[] = [];
if (this.filteredItems.length === 0) {
lines.push(this.theme.noMatch(" No matching commands"));
return lines;
}
const primaryColumnWidth = this.getPrimaryColumnWidth();
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);Markdown.render runs the marked lexer to get tokens, converts each token to styled lines, re-wraps them with wrapTextWithAnsi to the column width, then applies padding/background:
// packages/tui/src/components/markdown.ts:116-159
render(width: number): string[] {
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
return this.cachedLines;
}
const contentWidth = Math.max(1, width - this.paddingX * 2);
// ...
const tokens = markdownParser.lexer(normalizedText);
const renderedLines: string[] = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
renderedLines.push(...tokenLines);
}Data Flow
How a component's render output reaches the TUI diff:
Edges and Failures
- Cache invalidation via
invalidate: when theTUItheme changes, it callsContainer.invalidate, which recursively clears every child's cache. Subclasses must overrideinvalidateto clear their owncachedLines, or they will serve lines themed with the old palette. Seepackages/tui/src/components/markdown.ts:110-114. visibleWidthmust be right: afterBoxapplies padding, the line width may exceedwidth, whichTUIwill crash on.Box.matchCacheuses abgSampleto detect whetherbgFnoutput changed and decide cache hits. Seepackages/tui/src/components/box.ts:56-65.SelectListempty-list fallback: whenfilteredItems.length === 0, it drawstheme.noMatch("No matching commands")instead of throwing, so the caller keeps running.Markdowndoes not wrap image lines: lines flagged byisImageLine(line)are pushed as-is and skipped bywrapTextWithAnsi, because wrapping a kitty graphics sequence would corrupt it.Inputis single-line, no newlines:Inputis a simplifiedEditor. What happens when a paste contains\ndepends onhandlePaste.Inputis mainly used for theSelectListsearch field andSettingsListsubmenus; it is not suited to multiline editing.
Summary
components/ is the set of Component implementations. Layout components (Box/Text) handle layout, interactive components (Input/SelectList/Editor) handle input, and display components (Markdown/Loader) handle rendering. They all rely on the diff loop in TUI Class to write changes to the terminal. Editor is the most complex of the bunch; see Editor Component. For how the keyboard byte stream reaches a component's handleInput, see Keyboard Parsing: The Kitty Protocol.