{"_id":"@cyberxninja-omp/pi-tui","name":"@cyberxninja-omp/pi-tui","dist-tags":{"latest":"17.5.3"},"versions":{"17.5.3":{"type":"module","name":"@cyberxninja-omp/pi-tui","version":"17.5.3","description":"Terminal User Interface library with differential rendering for efficient text-based applications","homepage":"https://omp.sh","author":{"name":"Can Boluk"},"contributors":[{"name":"Mario Zechner"}],"license":"MIT","repository":{"type":"git","url":"git+https://github.com/can1357/oh-my-pi.git","directory":"packages/tui"},"bugs":{"url":"https://github.com/can1357/oh-my-pi/issues"},"keywords":["tui","terminal","ui","text-editor","differential-rendering","typescript","cli"],"main":"./src/index.ts","types":"./dist/types/index.d.ts","scripts":{"check":"biome check . && bun run check:types","check:types":"tsgo -p tsconfig.json --noEmit","lint":"biome lint .","test":"bun test --parallel test/*.test.ts","fix":"biome check --write --unsafe .","fmt":"biome format --write ."},"dependencies":{"@cyberxninja-omp/pi-natives":"17.5.3","@cyberxninja-omp/pi-utils":"17.5.3"},"devDependencies":{"ghostty-web":"^0.4.0"},"engines":{"bun":">=1.3.14"},"exports":{".":{"types":"./dist/types/index.d.ts","import":"./src/index.ts"},"./*":{"types":"./dist/types/*.d.ts","import":"./src/*.ts"},"./components/*":{"types":"./dist/types/components/*.d.ts","import":"./src/components/*.ts"},"./*.js":"./src/*.ts"},"_id":"@cyberxninja-omp/pi-tui@17.5.3","_integrity":"sha512-3wDX3kPmuQqO9y142v3cY4hyD3S6T3KeWl1CNKunvF44hwnmUhVh5qkj779xgzKx9yf7JdfyMmc4nwf5lXAvLQ==","_resolved":"/tmp/cxn-pack-qTxlny/cyberxninja-omp-pi-tui-17.5.3.tgz","_from":"file:/tmp/cxn-pack-qTxlny/cyberxninja-omp-pi-tui-17.5.3.tgz","_nodeVersion":"24.3.0","_npmVersion":"11.17.0","dist":{"integrity":"sha512-3wDX3kPmuQqO9y142v3cY4hyD3S6T3KeWl1CNKunvF44hwnmUhVh5qkj779xgzKx9yf7JdfyMmc4nwf5lXAvLQ==","shasum":"1678a1c2ff0817921438a18f99a0f914e19ebcaa","tarball":"https://registry.npmjs.org/@cyberxninja-omp/pi-tui/-/pi-tui-17.5.3.tgz","fileCount":77,"unpackedSize":1366276,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDOXb7YHX87fQm/heaa7NGViZ2pF2VOJbdL3hJeMnU4xQIhAKNrKOVgVv/lbypmIJKteTfKNjypObT39Cd0y0wD47+E"}]},"_npmUser":{"name":"cyberxninja","email":"jailbreaker20th@gmail.com"},"directories":{},"maintainers":[{"name":"cyberxninja","email":"jailbreaker20th@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/pi-tui_17.5.3_1787233040240_0.08595321532561884"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-20T13:37:20.113Z","17.5.3":"2026-08-20T13:37:20.385Z","modified":"2026-08-20T13:37:20.685Z"},"maintainers":[{"name":"cyberxninja","email":"jailbreaker20th@gmail.com"}],"description":"Terminal User Interface library with differential rendering for efficient text-based applications","homepage":"https://omp.sh","keywords":["tui","terminal","ui","text-editor","differential-rendering","typescript","cli"],"repository":{"type":"git","url":"git+https://github.com/can1357/oh-my-pi.git","directory":"packages/tui"},"contributors":[{"name":"Mario Zechner"}],"author":{"name":"Can Boluk"},"bugs":{"url":"https://github.com/can1357/oh-my-pi/issues"},"license":"MIT","readme":"# @cyberxninja-omp/pi-tui\n\nMinimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications.\n\n## Features\n\n- **Differential Rendering**: Three-strategy rendering system that only updates what changed\n- **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker)\n- **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes\n- **Component-based**: Simple Component interface with render() method\n- **Theme Support**: Components accept theme interfaces for customizable styling\n- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container\n- **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols\n- **Autocomplete Support**: File paths and slash commands\n\n## Quick Start\n\n```typescript\nimport { TUI, Text, Editor, ProcessTerminal } from \"@cyberxninja-omp/pi-tui\";\n\n// Create terminal\nconst terminal = new ProcessTerminal();\n\n// Create TUI\nconst tui = new TUI(terminal);\n\n// Add components\ntui.addChild(new Text(\"Welcome to my app!\"));\n\nconst editor = new Editor(editorTheme);\neditor.onSubmit = (text) => {\n\tconsole.log(\"Submitted:\", text);\n\ttui.addChild(new Text(`You said: ${text}`));\n};\ntui.addChild(editor);\n\n// Start\ntui.start();\n```\n\n## Core API\n\n### TUI\n\nMain container that manages components and rendering.\n\n```typescript\nconst tui = new TUI(terminal);\ntui.addChild(component);\ntui.removeChild(component);\ntui.start();\ntui.stop();\ntui.requestRender(); // Request a re-render\ntui.requestComponentRender(component); // Re-render only the root subtree containing `component` when safe (falls back to a full render on resize, overlays, images, or concurrent full requests)\n\n// Global debug key handler (Shift+Ctrl+D)\ntui.onDebug = () => console.log(\"Debug triggered\");\n```\n\n### Component Interface\n\nAll components implement:\n\n```typescript\ninterface Component {\n\trender(width: number): readonly string[];\n\thandleInput?(data: string): void;\n\tinvalidate?(): void;\n}\n```\n\n| Method               | Description                                                                                                                                                        |\n| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `render(width)`      | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. The result is component-owned and immutable to callers; return the same array reference when unchanged (enables renderer memoization) and a new array when content changed. |\n| `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences).                |\n| `invalidate?()`      | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call.                                                     |\n\n## Built-in Components\n\n### Container\n\nGroups child components.\n\n```typescript\nconst container = new Container();\ncontainer.addChild(component);\ncontainer.removeChild(component);\n```\n\n### Box\n\nContainer that applies padding and background color to all children.\n\n```typescript\nconst box = new Box(\n\t1, // paddingX (default: 1)\n\t1, // paddingY (default: 1)\n\t(text) => chalk.bgGray(text), // optional background function\n);\nbox.addChild(new Text(\"Content\"));\nbox.setBgFn((text) => chalk.bgBlue(text)); // Change background dynamically\n```\n\n### Text\n\nDisplays multi-line text with word wrapping and padding.\n\n```typescript\nconst text = new Text(\n\t\"Hello World\", // text content\n\t1, // paddingX (default: 1)\n\t1, // paddingY (default: 1)\n\t(text) => chalk.bgGray(text), // optional background function\n);\ntext.setText(\"Updated text\");\ntext.setCustomBgFn((text) => chalk.bgBlue(text));\n```\n\n### TruncatedText\n\nSingle-line text that truncates to fit viewport width. Useful for status lines and headers.\n\n```typescript\nconst truncated = new TruncatedText(\n\t\"This is a very long line that will be truncated...\",\n\t0, // paddingX (default: 0)\n\t0, // paddingY (default: 0)\n);\n```\n\n### Input\n\nSingle-line text input with horizontal scrolling.\n\n```typescript\nconst input = new Input();\ninput.onSubmit = (value) => console.log(value);\ninput.setValue(\"initial\");\ninput.getValue();\n```\n\n**Key Bindings:**\n\n- `Enter` - Submit\n- `Ctrl+A` / `Ctrl+E` - Line start/end\n- `Ctrl+W` or `Alt+Backspace` - Delete word backwards\n- `Ctrl+U` - Delete to start of line\n- `Ctrl+K` - Delete to end of line\n- `Ctrl+Left` / `Ctrl+Right` - Word navigation\n- `Alt+Left` / `Alt+Right` - Word navigation\n- Arrow keys, Backspace, Delete work as expected\n\n### Editor\n\nMulti-line text editor with autocomplete, file completion, and paste handling.\n\n```typescript\ninterface SymbolTheme {\n\tcursor: string;\n\tellipsis: string;\n\tboxRound: {\n\t\ttopLeft: string;\n\t\ttopRight: string;\n\t\tbottomLeft: string;\n\t\tbottomRight: string;\n\t\thorizontal: string;\n\t\tvertical: string;\n\t};\n\tboxSharp: {\n\t\ttopLeft: string;\n\t\ttopRight: string;\n\t\tbottomLeft: string;\n\t\tbottomRight: string;\n\t\thorizontal: string;\n\t\tvertical: string;\n\t\tteeDown: string;\n\t\tteeUp: string;\n\t\tteeLeft: string;\n\t\tteeRight: string;\n\t\tcross: string;\n\t};\n\ttable: {\n\t\ttopLeft: string;\n\t\ttopRight: string;\n\t\tbottomLeft: string;\n\t\tbottomRight: string;\n\t\thorizontal: string;\n\t\tvertical: string;\n\t\tteeDown: string;\n\t\tteeUp: string;\n\t\tteeLeft: string;\n\t\tteeRight: string;\n\t\tcross: string;\n\t};\n\tquoteBorder: string;\n\thrChar: string;\n\tspinnerFrames: string[];\n}\n\ninterface EditorTheme {\n\tborderColor: (str: string) => string;\n\tselectList: SelectListTheme;\n\tsymbols: SymbolTheme;\n}\n\nconst editor = new Editor(theme);\neditor.onSubmit = (text) => console.log(text);\neditor.onChange = (text) => console.log(\"Changed:\", text);\neditor.disableSubmit = true; // Disable submit temporarily\neditor.setAutocompleteProvider(provider);\neditor.borderColor = (s) => chalk.blue(s); // Change border dynamically\n```\n\n**Features:**\n\n- Multi-line editing with word wrap\n- Slash command autocomplete (type `/`)\n- File path autocomplete (press `Tab`)\n- Large paste handling (>10 lines creates `[paste #1 +50 lines]` marker)\n- Horizontal lines above/below editor\n- Fake cursor rendering (hidden real cursor)\n\n**Key Bindings:**\n\n- `Enter` - Submit\n- `Shift+Enter`, `Ctrl+Enter`, or `Alt+Enter` - New line (terminal-dependent, Alt+Enter most reliable)\n- `Tab` - Autocomplete\n- `Ctrl+K` - Delete line\n- `Alt+D` / `Alt+Delete` - Delete word forward\n- `Ctrl+A` / `Ctrl+E` - Line start/end\n- `Ctrl+-` - Undo last edit\n- Arrow keys, Backspace, Delete work as expected\n\n### Markdown\n\nRenders markdown with syntax highlighting and theming support.\n\n```typescript\ninterface MarkdownTheme {\n\theading: (text: string) => string;\n\tlink: (text: string) => string;\n\tlinkUrl: (text: string) => string;\n\tcode: (text: string) => string;\n\tcodeBlock: (text: string) => string;\n\tcodeBlockBorder: (text: string) => string;\n\tquote: (text: string) => string;\n\tquoteBorder: (text: string) => string;\n\thr: (text: string) => string;\n\tlistBullet: (text: string) => string;\n\tbold: (text: string) => string;\n\titalic: (text: string) => string;\n\tstrikethrough: (text: string) => string;\n\tunderline: (text: string) => string;\n\thighlightCode?: (code: string, lang?: string) => string[];\n\tsymbols: SymbolTheme;\n}\n\ninterface DefaultTextStyle {\n\tcolor?: (text: string) => string;\n\tbgColor?: (text: string) => string;\n\tbold?: boolean;\n\titalic?: boolean;\n\tstrikethrough?: boolean;\n\tunderline?: boolean;\n}\n\nconst md = new Markdown(\n\t\"# Hello\\n\\nSome **bold** text\",\n\t1, // paddingX\n\t1, // paddingY\n\ttheme, // MarkdownTheme\n\tdefaultStyle, // optional DefaultTextStyle\n\t2, // optional code block indent (spaces)\n);\nmd.setText(\"Updated markdown\");\n```\n\n**Features:**\n\n- Headings, bold, italic, code blocks, lists, links, blockquotes\n- HTML tags rendered as plain text\n- Optional syntax highlighting via `highlightCode`\n- Padding support\n- Render caching for performance\n\n### Loader\n\nAnimated loading spinner.\n\n```typescript\nconst loader = new Loader(\n\ttui, // TUI instance for render updates\n\t(s) => chalk.cyan(s), // spinner color function\n\t(s) => chalk.gray(s), // message color function\n\t\"Loading...\", // message (default: \"Loading...\")\n);\nloader.start();\nloader.setMessage(\"Still loading...\");\nloader.stop();\n```\n\n### CancellableLoader\n\nExtends Loader with Escape key handling and an AbortSignal for cancelling async operations.\n\n```typescript\nconst loader = new CancellableLoader(\n\ttui, // TUI instance for render updates\n\t(s) => chalk.cyan(s), // spinner color function\n\t(s) => chalk.gray(s), // message color function\n\t\"Working...\", // message\n);\nloader.onAbort = () => done(null); // Called when user presses Escape\ndoAsyncWork(loader.signal).then(done);\n```\n\n**Properties:**\n\n- `signal: AbortSignal` - Aborted when user presses Escape\n- `aborted: boolean` - Whether the loader was aborted\n- `onAbort?: () => void` - Callback when user presses Escape\n\n### SelectList\n\nInteractive selection list with keyboard navigation.\n\n```typescript\ninterface SelectItem {\n\tvalue: string;\n\tlabel: string;\n\tdescription?: string;\n}\n\ninterface SelectListTheme {\n\tselectedPrefix: (text: string) => string;\n\tselectedText: (text: string) => string;\n\tdescription: (text: string) => string;\n\tscrollInfo: (text: string) => string;\n\tnoMatch: (text: string) => string;\n\tsymbols: SymbolTheme;\n}\n\nconst list = new SelectList(\n\t[\n\t\t{ value: \"opt1\", label: \"Option 1\", description: \"First option\" },\n\t\t{ value: \"opt2\", label: \"Option 2\", description: \"Second option\" },\n\t],\n\t5, // maxVisible\n\ttheme, // SelectListTheme\n);\n\nlist.onSelect = (item) => console.log(\"Selected:\", item);\nlist.onCancel = () => console.log(\"Cancelled\");\nlist.onSelectionChange = (item) => console.log(\"Highlighted:\", item);\nlist.setFilter(\"opt\"); // Filter items\n```\n\n**Controls:**\n\n- Arrow keys: Navigate\n- Enter: Select\n- Escape: Cancel\n\n### SettingsList\n\nSettings panel with value cycling and submenus.\n\n```typescript\ninterface SettingItem {\n\tid: string;\n\tlabel: string;\n\tdescription?: string;\n\tcurrentValue: string;\n\tvalues?: string[]; // If provided, Enter/Space cycles through these\n\tsubmenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;\n}\n\ninterface SettingsListTheme {\n\tlabel: (text: string, selected: boolean) => string;\n\tvalue: (text: string, selected: boolean) => string;\n\tdescription: (text: string) => string;\n\tcursor: string;\n\thint: (text: string) => string;\n}\n\nconst settings = new SettingsList(\n\t[\n\t\t{ id: \"theme\", label: \"Theme\", currentValue: \"dark\", values: [\"dark\", \"light\"] },\n\t\t{ id: \"model\", label: \"Model\", currentValue: \"gpt-4\", submenu: (val, done) => modelSelector },\n\t],\n\t10, // maxVisible\n\ttheme, // SettingsListTheme\n\t(id, newValue) => console.log(`${id} changed to ${newValue}`),\n\t() => console.log(\"Cancelled\"),\n);\nsettings.updateValue(\"theme\", \"light\");\n```\n\n**Controls:**\n\n- Arrow keys: Navigate\n- Enter/Space: Activate (cycle value or open submenu)\n- Escape: Cancel\n\n### Spacer\n\nEmpty lines for vertical spacing.\n\n```typescript\nconst spacer = new Spacer(2); // 2 empty lines (default: 1)\n```\n\n### Image\n\nRenders images inline for terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm, and Warp on macOS/Linux) or iTerm2 inline images. Falls back to a text placeholder on unsupported terminals.\n\n```typescript\ninterface ImageTheme {\n\tfallbackColor: (str: string) => string;\n}\n\ninterface ImageOptions {\n\tmaxWidthCells?: number;\n\tmaxHeightCells?: number;\n\tfilename?: string;\n}\n\nconst image = new Image(\n\tbase64Data, // base64-encoded image data\n\t\"image/png\", // MIME type\n\ttheme, // ImageTheme\n\toptions, // optional ImageOptions\n);\ntui.addChild(image);\n```\n\nSupported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.\n\n## Autocomplete\n\n### CombinedAutocompleteProvider\n\nSupports both slash commands and file paths.\n\n```typescript\nimport { CombinedAutocompleteProvider } from \"@cyberxninja-omp/pi-tui\";\nimport { getProjectDir } from \"@cyberxninja-omp/pi-utils\";\n\nconst provider = new CombinedAutocompleteProvider(\n\t[\n\t\t{ name: \"help\", description: \"Show help\" },\n\t\t{ name: \"clear\", description: \"Clear screen\" },\n\t\t{ name: \"delete\", description: \"Delete last message\" },\n\t],\n\tgetProjectDir(), // base path for file completion\n);\n\neditor.setAutocompleteProvider(provider);\n```\n\n**Features:**\n\n- Type `/` to see slash commands\n- Press `Tab` for file path completion\n- Works with `~/`, `./`, `../`, and `@` prefix\n- Filters to attachable files for `@` prefix\n\n## Key Detection\n\nHelper functions for detecting keyboard input (supports Kitty keyboard protocol):\n\n```typescript\nimport {\n\tisEnter,\n\tisEscape,\n\tisTab,\n\tisShiftTab,\n\tisArrowUp,\n\tisArrowDown,\n\tisArrowLeft,\n\tisArrowRight,\n\tisCtrlA,\n\tisCtrlC,\n\tisCtrlE,\n\tisCtrlK,\n\tisCtrlO,\n\tisCtrlP,\n\tisCtrlLeft,\n\tisCtrlRight,\n\tisAltLeft,\n\tisAltRight,\n\tisShiftEnter,\n\tisAltEnter,\n\tisShiftCtrlO,\n\tisShiftCtrlD,\n\tisShiftCtrlP,\n\tisBackspace,\n\tisDelete,\n\tisHome,\n\tisEnd,\n\t// ... and more\n} from \"@cyberxninja-omp/pi-tui\";\n\nif (isCtrlC(data)) {\n\tprocess.exit(0);\n}\n```\n\n## Differential Rendering\n\nThe TUI uses three rendering strategies:\n\n1. **First Render**: Output all lines without clearing scrollback\n2. **Width Changed or Change Above Viewport**: Clear screen and full re-render\n3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines\n\nAll updates are wrapped in **synchronized output** (`\\x1b[?2026h` ... `\\x1b[?2026l`) for atomic, flicker-free rendering unless `PI_NO_SYNC_OUTPUT=1` is set. The opt-out removes only the DEC 2026 wrapper; paint writes still guard terminal autowrap to avoid pending-wrap cursor artifacts.\n\n## Terminal Interface\n\nThe TUI works with any object implementing the `Terminal` interface:\n\n```typescript\ninterface Terminal {\n\tstart(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;\n\tstop(): void;\n\twrite(data: string): void;\n\tget columns(): number;\n\tget rows(): number;\n\tmoveBy(lines: number): void;\n\thideCursor(force?: boolean): void;\n\tshowCursor(force?: boolean): void;\n\tclearLine(): void;\n\tclearFromCursor(): void;\n\tclearScreen(): void;\n}\n```\n\n**Built-in implementations:**\n\n- `ProcessTerminal` - Uses `process.stdin/stdout`\n- `VirtualTerminal` - For testing (uses ghostty-web)\n\n## Utilities\n\n```typescript\nimport { Ellipsis, visibleWidth, truncateToWidth, wrapTextWithAnsi } from \"@cyberxninja-omp/pi-tui\";\n\n// Get visible width of string (ignoring ANSI codes, uses Bun.stringWidth)\nconst width = visibleWidth(\"\\x1b[31mHello\\x1b[0m\"); // 5\n\n// Truncate string to width (preserving ANSI codes, adds ellipsis)\nconst truncated = truncateToWidth(\"Hello World\", 8); // \"Hello…\" (default: Ellipsis.Unicode)\n\n// Truncate without ellipsis\nconst truncatedNoEllipsis = truncateToWidth(\"Hello World\", 8, Ellipsis.Omit); // \"Hello Wo\"\n\n// Wrap text to width (Bun.wrapAnsi word wrap, trims line ends, preserves ANSI)\nconst lines = wrapTextWithAnsi(\"This is a long line that needs wrapping\", 20);\n// [\"This is a long line\", \"that needs wrapping\"]\n```\n\n## Creating Custom Components\n\nWhen creating custom components, **each line returned by `render()` must not exceed the `width` parameter**. The TUI will error if any line is wider than the terminal.\n\n### Handling Input\n\nUse the key detection utilities to handle keyboard input:\n\n```typescript\nimport { isEnter, isEscape, isArrowUp, isArrowDown, isCtrlC, isTab, isBackspace } from \"@cyberxninja-omp/pi-tui\";\nimport type { Component } from \"@cyberxninja-omp/pi-tui\";\n\nclass MyInteractiveComponent implements Component {\n\tprivate selectedIndex = 0;\n\tprivate items = [\"Option 1\", \"Option 2\", \"Option 3\"];\n\n\tonSelect?: (index: number) => void;\n\tonCancel?: () => void;\n\n\thandleInput(data: string): void {\n\t\tif (isArrowUp(data)) {\n\t\t\tthis.selectedIndex = Math.max(0, this.selectedIndex - 1);\n\t\t} else if (isArrowDown(data)) {\n\t\t\tthis.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);\n\t\t} else if (isEnter(data)) {\n\t\t\tthis.onSelect?.(this.selectedIndex);\n\t\t} else if (isEscape(data) || isCtrlC(data)) {\n\t\t\tthis.onCancel?.();\n\t\t}\n\t}\n\n\trender(width: number): readonly string[] {\n\t\treturn this.items.map((item, i) => {\n\t\t\tconst prefix = i === this.selectedIndex ? \"> \" : \"  \";\n\t\t\treturn truncateToWidth(prefix + item, width);\n\t\t});\n\t}\n}\n```\n\n### Handling Line Width\n\nUse the provided utilities to ensure lines fit:\n\n```typescript\nimport { visibleWidth, truncateToWidth } from \"@cyberxninja-omp/pi-tui\";\nimport type { Component } from \"@cyberxninja-omp/pi-tui\";\n\nclass MyComponent implements Component {\n\tprivate text: string;\n\n\tconstructor(text: string) {\n\t\tthis.text = text;\n\t}\n\n\trender(width: number): readonly string[] {\n\t\t// Option 1: Truncate long lines\n\t\treturn [truncateToWidth(this.text, width)];\n\n\t\t// Option 2: Check and pad to exact width\n\t\tconst line = this.text;\n\t\tconst visible = visibleWidth(line);\n\t\tif (visible > width) {\n\t\t\treturn [truncateToWidth(line, width)];\n\t\t}\n\t\t// Pad to exact width (optional, for backgrounds)\n\t\treturn [line + \" \".repeat(width - visible)];\n\t}\n}\n```\n\n### ANSI Code Considerations\n\n`visibleWidth()`, `truncateToWidth()`, and `wrapTextWithAnsi()` correctly handle ANSI escape codes:\n\n- `visibleWidth()` ignores ANSI codes when calculating width (via `Bun.stringWidth`)\n- `truncateToWidth()` preserves ANSI codes and properly closes them when truncating\n- `wrapTextWithAnsi()` preserves ANSI codes while word-wrapping and trimming line ends\n\n```typescript\nimport chalk from \"@cyberxninja-omp/pi-utils/chalk\";\n\nconst styled = chalk.red(\"Hello\") + \" \" + chalk.blue(\"World\");\nconst width = visibleWidth(styled); // 11 (not counting ANSI codes)\nconst truncated = truncateToWidth(styled, 8); // Red \"Hello\" + \" W...\" with proper reset\n```\n\n### Caching\n\nFor performance, components should cache their rendered output and only re-render when necessary:\n\n```typescript\nclass CachedComponent implements Component {\n\tprivate text: string;\n\tprivate cachedWidth?: number;\n\tprivate cachedLines?: string[];\n\n\trender(width: number): readonly string[] {\n\t\tif (this.cachedLines && this.cachedWidth === width) {\n\t\t\treturn this.cachedLines;\n\t\t}\n\n\t\tconst lines = [truncateToWidth(this.text, width)];\n\n\t\tthis.cachedWidth = width;\n\t\tthis.cachedLines = lines;\n\t\treturn lines;\n\t}\n\n\tinvalidate(): void {\n\t\tthis.cachedWidth = undefined;\n\t\tthis.cachedLines = undefined;\n\t}\n}\n```\n\n## Example\n\nSee `test/chat-simple.ts` for a complete chat interface example with:\n\n- Markdown messages with custom background colors\n- Loading spinner during responses\n- Editor with autocomplete and slash commands\n- Spacers between messages\n\nRun it:\n\n```bash\nnpx tsx test/chat-simple.ts\n```\n\n## Development\n\n```bash\n# Install dependencies (from monorepo root)\nnpm install\n\n# Run type checking\nnpm run check\n\n# Run the demo\nnpx tsx test/chat-simple.ts\n```\n","readmeFilename":"README.md","_rev":"1-90eaedf21b042f9fe0113399f16d24e4"}