Skip to content

Component Library: Box/Input/Markdown/SelectList

源码版本v0.73.1

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:

  1. Unified interface: every component implements Component. render(width) returns an array of lines with ANSI escapes, and invalidate() clears caches on theme change. See packages/tui/src/tui.ts:39-63.
  2. Cached rendering: Text, Box, and Markdown all cache cachedLines. If the text and width have not changed, they return the previous result, avoiding a re-word-wrap on every frame. See packages/tui/src/components/text.ts:45-58.
  3. Focusable protocol: Input and Editor implement Focusable (the focused field). When TUI calls setFocus, the focused component emits a CURSOR_MARKER at the cursor position during render. See packages/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/:

Text.render returns the cache on a hit, otherwise re-wraps:

typescript
// 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 spaces

SelectList.render uses startIndex as a scroll window and only draws maxVisible items; the selected item gets a highlight prefix:

typescript
// 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:

typescript
// 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 the TUI theme changes, it calls Container.invalidate, which recursively clears every child's cache. Subclasses must override invalidate to clear their own cachedLines, or they will serve lines themed with the old palette. See packages/tui/src/components/markdown.ts:110-114.
  • visibleWidth must be right: after Box applies padding, the line width may exceed width, which TUI will crash on. Box.matchCache uses a bgSample to detect whether bgFn output changed and decide cache hits. See packages/tui/src/components/box.ts:56-65.
  • SelectList empty-list fallback: when filteredItems.length === 0, it draws theme.noMatch("No matching commands") instead of throwing, so the caller keeps running.
  • Markdown does not wrap image lines: lines flagged by isImageLine(line) are pushed as-is and skipped by wrapTextWithAnsi, because wrapping a kitty graphics sequence would corrupt it.
  • Input is single-line, no newlines: Input is a simplified Editor. What happens when a paste contains \n depends on handlePaste. Input is mainly used for the SelectList search field and SettingsList submenus; 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.