move pi-mono into companion-cloud as apps/companion-os

- Copy all pi-mono source into apps/companion-os/
- Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases
- Update deploy-staging.yml to build pi from source (bun compile) before Docker build
- Add apps/companion-os/** to path triggers
- No more cross-repo dispatch needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Harivansh Rathi 2026-03-07 09:22:50 -08:00
commit 0250f72976
579 changed files with 206942 additions and 0 deletions

536
packages/tui/CHANGELOG.md Normal file
View file

@ -0,0 +1,536 @@
# Changelog
## [Unreleased]
## [0.56.2] - 2026-03-05
### Added
- Exported `decodeKittyPrintable()` from `keys.ts` for decoding Kitty CSI-u sequences into printable characters
### Fixed
- Fixed `Input` component not accepting typed characters when Kitty keyboard protocol is active (e.g., VS Code 1.110+), causing model selector filter to ignore keystrokes ([#1857](https://github.com/badlogic/pi-mono/issues/1857))
- Fixed editor/footer visibility drift during terminal resize by forcing full redraws when terminal width or height changes ([#1844](https://github.com/badlogic/pi-mono/pull/1844) by [@ghoulr](https://github.com/ghoulr)).
## [0.56.1] - 2026-03-05
### Fixed
- Fixed markdown blockquote rendering to isolate blockquote styling from default text style, preventing style leakage.
## [0.56.0] - 2026-03-04
### Fixed
- Fixed TUI width calculation for regional indicator symbols (e.g. partial flag sequences like `🇨` during streaming) to prevent wrap drift and stale character artifacts in differential rendering.
- Fixed Kitty CSI-u handling to ignore unsupported modifiers so modifier-only events do not insert stray printable characters ([#1807](https://github.com/badlogic/pi-mono/issues/1807))
- Fixed single-line paste performance by inserting pasted text atomically instead of character-by-character, preventing repeated `@` autocomplete scans during paste ([#1812](https://github.com/badlogic/pi-mono/issues/1812))
- Fixed `visibleWidth()` to ignore generic OSC escape sequences (including OSC 133 semantic prompt markers), preventing width drift when terminals emit semantic zone markers ([#1805](https://github.com/badlogic/pi-mono/issues/1805))
- Fixed markdown blockquotes dropping nested list content by rendering blockquote children as block-level tokens ([#1787](https://github.com/badlogic/pi-mono/issues/1787))
## [0.55.4] - 2026-03-02
## [0.55.3] - 2026-02-27
## [0.55.2] - 2026-02-27
## [0.55.1] - 2026-02-26
### Fixed
- Fixed Windows VT input initialization in ESM by loading `koffi` via `createRequire`, restoring VT input mode while keeping `koffi` externalized from compiled binaries ([#1627](https://github.com/badlogic/pi-mono/pull/1627) by [@kaste](https://github.com/kaste))
## [0.55.0] - 2026-02-24
## [0.54.2] - 2026-02-23
## [0.54.1] - 2026-02-22
### Fixed
- Changed koffi import from top-level to dynamic require in `enableWindowsVTInput()` to prevent bun from embedding all 18 platform `.node` files (~74MB) into every compiled binary. Koffi is only needed on Windows.
## [0.54.0] - 2026-02-19
## [0.53.1] - 2026-02-19
## [0.53.0] - 2026-02-17
## [0.52.12] - 2026-02-13
## [0.52.11] - 2026-02-13
## [0.52.10] - 2026-02-12
### Added
- Added terminal input listeners in `TUI` (`addInputListener` and `removeInputListener`) to let callers intercept, transform, or consume raw input before component handling.
### Fixed
- Fixed `@` autocomplete fuzzy matching to score against path segments and prefixes, reducing irrelevant matches for nested paths ([#1423](https://github.com/badlogic/pi-mono/issues/1423))
## [0.52.9] - 2026-02-08
## [0.52.8] - 2026-02-07
### Added
- Added `pasteToEditor` to `EditorComponent` API for programmatic paste support ([#1351](https://github.com/badlogic/pi-mono/pull/1351) by [@kaofelix](https://github.com/kaofelix))
- Added kill ring (ctrl+k/ctrl+y/alt+y) and undo (ctrl+z) support to the Input component ([#1373](https://github.com/badlogic/pi-mono/pull/1373) by [@Perlence](https://github.com/Perlence))
## [0.52.7] - 2026-02-06
## [0.52.6] - 2026-02-05
## [0.52.5] - 2026-02-05
## [0.52.4] - 2026-02-05
## [0.52.3] - 2026-02-05
## [0.52.2] - 2026-02-05
## [0.52.1] - 2026-02-05
## [0.52.0] - 2026-02-05
## [0.51.6] - 2026-02-04
### Changed
- Slash command menu now triggers on the first line even when other lines have content, allowing commands to be prepended to existing text ([#1227](https://github.com/badlogic/pi-mono/pull/1227) by [@aliou](https://github.com/aliou))
### Fixed
- Fixed `/settings` crashing in narrow terminals by handling small widths in the settings list ([#1246](https://github.com/badlogic/pi-mono/pull/1246) by [@haoqixu](https://github.com/haoqixu))
## [0.51.5] - 2026-02-04
## [0.51.4] - 2026-02-03
### Fixed
- Fixed input scrolling to avoid splitting emoji sequences ([#1228](https://github.com/badlogic/pi-mono/pull/1228) by [@haoqixu](https://github.com/haoqixu))
## [0.51.3] - 2026-02-03
## [0.51.2] - 2026-02-03
### Added
- Added `Terminal.drainInput()` to drain stdin before exit (prevents Kitty key release events leaking over slow SSH)
### Fixed
- Fixed Kitty key release events leaking to parent shell over slow SSH connections by draining stdin for up to 1s ([#1204](https://github.com/badlogic/pi-mono/issues/1204))
- Fixed legacy newline handling in the editor to preserve previous newline behavior
- Fixed @ autocomplete to include hidden paths
- Fixed submit fallback to honor configured keybindings
## [0.51.1] - 2026-02-02
### Added
- Added `PI_DEBUG_REDRAW=1` env var for debugging full redraws (logs triggers to `~/.pi/agent/pi-debug.log`)
### Changed
- Terminal height changes no longer trigger full redraws, reducing flicker on resize
- `clearOnShrink` now defaults to `false` (use `PI_CLEAR_ON_SHRINK=1` or `setClearOnShrink(true)` to enable)
### Fixed
- Fixed emoji cursor positioning in Input component ([#1183](https://github.com/badlogic/pi-mono/pull/1183) by [@haoqixu](https://github.com/haoqixu))
- Fixed unnecessary full redraws when appending many lines after content had previously shrunk (viewport check now uses actual previous content size instead of stale maximum)
- Fixed Ctrl+D exit closing the parent SSH session due to stdin buffer race condition ([#1185](https://github.com/badlogic/pi-mono/issues/1185))
## [0.51.0] - 2026-02-01
## [0.50.9] - 2026-02-01
## [0.50.8] - 2026-02-01
### Added
- Added sticky column tracking for vertical cursor navigation so the editor restores the preferred column when moving across short lines. ([#1120](https://github.com/badlogic/pi-mono/pull/1120) by [@Perlence](https://github.com/Perlence))
### Fixed
- Fixed Kitty keyboard protocol base layout fallback so non-QWERTY layouts do not trigger wrong shortcuts ([#1096](https://github.com/badlogic/pi-mono/pull/1096) by [@rytswd](https://github.com/rytswd))
## [0.50.7] - 2026-01-31
## [0.50.6] - 2026-01-30
### Changed
- Optimized `isImageLine()` with `startsWith` short-circuit for faster image line detection
### Fixed
- Fixed empty rows appearing below footer when content shrinks (e.g., closing `/tree`, clearing multi-line editor) ([#1095](https://github.com/badlogic/pi-mono/pull/1095) by [@marckrenn](https://github.com/marckrenn))
- Fixed terminal cursor remaining hidden after exiting TUI via `stop()` when a render was pending ([#1099](https://github.com/badlogic/pi-mono/pull/1099) by [@haoqixu](https://github.com/haoqixu))
## [0.50.5] - 2026-01-30
### Fixed
- Fixed `isImageLine()` to check for image escape sequences anywhere in a line, not just at the start. This prevents TUI crashes when rendering lines containing image data. ([#1091](https://github.com/badlogic/pi-mono/pull/1091) by [@zedrdave](https://github.com/zedrdave))
## [0.50.4] - 2026-01-30
### Added
- Added Ctrl+B and Ctrl+F as alternative keybindings for cursor word left/right navigation ([#1053](https://github.com/badlogic/pi-mono/pull/1053) by [@ninlds](https://github.com/ninlds))
- Added character jump navigation: Ctrl+] jumps forward to next character, Ctrl+Alt+] jumps backward ([#1074](https://github.com/badlogic/pi-mono/pull/1074) by [@Perlence](https://github.com/Perlence))
- Editor now jumps to line start when pressing Up at first visual line, and line end when pressing Down at last visual line ([#1050](https://github.com/badlogic/pi-mono/pull/1050) by [@4h9fbZ](https://github.com/4h9fbZ))
### Changed
- Optimized image line detection and box rendering cache for better performance ([#1084](https://github.com/badlogic/pi-mono/pull/1084) by [@can1357](https://github.com/can1357))
### Fixed
- Fixed autocomplete for paths with spaces by supporting quoted path tokens ([#1077](https://github.com/badlogic/pi-mono/issues/1077))
- Fixed quoted path completions to avoid duplicating closing quotes during autocomplete ([#1077](https://github.com/badlogic/pi-mono/issues/1077))
## [0.50.3] - 2026-01-29
## [0.50.2] - 2026-01-29
### Added
- Added `autocompleteMaxVisible` option to `EditorOptions` with getter/setter methods for configurable autocomplete dropdown height ([#972](https://github.com/badlogic/pi-mono/pull/972) by [@masonc15](https://github.com/masonc15))
- Added `alt+b` and `alt+f` as alternative keybindings for word navigation (`cursorWordLeft`, `cursorWordRight`) and `ctrl+d` for `deleteCharForward` ([#1043](https://github.com/badlogic/pi-mono/issues/1043) by [@jasonish](https://github.com/jasonish))
- Editor auto-applies single suggestion when force file autocomplete triggers with exactly one match ([#993](https://github.com/badlogic/pi-mono/pull/993) by [@Perlence](https://github.com/Perlence))
### Changed
- Improved `extractCursorPosition` performance: scans lines in reverse order, early-outs when cursor is above viewport, and limits scan to bottom terminal height ([#1004](https://github.com/badlogic/pi-mono/pull/1004) by [@can1357](https://github.com/can1357))
- Autocomplete improvements: better handling of partial matches and edge cases ([#1024](https://github.com/badlogic/pi-mono/pull/1024) by [@Perlence](https://github.com/Perlence))
### Fixed
- Fixed backslash input buffering causing delayed character display in editor and input components ([#1037](https://github.com/badlogic/pi-mono/pull/1037) by [@Perlence](https://github.com/Perlence))
- Fixed markdown table rendering with proper row dividers and minimum column width ([#997](https://github.com/badlogic/pi-mono/pull/997) by [@tmustier](https://github.com/tmustier))
## [0.50.1] - 2026-01-26
## [0.50.0] - 2026-01-26
### Added
- Added `fullRedraws` readonly property to TUI class for tracking full screen redraws
- Added `PI_TUI_WRITE_LOG` environment variable to capture raw ANSI output for debugging
### Fixed
- Fixed appended lines not being committed to scrollback, causing earlier content to be overwritten when viewport fills ([#954](https://github.com/badlogic/pi-mono/issues/954))
- Slash command menu now only triggers when the editor input is otherwise empty ([#904](https://github.com/badlogic/pi-mono/issues/904))
- Center-anchored overlays now stay vertically centered when resizing the terminal taller after a shrink ([#950](https://github.com/badlogic/pi-mono/pull/950) by [@nicobailon](https://github.com/nicobailon))
- Fixed editor multi-line insertion handling and lastAction tracking ([#945](https://github.com/badlogic/pi-mono/pull/945) by [@Perlence](https://github.com/Perlence))
- Fixed editor word wrapping to reserve a cursor column ([#934](https://github.com/badlogic/pi-mono/pull/934) by [@Perlence](https://github.com/Perlence))
- Fixed editor word wrapping to use single-pass backtracking for whitespace handling ([#924](https://github.com/badlogic/pi-mono/pull/924) by [@Perlence](https://github.com/Perlence))
- Fixed Kitty image ID allocation and cleanup to prevent image ID collisions between modules
## [0.49.3] - 2026-01-22
### Added
- `codeBlockIndent` property on `MarkdownTheme` to customize code block content indentation (default: 2 spaces) ([#855](https://github.com/badlogic/pi-mono/pull/855) by [@terrorobe](https://github.com/terrorobe))
- Added Alt+Delete as hotkey for delete word forwards ([#878](https://github.com/badlogic/pi-mono/pull/878) by [@Perlence](https://github.com/Perlence))
### Changed
- Fuzzy matching now scores consecutive matches higher and penalizes gaps more heavily for better relevance ([#860](https://github.com/badlogic/pi-mono/pull/860) by [@mitsuhiko](https://github.com/mitsuhiko))
### Fixed
- Autolinked emails no longer display redundant `(mailto:...)` suffix in markdown output ([#888](https://github.com/badlogic/pi-mono/pull/888) by [@terrorobe](https://github.com/terrorobe))
- Fixed viewport tracking and cursor positioning for overlays and content shrink scenarios
- Autocomplete now allows searches with `/` characters (e.g., `folder1/folder2`) ([#882](https://github.com/badlogic/pi-mono/pull/882) by [@richardgill](https://github.com/richardgill))
- Directory completions for `@` file attachments no longer add trailing space, allowing continued autocomplete into subdirectories
## [0.49.2] - 2026-01-19
## [0.49.1] - 2026-01-18
### Added
- Added undo support to Editor with Ctrl+- hotkey. Undo coalesces consecutive word characters into one unit (fish-style). ([#831](https://github.com/badlogic/pi-mono/pull/831) by [@Perlence](https://github.com/Perlence))
- Added legacy terminal support for Ctrl+symbol keys (Ctrl+\, Ctrl+], Ctrl+-) and their Ctrl+Alt variants. ([#831](https://github.com/badlogic/pi-mono/pull/831) by [@Perlence](https://github.com/Perlence))
## [0.49.0] - 2026-01-17
### Added
- Added `showHardwareCursor` getter and setter to control cursor visibility while keeping IME positioning active. ([#800](https://github.com/badlogic/pi-mono/pull/800) by [@ghoulr](https://github.com/ghoulr))
- Added Emacs-style kill ring editing with yank and yank-pop keybindings. ([#810](https://github.com/badlogic/pi-mono/pull/810) by [@Perlence](https://github.com/Perlence))
- Added legacy Alt+letter handling and Alt+D delete word forward support in the editor keymap. ([#810](https://github.com/badlogic/pi-mono/pull/810) by [@Perlence](https://github.com/Perlence))
## [0.48.0] - 2026-01-16
### Added
- `EditorOptions` with optional `paddingX` for horizontal content padding, plus `getPaddingX()`/`setPaddingX()` methods ([#791](https://github.com/badlogic/pi-mono/pull/791) by [@ferologics](https://github.com/ferologics))
### Changed
- Hardware cursor is now disabled by default for better terminal compatibility. Set `PI_HARDWARE_CURSOR=1` to enable (replaces `PI_NO_HARDWARE_CURSOR=1` which disabled it).
### Fixed
- Decode Kitty CSI-u printable sequences in the editor so shifted symbol keys (e.g., `@`, `?`) work in terminals that enable Kitty keyboard protocol ([#779](https://github.com/badlogic/pi-mono/pull/779) by [@iamd3vil](https://github.com/iamd3vil))
## [0.47.0] - 2026-01-16
### Breaking Changes
- `Editor` constructor now requires `TUI` as first parameter: `new Editor(tui, theme)`. This enables automatic vertical scrolling when content exceeds terminal height. ([#732](https://github.com/badlogic/pi-mono/issues/732))
### Added
- Hardware cursor positioning for IME support in `Editor` and `Input` components. The terminal cursor now follows the text cursor position, enabling proper IME candidate window placement for CJK input. ([#719](https://github.com/badlogic/pi-mono/pull/719))
- `Focusable` interface for components that need hardware cursor positioning. Implement `focused: boolean` and emit `CURSOR_MARKER` in render output when focused.
- `CURSOR_MARKER` constant and `isFocusable()` type guard exported from the package
- Editor now supports Page Up/Down keys (Fn+Up/Down on MacBook) for scrolling through large content ([#732](https://github.com/badlogic/pi-mono/issues/732))
- Expanded keymap coverage for terminal compatibility: added support for Home/End keys in tmux, additional modifier combinations, and improved key sequence parsing ([#752](https://github.com/badlogic/pi-mono/pull/752) by [@richardgill](https://github.com/richardgill))
### Fixed
- Editor no longer corrupts terminal display when text exceeds screen height. Content now scrolls vertically with indicators showing lines above/below the viewport. Max height is 30% of terminal (minimum 5 lines). ([#732](https://github.com/badlogic/pi-mono/issues/732))
- `visibleWidth()` and `extractAnsiCode()` now handle APC escape sequences (`ESC _ ... BEL`), fixing width calculation and string slicing for strings containing cursor markers
- SelectList now handles multi-line descriptions by replacing newlines with spaces ([#728](https://github.com/badlogic/pi-mono/pull/728) by [@richardgill](https://github.com/richardgill))
## [0.46.0] - 2026-01-15
### Fixed
- Keyboard shortcuts (Ctrl+C, Ctrl+D, etc.) now work on non-Latin keyboard layouts (Russian, Ukrainian, Bulgarian, etc.) in terminals supporting Kitty keyboard protocol with alternate key reporting ([#718](https://github.com/badlogic/pi-mono/pull/718) by [@dannote](https://github.com/dannote))
## [0.45.7] - 2026-01-13
## [0.45.6] - 2026-01-13
### Added
- `OverlayOptions` API for overlay positioning and sizing with CSS-like values: `width`, `maxHeight`, `row`, `col` accept numbers (absolute) or percentage strings (e.g., `"50%"`). Also supports `minWidth`, `anchor`, `offsetX`, `offsetY`, `margin`. ([#667](https://github.com/badlogic/pi-mono/pull/667) by [@nicobailon](https://github.com/nicobailon))
- `OverlayOptions.visible` callback for responsive overlays - receives terminal dimensions, return false to hide ([#667](https://github.com/badlogic/pi-mono/pull/667) by [@nicobailon](https://github.com/nicobailon))
- `showOverlay()` now returns `OverlayHandle` with `hide()`, `setHidden(boolean)`, `isHidden()` for programmatic visibility control ([#667](https://github.com/badlogic/pi-mono/pull/667) by [@nicobailon](https://github.com/nicobailon))
- New exported types: `OverlayAnchor`, `OverlayHandle`, `OverlayMargin`, `OverlayOptions`, `SizeValue` ([#667](https://github.com/badlogic/pi-mono/pull/667) by [@nicobailon](https://github.com/nicobailon))
- `truncateToWidth()` now accepts optional `pad` parameter to pad result with spaces to exactly `maxWidth` ([#667](https://github.com/badlogic/pi-mono/pull/667) by [@nicobailon](https://github.com/nicobailon))
### Fixed
- Overlay compositing crash when rendered lines exceed terminal width due to complex ANSI/OSC sequences (e.g., hyperlinks in subagent output) ([#667](https://github.com/badlogic/pi-mono/pull/667) by [@nicobailon](https://github.com/nicobailon))
## [0.45.5] - 2026-01-13
## [0.45.4] - 2026-01-13
## [0.45.3] - 2026-01-13
## [0.45.2] - 2026-01-13
## [0.45.1] - 2026-01-13
## [0.45.0] - 2026-01-13
## [0.44.0] - 2026-01-12
### Added
- `SettingsListOptions` with `enableSearch` for fuzzy filtering in `SettingsList` ([#643](https://github.com/badlogic/pi-mono/pull/643) by [@ninlds](https://github.com/ninlds))
- `pageUp` and `pageDown` key support with `selectPageUp`/`selectPageDown` editor actions ([#662](https://github.com/badlogic/pi-mono/pull/662) by [@aliou](https://github.com/aliou))
### Fixed
- Numbered list items showing "1." for all items when code blocks break list continuity ([#660](https://github.com/badlogic/pi-mono/pull/660) by [@ogulcancelik](https://github.com/ogulcancelik))
## [0.43.0] - 2026-01-11
### Added
- `fuzzyFilter()` and `fuzzyMatch()` utilities for fuzzy text matching
- Slash command autocomplete now uses fuzzy matching instead of prefix matching
### Fixed
- Cursor now moves to end of content on exit, preventing status line from being overwritten ([#629](https://github.com/badlogic/pi-mono/pull/629) by [@tallshort](https://github.com/tallshort))
- Reset ANSI styles after each rendered line to prevent style leakage
## [0.42.5] - 2026-01-11
### Fixed
- Reduced flicker by only re-rendering changed lines ([#617](https://github.com/badlogic/pi-mono/pull/617) by [@ogulcancelik](https://github.com/ogulcancelik))
- Cursor position tracking when content shrinks with unchanged remaining lines
- TUI renders with wrong dimensions after suspend/resume if terminal was resized while suspended ([#599](https://github.com/badlogic/pi-mono/issues/599))
- Pasted content containing Kitty key release patterns (e.g., `:3F` in MAC addresses) was incorrectly filtered out ([#623](https://github.com/badlogic/pi-mono/pull/623) by [@ogulcancelik](https://github.com/ogulcancelik))
## [0.42.4] - 2026-01-10
## [0.42.3] - 2026-01-10
## [0.42.2] - 2026-01-10
## [0.42.1] - 2026-01-09
## [0.42.0] - 2026-01-09
## [0.41.0] - 2026-01-09
## [0.40.1] - 2026-01-09
## [0.40.0] - 2026-01-08
## [0.39.1] - 2026-01-08
## [0.39.0] - 2026-01-08
### Added
- **Experimental:** Overlay compositing for `ctx.ui.custom()` with `{ overlay: true }` option ([#558](https://github.com/badlogic/pi-mono/pull/558) by [@nicobailon](https://github.com/nicobailon))
## [0.38.0] - 2026-01-08
### Added
- `EditorComponent` interface for custom editor implementations
- `StdinBuffer` class to split batched stdin into individual sequences (adapted from [OpenTUI](https://github.com/anomalyco/opentui), MIT license)
### Fixed
- Key presses no longer dropped when batched with other events over SSH ([#538](https://github.com/badlogic/pi-mono/pull/538))
## [0.37.8] - 2026-01-07
### Added
- `Component.wantsKeyRelease` property to opt-in to key release events (default false)
### Fixed
- TUI now filters out key release events by default, preventing double-processing of keys in editors and other components
## [0.37.7] - 2026-01-07
### Fixed
- `matchesKey()` now correctly matches Kitty protocol sequences for unmodified letter keys (needed for key release events)
## [0.37.6] - 2026-01-06
### Added
- Kitty keyboard protocol flag 2 support for key release events. New exports: `isKeyRelease(data)`, `isKeyRepeat(data)`, `KeyEventType` type. Terminals supporting Kitty protocol (Kitty, Ghostty, WezTerm) now send proper key-up events.
## [0.37.5] - 2026-01-06
## [0.37.4] - 2026-01-06
## [0.37.3] - 2026-01-06
## [0.37.2] - 2026-01-05
## [0.37.1] - 2026-01-05
## [0.37.0] - 2026-01-05
### Fixed
- Crash when pasting text with trailing whitespace exceeding terminal width through Markdown rendering ([#457](https://github.com/badlogic/pi-mono/pull/457) by [@robinwander](https://github.com/robinwander))
## [0.36.0] - 2026-01-05
## [0.35.0] - 2026-01-05
## [0.34.2] - 2026-01-04
## [0.34.1] - 2026-01-04
### Added
- Symbol key support in keybinding system: `SymbolKey` type with 32 symbol keys, `Key` constants (e.g., `Key.backtick`, `Key.comma`), updated `matchesKey()` and `parseKey()` to handle symbol input ([#450](https://github.com/badlogic/pi-mono/pull/450) by [@kaofelix](https://github.com/kaofelix))
## [0.34.0] - 2026-01-04
### Added
- `Editor.getExpandedText()` method that returns text with paste markers expanded to their actual content ([#444](https://github.com/badlogic/pi-mono/pull/444) by [@aliou](https://github.com/aliou))
## [0.33.0] - 2026-01-04
### Breaking Changes
- **Key detection functions removed**: All `isXxx()` key detection functions (`isEnter()`, `isEscape()`, `isCtrlC()`, etc.) have been removed. Use `matchesKey(data, keyId)` instead (e.g., `matchesKey(data, "enter")`, `matchesKey(data, "ctrl+c")`). This affects hooks and custom tools that use `ctx.ui.custom()` with keyboard input handling. ([#405](https://github.com/badlogic/pi-mono/pull/405))
### Added
- `Editor.insertTextAtCursor(text)` method for programmatic text insertion ([#419](https://github.com/badlogic/pi-mono/issues/419))
- `EditorKeybindingsManager` for configurable editor keybindings. Components now use `matchesKey()` and keybindings manager instead of individual `isXxx()` functions. ([#405](https://github.com/badlogic/pi-mono/pull/405) by [@hjanuschka](https://github.com/hjanuschka))
### Changed
- Key detection refactored: consolidated `is*()` functions into generic `matchesKey(data, keyId)` function that accepts key identifiers like `"ctrl+c"`, `"shift+enter"`, `"alt+left"`, etc.
## [0.32.3] - 2026-01-03
## [0.32.2] - 2026-01-03
### Fixed
- Slash command autocomplete now triggers for commands starting with `.`, `-`, or `_` (e.g., `/.land`, `/-foo`) ([#422](https://github.com/badlogic/pi-mono/issues/422))
## [0.32.1] - 2026-01-03
## [0.32.0] - 2026-01-03
### Changed
- Editor component now uses word wrapping instead of character-level wrapping for better readability ([#382](https://github.com/badlogic/pi-mono/pull/382) by [@nickseelert](https://github.com/nickseelert))
### Fixed
- Shift+Space, Shift+Backspace, and Shift+Delete now work correctly in Kitty-protocol terminals (Kitty, WezTerm, etc.) instead of being silently ignored ([#411](https://github.com/badlogic/pi-mono/pull/411) by [@nathyong](https://github.com/nathyong))
## [0.31.1] - 2026-01-02
### Fixed
- `visibleWidth()` now strips OSC 8 hyperlink sequences, fixing text wrapping for clickable links ([#396](https://github.com/badlogic/pi-mono/pull/396) by [@Cursivez](https://github.com/Cursivez))
## [0.31.0] - 2026-01-02
### Added
- `isShiftCtrlO()` key detection function for Shift+Ctrl+O (Kitty protocol)
- `isShiftCtrlD()` key detection function for Shift+Ctrl+D (Kitty protocol)
- `TUI.onDebug` callback for global debug key handling (Shift+Ctrl+D)
- `wrapTextWithAnsi()` utility now exported (wraps text to width, preserving ANSI codes)
### Changed
- README.md completely rewritten with accurate component documentation, theme interfaces, and examples
- `visibleWidth()` reimplemented with grapheme-based width calculation, 10x faster on Bun and ~15% faster on Node ([#369](https://github.com/badlogic/pi-mono/pull/369) by [@nathyong](https://github.com/nathyong))
### Fixed
- Markdown component now renders HTML tags as plain text instead of silently dropping them ([#359](https://github.com/badlogic/pi-mono/issues/359))
- Crash in `visibleWidth()` and grapheme iteration when encountering undefined code points ([#372](https://github.com/badlogic/pi-mono/pull/372) by [@HACKE-RC](https://github.com/HACKE-RC))
- ZWJ emoji sequences (rainbow flag, family, etc.) now render with correct width instead of being split into multiple characters ([#369](https://github.com/badlogic/pi-mono/pull/369) by [@nathyong](https://github.com/nathyong))
## [0.29.0] - 2025-12-25
### Added
- **Auto-space before pasted file paths**: When pasting a file path (starting with `/`, `~`, or `.`) and the cursor is after a word character, a space is automatically prepended for better readability. Useful when dragging screenshots from macOS. ([#307](https://github.com/badlogic/pi-mono/pull/307) by [@mitsuhiko](https://github.com/mitsuhiko))
- **Word navigation for Input component**: Added Ctrl+Left/Right and Alt+Left/Right support for word-by-word cursor movement. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
- **Full Unicode input**: Input component now accepts Unicode characters beyond ASCII. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
### Fixed
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))

806
packages/tui/README.md Normal file
View file

@ -0,0 +1,806 @@
# @mariozechner/pi-tui
Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications.
## Features
- **Differential Rendering**: Three-strategy rendering system that only updates what changed
- **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker)
- **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
- **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols
- **Autocomplete Support**: File paths and slash commands
## Quick Start
```typescript
import { TUI, Text, Editor, ProcessTerminal } from "@mariozechner/pi-tui";
// Create terminal
const terminal = new ProcessTerminal();
// Create TUI
const tui = new TUI(terminal);
// Add components
tui.addChild(new Text("Welcome to my app!"));
const editor = new Editor(tui, editorTheme);
editor.onSubmit = (text) => {
console.log("Submitted:", text);
tui.addChild(new Text(`You said: ${text}`));
};
tui.addChild(editor);
// Start
tui.start();
```
## Core API
### TUI
Main container that manages components and rendering.
```typescript
const tui = new TUI(terminal);
tui.addChild(component);
tui.removeChild(component);
tui.start();
tui.stop();
tui.requestRender(); // Request a re-render
// Global debug key handler (Shift+Ctrl+D)
tui.onDebug = () => console.log("Debug triggered");
```
### Overlays
Overlays render components on top of existing content without replacing it. Useful for dialogs, menus, and modal UI.
```typescript
// Show overlay with default options (centered, max 80 cols)
const handle = tui.showOverlay(component);
// Show overlay with custom positioning and sizing
// Values can be numbers (absolute) or percentage strings (e.g., "50%")
const handle = tui.showOverlay(component, {
// Sizing
width: 60, // Fixed width in columns
width: "80%", // Width as percentage of terminal
minWidth: 40, // Minimum width floor
maxHeight: 20, // Maximum height in rows
maxHeight: "50%", // Maximum height as percentage of terminal
// Anchor-based positioning (default: 'center')
anchor: "bottom-right", // Position relative to anchor point
offsetX: 2, // Horizontal offset from anchor
offsetY: -1, // Vertical offset from anchor
// Percentage-based positioning (alternative to anchor)
row: "25%", // Vertical position (0%=top, 100%=bottom)
col: "50%", // Horizontal position (0%=left, 100%=right)
// Absolute positioning (overrides anchor/percent)
row: 5, // Exact row position
col: 10, // Exact column position
// Margin from terminal edges
margin: 2, // All sides
margin: { top: 1, right: 2, bottom: 1, left: 2 },
// Responsive visibility
visible: (termWidth, termHeight) => termWidth >= 100, // Hide on narrow terminals
});
// OverlayHandle methods
handle.hide(); // Permanently remove the overlay
handle.setHidden(true); // Temporarily hide (can show again)
handle.setHidden(false); // Show again after hiding
handle.isHidden(); // Check if temporarily hidden
// Hide topmost overlay
tui.hideOverlay();
// Check if any visible overlay is active
tui.hasOverlay();
```
**Anchor values**: `'center'`, `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'`, `'top-center'`, `'bottom-center'`, `'left-center'`, `'right-center'`
**Resolution order**:
1. `minWidth` is applied as a floor after width calculation
2. For position: absolute `row`/`col` > percentage `row`/`col` > `anchor`
3. `margin` clamps final position to stay within terminal bounds
4. `visible` callback controls whether overlay renders (called each frame)
### Component Interface
All components implement:
```typescript
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
invalidate?(): void;
}
```
| Method | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `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. |
| `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
| `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. |
The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.
### Focusable Interface (IME Support)
Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:
```typescript
import {
CURSOR_MARKER,
type Component,
type Focusable,
} from "@mariozechner/pi-tui";
class MyInput implements Component, Focusable {
focused: boolean = false; // Set by TUI when focus changes
render(width: number): string[] {
const marker = this.focused ? CURSOR_MARKER : "";
// Emit marker right before the fake cursor
return [
`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`,
];
}
}
```
When a `Focusable` component has focus, TUI:
1. Sets `focused = true` on the component
2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence)
3. Positions the hardware terminal cursor at that location
4. Shows the hardware cursor
This enables IME candidate windows to appear at the correct position for CJK input methods. The `Editor` and `Input` built-in components already implement this interface.
**Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child:
```typescript
import { Container, type Focusable, Input } from "@mariozechner/pi-tui";
class SearchDialog extends Container implements Focusable {
private searchInput: Input;
// Propagate focus to child input for IME cursor positioning
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.searchInput.focused = value;
}
constructor() {
super();
this.searchInput = new Input();
this.addChild(this.searchInput);
}
}
```
Without this propagation, typing with an IME (Chinese, Japanese, Korean, etc.) will show the candidate window in the wrong position.
## Built-in Components
### Container
Groups child components.
```typescript
const container = new Container();
container.addChild(component);
container.removeChild(component);
```
### Box
Container that applies padding and background color to all children.
```typescript
const box = new Box(
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(text) => chalk.bgGray(text), // optional background function
);
box.addChild(new Text("Content"));
box.setBgFn((text) => chalk.bgBlue(text)); // Change background dynamically
```
### Text
Displays multi-line text with word wrapping and padding.
```typescript
const text = new Text(
"Hello World", // text content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(text) => chalk.bgGray(text), // optional background function
);
text.setText("Updated text");
text.setCustomBgFn((text) => chalk.bgBlue(text));
```
### TruncatedText
Single-line text that truncates to fit viewport width. Useful for status lines and headers.
```typescript
const truncated = new TruncatedText(
"This is a very long line that will be truncated...",
0, // paddingX (default: 0)
0, // paddingY (default: 0)
);
```
### Input
Single-line text input with horizontal scrolling.
```typescript
const input = new Input();
input.onSubmit = (value) => console.log(value);
input.setValue("initial");
input.getValue();
```
**Key Bindings:**
- `Enter` - Submit
- `Ctrl+A` / `Ctrl+E` - Line start/end
- `Ctrl+W` or `Alt+Backspace` - Delete word backwards
- `Ctrl+U` - Delete to start of line
- `Ctrl+K` - Delete to end of line
- `Ctrl+Left` / `Ctrl+Right` - Word navigation
- `Alt+Left` / `Alt+Right` - Word navigation
- Arrow keys, Backspace, Delete work as expected
### Editor
Multi-line text editor with autocomplete, file completion, paste handling, and vertical scrolling when content exceeds terminal height.
```typescript
interface EditorTheme {
borderColor: (str: string) => string;
selectList: SelectListTheme;
}
interface EditorOptions {
paddingX?: number; // Horizontal padding (default: 0)
}
const editor = new Editor(tui, theme, options?); // tui is required for height-aware scrolling
editor.onSubmit = (text) => console.log(text);
editor.onChange = (text) => console.log("Changed:", text);
editor.disableSubmit = true; // Disable submit temporarily
editor.setAutocompleteProvider(provider);
editor.borderColor = (s) => chalk.blue(s); // Change border dynamically
editor.setPaddingX(1); // Update horizontal padding dynamically
editor.getPaddingX(); // Get current padding
```
**Features:**
- Multi-line editing with word wrap
- Slash command autocomplete (type `/`)
- File path autocomplete (press `Tab`)
- Large paste handling (>10 lines creates `[paste #1 +50 lines]` marker)
- Horizontal lines above/below editor
- Fake cursor rendering (hidden real cursor)
**Key Bindings:**
- `Enter` - Submit
- `Shift+Enter`, `Ctrl+Enter`, or `Alt+Enter` - New line (terminal-dependent, Alt+Enter most reliable)
- `Tab` - Autocomplete
- `Ctrl+K` - Delete to end of line
- `Ctrl+U` - Delete to start of line
- `Ctrl+W` or `Alt+Backspace` - Delete word backwards
- `Alt+D` or `Alt+Delete` - Delete word forwards
- `Ctrl+A` / `Ctrl+E` - Line start/end
- `Ctrl+]` - Jump forward to character (awaits next keypress, then moves cursor to first occurrence)
- `Ctrl+Alt+]` - Jump backward to character
- Arrow keys, Backspace, Delete work as expected
### Markdown
Renders markdown with syntax highlighting and theming support.
```typescript
interface MarkdownTheme {
heading: (text: string) => string;
link: (text: string) => string;
linkUrl: (text: string) => string;
code: (text: string) => string;
codeBlock: (text: string) => string;
codeBlockBorder: (text: string) => string;
quote: (text: string) => string;
quoteBorder: (text: string) => string;
hr: (text: string) => string;
listBullet: (text: string) => string;
bold: (text: string) => string;
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
highlightCode?: (code: string, lang?: string) => string[];
}
interface DefaultTextStyle {
color?: (text: string) => string;
bgColor?: (text: string) => string;
bold?: boolean;
italic?: boolean;
strikethrough?: boolean;
underline?: boolean;
}
const md = new Markdown(
"# Hello\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme, // MarkdownTheme
defaultStyle, // optional DefaultTextStyle
);
md.setText("Updated markdown");
```
**Features:**
- Headings, bold, italic, code blocks, lists, links, blockquotes
- HTML tags rendered as plain text
- Optional syntax highlighting via `highlightCode`
- Padding support
- Render caching for performance
### Loader
Animated loading spinner.
```typescript
const loader = new Loader(
tui, // TUI instance for render updates
(s) => chalk.cyan(s), // spinner color function
(s) => chalk.gray(s), // message color function
"Loading...", // message (default: "Loading...")
);
loader.start();
loader.setMessage("Still loading...");
loader.stop();
```
### CancellableLoader
Extends Loader with Escape key handling and an AbortSignal for cancelling async operations.
```typescript
const loader = new CancellableLoader(
tui, // TUI instance for render updates
(s) => chalk.cyan(s), // spinner color function
(s) => chalk.gray(s), // message color function
"Working...", // message
);
loader.onAbort = () => done(null); // Called when user presses Escape
doAsyncWork(loader.signal).then(done);
```
**Properties:**
- `signal: AbortSignal` - Aborted when user presses Escape
- `aborted: boolean` - Whether the loader was aborted
- `onAbort?: () => void` - Callback when user presses Escape
### SelectList
Interactive selection list with keyboard navigation.
```typescript
interface SelectItem {
value: string;
label: string;
description?: string;
}
interface SelectListTheme {
selectedPrefix: (text: string) => string;
selectedText: (text: string) => string;
description: (text: string) => string;
scrollInfo: (text: string) => string;
noMatch: (text: string) => string;
}
const list = new SelectList(
[
{ value: "opt1", label: "Option 1", description: "First option" },
{ value: "opt2", label: "Option 2", description: "Second option" },
],
5, // maxVisible
theme, // SelectListTheme
);
list.onSelect = (item) => console.log("Selected:", item);
list.onCancel = () => console.log("Cancelled");
list.onSelectionChange = (item) => console.log("Highlighted:", item);
list.setFilter("opt"); // Filter items
```
**Controls:**
- Arrow keys: Navigate
- Enter: Select
- Escape: Cancel
### SettingsList
Settings panel with value cycling and submenus.
```typescript
interface SettingItem {
id: string;
label: string;
description?: string;
currentValue: string;
values?: string[]; // If provided, Enter/Space cycles through these
submenu?: (
currentValue: string,
done: (selectedValue?: string) => void,
) => Component;
}
interface SettingsListTheme {
label: (text: string, selected: boolean) => string;
value: (text: string, selected: boolean) => string;
description: (text: string) => string;
cursor: string;
hint: (text: string) => string;
}
const settings = new SettingsList(
[
{
id: "theme",
label: "Theme",
currentValue: "dark",
values: ["dark", "light"],
},
{
id: "model",
label: "Model",
currentValue: "gpt-4",
submenu: (val, done) => modelSelector,
},
],
10, // maxVisible
theme, // SettingsListTheme
(id, newValue) => console.log(`${id} changed to ${newValue}`),
() => console.log("Cancelled"),
);
settings.updateValue("theme", "light");
```
**Controls:**
- Arrow keys: Navigate
- Enter/Space: Activate (cycle value or open submenu)
- Escape: Cancel
### Spacer
Empty lines for vertical spacing.
```typescript
const spacer = new Spacer(2); // 2 empty lines (default: 1)
```
### Image
Renders images inline for terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images. Falls back to a text placeholder on unsupported terminals.
```typescript
interface ImageTheme {
fallbackColor: (str: string) => string;
}
interface ImageOptions {
maxWidthCells?: number;
maxHeightCells?: number;
filename?: string;
}
const image = new Image(
base64Data, // base64-encoded image data
"image/png", // MIME type
theme, // ImageTheme
options, // optional ImageOptions
);
tui.addChild(image);
```
Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.
## Autocomplete
### CombinedAutocompleteProvider
Supports both slash commands and file paths.
```typescript
import { CombinedAutocompleteProvider } from "@mariozechner/pi-tui";
const provider = new CombinedAutocompleteProvider(
[
{ name: "help", description: "Show help" },
{ name: "clear", description: "Clear screen" },
{ name: "delete", description: "Delete last message" },
],
process.cwd(), // base path for file completion
);
editor.setAutocompleteProvider(provider);
```
**Features:**
- Type `/` to see slash commands
- Press `Tab` for file path completion
- Works with `~/`, `./`, `../`, and `@` prefix
- Filters to attachable files for `@` prefix
## Key Detection
Use `matchesKey()` with the `Key` helper for detecting keyboard input (supports Kitty keyboard protocol):
```typescript
import { matchesKey, Key } from "@mariozechner/pi-tui";
if (matchesKey(data, Key.ctrl("c"))) {
process.exit(0);
}
if (matchesKey(data, Key.enter)) {
submit();
} else if (matchesKey(data, Key.escape)) {
cancel();
} else if (matchesKey(data, Key.up)) {
moveUp();
}
```
**Key identifiers** (use `Key.*` for autocomplete, or string literals):
- Basic keys: `Key.enter`, `Key.escape`, `Key.tab`, `Key.space`, `Key.backspace`, `Key.delete`, `Key.home`, `Key.end`
- Arrow keys: `Key.up`, `Key.down`, `Key.left`, `Key.right`
- With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")`
- String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"`
## Differential Rendering
The TUI uses three rendering strategies:
1. **First Render**: Output all lines without clearing scrollback
2. **Width Changed or Change Above Viewport**: Clear screen and full re-render
3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines
All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.
## Terminal Interface
The TUI works with any object implementing the `Terminal` interface:
```typescript
interface Terminal {
start(onInput: (data: string) => void, onResize: () => void): void;
stop(): void;
write(data: string): void;
get columns(): number;
get rows(): number;
moveBy(lines: number): void;
hideCursor(): void;
showCursor(): void;
clearLine(): void;
clearFromCursor(): void;
clearScreen(): void;
}
```
**Built-in implementations:**
- `ProcessTerminal` - Uses `process.stdin/stdout`
- `VirtualTerminal` - For testing (uses `@xterm/headless`)
## Utilities
```typescript
import {
visibleWidth,
truncateToWidth,
wrapTextWithAnsi,
} from "@mariozechner/pi-tui";
// Get visible width of string (ignoring ANSI codes)
const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
// Truncate string to width (preserving ANSI codes, adds ellipsis)
const truncated = truncateToWidth("Hello World", 8); // "Hello..."
// Truncate without ellipsis
const truncatedNoEllipsis = truncateToWidth("Hello World", 8, ""); // "Hello Wo"
// Wrap text to width (preserving ANSI codes across line breaks)
const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
// ["This is a long line", "that needs wrapping"]
```
## Creating Custom Components
When 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.
### Handling Input
Use `matchesKey()` with the `Key` helper for keyboard input:
```typescript
import { matchesKey, Key, truncateToWidth } from "@mariozechner/pi-tui";
import type { Component } from "@mariozechner/pi-tui";
class MyInteractiveComponent implements Component {
private selectedIndex = 0;
private items = ["Option 1", "Option 2", "Option 3"];
public onSelect?: (index: number) => void;
public onCancel?: () => void;
handleInput(data: string): void {
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
} else if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(
this.items.length - 1,
this.selectedIndex + 1,
);
} else if (matchesKey(data, Key.enter)) {
this.onSelect?.(this.selectedIndex);
} else if (
matchesKey(data, Key.escape) ||
matchesKey(data, Key.ctrl("c"))
) {
this.onCancel?.();
}
}
render(width: number): string[] {
return this.items.map((item, i) => {
const prefix = i === this.selectedIndex ? "> " : " ";
return truncateToWidth(prefix + item, width);
});
}
}
```
### Handling Line Width
Use the provided utilities to ensure lines fit:
```typescript
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
import type { Component } from "@mariozechner/pi-tui";
class MyComponent implements Component {
private text: string;
constructor(text: string) {
this.text = text;
}
render(width: number): string[] {
// Option 1: Truncate long lines
return [truncateToWidth(this.text, width)];
// Option 2: Check and pad to exact width
const line = this.text;
const visible = visibleWidth(line);
if (visible > width) {
return [truncateToWidth(line, width)];
}
// Pad to exact width (optional, for backgrounds)
return [line + " ".repeat(width - visible)];
}
}
```
### ANSI Code Considerations
Both `visibleWidth()` and `truncateToWidth()` correctly handle ANSI escape codes:
- `visibleWidth()` ignores ANSI codes when calculating width
- `truncateToWidth()` preserves ANSI codes and properly closes them when truncating
```typescript
import chalk from "chalk";
const styled = chalk.red("Hello") + " " + chalk.blue("World");
const width = visibleWidth(styled); // 11 (not counting ANSI codes)
const truncated = truncateToWidth(styled, 8); // Red "Hello" + " W..." with proper reset
```
### Caching
For performance, components should cache their rendered output and only re-render when necessary:
```typescript
class CachedComponent implements Component {
private text: string;
private cachedWidth?: number;
private cachedLines?: string[];
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const lines = [truncateToWidth(this.text, width)];
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
```
## Example
See `test/chat-simple.ts` for a complete chat interface example with:
- Markdown messages with custom background colors
- Loading spinner during responses
- Editor with autocomplete and slash commands
- Spacers between messages
Run it:
```bash
npx tsx test/chat-simple.ts
```
## Development
```bash
# Install dependencies (from monorepo root)
npm install
# Run type checking
npm run check
# Run the demo
npx tsx test/chat-simple.ts
```
### Debug logging
Set `PI_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout.
```bash
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx test/chat-simple.ts
```

52
packages/tui/package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "@mariozechner/pi-tui",
"version": "0.56.2",
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"type": "module",
"main": "dist/index.js",
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json",
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"test": "node --test --import tsx test/*.test.ts",
"prepublishOnly": "npm run clean && npm run build"
},
"files": [
"dist/**/*",
"README.md"
],
"keywords": [
"tui",
"terminal",
"ui",
"text-editor",
"differential-rendering",
"typescript",
"cli"
],
"author": "Mario Zechner",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/getcompanion-ai/co-mono.git",
"directory": "packages/tui"
},
"engines": {
"node": ">=20.0.0"
},
"types": "./dist/index.d.ts",
"dependencies": {
"@types/mime-types": "^2.1.4",
"chalk": "^5.5.0",
"get-east-asian-width": "^1.3.0",
"marked": "^15.0.12",
"mime-types": "^3.0.1"
},
"optionalDependencies": {
"koffi": "^2.9.0"
},
"devDependencies": {
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^5.5.0"
}
}

View file

@ -0,0 +1,825 @@
import { spawnSync } from "child_process";
import { readdirSync, statSync } from "fs";
import { homedir } from "os";
import { basename, dirname, join } from "path";
import { fuzzyFilter } from "./fuzzy.js";
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
function findLastDelimiter(text: string): number {
for (let i = text.length - 1; i >= 0; i -= 1) {
if (PATH_DELIMITERS.has(text[i] ?? "")) {
return i;
}
}
return -1;
}
function findUnclosedQuoteStart(text: string): number | null {
let inQuotes = false;
let quoteStart = -1;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '"') {
inQuotes = !inQuotes;
if (inQuotes) {
quoteStart = i;
}
}
}
return inQuotes ? quoteStart : null;
}
function isTokenStart(text: string, index: number): boolean {
return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
}
function extractQuotedPrefix(text: string): string | null {
const quoteStart = findUnclosedQuoteStart(text);
if (quoteStart === null) {
return null;
}
if (quoteStart > 0 && text[quoteStart - 1] === "@") {
if (!isTokenStart(text, quoteStart - 1)) {
return null;
}
return text.slice(quoteStart - 1);
}
if (!isTokenStart(text, quoteStart)) {
return null;
}
return text.slice(quoteStart);
}
function parsePathPrefix(prefix: string): {
rawPrefix: string;
isAtPrefix: boolean;
isQuotedPrefix: boolean;
} {
if (prefix.startsWith('@"')) {
return {
rawPrefix: prefix.slice(2),
isAtPrefix: true,
isQuotedPrefix: true,
};
}
if (prefix.startsWith('"')) {
return {
rawPrefix: prefix.slice(1),
isAtPrefix: false,
isQuotedPrefix: true,
};
}
if (prefix.startsWith("@")) {
return {
rawPrefix: prefix.slice(1),
isAtPrefix: true,
isQuotedPrefix: false,
};
}
return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };
}
function buildCompletionValue(
path: string,
options: {
isDirectory: boolean;
isAtPrefix: boolean;
isQuotedPrefix: boolean;
},
): string {
const needsQuotes = options.isQuotedPrefix || path.includes(" ");
const prefix = options.isAtPrefix ? "@" : "";
if (!needsQuotes) {
return `${prefix}${path}`;
}
const openQuote = `${prefix}"`;
const closeQuote = '"';
return `${openQuote}${path}${closeQuote}`;
}
// Use fd to walk directory tree (fast, respects .gitignore)
function walkDirectoryWithFd(
baseDir: string,
fdPath: string,
query: string,
maxResults: number,
): Array<{ path: string; isDirectory: boolean }> {
const args = [
"--base-directory",
baseDir,
"--max-results",
String(maxResults),
"--type",
"f",
"--type",
"d",
"--full-path",
"--hidden",
"--exclude",
".git",
"--exclude",
".git/*",
"--exclude",
".git/**",
];
// Add query as pattern if provided
if (query) {
args.push(query);
}
const result = spawnSync(fdPath, args, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: 10 * 1024 * 1024,
});
if (result.status !== 0 || !result.stdout) {
return [];
}
const lines = result.stdout.trim().split("\n").filter(Boolean);
const results: Array<{ path: string; isDirectory: boolean }> = [];
for (const line of lines) {
const normalizedPath = line.endsWith("/") ? line.slice(0, -1) : line;
if (
normalizedPath === ".git" ||
normalizedPath.startsWith(".git/") ||
normalizedPath.includes("/.git/")
) {
continue;
}
// fd outputs directories with trailing /
const isDirectory = line.endsWith("/");
results.push({
path: line,
isDirectory,
});
}
return results;
}
export interface AutocompleteItem {
value: string;
label: string;
description?: string;
}
export interface SlashCommand {
name: string;
description?: string;
// Function to get argument completions for this command
// Returns null if no argument completion is available
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
}
export interface AutocompleteProvider {
// Get autocomplete suggestions for current text/cursor position
// Returns null if no suggestions available
getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
): {
items: AutocompleteItem[];
prefix: string; // What we're matching against (e.g., "/" or "src/")
} | null;
// Apply the selected item
// Returns the new text and cursor position
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): {
lines: string[];
cursorLine: number;
cursorCol: number;
};
}
// Combined provider that handles both slash commands and file paths
export class CombinedAutocompleteProvider implements AutocompleteProvider {
private commands: (SlashCommand | AutocompleteItem)[];
private basePath: string;
private fdPath: string | null;
constructor(
commands: (SlashCommand | AutocompleteItem)[] = [],
basePath: string = process.cwd(),
fdPath: string | null = null,
) {
this.commands = commands;
this.basePath = basePath;
this.fdPath = fdPath;
}
getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
): { items: AutocompleteItem[]; prefix: string } | null {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
// Check for @ file reference (fuzzy search) - must be after a delimiter or at start
const atPrefix = this.extractAtPrefix(textBeforeCursor);
if (atPrefix) {
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
const suggestions = this.getFuzzyFileSuggestions(rawPrefix, {
isQuotedPrefix: isQuotedPrefix,
});
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: atPrefix,
};
}
// Check for slash commands
if (textBeforeCursor.startsWith("/")) {
const spaceIndex = textBeforeCursor.indexOf(" ");
if (spaceIndex === -1) {
// No space yet - complete command names with fuzzy matching
const prefix = textBeforeCursor.slice(1); // Remove the "/"
const commandItems = this.commands.map((cmd) => ({
name: "name" in cmd ? cmd.name : cmd.value,
label: "name" in cmd ? cmd.name : cmd.label,
description: cmd.description,
}));
const filtered = fuzzyFilter(
commandItems,
prefix,
(item) => item.name,
).map((item) => ({
value: item.name,
label: item.label,
...(item.description && { description: item.description }),
}));
if (filtered.length === 0) return null;
return {
items: filtered,
prefix: textBeforeCursor,
};
} else {
// Space found - complete command arguments
const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
const command = this.commands.find((cmd) => {
const name = "name" in cmd ? cmd.name : cmd.value;
return name === commandName;
});
if (
!command ||
!("getArgumentCompletions" in command) ||
!command.getArgumentCompletions
) {
return null; // No argument completion for this command
}
const argumentSuggestions =
command.getArgumentCompletions(argumentText);
if (!argumentSuggestions || argumentSuggestions.length === 0) {
return null;
}
return {
items: argumentSuggestions,
prefix: argumentText,
};
}
}
// Check for file paths - triggered by Tab or if we detect a path pattern
const pathMatch = this.extractPathPrefix(textBeforeCursor, false);
if (pathMatch !== null) {
const suggestions = this.getFileSuggestions(pathMatch);
if (suggestions.length === 0) return null;
// Check if we have an exact match that is a directory
// In that case, we might want to return suggestions for the directory content instead
// But only if the prefix ends with /
if (
suggestions.length === 1 &&
suggestions[0]?.value === pathMatch &&
!pathMatch.endsWith("/")
) {
// Exact match found (e.g. user typed "src" and "src/" is the only match)
// We still return it so user can select it and add /
return {
items: suggestions,
prefix: pathMatch,
};
}
return {
items: suggestions,
prefix: pathMatch,
};
}
return null;
}
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } {
const currentLine = lines[cursorLine] || "";
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
const afterCursor = currentLine.slice(cursorCol);
const isQuotedPrefix = prefix.startsWith('"') || prefix.startsWith('@"');
const hasLeadingQuoteAfterCursor = afterCursor.startsWith('"');
const hasTrailingQuoteInItem = item.value.endsWith('"');
const adjustedAfterCursor =
isQuotedPrefix && hasTrailingQuoteInItem && hasLeadingQuoteAfterCursor
? afterCursor.slice(1)
: afterCursor;
// Check if we're completing a slash command (prefix starts with "/" but NOT a file path)
// Slash commands are at the start of the line and don't contain path separators after the first /
const isSlashCommand =
prefix.startsWith("/") &&
beforePrefix.trim() === "" &&
!prefix.slice(1).includes("/");
if (isSlashCommand) {
// This is a command name completion
const newLine = `${beforePrefix}/${item.value} ${adjustedAfterCursor}`;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space
};
}
// Check if we're completing a file attachment (prefix starts with "@")
if (prefix.startsWith("@")) {
// This is a file attachment completion
// Don't add space after directories so user can continue autocompleting
const isDirectory = item.label.endsWith("/");
const suffix = isDirectory ? "" : " ";
const newLine = `${beforePrefix + item.value}${suffix}${adjustedAfterCursor}`;
const newLines = [...lines];
newLines[cursorLine] = newLine;
const hasTrailingQuote = item.value.endsWith('"');
const cursorOffset =
isDirectory && hasTrailingQuote
? item.value.length - 1
: item.value.length;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + cursorOffset + suffix.length,
};
}
// Check if we're in a slash command context (beforePrefix contains "/command ")
const textBeforeCursor = currentLine.slice(0, cursorCol);
if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) {
// This is likely a command argument completion
const newLine = beforePrefix + item.value + adjustedAfterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
const isDirectory = item.label.endsWith("/");
const hasTrailingQuote = item.value.endsWith('"');
const cursorOffset =
isDirectory && hasTrailingQuote
? item.value.length - 1
: item.value.length;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + cursorOffset,
};
}
// For file paths, complete the path
const newLine = beforePrefix + item.value + adjustedAfterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
const isDirectory = item.label.endsWith("/");
const hasTrailingQuote = item.value.endsWith('"');
const cursorOffset =
isDirectory && hasTrailingQuote
? item.value.length - 1
: item.value.length;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + cursorOffset,
};
}
// Extract @ prefix for fuzzy file suggestions
private extractAtPrefix(text: string): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix?.startsWith('@"')) {
return quotedPrefix;
}
const lastDelimiterIndex = findLastDelimiter(text);
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
if (text[tokenStart] === "@") {
return text.slice(tokenStart);
}
return null;
}
// Extract a path-like prefix from the text before cursor
private extractPathPrefix(
text: string,
forceExtract: boolean = false,
): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix) {
return quotedPrefix;
}
const lastDelimiterIndex = findLastDelimiter(text);
const pathPrefix =
lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
// For forced extraction (Tab key), always return something
if (forceExtract) {
return pathPrefix;
}
// For natural triggers, return if it looks like a path, ends with /, starts with ~/, .
// Only return empty string if the text looks like it's starting a path context
if (
pathPrefix.includes("/") ||
pathPrefix.startsWith(".") ||
pathPrefix.startsWith("~/")
) {
return pathPrefix;
}
// Return empty string only after a space (not for completely empty text)
// Empty text should not trigger file suggestions - that's for forced Tab completion
if (pathPrefix === "" && text.endsWith(" ")) {
return pathPrefix;
}
return null;
}
// Expand home directory (~/) to actual home path
private expandHomePath(path: string): string {
if (path.startsWith("~/")) {
const expandedPath = join(homedir(), path.slice(2));
// Preserve trailing slash if original path had one
return path.endsWith("/") && !expandedPath.endsWith("/")
? `${expandedPath}/`
: expandedPath;
} else if (path === "~") {
return homedir();
}
return path;
}
private resolveScopedFuzzyQuery(
rawQuery: string,
): { baseDir: string; query: string; displayBase: string } | null {
const slashIndex = rawQuery.lastIndexOf("/");
if (slashIndex === -1) {
return null;
}
const displayBase = rawQuery.slice(0, slashIndex + 1);
const query = rawQuery.slice(slashIndex + 1);
let baseDir: string;
if (displayBase.startsWith("~/")) {
baseDir = this.expandHomePath(displayBase);
} else if (displayBase.startsWith("/")) {
baseDir = displayBase;
} else {
baseDir = join(this.basePath, displayBase);
}
try {
if (!statSync(baseDir).isDirectory()) {
return null;
}
} catch {
return null;
}
return { baseDir, query, displayBase };
}
private scopedPathForDisplay(
displayBase: string,
relativePath: string,
): string {
if (displayBase === "/") {
return `/${relativePath}`;
}
return `${displayBase}${relativePath}`;
}
// Get file/directory suggestions for a given path prefix
private getFileSuggestions(prefix: string): AutocompleteItem[] {
try {
let searchDir: string;
let searchPrefix: string;
const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix);
let expandedPrefix = rawPrefix;
// Handle home directory expansion
if (expandedPrefix.startsWith("~")) {
expandedPrefix = this.expandHomePath(expandedPrefix);
}
const isRootPrefix =
rawPrefix === "" ||
rawPrefix === "./" ||
rawPrefix === "../" ||
rawPrefix === "~" ||
rawPrefix === "~/" ||
rawPrefix === "/" ||
(isAtPrefix && rawPrefix === "");
if (isRootPrefix) {
// Complete from specified position
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = join(this.basePath, expandedPrefix);
}
searchPrefix = "";
} else if (rawPrefix.endsWith("/")) {
// If prefix ends with /, show contents of that directory
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = join(this.basePath, expandedPrefix);
}
searchPrefix = "";
} else {
// Split into directory and file prefix
const dir = dirname(expandedPrefix);
const file = basename(expandedPrefix);
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = dir;
} else {
searchDir = join(this.basePath, dir);
}
searchPrefix = file;
}
const entries = readdirSync(searchDir, { withFileTypes: true });
const suggestions: AutocompleteItem[] = [];
for (const entry of entries) {
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
continue;
}
// Check if entry is a directory (or a symlink pointing to a directory)
let isDirectory = entry.isDirectory();
if (!isDirectory && entry.isSymbolicLink()) {
try {
const fullPath = join(searchDir, entry.name);
isDirectory = statSync(fullPath).isDirectory();
} catch {
// Broken symlink or permission error - treat as file
}
}
let relativePath: string;
const name = entry.name;
const displayPrefix = rawPrefix;
if (displayPrefix.endsWith("/")) {
// If prefix ends with /, append entry to the prefix
relativePath = displayPrefix + name;
} else if (displayPrefix.includes("/")) {
// Preserve ~/ format for home directory paths
if (displayPrefix.startsWith("~/")) {
const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
const dir = dirname(homeRelativeDir);
relativePath = `~/${dir === "." ? name : join(dir, name)}`;
} else if (displayPrefix.startsWith("/")) {
// Absolute path - construct properly
const dir = dirname(displayPrefix);
if (dir === "/") {
relativePath = `/${name}`;
} else {
relativePath = `${dir}/${name}`;
}
} else {
relativePath = join(dirname(displayPrefix), name);
}
} else {
// For standalone entries, preserve ~/ if original prefix was ~/
if (displayPrefix.startsWith("~")) {
relativePath = `~/${name}`;
} else {
relativePath = name;
}
}
const pathValue = isDirectory ? `${relativePath}/` : relativePath;
const value = buildCompletionValue(pathValue, {
isDirectory,
isAtPrefix,
isQuotedPrefix,
});
suggestions.push({
value,
label: name + (isDirectory ? "/" : ""),
});
}
// Sort directories first, then alphabetically
suggestions.sort((a, b) => {
const aIsDir = a.value.endsWith("/");
const bIsDir = b.value.endsWith("/");
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.label.localeCompare(b.label);
});
return suggestions;
} catch (_e) {
// Directory doesn't exist or not accessible
return [];
}
}
// Score an entry against the query (higher = better match)
// isDirectory adds bonus to prioritize folders
private scoreEntry(
filePath: string,
query: string,
isDirectory: boolean,
): number {
const fileName = basename(filePath);
const lowerFileName = fileName.toLowerCase();
const lowerQuery = query.toLowerCase();
let score = 0;
// Exact filename match (highest)
if (lowerFileName === lowerQuery) score = 100;
// Filename starts with query
else if (lowerFileName.startsWith(lowerQuery)) score = 80;
// Substring match in filename
else if (lowerFileName.includes(lowerQuery)) score = 50;
// Substring match in full path
else if (filePath.toLowerCase().includes(lowerQuery)) score = 30;
// Directories get a bonus to appear first
if (isDirectory && score > 0) score += 10;
return score;
}
// Fuzzy file search using fd (fast, respects .gitignore)
private getFuzzyFileSuggestions(
query: string,
options: { isQuotedPrefix: boolean },
): AutocompleteItem[] {
if (!this.fdPath) {
// fd not available, return empty results
return [];
}
try {
const scopedQuery = this.resolveScopedFuzzyQuery(query);
const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
const fdQuery = scopedQuery?.query ?? query;
const entries = walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100);
// Score entries
const scoredEntries = entries
.map((entry) => ({
...entry,
score: fdQuery
? this.scoreEntry(entry.path, fdQuery, entry.isDirectory)
: 1,
}))
.filter((entry) => entry.score > 0);
// Sort by score (descending) and take top 20
scoredEntries.sort((a, b) => b.score - a.score);
const topEntries = scoredEntries.slice(0, 20);
// Build suggestions
const suggestions: AutocompleteItem[] = [];
for (const { path: entryPath, isDirectory } of topEntries) {
// fd already includes trailing / for directories
const pathWithoutSlash = isDirectory
? entryPath.slice(0, -1)
: entryPath;
const displayPath = scopedQuery
? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)
: pathWithoutSlash;
const entryName = basename(pathWithoutSlash);
const completionPath = isDirectory ? `${displayPath}/` : displayPath;
const value = buildCompletionValue(completionPath, {
isDirectory,
isAtPrefix: true,
isQuotedPrefix: options.isQuotedPrefix,
});
suggestions.push({
value,
label: entryName + (isDirectory ? "/" : ""),
description: displayPath,
});
}
return suggestions;
} catch {
return [];
}
}
// Force file completion (called on Tab key) - always returns suggestions
getForceFileSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
): { items: AutocompleteItem[]; prefix: string } | null {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
// Don't trigger if we're typing a slash command at the start of the line
if (
textBeforeCursor.trim().startsWith("/") &&
!textBeforeCursor.trim().includes(" ")
) {
return null;
}
// Force extract path prefix - this will always return something
const pathMatch = this.extractPathPrefix(textBeforeCursor, true);
if (pathMatch !== null) {
const suggestions = this.getFileSuggestions(pathMatch);
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: pathMatch,
};
}
return null;
}
// Check if we should trigger file completion (called on Tab key)
shouldTriggerFileCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
): boolean {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
// Don't trigger if we're typing a slash command at the start of the line
if (
textBeforeCursor.trim().startsWith("/") &&
!textBeforeCursor.trim().includes(" ")
) {
return false;
}
return true;
}
}

View file

@ -0,0 +1,141 @@
import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth } from "../utils.js";
type RenderCache = {
childLines: string[];
width: number;
bgSample: string | undefined;
lines: string[];
};
/**
* Box component - a container that applies padding and background to all children
*/
export class Box implements Component {
children: Component[] = [];
private paddingX: number;
private paddingY: number;
private bgFn?: (text: string) => string;
// Cache for rendered output
private cache?: RenderCache;
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
this.paddingX = paddingX;
this.paddingY = paddingY;
this.bgFn = bgFn;
}
addChild(component: Component): void {
this.children.push(component);
this.invalidateCache();
}
removeChild(component: Component): void {
const index = this.children.indexOf(component);
if (index !== -1) {
this.children.splice(index, 1);
this.invalidateCache();
}
}
clear(): void {
this.children = [];
this.invalidateCache();
}
setBgFn(bgFn?: (text: string) => string): void {
this.bgFn = bgFn;
// Don't invalidate here - we'll detect bgFn changes by sampling output
}
private invalidateCache(): void {
this.cache = undefined;
}
private matchCache(
width: number,
childLines: string[],
bgSample: string | undefined,
): boolean {
const cache = this.cache;
return (
!!cache &&
cache.width === width &&
cache.bgSample === bgSample &&
cache.childLines.length === childLines.length &&
cache.childLines.every((line, i) => line === childLines[i])
);
}
invalidate(): void {
this.invalidateCache();
for (const child of this.children) {
child.invalidate?.();
}
}
render(width: number): string[] {
if (this.children.length === 0) {
return [];
}
const contentWidth = Math.max(1, width - this.paddingX * 2);
const leftPad = " ".repeat(this.paddingX);
// Render all children
const childLines: string[] = [];
for (const child of this.children) {
const lines = child.render(contentWidth);
for (const line of lines) {
childLines.push(leftPad + line);
}
}
if (childLines.length === 0) {
return [];
}
// Check if bgFn output changed by sampling
const bgSample = this.bgFn ? this.bgFn("test") : undefined;
// Check cache validity
if (this.matchCache(width, childLines, bgSample)) {
return this.cache!.lines;
}
// Apply background and padding
const result: string[] = [];
// Top padding
for (let i = 0; i < this.paddingY; i++) {
result.push(this.applyBg("", width));
}
// Content
for (const line of childLines) {
result.push(this.applyBg(line, width));
}
// Bottom padding
for (let i = 0; i < this.paddingY; i++) {
result.push(this.applyBg("", width));
}
// Update cache
this.cache = { childLines, width, bgSample, lines: result };
return result;
}
private applyBg(line: string, width: number): string {
const visLen = visibleWidth(line);
const padNeeded = Math.max(0, width - visLen);
const padded = line + " ".repeat(padNeeded);
if (this.bgFn) {
return applyBackgroundToLine(padded, width, this.bgFn);
}
return padded;
}
}

View file

@ -0,0 +1,40 @@
import { getEditorKeybindings } from "../keybindings.js";
import { Loader } from "./loader.js";
/**
* Loader that can be cancelled with Escape.
* Extends Loader with an AbortSignal for cancelling async operations.
*
* @example
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done);
*/
export class CancellableLoader extends Loader {
private abortController = new AbortController();
/** Called when user presses Escape */
onAbort?: () => void;
/** AbortSignal that is aborted when user presses Escape */
get signal(): AbortSignal {
return this.abortController.signal;
}
/** Whether the loader was aborted */
get aborted(): boolean {
return this.abortController.signal.aborted;
}
handleInput(data: string): void {
const kb = getEditorKeybindings();
if (kb.matches(data, "selectCancel")) {
this.abortController.abort();
this.onAbort?.();
}
}
dispose(): void {
this.stop();
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,116 @@
import {
getCapabilities,
getImageDimensions,
type ImageDimensions,
imageFallback,
renderImage,
} from "../terminal-image.js";
import type { Component } from "../tui.js";
export interface ImageTheme {
fallbackColor: (str: string) => string;
}
export interface ImageOptions {
maxWidthCells?: number;
maxHeightCells?: number;
filename?: string;
/** Kitty image ID. If provided, reuses this ID (for animations/updates). */
imageId?: number;
}
export class Image implements Component {
private base64Data: string;
private mimeType: string;
private dimensions: ImageDimensions;
private theme: ImageTheme;
private options: ImageOptions;
private imageId?: number;
private cachedLines?: string[];
private cachedWidth?: number;
constructor(
base64Data: string,
mimeType: string,
theme: ImageTheme,
options: ImageOptions = {},
dimensions?: ImageDimensions,
) {
this.base64Data = base64Data;
this.mimeType = mimeType;
this.theme = theme;
this.options = options;
this.dimensions = dimensions ||
getImageDimensions(base64Data, mimeType) || {
widthPx: 800,
heightPx: 600,
};
this.imageId = options.imageId;
}
/** Get the Kitty image ID used by this image (if any). */
getImageId(): number | undefined {
return this.imageId;
}
invalidate(): void {
this.cachedLines = undefined;
this.cachedWidth = undefined;
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const maxWidth = Math.min(width - 2, this.options.maxWidthCells ?? 60);
const caps = getCapabilities();
let lines: string[];
if (caps.images) {
const result = renderImage(this.base64Data, this.dimensions, {
maxWidthCells: maxWidth,
imageId: this.imageId,
});
if (result) {
// Store the image ID for later cleanup
if (result.imageId) {
this.imageId = result.imageId;
}
// Return `rows` lines so TUI accounts for image height
// First (rows-1) lines are empty (TUI clears them)
// Last line: move cursor back up, then output image sequence
lines = [];
for (let i = 0; i < result.rows - 1; i++) {
lines.push("");
}
// Move cursor up to first row, then output image
const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
lines.push(moveUp + result.sequence);
} else {
const fallback = imageFallback(
this.mimeType,
this.dimensions,
this.options.filename,
);
lines = [this.theme.fallbackColor(fallback)];
}
} else {
const fallback = imageFallback(
this.mimeType,
this.dimensions,
this.options.filename,
);
lines = [this.theme.fallbackColor(fallback)];
}
this.cachedLines = lines;
this.cachedWidth = width;
return lines;
}
}

View file

@ -0,0 +1,562 @@
import { getEditorKeybindings } from "../keybindings.js";
import { decodeKittyPrintable } from "../keys.js";
import { KillRing } from "../kill-ring.js";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js";
import { UndoStack } from "../undo-stack.js";
import {
getSegmenter,
isPunctuationChar,
isWhitespaceChar,
visibleWidth,
} from "../utils.js";
const segmenter = getSegmenter();
interface InputState {
value: string;
cursor: number;
}
/**
* Input component - single-line text input with horizontal scrolling
*/
export class Input implements Component, Focusable {
private value: string = "";
private cursor: number = 0; // Cursor position in the value
public onSubmit?: (value: string) => void;
public onEscape?: () => void;
/** Focusable interface - set by TUI when focus changes */
focused: boolean = false;
// Bracketed paste mode buffering
private pasteBuffer: string = "";
private isInPaste: boolean = false;
// Kill ring for Emacs-style kill/yank operations
private killRing = new KillRing();
private lastAction: "kill" | "yank" | "type-word" | null = null;
// Undo support
private undoStack = new UndoStack<InputState>();
getValue(): string {
return this.value;
}
setValue(value: string): void {
this.value = value;
this.cursor = Math.min(this.cursor, value.length);
}
handleInput(data: string): void {
// Handle bracketed paste mode
// Start of paste: \x1b[200~
// End of paste: \x1b[201~
// Check if we're starting a bracketed paste
if (data.includes("\x1b[200~")) {
this.isInPaste = true;
this.pasteBuffer = "";
data = data.replace("\x1b[200~", "");
}
// If we're in a paste, buffer the data
if (this.isInPaste) {
// Check if this chunk contains the end marker
this.pasteBuffer += data;
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
if (endIndex !== -1) {
// Extract the pasted content
const pasteContent = this.pasteBuffer.substring(0, endIndex);
// Process the complete paste
this.handlePaste(pasteContent);
// Reset paste state
this.isInPaste = false;
// Handle any remaining input after the paste marker
const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
this.pasteBuffer = "";
if (remaining) {
this.handleInput(remaining);
}
}
return;
}
const kb = getEditorKeybindings();
// Escape/Cancel
if (kb.matches(data, "selectCancel")) {
if (this.onEscape) this.onEscape();
return;
}
// Undo
if (kb.matches(data, "undo")) {
this.undo();
return;
}
// Submit
if (kb.matches(data, "submit") || data === "\n") {
if (this.onSubmit) this.onSubmit(this.value);
return;
}
// Deletion
if (kb.matches(data, "deleteCharBackward")) {
this.handleBackspace();
return;
}
if (kb.matches(data, "deleteCharForward")) {
this.handleForwardDelete();
return;
}
if (kb.matches(data, "deleteWordBackward")) {
this.deleteWordBackwards();
return;
}
if (kb.matches(data, "deleteWordForward")) {
this.deleteWordForward();
return;
}
if (kb.matches(data, "deleteToLineStart")) {
this.deleteToLineStart();
return;
}
if (kb.matches(data, "deleteToLineEnd")) {
this.deleteToLineEnd();
return;
}
// Kill ring actions
if (kb.matches(data, "yank")) {
this.yank();
return;
}
if (kb.matches(data, "yankPop")) {
this.yankPop();
return;
}
// Cursor movement
if (kb.matches(data, "cursorLeft")) {
this.lastAction = null;
if (this.cursor > 0) {
const beforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1;
}
return;
}
if (kb.matches(data, "cursorRight")) {
this.lastAction = null;
if (this.cursor < this.value.length) {
const afterCursor = this.value.slice(this.cursor);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1;
}
return;
}
if (kb.matches(data, "cursorLineStart")) {
this.lastAction = null;
this.cursor = 0;
return;
}
if (kb.matches(data, "cursorLineEnd")) {
this.lastAction = null;
this.cursor = this.value.length;
return;
}
if (kb.matches(data, "cursorWordLeft")) {
this.moveWordBackwards();
return;
}
if (kb.matches(data, "cursorWordRight")) {
this.moveWordForwards();
return;
}
// Kitty CSI-u printable character (e.g. \x1b[97u for 'a').
// Terminals with Kitty protocol flag 1 (disambiguate) send CSI-u for all keys,
// including plain printable characters. Decode before the control-char check
// since CSI-u sequences contain \x1b which would be rejected.
const kittyPrintable = decodeKittyPrintable(data);
if (kittyPrintable !== undefined) {
this.insertCharacter(kittyPrintable);
return;
}
// Regular character input - accept printable characters including Unicode,
// but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F)
const hasControlChars = [...data].some((ch) => {
const code = ch.charCodeAt(0);
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
});
if (!hasControlChars) {
this.insertCharacter(data);
}
}
private insertCharacter(char: string): void {
// Undo coalescing: consecutive word chars coalesce into one undo unit
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
this.pushUndo();
}
this.lastAction = "type-word";
this.value =
this.value.slice(0, this.cursor) + char + this.value.slice(this.cursor);
this.cursor += char.length;
}
private handleBackspace(): void {
this.lastAction = null;
if (this.cursor > 0) {
this.pushUndo();
const beforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
this.value =
this.value.slice(0, this.cursor - graphemeLength) +
this.value.slice(this.cursor);
this.cursor -= graphemeLength;
}
}
private handleForwardDelete(): void {
this.lastAction = null;
if (this.cursor < this.value.length) {
this.pushUndo();
const afterCursor = this.value.slice(this.cursor);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
this.value =
this.value.slice(0, this.cursor) +
this.value.slice(this.cursor + graphemeLength);
}
}
private deleteToLineStart(): void {
if (this.cursor === 0) return;
this.pushUndo();
const deletedText = this.value.slice(0, this.cursor);
this.killRing.push(deletedText, {
prepend: true,
accumulate: this.lastAction === "kill",
});
this.lastAction = "kill";
this.value = this.value.slice(this.cursor);
this.cursor = 0;
}
private deleteToLineEnd(): void {
if (this.cursor >= this.value.length) return;
this.pushUndo();
const deletedText = this.value.slice(this.cursor);
this.killRing.push(deletedText, {
prepend: false,
accumulate: this.lastAction === "kill",
});
this.lastAction = "kill";
this.value = this.value.slice(0, this.cursor);
}
private deleteWordBackwards(): void {
if (this.cursor === 0) return;
// Save lastAction before cursor movement (moveWordBackwards resets it)
const wasKill = this.lastAction === "kill";
this.pushUndo();
const oldCursor = this.cursor;
this.moveWordBackwards();
const deleteFrom = this.cursor;
this.cursor = oldCursor;
const deletedText = this.value.slice(deleteFrom, this.cursor);
this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });
this.lastAction = "kill";
this.value =
this.value.slice(0, deleteFrom) + this.value.slice(this.cursor);
this.cursor = deleteFrom;
}
private deleteWordForward(): void {
if (this.cursor >= this.value.length) return;
// Save lastAction before cursor movement (moveWordForwards resets it)
const wasKill = this.lastAction === "kill";
this.pushUndo();
const oldCursor = this.cursor;
this.moveWordForwards();
const deleteTo = this.cursor;
this.cursor = oldCursor;
const deletedText = this.value.slice(this.cursor, deleteTo);
this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });
this.lastAction = "kill";
this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo);
}
private yank(): void {
const text = this.killRing.peek();
if (!text) return;
this.pushUndo();
this.value =
this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
this.cursor += text.length;
this.lastAction = "yank";
}
private yankPop(): void {
if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
this.pushUndo();
// Delete the previously yanked text (still at end of ring before rotation)
const prevText = this.killRing.peek() || "";
this.value =
this.value.slice(0, this.cursor - prevText.length) +
this.value.slice(this.cursor);
this.cursor -= prevText.length;
// Rotate and insert new entry
this.killRing.rotate();
const text = this.killRing.peek() || "";
this.value =
this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
this.cursor += text.length;
this.lastAction = "yank";
}
private pushUndo(): void {
this.undoStack.push({ value: this.value, cursor: this.cursor });
}
private undo(): void {
const snapshot = this.undoStack.pop();
if (!snapshot) return;
this.value = snapshot.value;
this.cursor = snapshot.cursor;
this.lastAction = null;
}
private moveWordBackwards(): void {
if (this.cursor === 0) {
return;
}
this.lastAction = null;
const textBeforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(textBeforeCursor)];
// Skip trailing whitespace
while (
graphemes.length > 0 &&
isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")
) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
if (graphemes.length > 0) {
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
if (isPunctuationChar(lastGrapheme)) {
// Skip punctuation run
while (
graphemes.length > 0 &&
isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
} else {
// Skip word run
while (
graphemes.length > 0 &&
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
}
}
}
private moveWordForwards(): void {
if (this.cursor >= this.value.length) {
return;
}
this.lastAction = null;
const textAfterCursor = this.value.slice(this.cursor);
const segments = segmenter.segment(textAfterCursor);
const iterator = segments[Symbol.iterator]();
let next = iterator.next();
// Skip leading whitespace
while (!next.done && isWhitespaceChar(next.value.segment)) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
if (!next.done) {
const firstGrapheme = next.value.segment;
if (isPunctuationChar(firstGrapheme)) {
// Skip punctuation run
while (!next.done && isPunctuationChar(next.value.segment)) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
} else {
// Skip word run
while (
!next.done &&
!isWhitespaceChar(next.value.segment) &&
!isPunctuationChar(next.value.segment)
) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
}
}
}
private handlePaste(pastedText: string): void {
this.lastAction = null;
this.pushUndo();
// Clean the pasted text - remove newlines and carriage returns
const cleanText = pastedText
.replace(/\r\n/g, "")
.replace(/\r/g, "")
.replace(/\n/g, "");
// Insert at cursor position
this.value =
this.value.slice(0, this.cursor) +
cleanText +
this.value.slice(this.cursor);
this.cursor += cleanText.length;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
// Calculate visible window
const prompt = "> ";
const availableWidth = width - prompt.length;
if (availableWidth <= 0) {
return [prompt];
}
let visibleText = "";
let cursorDisplay = this.cursor;
if (this.value.length < availableWidth) {
// Everything fits (leave room for cursor at end)
visibleText = this.value;
} else {
// Need horizontal scrolling
// Reserve one character for cursor if it's at the end
const scrollWidth =
this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
const halfWidth = Math.floor(scrollWidth / 2);
const findValidStart = (start: number) => {
while (start < this.value.length) {
const charCode = this.value.charCodeAt(start);
// this is low surrogate, not a valid start
if (charCode >= 0xdc00 && charCode < 0xe000) {
start++;
continue;
}
break;
}
return start;
};
const findValidEnd = (end: number) => {
while (end > 0) {
const charCode = this.value.charCodeAt(end - 1);
// this is high surrogate, might be split.
if (charCode >= 0xd800 && charCode < 0xdc00) {
end--;
continue;
}
break;
}
return end;
};
if (this.cursor < halfWidth) {
// Cursor near start
visibleText = this.value.slice(0, findValidEnd(scrollWidth));
cursorDisplay = this.cursor;
} else if (this.cursor > this.value.length - halfWidth) {
// Cursor near end
const start = findValidStart(this.value.length - scrollWidth);
visibleText = this.value.slice(start);
cursorDisplay = this.cursor - start;
} else {
// Cursor in middle
const start = findValidStart(this.cursor - halfWidth);
visibleText = this.value.slice(
start,
findValidEnd(start + scrollWidth),
);
cursorDisplay = halfWidth;
}
}
// Build line with fake cursor
// Insert cursor character at cursor position
const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))];
const cursorGrapheme = graphemes[0];
const beforeCursor = visibleText.slice(0, cursorDisplay);
const atCursor = cursorGrapheme?.segment ?? " "; // Character at cursor, or space if at end
const afterCursor = visibleText.slice(cursorDisplay + atCursor.length);
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
const marker = this.focused ? CURSOR_MARKER : "";
// Use inverse video to show cursor
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
// Calculate visual width
const visualLength = visibleWidth(textWithCursor);
const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
const line = prompt + textWithCursor + padding;
return [line];
}
}

View file

@ -0,0 +1,57 @@
import type { TUI } from "../tui.js";
import { Text } from "./text.js";
/**
* Loader component that updates every 80ms with spinning animation
*/
export class Loader extends Text {
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null;
private ui: TUI | null = null;
constructor(
ui: TUI,
private spinnerColorFn: (str: string) => string,
private messageColorFn: (str: string) => string,
private message: string = "Loading...",
) {
super("", 1, 0);
this.ui = ui;
this.start();
}
render(width: number): string[] {
return ["", ...super.render(width)];
}
start() {
this.updateDisplay();
this.intervalId = setInterval(() => {
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
this.updateDisplay();
}, 80);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
setMessage(message: string) {
this.message = message;
this.updateDisplay();
}
private updateDisplay() {
const frame = this.frames[this.currentFrame];
this.setText(
`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`,
);
if (this.ui) {
this.ui.requestRender();
}
}
}

View file

@ -0,0 +1,913 @@
import { marked, type Token } from "marked";
import { isImageLine } from "../terminal-image.js";
import type { Component } from "../tui.js";
import {
applyBackgroundToLine,
visibleWidth,
wrapTextWithAnsi,
} from "../utils.js";
/**
* Default text styling for markdown content.
* Applied to all text unless overridden by markdown formatting.
*/
export interface DefaultTextStyle {
/** Foreground color function */
color?: (text: string) => string;
/** Background color function */
bgColor?: (text: string) => string;
/** Bold text */
bold?: boolean;
/** Italic text */
italic?: boolean;
/** Strikethrough text */
strikethrough?: boolean;
/** Underline text */
underline?: boolean;
}
/**
* Theme functions for markdown elements.
* Each function takes text and returns styled text with ANSI codes.
*/
export interface MarkdownTheme {
heading: (text: string) => string;
link: (text: string) => string;
linkUrl: (text: string) => string;
code: (text: string) => string;
codeBlock: (text: string) => string;
codeBlockBorder: (text: string) => string;
quote: (text: string) => string;
quoteBorder: (text: string) => string;
hr: (text: string) => string;
listBullet: (text: string) => string;
bold: (text: string) => string;
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
highlightCode?: (code: string, lang?: string) => string[];
/** Prefix applied to each rendered code block line (default: " ") */
codeBlockIndent?: string;
}
interface InlineStyleContext {
applyText: (text: string) => string;
stylePrefix: string;
}
export class Markdown implements Component {
private text: string;
private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding
private defaultTextStyle?: DefaultTextStyle;
private theme: MarkdownTheme;
private defaultStylePrefix?: string;
// Cache for rendered output
private cachedText?: string;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(
text: string,
paddingX: number,
paddingY: number,
theme: MarkdownTheme,
defaultTextStyle?: DefaultTextStyle,
) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
this.theme = theme;
this.defaultTextStyle = defaultTextStyle;
}
setText(text: string): void {
this.text = text;
this.invalidate();
}
invalidate(): void {
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
render(width: number): string[] {
// Check cache
if (
this.cachedLines &&
this.cachedText === this.text &&
this.cachedWidth === width
) {
return this.cachedLines;
}
// Calculate available width for content (subtract horizontal padding)
const contentWidth = Math.max(1, width - this.paddingX * 2);
// Don't render anything if there's no actual text
if (!this.text || this.text.trim() === "") {
const result: string[] = [];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result;
}
// Replace tabs with 3 spaces for consistent rendering
const normalizedText = this.text.replace(/\t/g, " ");
// Parse markdown to HTML-like tokens
const tokens = marked.lexer(normalizedText);
// Convert tokens to styled terminal output
const renderedLines: string[] = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const nextToken = tokens[i + 1];
const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
renderedLines.push(...tokenLines);
}
// Wrap lines (NO padding, NO background yet)
const wrappedLines: string[] = [];
for (const line of renderedLines) {
if (isImageLine(line)) {
wrappedLines.push(line);
} else {
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
}
}
// Add margins and background to each wrapped line
const leftMargin = " ".repeat(this.paddingX);
const rightMargin = " ".repeat(this.paddingX);
const bgFn = this.defaultTextStyle?.bgColor;
const contentLines: string[] = [];
for (const line of wrappedLines) {
if (isImageLine(line)) {
contentLines.push(line);
continue;
}
const lineWithMargins = leftMargin + line + rightMargin;
if (bgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
} else {
// No background - just pad to width
const visibleLen = visibleWidth(lineWithMargins);
const paddingNeeded = Math.max(0, width - visibleLen);
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
}
}
// Add top/bottom padding (empty lines)
const emptyLine = " ".repeat(width);
const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
const line = bgFn
? applyBackgroundToLine(emptyLine, width, bgFn)
: emptyLine;
emptyLines.push(line);
}
// Combine top padding, content, and bottom padding
const result = [...emptyLines, ...contentLines, ...emptyLines];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result.length > 0 ? result : [""];
}
/**
* Apply default text style to a string.
* This is the base styling applied to all text content.
* NOTE: Background color is NOT applied here - it's applied at the padding stage
* to ensure it extends to the full line width.
*/
private applyDefaultStyle(text: string): string {
if (!this.defaultTextStyle) {
return text;
}
let styled = text;
// Apply foreground color (NOT background - that's applied at padding stage)
if (this.defaultTextStyle.color) {
styled = this.defaultTextStyle.color(styled);
}
// Apply text decorations using this.theme
if (this.defaultTextStyle.bold) {
styled = this.theme.bold(styled);
}
if (this.defaultTextStyle.italic) {
styled = this.theme.italic(styled);
}
if (this.defaultTextStyle.strikethrough) {
styled = this.theme.strikethrough(styled);
}
if (this.defaultTextStyle.underline) {
styled = this.theme.underline(styled);
}
return styled;
}
private getDefaultStylePrefix(): string {
if (!this.defaultTextStyle) {
return "";
}
if (this.defaultStylePrefix !== undefined) {
return this.defaultStylePrefix;
}
const sentinel = "\u0000";
let styled = sentinel;
if (this.defaultTextStyle.color) {
styled = this.defaultTextStyle.color(styled);
}
if (this.defaultTextStyle.bold) {
styled = this.theme.bold(styled);
}
if (this.defaultTextStyle.italic) {
styled = this.theme.italic(styled);
}
if (this.defaultTextStyle.strikethrough) {
styled = this.theme.strikethrough(styled);
}
if (this.defaultTextStyle.underline) {
styled = this.theme.underline(styled);
}
const sentinelIndex = styled.indexOf(sentinel);
this.defaultStylePrefix =
sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
return this.defaultStylePrefix;
}
private getStylePrefix(styleFn: (text: string) => string): string {
const sentinel = "\u0000";
const styled = styleFn(sentinel);
const sentinelIndex = styled.indexOf(sentinel);
return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
}
private getDefaultInlineStyleContext(): InlineStyleContext {
return {
applyText: (text: string) => this.applyDefaultStyle(text),
stylePrefix: this.getDefaultStylePrefix(),
};
}
private renderToken(
token: Token,
width: number,
nextTokenType?: string,
styleContext?: InlineStyleContext,
): string[] {
const lines: string[] = [];
switch (token.type) {
case "heading": {
const headingLevel = token.depth;
const headingPrefix = `${"#".repeat(headingLevel)} `;
const headingText = this.renderInlineTokens(
token.tokens || [],
styleContext,
);
let styledHeading: string;
if (headingLevel === 1) {
styledHeading = this.theme.heading(
this.theme.bold(this.theme.underline(headingText)),
);
} else if (headingLevel === 2) {
styledHeading = this.theme.heading(this.theme.bold(headingText));
} else {
styledHeading = this.theme.heading(
this.theme.bold(headingPrefix + headingText),
);
}
lines.push(styledHeading);
if (nextTokenType !== "space") {
lines.push(""); // Add spacing after headings (unless space token follows)
}
break;
}
case "paragraph": {
const paragraphText = this.renderInlineTokens(
token.tokens || [],
styleContext,
);
lines.push(paragraphText);
// Don't add spacing if next token is space or list
if (
nextTokenType &&
nextTokenType !== "list" &&
nextTokenType !== "space"
) {
lines.push("");
}
break;
}
case "code": {
const indent = this.theme.codeBlockIndent ?? " ";
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
if (this.theme.highlightCode) {
const highlightedLines = this.theme.highlightCode(
token.text,
token.lang,
);
for (const hlLine of highlightedLines) {
lines.push(`${indent}${hlLine}`);
}
} else {
// Split code by newlines and style each line
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(`${indent}${this.theme.codeBlock(codeLine)}`);
}
}
lines.push(this.theme.codeBlockBorder("```"));
if (nextTokenType !== "space") {
lines.push(""); // Add spacing after code blocks (unless space token follows)
}
break;
}
case "list": {
const listLines = this.renderList(token as any, 0, styleContext);
lines.push(...listLines);
// Don't add spacing after lists if a space token follows
// (the space token will handle it)
break;
}
case "table": {
const tableLines = this.renderTable(token as any, width, styleContext);
lines.push(...tableLines);
break;
}
case "blockquote": {
const quoteStyle = (text: string) =>
this.theme.quote(this.theme.italic(text));
const quoteStylePrefix = this.getStylePrefix(quoteStyle);
const applyQuoteStyle = (line: string): string => {
if (!quoteStylePrefix) {
return quoteStyle(line);
}
const lineWithReappliedStyle = line.replace(
/\x1b\[0m/g,
`\x1b[0m${quoteStylePrefix}`,
);
return quoteStyle(lineWithReappliedStyle);
};
// Calculate available width for quote content (subtract border "│ " = 2 chars)
const quoteContentWidth = Math.max(1, width - 2);
// Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render
// children with renderToken() instead of renderInlineTokens().
// Default message style should not apply inside blockquotes.
const quoteInlineStyleContext: InlineStyleContext = {
applyText: (text: string) => text,
stylePrefix: "",
};
const quoteTokens = token.tokens || [];
const renderedQuoteLines: string[] = [];
for (let i = 0; i < quoteTokens.length; i++) {
const quoteToken = quoteTokens[i];
const nextQuoteToken = quoteTokens[i + 1];
renderedQuoteLines.push(
...this.renderToken(
quoteToken,
quoteContentWidth,
nextQuoteToken?.type,
quoteInlineStyleContext,
),
);
}
// Avoid rendering an extra empty quote line before the outer blockquote spacing.
while (
renderedQuoteLines.length > 0 &&
renderedQuoteLines[renderedQuoteLines.length - 1] === ""
) {
renderedQuoteLines.pop();
}
for (const quoteLine of renderedQuoteLines) {
const styledLine = applyQuoteStyle(quoteLine);
const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth);
for (const wrappedLine of wrappedLines) {
lines.push(this.theme.quoteBorder("│ ") + wrappedLine);
}
}
if (nextTokenType !== "space") {
lines.push(""); // Add spacing after blockquotes (unless space token follows)
}
break;
}
case "hr":
lines.push(this.theme.hr("─".repeat(Math.min(width, 80))));
if (nextTokenType !== "space") {
lines.push(""); // Add spacing after horizontal rules (unless space token follows)
}
break;
case "html":
// Render HTML as plain text (escaped for terminal)
if ("raw" in token && typeof token.raw === "string") {
lines.push(this.applyDefaultStyle(token.raw.trim()));
}
break;
case "space":
// Space tokens represent blank lines in markdown
lines.push("");
break;
default:
// Handle any other token types as plain text
if ("text" in token && typeof token.text === "string") {
lines.push(token.text);
}
}
return lines;
}
private renderInlineTokens(
tokens: Token[],
styleContext?: InlineStyleContext,
): string {
let result = "";
const resolvedStyleContext =
styleContext ?? this.getDefaultInlineStyleContext();
const { applyText, stylePrefix } = resolvedStyleContext;
const applyTextWithNewlines = (text: string): string => {
const segments: string[] = text.split("\n");
return segments.map((segment: string) => applyText(segment)).join("\n");
};
for (const token of tokens) {
switch (token.type) {
case "text":
// Text tokens in list items can have nested tokens for inline formatting
if (token.tokens && token.tokens.length > 0) {
result += this.renderInlineTokens(
token.tokens,
resolvedStyleContext,
);
} else {
result += applyTextWithNewlines(token.text);
}
break;
case "paragraph":
// Paragraph tokens contain nested inline tokens
result += this.renderInlineTokens(
token.tokens || [],
resolvedStyleContext,
);
break;
case "strong": {
const boldContent = this.renderInlineTokens(
token.tokens || [],
resolvedStyleContext,
);
result += this.theme.bold(boldContent) + stylePrefix;
break;
}
case "em": {
const italicContent = this.renderInlineTokens(
token.tokens || [],
resolvedStyleContext,
);
result += this.theme.italic(italicContent) + stylePrefix;
break;
}
case "codespan":
result += this.theme.code(token.text) + stylePrefix;
break;
case "link": {
const linkText = this.renderInlineTokens(
token.tokens || [],
resolvedStyleContext,
);
// If link text matches href, only show the link once
// Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
// For mailto: links, strip the prefix before comparing (autolinked emails have
// text="foo@bar.com" but href="mailto:foo@bar.com")
const hrefForComparison = token.href.startsWith("mailto:")
? token.href.slice(7)
: token.href;
if (token.text === token.href || token.text === hrefForComparison) {
result +=
this.theme.link(this.theme.underline(linkText)) + stylePrefix;
} else {
result +=
this.theme.link(this.theme.underline(linkText)) +
this.theme.linkUrl(` (${token.href})`) +
stylePrefix;
}
break;
}
case "br":
result += "\n";
break;
case "del": {
const delContent = this.renderInlineTokens(
token.tokens || [],
resolvedStyleContext,
);
result += this.theme.strikethrough(delContent) + stylePrefix;
break;
}
case "html":
// Render inline HTML as plain text
if ("raw" in token && typeof token.raw === "string") {
result += applyTextWithNewlines(token.raw);
}
break;
default:
// Handle any other inline token types as plain text
if ("text" in token && typeof token.text === "string") {
result += applyTextWithNewlines(token.text);
}
}
}
return result;
}
/**
* Render a list with proper nesting support
*/
private renderList(
token: Token & { items: any[]; ordered: boolean; start?: number },
depth: number,
styleContext?: InlineStyleContext,
): string[] {
const lines: string[] = [];
const indent = " ".repeat(depth);
// Use the list's start property (defaults to 1 for ordered lists)
const startNumber = token.start ?? 1;
for (let i = 0; i < token.items.length; i++) {
const item = token.items[i];
const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
// Process item tokens to handle nested lists
const itemLines = this.renderListItem(
item.tokens || [],
depth,
styleContext,
);
if (itemLines.length > 0) {
// First line - check if it's a nested list
// A nested list will start with indent (spaces) followed by cyan bullet
const firstLine = itemLines[0];
const isNestedList = /^\s+\x1b\[36m[-\d]/.test(firstLine); // starts with spaces + cyan + bullet char
if (isNestedList) {
// This is a nested list, just add it as-is (already has full indent)
lines.push(firstLine);
} else {
// Regular text content - add indent and bullet
lines.push(indent + this.theme.listBullet(bullet) + firstLine);
}
// Rest of the lines
for (let j = 1; j < itemLines.length; j++) {
const line = itemLines[j];
const isNestedListLine = /^\s+\x1b\[36m[-\d]/.test(line); // starts with spaces + cyan + bullet char
if (isNestedListLine) {
// Nested list line - already has full indent
lines.push(line);
} else {
// Regular content - add parent indent + 2 spaces for continuation
lines.push(`${indent} ${line}`);
}
}
} else {
lines.push(indent + this.theme.listBullet(bullet));
}
}
return lines;
}
/**
* Render list item tokens, handling nested lists
* Returns lines WITHOUT the parent indent (renderList will add it)
*/
private renderListItem(
tokens: Token[],
parentDepth: number,
styleContext?: InlineStyleContext,
): string[] {
const lines: string[] = [];
for (const token of tokens) {
if (token.type === "list") {
// Nested list - render with one additional indent level
// These lines will have their own indent, so we just add them as-is
const nestedLines = this.renderList(
token as any,
parentDepth + 1,
styleContext,
);
lines.push(...nestedLines);
} else if (token.type === "text") {
// Text content (may have inline tokens)
const text =
token.tokens && token.tokens.length > 0
? this.renderInlineTokens(token.tokens, styleContext)
: token.text || "";
lines.push(text);
} else if (token.type === "paragraph") {
// Paragraph in list item
const text = this.renderInlineTokens(token.tokens || [], styleContext);
lines.push(text);
} else if (token.type === "code") {
// Code block in list item
const indent = this.theme.codeBlockIndent ?? " ";
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
if (this.theme.highlightCode) {
const highlightedLines = this.theme.highlightCode(
token.text,
token.lang,
);
for (const hlLine of highlightedLines) {
lines.push(`${indent}${hlLine}`);
}
} else {
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(`${indent}${this.theme.codeBlock(codeLine)}`);
}
}
lines.push(this.theme.codeBlockBorder("```"));
} else {
// Other token types - try to render as inline
const text = this.renderInlineTokens([token], styleContext);
if (text) {
lines.push(text);
}
}
}
return lines;
}
/**
* Get the visible width of the longest word in a string.
*/
private getLongestWordWidth(text: string, maxWidth?: number): number {
const words = text.split(/\s+/).filter((word) => word.length > 0);
let longest = 0;
for (const word of words) {
longest = Math.max(longest, visibleWidth(word));
}
if (maxWidth === undefined) {
return longest;
}
return Math.min(longest, maxWidth);
}
/**
* Wrap a table cell to fit into a column.
*
* Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
* consistently with the rest of the renderer.
*/
private wrapCellText(text: string, maxWidth: number): string[] {
return wrapTextWithAnsi(text, Math.max(1, maxWidth));
}
/**
* Render a table with width-aware cell wrapping.
* Cells that don't fit are wrapped to multiple lines.
*/
private renderTable(
token: Token & { header: any[]; rows: any[][]; raw?: string },
availableWidth: number,
styleContext?: InlineStyleContext,
): string[] {
const lines: string[] = [];
const numCols = token.header.length;
if (numCols === 0) {
return lines;
}
// Calculate border overhead: "│ " + (n-1) * " │ " + " │"
// = 2 + (n-1) * 3 + 2 = 3n + 1
const borderOverhead = 3 * numCols + 1;
const availableForCells = availableWidth - borderOverhead;
if (availableForCells < numCols) {
// Too narrow to render a stable table. Fall back to raw markdown.
const fallbackLines = token.raw
? wrapTextWithAnsi(token.raw, availableWidth)
: [];
fallbackLines.push("");
return fallbackLines;
}
const maxUnbrokenWordWidth = 30;
// Calculate natural column widths (what each column needs without constraints)
const naturalWidths: number[] = [];
const minWordWidths: number[] = [];
for (let i = 0; i < numCols; i++) {
const headerText = this.renderInlineTokens(
token.header[i].tokens || [],
styleContext,
);
naturalWidths[i] = visibleWidth(headerText);
minWordWidths[i] = Math.max(
1,
this.getLongestWordWidth(headerText, maxUnbrokenWordWidth),
);
}
for (const row of token.rows) {
for (let i = 0; i < row.length; i++) {
const cellText = this.renderInlineTokens(
row[i].tokens || [],
styleContext,
);
naturalWidths[i] = Math.max(
naturalWidths[i] || 0,
visibleWidth(cellText),
);
minWordWidths[i] = Math.max(
minWordWidths[i] || 1,
this.getLongestWordWidth(cellText, maxUnbrokenWordWidth),
);
}
}
let minColumnWidths = minWordWidths;
let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
if (minCellsWidth > availableForCells) {
minColumnWidths = new Array(numCols).fill(1);
const remaining = availableForCells - numCols;
if (remaining > 0) {
const totalWeight = minWordWidths.reduce(
(total, width) => total + Math.max(0, width - 1),
0,
);
const growth = minWordWidths.map((width) => {
const weight = Math.max(0, width - 1);
return totalWeight > 0
? Math.floor((weight / totalWeight) * remaining)
: 0;
});
for (let i = 0; i < numCols; i++) {
minColumnWidths[i] += growth[i] ?? 0;
}
const allocated = growth.reduce((total, width) => total + width, 0);
let leftover = remaining - allocated;
for (let i = 0; leftover > 0 && i < numCols; i++) {
minColumnWidths[i]++;
leftover--;
}
}
minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
}
// Calculate column widths that fit within available width
const totalNaturalWidth =
naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
let columnWidths: number[];
if (totalNaturalWidth <= availableWidth) {
// Everything fits naturally
columnWidths = naturalWidths.map((width, index) =>
Math.max(width, minColumnWidths[index]),
);
} else {
// Need to shrink columns to fit
const totalGrowPotential = naturalWidths.reduce((total, width, index) => {
return total + Math.max(0, width - minColumnWidths[index]);
}, 0);
const extraWidth = Math.max(0, availableForCells - minCellsWidth);
columnWidths = minColumnWidths.map((minWidth, index) => {
const naturalWidth = naturalWidths[index];
const minWidthDelta = Math.max(0, naturalWidth - minWidth);
let grow = 0;
if (totalGrowPotential > 0) {
grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth);
}
return minWidth + grow;
});
// Adjust for rounding errors - distribute remaining space
const allocated = columnWidths.reduce((a, b) => a + b, 0);
let remaining = availableForCells - allocated;
while (remaining > 0) {
let grew = false;
for (let i = 0; i < numCols && remaining > 0; i++) {
if (columnWidths[i] < naturalWidths[i]) {
columnWidths[i]++;
remaining--;
grew = true;
}
}
if (!grew) {
break;
}
}
}
// Render top border
const topBorderCells = columnWidths.map((w) => "─".repeat(w));
lines.push(`┌─${topBorderCells.join("─┬─")}─┐`);
// Render header with wrapping
const headerCellLines: string[][] = token.header.map((cell, i) => {
const text = this.renderInlineTokens(cell.tokens || [], styleContext);
return this.wrapCellText(text, columnWidths[i]);
});
const headerLineCount = Math.max(...headerCellLines.map((c) => c.length));
for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
const rowParts = headerCellLines.map((cellLines, colIdx) => {
const text = cellLines[lineIdx] || "";
const padded =
text +
" ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
return this.theme.bold(padded);
});
lines.push(`${rowParts.join(" │ ")}`);
}
// Render separator
const separatorCells = columnWidths.map((w) => "─".repeat(w));
const separatorLine = `├─${separatorCells.join("─┼─")}─┤`;
lines.push(separatorLine);
// Render rows with wrapping
for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
const row = token.rows[rowIndex];
const rowCellLines: string[][] = row.map((cell, i) => {
const text = this.renderInlineTokens(cell.tokens || [], styleContext);
return this.wrapCellText(text, columnWidths[i]);
});
const rowLineCount = Math.max(...rowCellLines.map((c) => c.length));
for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
const rowParts = rowCellLines.map((cellLines, colIdx) => {
const text = cellLines[lineIdx] || "";
return (
text +
" ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)))
);
});
lines.push(`${rowParts.join(" │ ")}`);
}
if (rowIndex < token.rows.length - 1) {
lines.push(separatorLine);
}
}
// Render bottom border
const bottomBorderCells = columnWidths.map((w) => "─".repeat(w));
lines.push(`└─${bottomBorderCells.join("─┴─")}─┘`);
lines.push(""); // Add spacing after table
return lines;
}
}

View file

@ -0,0 +1,234 @@
import { getEditorKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { truncateToWidth } from "../utils.js";
const normalizeToSingleLine = (text: string): string =>
text.replace(/[\r\n]+/g, " ").trim();
export interface SelectItem {
value: string;
label: string;
description?: string;
}
export interface SelectListTheme {
selectedPrefix: (text: string) => string;
selectedText: (text: string) => string;
description: (text: string) => string;
scrollInfo: (text: string) => string;
noMatch: (text: string) => string;
}
export class SelectList implements Component {
private items: SelectItem[] = [];
private filteredItems: SelectItem[] = [];
private selectedIndex: number = 0;
private maxVisible: number = 5;
private theme: SelectListTheme;
public onSelect?: (item: SelectItem) => void;
public onCancel?: () => void;
public onSelectionChange?: (item: SelectItem) => void;
constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
}
setFilter(filter: string): void {
this.filteredItems = this.items.filter((item) =>
item.value.toLowerCase().startsWith(filter.toLowerCase()),
);
// Reset selection when filter changes
this.selectedIndex = 0;
}
setSelectedIndex(index: number): void {
this.selectedIndex = Math.max(
0,
Math.min(index, this.filteredItems.length - 1),
);
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
// If no items match filter, show message
if (this.filteredItems.length === 0) {
lines.push(this.theme.noMatch(" No matching commands"));
return lines;
}
// Calculate visible range with scrolling
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,
);
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const descriptionSingleLine = item.description
? normalizeToSingleLine(item.description)
: undefined;
let line = "";
if (isSelected) {
// Use arrow indicator for selection - entire line uses selectedText color
const prefixWidth = 2; // "→ " is 2 characters visually
const displayValue = item.label || item.value;
if (descriptionSingleLine && width > 40) {
// Calculate how much space we have for value + description
const maxValueWidth = Math.min(30, width - prefixWidth - 4);
const truncatedValue = truncateToWidth(
displayValue,
maxValueWidth,
"",
);
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
// Calculate remaining space for description using visible widths
const descriptionStart =
prefixWidth + truncatedValue.length + spacing.length;
const remainingWidth = width - descriptionStart - 2; // -2 for safety
if (remainingWidth > 10) {
const truncatedDesc = truncateToWidth(
descriptionSingleLine,
remainingWidth,
"",
);
// Apply selectedText to entire line content
line = this.theme.selectedText(
`${truncatedValue}${spacing}${truncatedDesc}`,
);
} else {
// Not enough space for description
const maxWidth = width - prefixWidth - 2;
line = this.theme.selectedText(
`${truncateToWidth(displayValue, maxWidth, "")}`,
);
}
} else {
// No description or not enough width
const maxWidth = width - prefixWidth - 2;
line = this.theme.selectedText(
`${truncateToWidth(displayValue, maxWidth, "")}`,
);
}
} else {
const displayValue = item.label || item.value;
const prefix = " ";
if (descriptionSingleLine && width > 40) {
// Calculate how much space we have for value + description
const maxValueWidth = Math.min(30, width - prefix.length - 4);
const truncatedValue = truncateToWidth(
displayValue,
maxValueWidth,
"",
);
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
// Calculate remaining space for description
const descriptionStart =
prefix.length + truncatedValue.length + spacing.length;
const remainingWidth = width - descriptionStart - 2; // -2 for safety
if (remainingWidth > 10) {
const truncatedDesc = truncateToWidth(
descriptionSingleLine,
remainingWidth,
"",
);
const descText = this.theme.description(spacing + truncatedDesc);
line = prefix + truncatedValue + descText;
} else {
// Not enough space for description
const maxWidth = width - prefix.length - 2;
line = prefix + truncateToWidth(displayValue, maxWidth, "");
}
} else {
// No description or not enough width
const maxWidth = width - prefix.length - 2;
line = prefix + truncateToWidth(displayValue, maxWidth, "");
}
}
lines.push(line);
}
// Add scroll indicators if needed
if (startIndex > 0 || endIndex < this.filteredItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`;
// Truncate if too long for terminal
lines.push(
this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, "")),
);
}
return lines;
}
handleInput(keyData: string): void {
const kb = getEditorKeybindings();
// Up arrow - wrap to bottom when at top
if (kb.matches(keyData, "selectUp")) {
this.selectedIndex =
this.selectedIndex === 0
? this.filteredItems.length - 1
: this.selectedIndex - 1;
this.notifySelectionChange();
}
// Down arrow - wrap to top when at bottom
else if (kb.matches(keyData, "selectDown")) {
this.selectedIndex =
this.selectedIndex === this.filteredItems.length - 1
? 0
: this.selectedIndex + 1;
this.notifySelectionChange();
}
// Enter
else if (kb.matches(keyData, "selectConfirm")) {
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelect) {
this.onSelect(selectedItem);
}
}
// Escape or Ctrl+C
else if (kb.matches(keyData, "selectCancel")) {
if (this.onCancel) {
this.onCancel();
}
}
}
private notifySelectionChange(): void {
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelectionChange) {
this.onSelectionChange(selectedItem);
}
}
getSelectedItem(): SelectItem | null {
const item = this.filteredItems[this.selectedIndex];
return item || null;
}
}

View file

@ -0,0 +1,282 @@
import { fuzzyFilter } from "../fuzzy.js";
import { getEditorKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js";
import { Input } from "./input.js";
export interface SettingItem {
/** Unique identifier for this setting */
id: string;
/** Display label (left side) */
label: string;
/** Optional description shown when selected */
description?: string;
/** Current value to display (right side) */
currentValue: string;
/** If provided, Enter/Space cycles through these values */
values?: string[];
/** If provided, Enter opens this submenu. Receives current value and done callback. */
submenu?: (
currentValue: string,
done: (selectedValue?: string) => void,
) => Component;
}
export interface SettingsListTheme {
label: (text: string, selected: boolean) => string;
value: (text: string, selected: boolean) => string;
description: (text: string) => string;
cursor: string;
hint: (text: string) => string;
}
export interface SettingsListOptions {
enableSearch?: boolean;
}
export class SettingsList implements Component {
private items: SettingItem[];
private filteredItems: SettingItem[];
private theme: SettingsListTheme;
private selectedIndex = 0;
private maxVisible: number;
private onChange: (id: string, newValue: string) => void;
private onCancel: () => void;
private searchInput?: Input;
private searchEnabled: boolean;
// Submenu state
private submenuComponent: Component | null = null;
private submenuItemIndex: number | null = null;
constructor(
items: SettingItem[],
maxVisible: number,
theme: SettingsListTheme,
onChange: (id: string, newValue: string) => void,
onCancel: () => void,
options: SettingsListOptions = {},
) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
this.onChange = onChange;
this.onCancel = onCancel;
this.searchEnabled = options.enableSearch ?? false;
if (this.searchEnabled) {
this.searchInput = new Input();
}
}
/** Update an item's currentValue */
updateValue(id: string, newValue: string): void {
const item = this.items.find((i) => i.id === id);
if (item) {
item.currentValue = newValue;
}
}
invalidate(): void {
this.submenuComponent?.invalidate?.();
}
render(width: number): string[] {
// If submenu is active, render it instead
if (this.submenuComponent) {
return this.submenuComponent.render(width);
}
return this.renderMainList(width);
}
private renderMainList(width: number): string[] {
const lines: string[] = [];
if (this.searchEnabled && this.searchInput) {
lines.push(...this.searchInput.render(width));
lines.push("");
}
if (this.items.length === 0) {
lines.push(this.theme.hint(" No settings available"));
if (this.searchEnabled) {
this.addHintLine(lines, width);
}
return lines;
}
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (displayItems.length === 0) {
lines.push(
truncateToWidth(this.theme.hint(" No matching settings"), width),
);
this.addHintLine(lines, width);
return lines;
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(
this.selectedIndex - Math.floor(this.maxVisible / 2),
displayItems.length - this.maxVisible,
),
);
const endIndex = Math.min(
startIndex + this.maxVisible,
displayItems.length,
);
// Calculate max label width for alignment
const maxLabelWidth = Math.min(
30,
Math.max(...this.items.map((item) => visibleWidth(item.label))),
);
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = displayItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? this.theme.cursor : " ";
const prefixWidth = visibleWidth(prefix);
// Pad label to align values
const labelPadded =
item.label +
" ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
const labelText = this.theme.label(labelPadded, isSelected);
// Calculate space for value
const separator = " ";
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
const valueMaxWidth = width - usedWidth - 2;
const valueText = this.theme.value(
truncateToWidth(item.currentValue, valueMaxWidth, ""),
isSelected,
);
lines.push(
truncateToWidth(prefix + labelText + separator + valueText, width),
);
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < displayItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
}
// Add description for selected item
const selectedItem = displayItems[this.selectedIndex];
if (selectedItem?.description) {
lines.push("");
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
for (const line of wrappedDesc) {
lines.push(this.theme.description(` ${line}`));
}
}
// Add hint
this.addHintLine(lines, width);
return lines;
}
handleInput(data: string): void {
// If submenu is active, delegate all input to it
// The submenu's onCancel (triggered by escape) will call done() which closes it
if (this.submenuComponent) {
this.submenuComponent.handleInput?.(data);
return;
}
// Main list input handling
const kb = getEditorKeybindings();
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (kb.matches(data, "selectUp")) {
if (displayItems.length === 0) return;
this.selectedIndex =
this.selectedIndex === 0
? displayItems.length - 1
: this.selectedIndex - 1;
} else if (kb.matches(data, "selectDown")) {
if (displayItems.length === 0) return;
this.selectedIndex =
this.selectedIndex === displayItems.length - 1
? 0
: this.selectedIndex + 1;
} else if (kb.matches(data, "selectConfirm") || data === " ") {
this.activateItem();
} else if (kb.matches(data, "selectCancel")) {
this.onCancel();
} else if (this.searchEnabled && this.searchInput) {
const sanitized = data.replace(/ /g, "");
if (!sanitized) {
return;
}
this.searchInput.handleInput(sanitized);
this.applyFilter(this.searchInput.getValue());
}
}
private activateItem(): void {
const item = this.searchEnabled
? this.filteredItems[this.selectedIndex]
: this.items[this.selectedIndex];
if (!item) return;
if (item.submenu) {
// Open submenu, passing current value so it can pre-select correctly
this.submenuItemIndex = this.selectedIndex;
this.submenuComponent = item.submenu(
item.currentValue,
(selectedValue?: string) => {
if (selectedValue !== undefined) {
item.currentValue = selectedValue;
this.onChange(item.id, selectedValue);
}
this.closeSubmenu();
},
);
} else if (item.values && item.values.length > 0) {
// Cycle through values
const currentIndex = item.values.indexOf(item.currentValue);
const nextIndex = (currentIndex + 1) % item.values.length;
const newValue = item.values[nextIndex];
item.currentValue = newValue;
this.onChange(item.id, newValue);
}
}
private closeSubmenu(): void {
this.submenuComponent = null;
// Restore selection to the item that opened the submenu
if (this.submenuItemIndex !== null) {
this.selectedIndex = this.submenuItemIndex;
this.submenuItemIndex = null;
}
}
private applyFilter(query: string): void {
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
this.selectedIndex = 0;
}
private addHintLine(lines: string[], width: number): void {
lines.push("");
lines.push(
truncateToWidth(
this.theme.hint(
this.searchEnabled
? " Type to search · Enter/Space to change · Esc to cancel"
: " Enter/Space to change · Esc to cancel",
),
width,
),
);
}
}

View file

@ -0,0 +1,28 @@
import type { Component } from "../tui.js";
/**
* Spacer component that renders empty lines
*/
export class Spacer implements Component {
private lines: number;
constructor(lines: number = 1) {
this.lines = lines;
}
setLines(lines: number): void {
this.lines = lines;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(_width: number): string[] {
const result: string[] = [];
for (let i = 0; i < this.lines; i++) {
result.push("");
}
return result;
}
}

View file

@ -0,0 +1,123 @@
import type { Component } from "../tui.js";
import {
applyBackgroundToLine,
visibleWidth,
wrapTextWithAnsi,
} from "../utils.js";
/**
* Text component - displays multi-line text with word wrapping
*/
export class Text implements Component {
private text: string;
private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding
private customBgFn?: (text: string) => string;
// Cache for rendered output
private cachedText?: string;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(
text: string = "",
paddingX: number = 1,
paddingY: number = 1,
customBgFn?: (text: string) => string,
) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
this.customBgFn = customBgFn;
}
setText(text: string): void {
this.text = text;
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
setCustomBgFn(customBgFn?: (text: string) => string): void {
this.customBgFn = customBgFn;
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
invalidate(): void {
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
render(width: number): string[] {
// Check cache
if (
this.cachedLines &&
this.cachedText === this.text &&
this.cachedWidth === width
) {
return this.cachedLines;
}
// Don't render anything if there's no actual text
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
const normalizedText = this.text.replace(/\t/g, " ");
// Calculate content width (subtract left/right margins)
const contentWidth = Math.max(1, width - this.paddingX * 2);
// Wrap text (this preserves ANSI codes but does NOT pad)
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
// Add margins and background to each line
const leftMargin = " ".repeat(this.paddingX);
const rightMargin = " ".repeat(this.paddingX);
const contentLines: string[] = [];
for (const line of wrappedLines) {
// Add margins
const lineWithMargins = leftMargin + line + rightMargin;
// Apply background if specified (this also pads to full width)
if (this.customBgFn) {
contentLines.push(
applyBackgroundToLine(lineWithMargins, width, this.customBgFn),
);
} else {
// No background - just pad to width with spaces
const visibleLen = visibleWidth(lineWithMargins);
const paddingNeeded = Math.max(0, width - visibleLen);
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
}
}
// Add top/bottom padding (empty lines)
const emptyLine = " ".repeat(width);
const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
const line = this.customBgFn
? applyBackgroundToLine(emptyLine, width, this.customBgFn)
: emptyLine;
emptyLines.push(line);
}
const result = [...emptyLines, ...contentLines, ...emptyLines];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result.length > 0 ? result : [""];
}
}

View file

@ -0,0 +1,65 @@
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth } from "../utils.js";
/**
* Text component that truncates to fit viewport width
*/
export class TruncatedText implements Component {
private text: string;
private paddingX: number;
private paddingY: number;
constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const result: string[] = [];
// Empty line padded to width
const emptyLine = " ".repeat(width);
// Add vertical padding above
for (let i = 0; i < this.paddingY; i++) {
result.push(emptyLine);
}
// Calculate available width after horizontal padding
const availableWidth = Math.max(1, width - this.paddingX * 2);
// Take only the first line (stop at newline)
let singleLineText = this.text;
const newlineIndex = this.text.indexOf("\n");
if (newlineIndex !== -1) {
singleLineText = this.text.substring(0, newlineIndex);
}
// Truncate text if needed (accounting for ANSI codes)
const displayText = truncateToWidth(singleLineText, availableWidth);
// Add horizontal padding
const leftPadding = " ".repeat(this.paddingX);
const rightPadding = " ".repeat(this.paddingX);
const lineWithPadding = leftPadding + displayText + rightPadding;
// Pad line to exactly width characters
const lineVisibleWidth = visibleWidth(lineWithPadding);
const paddingNeeded = Math.max(0, width - lineVisibleWidth);
const finalLine = lineWithPadding + " ".repeat(paddingNeeded);
result.push(finalLine);
// Add vertical padding below
for (let i = 0; i < this.paddingY; i++) {
result.push(emptyLine);
}
return result;
}
}

View file

@ -0,0 +1,74 @@
import type { AutocompleteProvider } from "./autocomplete.js";
import type { Component } from "./tui.js";
/**
* Interface for custom editor components.
*
* This allows extensions to provide their own editor implementation
* (e.g., vim mode, emacs mode, custom keybindings) while maintaining
* compatibility with the core application.
*/
export interface EditorComponent extends Component {
// =========================================================================
// Core text access (required)
// =========================================================================
/** Get the current text content */
getText(): string;
/** Set the text content */
setText(text: string): void;
/** Handle raw terminal input (key presses, paste sequences, etc.) */
handleInput(data: string): void;
// =========================================================================
// Callbacks (required)
// =========================================================================
/** Called when user submits (e.g., Enter key) */
onSubmit?: (text: string) => void;
/** Called when text changes */
onChange?: (text: string) => void;
// =========================================================================
// History support (optional)
// =========================================================================
/** Add text to history for up/down navigation */
addToHistory?(text: string): void;
// =========================================================================
// Advanced text manipulation (optional)
// =========================================================================
/** Insert text at current cursor position */
insertTextAtCursor?(text: string): void;
/**
* Get text with any markers expanded (e.g., paste markers).
* Falls back to getText() if not implemented.
*/
getExpandedText?(): string;
// =========================================================================
// Autocomplete support (optional)
// =========================================================================
/** Set the autocomplete provider */
setAutocompleteProvider?(provider: AutocompleteProvider): void;
// =========================================================================
// Appearance (optional)
// =========================================================================
/** Border color function */
borderColor?: (str: string) => string;
/** Set horizontal padding */
setPaddingX?(padding: number): void;
/** Set max visible items in autocomplete dropdown */
setAutocompleteMaxVisible?(maxVisible: number): void;
}

145
packages/tui/src/fuzzy.ts Normal file
View file

@ -0,0 +1,145 @@
/**
* Fuzzy matching utilities.
* Matches if all query characters appear in order (not necessarily consecutive).
* Lower score = better match.
*/
export interface FuzzyMatch {
matches: boolean;
score: number;
}
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
const matchQuery = (normalizedQuery: string): FuzzyMatch => {
if (normalizedQuery.length === 0) {
return { matches: true, score: 0 };
}
if (normalizedQuery.length > textLower.length) {
return { matches: false, score: 0 };
}
let queryIndex = 0;
let score = 0;
let lastMatchIndex = -1;
let consecutiveMatches = 0;
for (
let i = 0;
i < textLower.length && queryIndex < normalizedQuery.length;
i++
) {
if (textLower[i] === normalizedQuery[queryIndex]) {
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
// Reward consecutive matches
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5;
} else {
consecutiveMatches = 0;
// Penalize gaps
if (lastMatchIndex >= 0) {
score += (i - lastMatchIndex - 1) * 2;
}
}
// Reward word boundary matches
if (isWordBoundary) {
score -= 10;
}
// Slight penalty for later matches
score += i * 0.1;
lastMatchIndex = i;
queryIndex++;
}
}
if (queryIndex < normalizedQuery.length) {
return { matches: false, score: 0 };
}
return { matches: true, score };
};
const primaryMatch = matchQuery(queryLower);
if (primaryMatch.matches) {
return primaryMatch;
}
const alphaNumericMatch = queryLower.match(
/^(?<letters>[a-z]+)(?<digits>[0-9]+)$/,
);
const numericAlphaMatch = queryLower.match(
/^(?<digits>[0-9]+)(?<letters>[a-z]+)$/,
);
const swappedQuery = alphaNumericMatch
? `${alphaNumericMatch.groups?.digits ?? ""}${alphaNumericMatch.groups?.letters ?? ""}`
: numericAlphaMatch
? `${numericAlphaMatch.groups?.letters ?? ""}${numericAlphaMatch.groups?.digits ?? ""}`
: "";
if (!swappedQuery) {
return primaryMatch;
}
const swappedMatch = matchQuery(swappedQuery);
if (!swappedMatch.matches) {
return primaryMatch;
}
return { matches: true, score: swappedMatch.score + 5 };
}
/**
* Filter and sort items by fuzzy match quality (best matches first).
* Supports space-separated tokens: all tokens must match.
*/
export function fuzzyFilter<T>(
items: T[],
query: string,
getText: (item: T) => string,
): T[] {
if (!query.trim()) {
return items;
}
const tokens = query
.trim()
.split(/\s+/)
.filter((t) => t.length > 0);
if (tokens.length === 0) {
return items;
}
const results: { item: T; totalScore: number }[] = [];
for (const item of items) {
const text = getText(item);
let totalScore = 0;
let allMatch = true;
for (const token of tokens) {
const match = fuzzyMatch(token, text);
if (match.matches) {
totalScore += match.score;
} else {
allMatch = false;
break;
}
}
if (allMatch) {
results.push({ item, totalScore });
}
}
results.sort((a, b) => a.totalScore - b.totalScore);
return results.map((r) => r.item);
}

117
packages/tui/src/index.ts Normal file
View file

@ -0,0 +1,117 @@
// Core TUI interfaces and classes
// Autocomplete support
export {
type AutocompleteItem,
type AutocompleteProvider,
CombinedAutocompleteProvider,
type SlashCommand,
} from "./autocomplete.js";
// Components
export { Box } from "./components/box.js";
export { CancellableLoader } from "./components/cancellable-loader.js";
export {
Editor,
type EditorOptions,
type EditorTheme,
} from "./components/editor.js";
export {
Image,
type ImageOptions,
type ImageTheme,
} from "./components/image.js";
export { Input } from "./components/input.js";
export { Loader } from "./components/loader.js";
export {
type DefaultTextStyle,
Markdown,
type MarkdownTheme,
} from "./components/markdown.js";
export {
type SelectItem,
SelectList,
type SelectListTheme,
} from "./components/select-list.js";
export {
type SettingItem,
SettingsList,
type SettingsListTheme,
} from "./components/settings-list.js";
export { Spacer } from "./components/spacer.js";
export { Text } from "./components/text.js";
export { TruncatedText } from "./components/truncated-text.js";
// Editor component interface (for custom editors)
export type { EditorComponent } from "./editor-component.js";
// Fuzzy matching
export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js";
// Keybindings
export {
DEFAULT_EDITOR_KEYBINDINGS,
type EditorAction,
type EditorKeybindingsConfig,
EditorKeybindingsManager,
getEditorKeybindings,
setEditorKeybindings,
} from "./keybindings.js";
// Keyboard input handling
export {
decodeKittyPrintable,
isKeyRelease,
isKeyRepeat,
isKittyProtocolActive,
Key,
type KeyEventType,
type KeyId,
matchesKey,
parseKey,
setKittyProtocolActive,
} from "./keys.js";
// Input buffering for batch splitting
export {
StdinBuffer,
type StdinBufferEventMap,
type StdinBufferOptions,
} from "./stdin-buffer.js";
// Terminal interface and implementations
export { ProcessTerminal, type Terminal } from "./terminal.js";
// Terminal image support
export {
allocateImageId,
type CellDimensions,
calculateImageRows,
deleteAllKittyImages,
deleteKittyImage,
detectCapabilities,
encodeITerm2,
encodeKitty,
getCapabilities,
getCellDimensions,
getGifDimensions,
getImageDimensions,
getJpegDimensions,
getPngDimensions,
getWebpDimensions,
type ImageDimensions,
type ImageProtocol,
type ImageRenderOptions,
imageFallback,
renderImage,
resetCapabilitiesCache,
setCellDimensions,
type TerminalCapabilities,
} from "./terminal-image.js";
export {
type Component,
Container,
CURSOR_MARKER,
type Focusable,
isFocusable,
type OverlayAnchor,
type OverlayHandle,
type OverlayMargin,
type OverlayOptions,
type SizeValue,
TUI,
} from "./tui.js";
// Utilities
export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.js";

View file

@ -0,0 +1,183 @@
import { type KeyId, matchesKey } from "./keys.js";
/**
* Editor actions that can be bound to keys.
*/
export type EditorAction =
// Cursor movement
| "cursorUp"
| "cursorDown"
| "cursorLeft"
| "cursorRight"
| "cursorWordLeft"
| "cursorWordRight"
| "cursorLineStart"
| "cursorLineEnd"
| "jumpForward"
| "jumpBackward"
| "pageUp"
| "pageDown"
// Deletion
| "deleteCharBackward"
| "deleteCharForward"
| "deleteWordBackward"
| "deleteWordForward"
| "deleteToLineStart"
| "deleteToLineEnd"
// Text input
| "newLine"
| "submit"
| "tab"
// Selection/autocomplete
| "selectUp"
| "selectDown"
| "selectPageUp"
| "selectPageDown"
| "selectConfirm"
| "selectCancel"
// Clipboard
| "copy"
// Kill ring
| "yank"
| "yankPop"
// Undo
| "undo"
// Tool output
| "expandTools"
// Session
| "toggleSessionPath"
| "toggleSessionSort"
| "renameSession"
| "deleteSession"
| "deleteSessionNoninvasive";
// Re-export KeyId from keys.ts
export type { KeyId };
/**
* Editor keybindings configuration.
*/
export type EditorKeybindingsConfig = {
[K in EditorAction]?: KeyId | KeyId[];
};
/**
* Default editor keybindings.
*/
export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
// Cursor movement
cursorUp: "up",
cursorDown: "down",
cursorLeft: ["left", "ctrl+b"],
cursorRight: ["right", "ctrl+f"],
cursorWordLeft: ["alt+left", "ctrl+left", "alt+b"],
cursorWordRight: ["alt+right", "ctrl+right", "alt+f"],
cursorLineStart: ["home", "ctrl+a"],
cursorLineEnd: ["end", "ctrl+e"],
jumpForward: "ctrl+]",
jumpBackward: "ctrl+alt+]",
pageUp: "pageUp",
pageDown: "pageDown",
// Deletion
deleteCharBackward: "backspace",
deleteCharForward: ["delete", "ctrl+d"],
deleteWordBackward: ["ctrl+w", "alt+backspace"],
deleteWordForward: ["alt+d", "alt+delete"],
deleteToLineStart: "ctrl+u",
deleteToLineEnd: "ctrl+k",
// Text input
newLine: "shift+enter",
submit: "enter",
tab: "tab",
// Selection/autocomplete
selectUp: "up",
selectDown: "down",
selectPageUp: "pageUp",
selectPageDown: "pageDown",
selectConfirm: "enter",
selectCancel: ["escape", "ctrl+c"],
// Clipboard
copy: "ctrl+c",
// Kill ring
yank: "ctrl+y",
yankPop: "alt+y",
// Undo
undo: "ctrl+-",
// Tool output
expandTools: "ctrl+o",
// Session
toggleSessionPath: "ctrl+p",
toggleSessionSort: "ctrl+s",
renameSession: "ctrl+r",
deleteSession: "ctrl+d",
deleteSessionNoninvasive: "ctrl+backspace",
};
/**
* Manages keybindings for the editor.
*/
export class EditorKeybindingsManager {
private actionToKeys: Map<EditorAction, KeyId[]>;
constructor(config: EditorKeybindingsConfig = {}) {
this.actionToKeys = new Map();
this.buildMaps(config);
}
private buildMaps(config: EditorKeybindingsConfig): void {
this.actionToKeys.clear();
// Start with defaults
for (const [action, keys] of Object.entries(DEFAULT_EDITOR_KEYBINDINGS)) {
const keyArray = Array.isArray(keys) ? keys : [keys];
this.actionToKeys.set(action as EditorAction, [...keyArray]);
}
// Override with user config
for (const [action, keys] of Object.entries(config)) {
if (keys === undefined) continue;
const keyArray = Array.isArray(keys) ? keys : [keys];
this.actionToKeys.set(action as EditorAction, keyArray);
}
}
/**
* Check if input matches a specific action.
*/
matches(data: string, action: EditorAction): boolean {
const keys = this.actionToKeys.get(action);
if (!keys) return false;
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
return false;
}
/**
* Get keys bound to an action.
*/
getKeys(action: EditorAction): KeyId[] {
return this.actionToKeys.get(action) ?? [];
}
/**
* Update configuration.
*/
setConfig(config: EditorKeybindingsConfig): void {
this.buildMaps(config);
}
}
// Global instance
let globalEditorKeybindings: EditorKeybindingsManager | null = null;
export function getEditorKeybindings(): EditorKeybindingsManager {
if (!globalEditorKeybindings) {
globalEditorKeybindings = new EditorKeybindingsManager();
}
return globalEditorKeybindings;
}
export function setEditorKeybindings(manager: EditorKeybindingsManager): void {
globalEditorKeybindings = manager;
}

1309
packages/tui/src/keys.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,46 @@
/**
* Ring buffer for Emacs-style kill/yank operations.
*
* Tracks killed (deleted) text entries. Consecutive kills can accumulate
* into a single entry. Supports yank (paste most recent) and yank-pop
* (cycle through older entries).
*/
export class KillRing {
private ring: string[] = [];
/**
* Add text to the kill ring.
*
* @param text - The killed text to add
* @param opts - Push options
* @param opts.prepend - If accumulating, prepend (backward deletion) or append (forward deletion)
* @param opts.accumulate - Merge with the most recent entry instead of creating a new one
*/
push(text: string, opts: { prepend: boolean; accumulate?: boolean }): void {
if (!text) return;
if (opts.accumulate && this.ring.length > 0) {
const last = this.ring.pop()!;
this.ring.push(opts.prepend ? text + last : last + text);
} else {
this.ring.push(text);
}
}
/** Get most recent entry without modifying the ring. */
peek(): string | undefined {
return this.ring.length > 0 ? this.ring[this.ring.length - 1] : undefined;
}
/** Move last entry to front (for yank-pop cycling). */
rotate(): void {
if (this.ring.length > 1) {
const last = this.ring.pop()!;
this.ring.unshift(last);
}
}
get length(): number {
return this.ring.length;
}
}

View file

@ -0,0 +1,397 @@
/**
* StdinBuffer buffers input and emits complete sequences.
*
* This is necessary because stdin data events can arrive in partial chunks,
* especially for escape sequences like mouse events. Without buffering,
* partial sequences can be misinterpreted as regular keypresses.
*
* For example, the mouse SGR sequence `\x1b[<35;20;5m` might arrive as:
* - Event 1: `\x1b`
* - Event 2: `[<35`
* - Event 3: `;20;5m`
*
* The buffer accumulates these until a complete sequence is detected.
* Call the `process()` method to feed input data.
*
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
* MIT License - Copyright (c) 2025 opentui
*/
import { EventEmitter } from "events";
const ESC = "\x1b";
const BRACKETED_PASTE_START = "\x1b[200~";
const BRACKETED_PASTE_END = "\x1b[201~";
/**
* Check if a string is a complete escape sequence or needs more data
*/
function isCompleteSequence(
data: string,
): "complete" | "incomplete" | "not-escape" {
if (!data.startsWith(ESC)) {
return "not-escape";
}
if (data.length === 1) {
return "incomplete";
}
const afterEsc = data.slice(1);
// CSI sequences: ESC [
if (afterEsc.startsWith("[")) {
// Check for old-style mouse sequence: ESC[M + 3 bytes
if (afterEsc.startsWith("[M")) {
// Old-style mouse needs ESC[M + 3 bytes = 6 total
return data.length >= 6 ? "complete" : "incomplete";
}
return isCompleteCsiSequence(data);
}
// OSC sequences: ESC ]
if (afterEsc.startsWith("]")) {
return isCompleteOscSequence(data);
}
// DCS sequences: ESC P ... ESC \ (includes XTVersion responses)
if (afterEsc.startsWith("P")) {
return isCompleteDcsSequence(data);
}
// APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses)
if (afterEsc.startsWith("_")) {
return isCompleteApcSequence(data);
}
// SS3 sequences: ESC O
if (afterEsc.startsWith("O")) {
// ESC O followed by a single character
return afterEsc.length >= 2 ? "complete" : "incomplete";
}
// Meta key sequences: ESC followed by a single character
if (afterEsc.length === 1) {
return "complete";
}
// Unknown escape sequence - treat as complete
return "complete";
}
/**
* Check if CSI sequence is complete
* CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E)
*/
function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}[`)) {
return "complete";
}
// Need at least ESC [ and one more character
if (data.length < 3) {
return "incomplete";
}
const payload = data.slice(2);
// CSI sequences end with a byte in the range 0x40-0x7E (@-~)
// This includes all letters and several special characters
const lastChar = payload[payload.length - 1];
const lastCharCode = lastChar.charCodeAt(0);
if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) {
// Special handling for SGR mouse sequences
// Format: ESC[<B;X;Ym or ESC[<B;X;YM
if (payload.startsWith("<")) {
// Must have format: <digits;digits;digits[Mm]
const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
if (mouseMatch) {
return "complete";
}
// If it ends with M or m but doesn't match the pattern, still incomplete
if (lastChar === "M" || lastChar === "m") {
// Check if we have the right structure
const parts = payload.slice(1, -1).split(";");
if (parts.length === 3 && parts.every((p) => /^\d+$/.test(p))) {
return "complete";
}
}
return "incomplete";
}
return "complete";
}
return "incomplete";
}
/**
* Check if OSC sequence is complete
* OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL)
*/
function isCompleteOscSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}]`)) {
return "complete";
}
// OSC sequences end with ST (ESC \) or BEL (\x07)
if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) {
return "complete";
}
return "incomplete";
}
/**
* Check if DCS (Device Control String) sequence is complete
* DCS sequences: ESC P ... ST (where ST is ESC \)
* Used for XTVersion responses like ESC P >| ... ESC \
*/
function isCompleteDcsSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}P`)) {
return "complete";
}
// DCS sequences end with ST (ESC \)
if (data.endsWith(`${ESC}\\`)) {
return "complete";
}
return "incomplete";
}
/**
* Check if APC (Application Program Command) sequence is complete
* APC sequences: ESC _ ... ST (where ST is ESC \)
* Used for Kitty graphics responses like ESC _ G ... ESC \
*/
function isCompleteApcSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}_`)) {
return "complete";
}
// APC sequences end with ST (ESC \)
if (data.endsWith(`${ESC}\\`)) {
return "complete";
}
return "incomplete";
}
/**
* Split accumulated buffer into complete sequences
*/
function extractCompleteSequences(buffer: string): {
sequences: string[];
remainder: string;
} {
const sequences: string[] = [];
let pos = 0;
while (pos < buffer.length) {
const remaining = buffer.slice(pos);
// Try to extract a sequence starting at this position
if (remaining.startsWith(ESC)) {
// Find the end of this escape sequence
let seqEnd = 1;
while (seqEnd <= remaining.length) {
const candidate = remaining.slice(0, seqEnd);
const status = isCompleteSequence(candidate);
if (status === "complete") {
sequences.push(candidate);
pos += seqEnd;
break;
} else if (status === "incomplete") {
seqEnd++;
} else {
// Should not happen when starting with ESC
sequences.push(candidate);
pos += seqEnd;
break;
}
}
if (seqEnd > remaining.length) {
return { sequences, remainder: remaining };
}
} else {
// Not an escape sequence - take a single character
sequences.push(remaining[0]!);
pos++;
}
}
return { sequences, remainder: "" };
}
export type StdinBufferOptions = {
/**
* Maximum time to wait for sequence completion (default: 10ms)
* After this time, the buffer is flushed even if incomplete
*/
timeout?: number;
};
export type StdinBufferEventMap = {
data: [string];
paste: [string];
};
/**
* Buffers stdin input and emits complete sequences via the 'data' event.
* Handles partial escape sequences that arrive across multiple chunks.
*/
export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
private buffer: string = "";
private timeout: ReturnType<typeof setTimeout> | null = null;
private readonly timeoutMs: number;
private pasteMode: boolean = false;
private pasteBuffer: string = "";
constructor(options: StdinBufferOptions = {}) {
super();
this.timeoutMs = options.timeout ?? 10;
}
public process(data: string | Buffer): void {
// Clear any pending timeout
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
// Handle high-byte conversion (for compatibility with parseKeypress)
// If buffer has single byte > 127, convert to ESC + (byte - 128)
let str: string;
if (Buffer.isBuffer(data)) {
if (data.length === 1 && data[0]! > 127) {
const byte = data[0]! - 128;
str = `\x1b${String.fromCharCode(byte)}`;
} else {
str = data.toString();
}
} else {
str = data;
}
if (str.length === 0 && this.buffer.length === 0) {
this.emit("data", "");
return;
}
this.buffer += str;
if (this.pasteMode) {
this.pasteBuffer += this.buffer;
this.buffer = "";
const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
if (endIndex !== -1) {
const pastedContent = this.pasteBuffer.slice(0, endIndex);
const remaining = this.pasteBuffer.slice(
endIndex + BRACKETED_PASTE_END.length,
);
this.pasteMode = false;
this.pasteBuffer = "";
this.emit("paste", pastedContent);
if (remaining.length > 0) {
this.process(remaining);
}
}
return;
}
const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START);
if (startIndex !== -1) {
if (startIndex > 0) {
const beforePaste = this.buffer.slice(0, startIndex);
const result = extractCompleteSequences(beforePaste);
for (const sequence of result.sequences) {
this.emit("data", sequence);
}
}
this.buffer = this.buffer.slice(
startIndex + BRACKETED_PASTE_START.length,
);
this.pasteMode = true;
this.pasteBuffer = this.buffer;
this.buffer = "";
const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
if (endIndex !== -1) {
const pastedContent = this.pasteBuffer.slice(0, endIndex);
const remaining = this.pasteBuffer.slice(
endIndex + BRACKETED_PASTE_END.length,
);
this.pasteMode = false;
this.pasteBuffer = "";
this.emit("paste", pastedContent);
if (remaining.length > 0) {
this.process(remaining);
}
}
return;
}
const result = extractCompleteSequences(this.buffer);
this.buffer = result.remainder;
for (const sequence of result.sequences) {
this.emit("data", sequence);
}
if (this.buffer.length > 0) {
this.timeout = setTimeout(() => {
const flushed = this.flush();
for (const sequence of flushed) {
this.emit("data", sequence);
}
}, this.timeoutMs);
}
}
flush(): string[] {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
if (this.buffer.length === 0) {
return [];
}
const sequences = [this.buffer];
this.buffer = "";
return sequences;
}
clear(): void {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
this.buffer = "";
this.pasteMode = false;
this.pasteBuffer = "";
}
getBuffer(): string {
return this.buffer;
}
destroy(): void {
this.clear();
}
}

View file

@ -0,0 +1,405 @@
export type ImageProtocol = "kitty" | "iterm2" | null;
export interface TerminalCapabilities {
images: ImageProtocol;
trueColor: boolean;
hyperlinks: boolean;
}
export interface CellDimensions {
widthPx: number;
heightPx: number;
}
export interface ImageDimensions {
widthPx: number;
heightPx: number;
}
export interface ImageRenderOptions {
maxWidthCells?: number;
maxHeightCells?: number;
preserveAspectRatio?: boolean;
/** Kitty image ID. If provided, reuses/replaces existing image with this ID. */
imageId?: number;
}
let cachedCapabilities: TerminalCapabilities | null = null;
// Default cell dimensions - updated by TUI when terminal responds to query
let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 };
export function getCellDimensions(): CellDimensions {
return cellDimensions;
}
export function setCellDimensions(dims: CellDimensions): void {
cellDimensions = dims;
}
export function detectCapabilities(): TerminalCapabilities {
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
const term = process.env.TERM?.toLowerCase() || "";
const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (
termProgram === "ghostty" ||
term.includes("ghostty") ||
process.env.GHOSTTY_RESOURCES_DIR
) {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (process.env.WEZTERM_PANE || termProgram === "wezterm") {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") {
return { images: "iterm2", trueColor: true, hyperlinks: true };
}
if (termProgram === "vscode") {
return { images: null, trueColor: true, hyperlinks: true };
}
if (termProgram === "alacritty") {
return { images: null, trueColor: true, hyperlinks: true };
}
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
return { images: null, trueColor, hyperlinks: true };
}
export function getCapabilities(): TerminalCapabilities {
if (!cachedCapabilities) {
cachedCapabilities = detectCapabilities();
}
return cachedCapabilities;
}
export function resetCapabilitiesCache(): void {
cachedCapabilities = null;
}
const KITTY_PREFIX = "\x1b_G";
const ITERM2_PREFIX = "\x1b]1337;File=";
export function isImageLine(line: string): boolean {
// Fast path: sequence at line start (single-row images)
if (line.startsWith(KITTY_PREFIX) || line.startsWith(ITERM2_PREFIX)) {
return true;
}
// Slow path: sequence elsewhere (multi-row images have cursor-up prefix)
return line.includes(KITTY_PREFIX) || line.includes(ITERM2_PREFIX);
}
/**
* Generate a random image ID for Kitty graphics protocol.
* Uses random IDs to avoid collisions between different module instances
* (e.g., main app vs extensions).
*/
export function allocateImageId(): number {
// Use random ID in range [1, 0xffffffff] to avoid collisions
return Math.floor(Math.random() * 0xfffffffe) + 1;
}
export function encodeKitty(
base64Data: string,
options: {
columns?: number;
rows?: number;
imageId?: number;
} = {},
): string {
const CHUNK_SIZE = 4096;
const params: string[] = ["a=T", "f=100", "q=2"];
if (options.columns) params.push(`c=${options.columns}`);
if (options.rows) params.push(`r=${options.rows}`);
if (options.imageId) params.push(`i=${options.imageId}`);
if (base64Data.length <= CHUNK_SIZE) {
return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
}
const chunks: string[] = [];
let offset = 0;
let isFirst = true;
while (offset < base64Data.length) {
const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
const isLast = offset + CHUNK_SIZE >= base64Data.length;
if (isFirst) {
chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`);
isFirst = false;
} else if (isLast) {
chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
} else {
chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
}
offset += CHUNK_SIZE;
}
return chunks.join("");
}
/**
* Delete a Kitty graphics image by ID.
* Uses uppercase 'I' to also free the image data.
*/
export function deleteKittyImage(imageId: number): string {
return `\x1b_Ga=d,d=I,i=${imageId}\x1b\\`;
}
/**
* Delete all visible Kitty graphics images.
* Uses uppercase 'A' to also free the image data.
*/
export function deleteAllKittyImages(): string {
return `\x1b_Ga=d,d=A\x1b\\`;
}
export function encodeITerm2(
base64Data: string,
options: {
width?: number | string;
height?: number | string;
name?: string;
preserveAspectRatio?: boolean;
inline?: boolean;
} = {},
): string {
const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`];
if (options.width !== undefined) params.push(`width=${options.width}`);
if (options.height !== undefined) params.push(`height=${options.height}`);
if (options.name) {
const nameBase64 = Buffer.from(options.name).toString("base64");
params.push(`name=${nameBase64}`);
}
if (options.preserveAspectRatio === false) {
params.push("preserveAspectRatio=0");
}
return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
}
export function calculateImageRows(
imageDimensions: ImageDimensions,
targetWidthCells: number,
cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
): number {
const targetWidthPx = targetWidthCells * cellDimensions.widthPx;
const scale = targetWidthPx / imageDimensions.widthPx;
const scaledHeightPx = imageDimensions.heightPx * scale;
const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
return Math.max(1, rows);
}
export function getPngDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 24) {
return null;
}
if (
buffer[0] !== 0x89 ||
buffer[1] !== 0x50 ||
buffer[2] !== 0x4e ||
buffer[3] !== 0x47
) {
return null;
}
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
return { widthPx: width, heightPx: height };
} catch {
return null;
}
}
export function getJpegDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 2) {
return null;
}
if (buffer[0] !== 0xff || buffer[1] !== 0xd8) {
return null;
}
let offset = 2;
while (offset < buffer.length - 9) {
if (buffer[offset] !== 0xff) {
offset++;
continue;
}
const marker = buffer[offset + 1];
if (marker >= 0xc0 && marker <= 0xc2) {
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
return { widthPx: width, heightPx: height };
}
if (offset + 3 >= buffer.length) {
return null;
}
const length = buffer.readUInt16BE(offset + 2);
if (length < 2) {
return null;
}
offset += 2 + length;
}
return null;
} catch {
return null;
}
}
export function getGifDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 10) {
return null;
}
const sig = buffer.slice(0, 6).toString("ascii");
if (sig !== "GIF87a" && sig !== "GIF89a") {
return null;
}
const width = buffer.readUInt16LE(6);
const height = buffer.readUInt16LE(8);
return { widthPx: width, heightPx: height };
} catch {
return null;
}
}
export function getWebpDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 30) {
return null;
}
const riff = buffer.slice(0, 4).toString("ascii");
const webp = buffer.slice(8, 12).toString("ascii");
if (riff !== "RIFF" || webp !== "WEBP") {
return null;
}
const chunk = buffer.slice(12, 16).toString("ascii");
if (chunk === "VP8 ") {
if (buffer.length < 30) return null;
const width = buffer.readUInt16LE(26) & 0x3fff;
const height = buffer.readUInt16LE(28) & 0x3fff;
return { widthPx: width, heightPx: height };
} else if (chunk === "VP8L") {
if (buffer.length < 25) return null;
const bits = buffer.readUInt32LE(21);
const width = (bits & 0x3fff) + 1;
const height = ((bits >> 14) & 0x3fff) + 1;
return { widthPx: width, heightPx: height };
} else if (chunk === "VP8X") {
if (buffer.length < 30) return null;
const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
return { widthPx: width, heightPx: height };
}
return null;
} catch {
return null;
}
}
export function getImageDimensions(
base64Data: string,
mimeType: string,
): ImageDimensions | null {
if (mimeType === "image/png") {
return getPngDimensions(base64Data);
}
if (mimeType === "image/jpeg") {
return getJpegDimensions(base64Data);
}
if (mimeType === "image/gif") {
return getGifDimensions(base64Data);
}
if (mimeType === "image/webp") {
return getWebpDimensions(base64Data);
}
return null;
}
export function renderImage(
base64Data: string,
imageDimensions: ImageDimensions,
options: ImageRenderOptions = {},
): { sequence: string; rows: number; imageId?: number } | null {
const caps = getCapabilities();
if (!caps.images) {
return null;
}
const maxWidth = options.maxWidthCells ?? 80;
const rows = calculateImageRows(
imageDimensions,
maxWidth,
getCellDimensions(),
);
if (caps.images === "kitty") {
// Only use imageId if explicitly provided - static images don't need IDs
const sequence = encodeKitty(base64Data, {
columns: maxWidth,
rows,
imageId: options.imageId,
});
return { sequence, rows, imageId: options.imageId };
}
if (caps.images === "iterm2") {
const sequence = encodeITerm2(base64Data, {
width: maxWidth,
height: "auto",
preserveAspectRatio: options.preserveAspectRatio ?? true,
});
return { sequence, rows };
}
return null;
}
export function imageFallback(
mimeType: string,
dimensions?: ImageDimensions,
filename?: string,
): string {
const parts: string[] = [];
if (filename) parts.push(filename);
parts.push(`[${mimeType}]`);
if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
return `[Image: ${parts.join(" ")}]`;
}

View file

@ -0,0 +1,332 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import { setKittyProtocolActive } from "./keys.js";
import { StdinBuffer } from "./stdin-buffer.js";
const cjsRequire = createRequire(import.meta.url);
/**
* Minimal terminal interface for TUI
*/
export interface Terminal {
// Start the terminal with input and resize handlers
start(onInput: (data: string) => void, onResize: () => void): void;
// Stop the terminal and restore state
stop(): void;
/**
* Drain stdin before exiting to prevent Kitty key release events from
* leaking to the parent shell over slow SSH connections.
* @param maxMs - Maximum time to drain (default: 1000ms)
* @param idleMs - Exit early if no input arrives within this time (default: 50ms)
*/
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
// Write output to terminal
write(data: string): void;
// Get terminal dimensions
get columns(): number;
get rows(): number;
// Whether Kitty keyboard protocol is active
get kittyProtocolActive(): boolean;
// Cursor positioning (relative to current position)
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
// Cursor visibility
hideCursor(): void; // Hide the cursor
showCursor(): void; // Show the cursor
// Clear operations
clearLine(): void; // Clear current line
clearFromCursor(): void; // Clear from cursor to end of screen
clearScreen(): void; // Clear entire screen and move cursor to (0,0)
// Title operations
setTitle(title: string): void; // Set terminal window title
}
/**
* Real terminal using process.stdin/stdout
*/
export class ProcessTerminal implements Terminal {
private wasRaw = false;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
private _kittyProtocolActive = false;
private stdinBuffer?: StdinBuffer;
private stdinDataHandler?: (data: string) => void;
private writeLogPath = process.env.PI_TUI_WRITE_LOG || "";
get kittyProtocolActive(): boolean {
return this._kittyProtocolActive;
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.inputHandler = onInput;
this.resizeHandler = onResize;
// Save previous state and enable raw mode
this.wasRaw = process.stdin.isRaw || false;
if (process.stdin.setRawMode) {
process.stdin.setRawMode(true);
}
process.stdin.setEncoding("utf8");
process.stdin.resume();
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
process.stdout.write("\x1b[?2004h");
// Set up resize handler immediately
process.stdout.on("resize", this.resizeHandler);
// Refresh terminal dimensions - they may be stale after suspend/resume
// (SIGWINCH is lost while process is stopped). Unix only.
if (process.platform !== "win32") {
process.kill(process.pid, "SIGWINCH");
}
// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
// events that lose modifier information. Must run AFTER setRawMode(true)
// since that resets console mode flags.
this.enableWindowsVTInput();
// Query and enable Kitty keyboard protocol
// The query handler intercepts input temporarily, then installs the user's handler
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
this.queryAndEnableKittyProtocol();
}
/**
* Set up StdinBuffer to split batched input into individual sequences.
* This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
*
* Also watches for Kitty protocol response and enables it when detected.
* This is done here (after stdinBuffer parsing) rather than on raw stdin
* to handle the case where the response arrives split across multiple events.
*/
private setupStdinBuffer(): void {
this.stdinBuffer = new StdinBuffer({ timeout: 10 });
// Kitty protocol response pattern: \x1b[?<flags>u
const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
// Forward individual sequences to the input handler
this.stdinBuffer.on("data", (sequence) => {
// Check for Kitty protocol response (only if not already enabled)
if (!this._kittyProtocolActive) {
const match = sequence.match(kittyResponsePattern);
if (match) {
this._kittyProtocolActive = true;
setKittyProtocolActive(true);
// Enable Kitty keyboard protocol (push flags)
// Flag 1 = disambiguate escape codes
// Flag 2 = report event types (press/repeat/release)
// Flag 4 = report alternate keys (shifted key, base layout key)
// Base layout key enables shortcuts to work with non-Latin keyboard layouts
process.stdout.write("\x1b[>7u");
return; // Don't forward protocol response to TUI
}
}
if (this.inputHandler) {
this.inputHandler(sequence);
}
});
// Re-wrap paste content with bracketed paste markers for existing editor handling
this.stdinBuffer.on("paste", (content) => {
if (this.inputHandler) {
this.inputHandler(`\x1b[200~${content}\x1b[201~`);
}
});
// Handler that pipes stdin data through the buffer
this.stdinDataHandler = (data: string) => {
this.stdinBuffer!.process(data);
};
}
/**
* Query terminal for Kitty keyboard protocol support and enable if available.
*
* Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
* it supports the protocol and we enable it with CSI > 1 u.
*
* The response is detected in setupStdinBuffer's data handler, which properly
* handles the case where the response arrives split across multiple stdin events.
*/
private queryAndEnableKittyProtocol(): void {
this.setupStdinBuffer();
process.stdin.on("data", this.stdinDataHandler!);
process.stdout.write("\x1b[?u");
}
/**
* On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200) to the stdin
* console handle so the terminal sends VT sequences for modified keys
* (e.g. \x1b[Z for Shift+Tab). Without this, libuv's ReadConsoleInputW
* discards modifier state and Shift+Tab arrives as plain \t.
*/
private enableWindowsVTInput(): void {
if (process.platform !== "win32") return;
try {
// Dynamic require to avoid bundling koffi's 74MB of cross-platform
// native binaries into every compiled binary. Koffi is only needed
// on Windows for VT input support.
const koffi = cjsRequire("koffi");
const k32 = koffi.load("kernel32.dll");
const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)");
const GetConsoleMode = k32.func(
"bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)",
);
const SetConsoleMode = k32.func(
"bool __stdcall SetConsoleMode(void*, uint32_t)",
);
const STD_INPUT_HANDLE = -10;
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
const handle = GetStdHandle(STD_INPUT_HANDLE);
const mode = new Uint32Array(1);
GetConsoleMode(handle, mode);
SetConsoleMode(handle, mode[0]! | ENABLE_VIRTUAL_TERMINAL_INPUT);
} catch {
// koffi not available — Shift+Tab won't be distinguishable from Tab
}
}
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
if (this._kittyProtocolActive) {
// Disable Kitty keyboard protocol first so any late key releases
// do not generate new Kitty escape sequences.
process.stdout.write("\x1b[<u");
this._kittyProtocolActive = false;
setKittyProtocolActive(false);
}
const previousHandler = this.inputHandler;
this.inputHandler = undefined;
let lastDataTime = Date.now();
const onData = () => {
lastDataTime = Date.now();
};
process.stdin.on("data", onData);
const endTime = Date.now() + maxMs;
try {
while (true) {
const now = Date.now();
const timeLeft = endTime - now;
if (timeLeft <= 0) break;
if (now - lastDataTime >= idleMs) break;
await new Promise((resolve) =>
setTimeout(resolve, Math.min(idleMs, timeLeft)),
);
}
} finally {
process.stdin.removeListener("data", onData);
this.inputHandler = previousHandler;
}
}
stop(): void {
// Disable bracketed paste mode
process.stdout.write("\x1b[?2004l");
// Disable Kitty keyboard protocol if not already done by drainInput()
if (this._kittyProtocolActive) {
process.stdout.write("\x1b[<u");
this._kittyProtocolActive = false;
setKittyProtocolActive(false);
}
// Clean up StdinBuffer
if (this.stdinBuffer) {
this.stdinBuffer.destroy();
this.stdinBuffer = undefined;
}
// Remove event handlers
if (this.stdinDataHandler) {
process.stdin.removeListener("data", this.stdinDataHandler);
this.stdinDataHandler = undefined;
}
this.inputHandler = undefined;
if (this.resizeHandler) {
process.stdout.removeListener("resize", this.resizeHandler);
this.resizeHandler = undefined;
}
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
// re-interpreted after raw mode is disabled. This fixes a race condition
// where Ctrl+D could close the parent shell over SSH.
process.stdin.pause();
// Restore raw mode state
if (process.stdin.setRawMode) {
process.stdin.setRawMode(this.wasRaw);
}
}
write(data: string): void {
process.stdout.write(data);
if (this.writeLogPath) {
try {
fs.appendFileSync(this.writeLogPath, data, { encoding: "utf8" });
} catch {
// Ignore logging errors
}
}
}
get columns(): number {
return process.stdout.columns || 80;
}
get rows(): number {
return process.stdout.rows || 24;
}
moveBy(lines: number): void {
if (lines > 0) {
// Move down
process.stdout.write(`\x1b[${lines}B`);
} else if (lines < 0) {
// Move up
process.stdout.write(`\x1b[${-lines}A`);
}
// lines === 0: no movement
}
hideCursor(): void {
process.stdout.write("\x1b[?25l");
}
showCursor(): void {
process.stdout.write("\x1b[?25h");
}
clearLine(): void {
process.stdout.write("\x1b[K");
}
clearFromCursor(): void {
process.stdout.write("\x1b[J");
}
clearScreen(): void {
process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
}
setTitle(title: string): void {
// OSC 0;title BEL - set terminal window title
process.stdout.write(`\x1b]0;${title}\x07`);
}
}

1328
packages/tui/src/tui.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
/**
* Generic undo stack with clone-on-push semantics.
*
* Stores deep clones of state snapshots. Popped snapshots are returned
* directly (no re-cloning) since they are already detached.
*/
export class UndoStack<S> {
private stack: S[] = [];
/** Push a deep clone of the given state onto the stack. */
push(state: S): void {
this.stack.push(structuredClone(state));
}
/** Pop and return the most recent snapshot, or undefined if empty. */
pop(): S | undefined {
return this.stack.pop();
}
/** Remove all snapshots. */
clear(): void {
this.stack.length = 0;
}
get length(): number {
return this.stack.length;
}
}

933
packages/tui/src/utils.ts Normal file
View file

@ -0,0 +1,933 @@
import { eastAsianWidth } from "get-east-asian-width";
// Grapheme segmenter (shared instance)
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
/**
* Get the shared grapheme segmenter instance.
*/
export function getSegmenter(): Intl.Segmenter {
return segmenter;
}
/**
* Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.
* This is a fast heuristic to avoid the expensive rgiEmojiRegex test.
* The tested Unicode blocks are deliberately broad to account for future
* Unicode additions.
*/
function couldBeEmoji(segment: string): boolean {
const cp = segment.codePointAt(0)!;
return (
(cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph
(cp >= 0x2300 && cp <= 0x23ff) || // Misc technical
(cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats
(cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles
segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector)
segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)
);
}
// Regexes for character classification (same as string-width library)
const zeroWidthRegex =
/^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
const leadingNonPrintingRegex =
/^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
// Cache for non-ASCII strings
const WIDTH_CACHE_SIZE = 512;
const widthCache = new Map<string, number>();
/**
* Calculate the terminal width of a single grapheme cluster.
* Based on code from the string-width library, but includes a possible-emoji
* check to avoid running the RGI_Emoji regex unnecessarily.
*/
function graphemeWidth(segment: string): number {
// Zero-width clusters
if (zeroWidthRegex.test(segment)) {
return 0;
}
// Emoji check with pre-filter
if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) {
return 2;
}
// Get base visible codepoint
const base = segment.replace(leadingNonPrintingRegex, "");
const cp = base.codePointAt(0);
if (cp === undefined) {
return 0;
}
// Regional indicator symbols (U+1F1E6..U+1F1FF) are often rendered as
// full-width emoji in terminals, even when isolated during streaming.
// Keep width conservative (2) to avoid terminal auto-wrap drift artifacts.
if (cp >= 0x1f1e6 && cp <= 0x1f1ff) {
return 2;
}
let width = eastAsianWidth(cp);
// Trailing halfwidth/fullwidth forms
if (segment.length > 1) {
for (const char of segment.slice(1)) {
const c = char.codePointAt(0)!;
if (c >= 0xff00 && c <= 0xffef) {
width += eastAsianWidth(c);
}
}
}
return width;
}
/**
* Calculate the visible width of a string in terminal columns.
*/
export function visibleWidth(str: string): number {
if (str.length === 0) {
return 0;
}
// Fast path: pure ASCII printable
let isPureAscii = true;
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code < 0x20 || code > 0x7e) {
isPureAscii = false;
break;
}
}
if (isPureAscii) {
return str.length;
}
// Check cache
const cached = widthCache.get(str);
if (cached !== undefined) {
return cached;
}
// Normalize: tabs to 3 spaces, strip ANSI escape codes
let clean = str;
if (str.includes("\t")) {
clean = clean.replace(/\t/g, " ");
}
if (clean.includes("\x1b")) {
// Strip supported ANSI/OSC/APC escape sequences in one pass.
// This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers,
// and APC sequences like CURSOR_MARKER.
let stripped = "";
let i = 0;
while (i < clean.length) {
const ansi = extractAnsiCode(clean, i);
if (ansi) {
i += ansi.length;
continue;
}
stripped += clean[i];
i++;
}
clean = stripped;
}
// Calculate width
let width = 0;
for (const { segment } of segmenter.segment(clean)) {
width += graphemeWidth(segment);
}
// Cache result
if (widthCache.size >= WIDTH_CACHE_SIZE) {
const firstKey = widthCache.keys().next().value;
if (firstKey !== undefined) {
widthCache.delete(firstKey);
}
}
widthCache.set(str, width);
return width;
}
/**
* Extract ANSI escape sequences from a string at the given position.
*/
export function extractAnsiCode(
str: string,
pos: number,
): { code: string; length: number } | null {
if (pos >= str.length || str[pos] !== "\x1b") return null;
const next = str[pos + 1];
// CSI sequence: ESC [ ... m/G/K/H/J
if (next === "[") {
let j = pos + 2;
while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++;
if (j < str.length)
return { code: str.substring(pos, j + 1), length: j + 1 - pos };
return null;
}
// OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \)
// Used for hyperlinks (OSC 8), window titles, etc.
if (next === "]") {
let j = pos + 2;
while (j < str.length) {
if (str[j] === "\x07")
return { code: str.substring(pos, j + 1), length: j + 1 - pos };
if (str[j] === "\x1b" && str[j + 1] === "\\")
return { code: str.substring(pos, j + 2), length: j + 2 - pos };
j++;
}
return null;
}
// APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \)
// Used for cursor marker and application-specific commands
if (next === "_") {
let j = pos + 2;
while (j < str.length) {
if (str[j] === "\x07")
return { code: str.substring(pos, j + 1), length: j + 1 - pos };
if (str[j] === "\x1b" && str[j + 1] === "\\")
return { code: str.substring(pos, j + 2), length: j + 2 - pos };
j++;
}
return null;
}
return null;
}
/**
* Track active ANSI SGR codes to preserve styling across line breaks.
*/
class AnsiCodeTracker {
// Track individual attributes separately so we can reset them specifically
private bold = false;
private dim = false;
private italic = false;
private underline = false;
private blink = false;
private inverse = false;
private hidden = false;
private strikethrough = false;
private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240"
private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240"
process(ansiCode: string): void {
if (!ansiCode.endsWith("m")) {
return;
}
// Extract the parameters between \x1b[ and m
const match = ansiCode.match(/\x1b\[([\d;]*)m/);
if (!match) return;
const params = match[1];
if (params === "" || params === "0") {
// Full reset
this.reset();
return;
}
// Parse parameters (can be semicolon-separated)
const parts = params.split(";");
let i = 0;
while (i < parts.length) {
const code = Number.parseInt(parts[i], 10);
// Handle 256-color and RGB codes which consume multiple parameters
if (code === 38 || code === 48) {
// 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg)
// 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg)
if (parts[i + 1] === "5" && parts[i + 2] !== undefined) {
// 256 color: 38;5;N or 48;5;N
const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`;
if (code === 38) {
this.fgColor = colorCode;
} else {
this.bgColor = colorCode;
}
i += 3;
continue;
} else if (parts[i + 1] === "2" && parts[i + 4] !== undefined) {
// RGB color: 38;2;R;G;B or 48;2;R;G;B
const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`;
if (code === 38) {
this.fgColor = colorCode;
} else {
this.bgColor = colorCode;
}
i += 5;
continue;
}
}
// Standard SGR codes
switch (code) {
case 0:
this.reset();
break;
case 1:
this.bold = true;
break;
case 2:
this.dim = true;
break;
case 3:
this.italic = true;
break;
case 4:
this.underline = true;
break;
case 5:
this.blink = true;
break;
case 7:
this.inverse = true;
break;
case 8:
this.hidden = true;
break;
case 9:
this.strikethrough = true;
break;
case 21:
this.bold = false;
break; // Some terminals
case 22:
this.bold = false;
this.dim = false;
break;
case 23:
this.italic = false;
break;
case 24:
this.underline = false;
break;
case 25:
this.blink = false;
break;
case 27:
this.inverse = false;
break;
case 28:
this.hidden = false;
break;
case 29:
this.strikethrough = false;
break;
case 39:
this.fgColor = null;
break; // Default fg
case 49:
this.bgColor = null;
break; // Default bg
default:
// Standard foreground colors 30-37, 90-97
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
this.fgColor = String(code);
}
// Standard background colors 40-47, 100-107
else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
this.bgColor = String(code);
}
break;
}
i++;
}
}
private reset(): void {
this.bold = false;
this.dim = false;
this.italic = false;
this.underline = false;
this.blink = false;
this.inverse = false;
this.hidden = false;
this.strikethrough = false;
this.fgColor = null;
this.bgColor = null;
}
/** Clear all state for reuse. */
clear(): void {
this.reset();
}
getActiveCodes(): string {
const codes: string[] = [];
if (this.bold) codes.push("1");
if (this.dim) codes.push("2");
if (this.italic) codes.push("3");
if (this.underline) codes.push("4");
if (this.blink) codes.push("5");
if (this.inverse) codes.push("7");
if (this.hidden) codes.push("8");
if (this.strikethrough) codes.push("9");
if (this.fgColor) codes.push(this.fgColor);
if (this.bgColor) codes.push(this.bgColor);
if (codes.length === 0) return "";
return `\x1b[${codes.join(";")}m`;
}
hasActiveCodes(): boolean {
return (
this.bold ||
this.dim ||
this.italic ||
this.underline ||
this.blink ||
this.inverse ||
this.hidden ||
this.strikethrough ||
this.fgColor !== null ||
this.bgColor !== null
);
}
/**
* Get reset codes for attributes that need to be turned off at line end,
* specifically underline which bleeds into padding.
* Returns empty string if no problematic attributes are active.
*/
getLineEndReset(): string {
// Only underline causes visual bleeding into padding
// Other attributes like colors don't visually bleed to padding
if (this.underline) {
return "\x1b[24m"; // Underline off only
}
return "";
}
}
function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
let i = 0;
while (i < text.length) {
const ansiResult = extractAnsiCode(text, i);
if (ansiResult) {
tracker.process(ansiResult.code);
i += ansiResult.length;
} else {
i++;
}
}
}
/**
* Split text into words while keeping ANSI codes attached.
*/
function splitIntoTokensWithAnsi(text: string): string[] {
const tokens: string[] = [];
let current = "";
let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content
let inWhitespace = false;
let i = 0;
while (i < text.length) {
const ansiResult = extractAnsiCode(text, i);
if (ansiResult) {
// Hold ANSI codes separately - they'll be attached to the next visible char
pendingAnsi += ansiResult.code;
i += ansiResult.length;
continue;
}
const char = text[i];
const charIsSpace = char === " ";
if (charIsSpace !== inWhitespace && current) {
// Switching between whitespace and non-whitespace, push current token
tokens.push(current);
current = "";
}
// Attach any pending ANSI codes to this visible character
if (pendingAnsi) {
current += pendingAnsi;
pendingAnsi = "";
}
inWhitespace = charIsSpace;
current += char;
i++;
}
// Handle any remaining pending ANSI codes (attach to last token)
if (pendingAnsi) {
current += pendingAnsi;
}
if (current) {
tokens.push(current);
}
return tokens;
}
/**
* Wrap text with ANSI codes preserved.
*
* ONLY does word wrapping - NO padding, NO background colors.
* Returns lines where each line is <= width visible chars.
* Active ANSI codes are preserved across line breaks.
*
* @param text - Text to wrap (may contain ANSI codes and newlines)
* @param width - Maximum visible width per line
* @returns Array of wrapped lines (NOT padded to width)
*/
export function wrapTextWithAnsi(text: string, width: number): string[] {
if (!text) {
return [""];
}
// Handle newlines by processing each line separately
// Track ANSI state across lines so styles carry over after literal newlines
const inputLines = text.split("\n");
const result: string[] = [];
const tracker = new AnsiCodeTracker();
for (const inputLine of inputLines) {
// Prepend active ANSI codes from previous lines (except for first line)
const prefix = result.length > 0 ? tracker.getActiveCodes() : "";
result.push(...wrapSingleLine(prefix + inputLine, width));
// Update tracker with codes from this line for next iteration
updateTrackerFromText(inputLine, tracker);
}
return result.length > 0 ? result : [""];
}
function wrapSingleLine(line: string, width: number): string[] {
if (!line) {
return [""];
}
const visibleLength = visibleWidth(line);
if (visibleLength <= width) {
return [line];
}
const wrapped: string[] = [];
const tracker = new AnsiCodeTracker();
const tokens = splitIntoTokensWithAnsi(line);
let currentLine = "";
let currentVisibleLength = 0;
for (const token of tokens) {
const tokenVisibleLength = visibleWidth(token);
const isWhitespace = token.trim() === "";
// Token itself is too long - break it character by character
if (tokenVisibleLength > width && !isWhitespace) {
if (currentLine) {
// Add specific reset for underline only (preserves background)
const lineEndReset = tracker.getLineEndReset();
if (lineEndReset) {
currentLine += lineEndReset;
}
wrapped.push(currentLine);
currentLine = "";
currentVisibleLength = 0;
}
// Break long token - breakLongWord handles its own resets
const broken = breakLongWord(token, width, tracker);
wrapped.push(...broken.slice(0, -1));
currentLine = broken[broken.length - 1];
currentVisibleLength = visibleWidth(currentLine);
continue;
}
// Check if adding this token would exceed width
const totalNeeded = currentVisibleLength + tokenVisibleLength;
if (totalNeeded > width && currentVisibleLength > 0) {
// Trim trailing whitespace, then add underline reset (not full reset, to preserve background)
let lineToWrap = currentLine.trimEnd();
const lineEndReset = tracker.getLineEndReset();
if (lineEndReset) {
lineToWrap += lineEndReset;
}
wrapped.push(lineToWrap);
if (isWhitespace) {
// Don't start new line with whitespace
currentLine = tracker.getActiveCodes();
currentVisibleLength = 0;
} else {
currentLine = tracker.getActiveCodes() + token;
currentVisibleLength = tokenVisibleLength;
}
} else {
// Add to current line
currentLine += token;
currentVisibleLength += tokenVisibleLength;
}
updateTrackerFromText(token, tracker);
}
if (currentLine) {
// No reset at end of final line - let caller handle it
wrapped.push(currentLine);
}
// Trailing whitespace can cause lines to exceed the requested width
return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""];
}
const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
/**
* Check if a character is whitespace.
*/
export function isWhitespaceChar(char: string): boolean {
return /\s/.test(char);
}
/**
* Check if a character is punctuation.
*/
export function isPunctuationChar(char: string): boolean {
return PUNCTUATION_REGEX.test(char);
}
function breakLongWord(
word: string,
width: number,
tracker: AnsiCodeTracker,
): string[] {
const lines: string[] = [];
let currentLine = tracker.getActiveCodes();
let currentWidth = 0;
// First, separate ANSI codes from visible content
// We need to handle ANSI codes specially since they're not graphemes
let i = 0;
const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
while (i < word.length) {
const ansiResult = extractAnsiCode(word, i);
if (ansiResult) {
segments.push({ type: "ansi", value: ansiResult.code });
i += ansiResult.length;
} else {
// Find the next ANSI code or end of string
let end = i;
while (end < word.length) {
const nextAnsi = extractAnsiCode(word, end);
if (nextAnsi) break;
end++;
}
// Segment this non-ANSI portion into graphemes
const textPortion = word.slice(i, end);
for (const seg of segmenter.segment(textPortion)) {
segments.push({ type: "grapheme", value: seg.segment });
}
i = end;
}
}
// Now process segments
for (const seg of segments) {
if (seg.type === "ansi") {
currentLine += seg.value;
tracker.process(seg.value);
continue;
}
const grapheme = seg.value;
// Skip empty graphemes to avoid issues with string-width calculation
if (!grapheme) continue;
const graphemeWidth = visibleWidth(grapheme);
if (currentWidth + graphemeWidth > width) {
// Add specific reset for underline only (preserves background)
const lineEndReset = tracker.getLineEndReset();
if (lineEndReset) {
currentLine += lineEndReset;
}
lines.push(currentLine);
currentLine = tracker.getActiveCodes();
currentWidth = 0;
}
currentLine += grapheme;
currentWidth += graphemeWidth;
}
if (currentLine) {
// No reset at end of final segment - caller handles continuation
lines.push(currentLine);
}
return lines.length > 0 ? lines : [""];
}
/**
* Apply background color to a line, padding to full width.
*
* @param line - Line of text (may contain ANSI codes)
* @param width - Total width to pad to
* @param bgFn - Background color function
* @returns Line with background applied and padded to width
*/
export function applyBackgroundToLine(
line: string,
width: number,
bgFn: (text: string) => string,
): string {
// Calculate padding needed
const visibleLen = visibleWidth(line);
const paddingNeeded = Math.max(0, width - visibleLen);
const padding = " ".repeat(paddingNeeded);
// Apply background to content + padding
const withPadding = line + padding;
return bgFn(withPadding);
}
/**
* Truncate text to fit within a maximum visible width, adding ellipsis if needed.
* Optionally pad with spaces to reach exactly maxWidth.
* Properly handles ANSI escape codes (they don't count toward width).
*
* @param text - Text to truncate (may contain ANSI codes)
* @param maxWidth - Maximum visible width
* @param ellipsis - Ellipsis string to append when truncating (default: "...")
* @param pad - If true, pad result with spaces to exactly maxWidth (default: false)
* @returns Truncated text, optionally padded to exactly maxWidth
*/
export function truncateToWidth(
text: string,
maxWidth: number,
ellipsis: string = "...",
pad: boolean = false,
): string {
const textVisibleWidth = visibleWidth(text);
if (textVisibleWidth <= maxWidth) {
return pad ? text + " ".repeat(maxWidth - textVisibleWidth) : text;
}
const ellipsisWidth = visibleWidth(ellipsis);
const targetWidth = maxWidth - ellipsisWidth;
if (targetWidth <= 0) {
return ellipsis.substring(0, maxWidth);
}
// Separate ANSI codes from visible content using grapheme segmentation
let i = 0;
const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
while (i < text.length) {
const ansiResult = extractAnsiCode(text, i);
if (ansiResult) {
segments.push({ type: "ansi", value: ansiResult.code });
i += ansiResult.length;
} else {
// Find the next ANSI code or end of string
let end = i;
while (end < text.length) {
const nextAnsi = extractAnsiCode(text, end);
if (nextAnsi) break;
end++;
}
// Segment this non-ANSI portion into graphemes
const textPortion = text.slice(i, end);
for (const seg of segmenter.segment(textPortion)) {
segments.push({ type: "grapheme", value: seg.segment });
}
i = end;
}
}
// Build truncated string from segments
let result = "";
let currentWidth = 0;
for (const seg of segments) {
if (seg.type === "ansi") {
result += seg.value;
continue;
}
const grapheme = seg.value;
// Skip empty graphemes to avoid issues with string-width calculation
if (!grapheme) continue;
const graphemeWidth = visibleWidth(grapheme);
if (currentWidth + graphemeWidth > targetWidth) {
break;
}
result += grapheme;
currentWidth += graphemeWidth;
}
// Add reset code before ellipsis to prevent styling leaking into it
const truncated = `${result}\x1b[0m${ellipsis}`;
if (pad) {
const truncatedWidth = visibleWidth(truncated);
return truncated + " ".repeat(Math.max(0, maxWidth - truncatedWidth));
}
return truncated;
}
/**
* Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
* @param strict - If true, exclude wide chars at boundary that would extend past the range
*/
export function sliceByColumn(
line: string,
startCol: number,
length: number,
strict = false,
): string {
return sliceWithWidth(line, startCol, length, strict).text;
}
/** Like sliceByColumn but also returns the actual visible width of the result. */
export function sliceWithWidth(
line: string,
startCol: number,
length: number,
strict = false,
): { text: string; width: number } {
if (length <= 0) return { text: "", width: 0 };
const endCol = startCol + length;
let result = "",
resultWidth = 0,
currentCol = 0,
i = 0,
pendingAnsi = "";
while (i < line.length) {
const ansi = extractAnsiCode(line, i);
if (ansi) {
if (currentCol >= startCol && currentCol < endCol) result += ansi.code;
else if (currentCol < startCol) pendingAnsi += ansi.code;
i += ansi.length;
continue;
}
let textEnd = i;
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
const w = graphemeWidth(segment);
const inRange = currentCol >= startCol && currentCol < endCol;
const fits = !strict || currentCol + w <= endCol;
if (inRange && fits) {
if (pendingAnsi) {
result += pendingAnsi;
pendingAnsi = "";
}
result += segment;
resultWidth += w;
}
currentCol += w;
if (currentCol >= endCol) break;
}
i = textEnd;
if (currentCol >= endCol) break;
}
return { text: result, width: resultWidth };
}
// Pooled tracker instance for extractSegments (avoids allocation per call)
const pooledStyleTracker = new AnsiCodeTracker();
/**
* Extract "before" and "after" segments from a line in a single pass.
* Used for overlay compositing where we need content before and after the overlay region.
* Preserves styling from before the overlay that should affect content after it.
*/
export function extractSegments(
line: string,
beforeEnd: number,
afterStart: number,
afterLen: number,
strictAfter = false,
): { before: string; beforeWidth: number; after: string; afterWidth: number } {
let before = "",
beforeWidth = 0,
after = "",
afterWidth = 0;
let currentCol = 0,
i = 0;
let pendingAnsiBefore = "";
let afterStarted = false;
const afterEnd = afterStart + afterLen;
// Track styling state so "after" inherits styling from before the overlay
pooledStyleTracker.clear();
while (i < line.length) {
const ansi = extractAnsiCode(line, i);
if (ansi) {
// Track all SGR codes to know styling state at afterStart
pooledStyleTracker.process(ansi.code);
// Include ANSI codes in their respective segments
if (currentCol < beforeEnd) {
pendingAnsiBefore += ansi.code;
} else if (
currentCol >= afterStart &&
currentCol < afterEnd &&
afterStarted
) {
// Only include after we've started "after" (styling already prepended)
after += ansi.code;
}
i += ansi.length;
continue;
}
let textEnd = i;
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
const w = graphemeWidth(segment);
if (currentCol < beforeEnd) {
if (pendingAnsiBefore) {
before += pendingAnsiBefore;
pendingAnsiBefore = "";
}
before += segment;
beforeWidth += w;
} else if (currentCol >= afterStart && currentCol < afterEnd) {
const fits = !strictAfter || currentCol + w <= afterEnd;
if (fits) {
// On first "after" grapheme, prepend inherited styling from before overlay
if (!afterStarted) {
after += pooledStyleTracker.getActiveCodes();
afterStarted = true;
}
after += segment;
afterWidth += w;
}
}
currentCol += w;
// Early exit: done with "before" only, or done with both segments
if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd)
break;
}
i = textEnd;
if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
}
return { before, beforeWidth, after, afterWidth };
}

View file

@ -0,0 +1,521 @@
import assert from "node:assert";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, it, test } from "node:test";
import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
const resolveFdPath = (): string | null => {
const command = process.platform === "win32" ? "where" : "which";
const result = spawnSync(command, ["fd"], { encoding: "utf-8" });
if (result.status !== 0 || !result.stdout) {
return null;
}
const firstLine = result.stdout.split(/\r?\n/).find(Boolean);
return firstLine ? firstLine.trim() : null;
};
type FolderStructure = {
dirs?: string[];
files?: Record<string, string>;
};
const setupFolder = (
baseDir: string,
structure: FolderStructure = {},
): void => {
const dirs = structure.dirs ?? [];
const files = structure.files ?? {};
dirs.forEach((dir) => {
mkdirSync(join(baseDir, dir), { recursive: true });
});
Object.entries(files).forEach(([filePath, contents]) => {
const fullPath = join(baseDir, filePath);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, contents);
});
};
const fdPath = resolveFdPath();
const isFdInstalled = Boolean(fdPath);
const requireFdPath = (): string => {
if (!fdPath) {
throw new Error("fd is not available");
}
return fdPath;
};
describe("CombinedAutocompleteProvider", () => {
describe("extractPathPrefix", () => {
it("extracts / from 'hey /' when forced", () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["hey /"];
const cursorLine = 0;
const cursorCol = 5; // After the "/"
const result = provider.getForceFileSuggestions(
lines,
cursorLine,
cursorCol,
);
assert.notEqual(
result,
null,
"Should return suggestions for root directory",
);
if (result) {
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
}
});
it("extracts /A from '/A' when forced", () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["/A"];
const cursorLine = 0;
const cursorCol = 2; // After the "A"
const result = provider.getForceFileSuggestions(
lines,
cursorLine,
cursorCol,
);
console.log("Result:", result);
// This might return null if /A doesn't match anything, which is fine
// We're mainly testing that the prefix extraction works
if (result) {
assert.strictEqual(result.prefix, "/A", "Prefix should be '/A'");
}
});
it("does not trigger for slash commands", () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["/model"];
const cursorLine = 0;
const cursorCol = 6; // After "model"
const result = provider.getForceFileSuggestions(
lines,
cursorLine,
cursorCol,
);
console.log("Result:", result);
assert.strictEqual(result, null, "Should not trigger for slash commands");
});
it("triggers for absolute paths after slash command argument", () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["/command /"];
const cursorLine = 0;
const cursorCol = 10; // After the second "/"
const result = provider.getForceFileSuggestions(
lines,
cursorLine,
cursorCol,
);
console.log("Result:", result);
assert.notEqual(
result,
null,
"Should trigger for absolute paths in command arguments",
);
if (result) {
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
}
});
});
describe("fd @ file suggestions", { skip: !isFdInstalled }, () => {
let rootDir = "";
let baseDir = "";
let outsideDir = "";
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-root-"));
baseDir = join(rootDir, "cwd");
outsideDir = join(rootDir, "outside");
mkdirSync(baseDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
test("returns all files and folders for empty @ query", () => {
setupFolder(baseDir, {
dirs: ["src"],
files: {
"README.md": "readme",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value).sort();
assert.deepStrictEqual(values, ["@README.md", "@src/"].sort());
});
test("matches file with extension in query", () => {
setupFolder(baseDir, {
files: {
"file.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@file.txt";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@file.txt"));
});
test("filters are case insensitive", () => {
setupFolder(baseDir, {
dirs: ["src"],
files: {
"README.md": "readme",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@re";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value).sort();
assert.deepStrictEqual(values, ["@README.md"]);
});
test("ranks directories before files", () => {
setupFolder(baseDir, {
dirs: ["src"],
files: {
"src.txt": "text",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@src";
const result = provider.getSuggestions([line], 0, line.length);
const firstValue = result?.items[0]?.value;
const hasSrcFile = result?.items?.some(
(item) => item.value === "@src.txt",
);
assert.strictEqual(firstValue, "@src/");
assert.ok(hasSrcFile);
});
test("returns nested file paths", () => {
setupFolder(baseDir, {
files: {
"src/index.ts": "export {};\n",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@index";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@src/index.ts"));
});
test("matches deeply nested paths", () => {
setupFolder(baseDir, {
files: {
"packages/tui/src/autocomplete.ts": "export {};",
"packages/ai/src/autocomplete.ts": "export {};",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@tui/src/auto";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@packages/tui/src/autocomplete.ts"));
assert.ok(!values?.includes("@packages/ai/src/autocomplete.ts"));
});
test("matches directory in middle of path with --full-path", () => {
setupFolder(baseDir, {
files: {
"src/components/Button.tsx": "export {};",
"src/utils/helpers.ts": "export {};",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@components/";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@src/components/Button.tsx"));
assert.ok(!values?.includes("@src/utils/helpers.ts"));
});
test("scopes fuzzy search to relative directories and searches recursively", () => {
setupFolder(outsideDir, {
files: {
"nested/alpha.ts": "export {};",
"nested/deeper/also-alpha.ts": "export {};",
"nested/deeper/zzz.ts": "export {};",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@../outside/a";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@../outside/nested/alpha.ts"));
assert.ok(values?.includes("@../outside/nested/deeper/also-alpha.ts"));
assert.ok(!values?.includes("@../outside/nested/deeper/zzz.ts"));
});
test("quotes paths with spaces for @ suggestions", () => {
setupFolder(baseDir, {
dirs: ["my folder"],
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@my";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('@"my folder/"'));
});
test("includes hidden paths but excludes .git", () => {
setupFolder(baseDir, {
dirs: [".pi", ".github", ".git"],
files: {
".pi/config.json": "{}",
".github/workflows/ci.yml": "name: ci",
".git/config": "[core]",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value) ?? [];
assert.ok(values.includes("@.pi/"));
assert.ok(values.includes("@.github/"));
assert.ok(
!values.some(
(value) => value === "@.git" || value.startsWith("@.git/"),
),
);
});
test("continues autocomplete inside quoted @ paths", () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
"my folder/other.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = '@"my folder/"';
const result = provider.getSuggestions([line], 0, line.length - 1);
assert.notEqual(
result,
null,
"Should return suggestions for quoted folder path",
);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('@"my folder/test.txt"'));
assert.ok(values?.includes('@"my folder/other.txt"'));
});
test("applies quoted @ completion without duplicating closing quote", () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = '@"my folder/te"';
const cursorCol = line.length - 1;
const result = provider.getSuggestions([line], 0, cursorCol);
assert.notEqual(
result,
null,
"Should return suggestions for quoted @ path",
);
const item = result?.items.find(
(entry) => entry.value === '@"my folder/test.txt"',
);
assert.ok(item, "Should find test.txt suggestion");
const applied = provider.applyCompletion(
[line],
0,
cursorCol,
item!,
result!.prefix,
);
assert.strictEqual(applied.lines[0], '@"my folder/test.txt" ');
});
});
describe("quoted path completion", () => {
let baseDir = "";
beforeEach(() => {
baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-"));
});
afterEach(() => {
rmSync(baseDir, { recursive: true, force: true });
});
test("quotes paths with spaces for direct completion", () => {
setupFolder(baseDir, {
dirs: ["my folder"],
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = "my";
const result = provider.getForceFileSuggestions([line], 0, line.length);
assert.notEqual(
result,
null,
"Should return suggestions for path completion",
);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('"my folder/"'));
});
test("continues completion inside quoted paths", () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
"my folder/other.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = '"my folder/"';
const result = provider.getForceFileSuggestions(
[line],
0,
line.length - 1,
);
assert.notEqual(
result,
null,
"Should return suggestions for quoted folder path",
);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('"my folder/test.txt"'));
assert.ok(values?.includes('"my folder/other.txt"'));
});
test("applies quoted completion without duplicating closing quote", () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = '"my folder/te"';
const cursorCol = line.length - 1;
const result = provider.getForceFileSuggestions([line], 0, cursorCol);
assert.notEqual(
result,
null,
"Should return suggestions for quoted path",
);
const item = result?.items.find(
(entry) => entry.value === '"my folder/test.txt"',
);
assert.ok(item, "Should find test.txt suggestion");
const applied = provider.applyCompletion(
[line],
0,
cursorCol,
item!,
result!.prefix,
);
assert.strictEqual(applied.lines[0], '"my folder/test.txt"');
});
});
});

View file

@ -0,0 +1,283 @@
/**
* Bug regression test for isImageLine() crash scenario
*
* Bug: When isImageLine() used startsWith() and terminal doesn't support images,
* it would return false for lines containing image escape sequences, causing TUI to
* crash with "Rendered line exceeds terminal width" error.
*
* Fix: Changed to use includes() to detect escape sequences anywhere in the line.
*
* This test demonstrates:
* 1. The bug scenario with the old implementation
* 2. That the fix works correctly
*/
import assert from "node:assert";
import { describe, it } from "node:test";
describe("Bug regression: isImageLine() crash with image escape sequences", () => {
describe("Bug scenario: Terminal without image support", () => {
it("old implementation would return false, causing crash", () => {
/**
* OLD IMPLEMENTATION (buggy):
* ```typescript
* export function isImageLine(line: string): boolean {
* const prefix = getImageEscapePrefix();
* return prefix !== null && line.startsWith(prefix);
* }
* ```
*
* When terminal doesn't support images:
* - getImageEscapePrefix() returns null
* - isImageLine() returns false even for lines containing image sequences
* - TUI performs width check on line containing 300KB+ of base64 data
* - Crash: "Rendered line exceeds terminal width (304401 > 115)"
*/
// Simulate old implementation behavior
const oldIsImageLine = (
line: string,
imageEscapePrefix: string | null,
): boolean => {
return imageEscapePrefix !== null && line.startsWith(imageEscapePrefix);
};
// When terminal doesn't support images, prefix is null
const terminalWithoutImageSupport = null;
// Line containing image escape sequence with text before it (common bug scenario)
const lineWithImageSequence =
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07";
// Old implementation would return false (BUG!)
const oldResult = oldIsImageLine(
lineWithImageSequence,
terminalWithoutImageSupport,
);
assert.strictEqual(
oldResult,
false,
"Bug: old implementation returns false for line containing image sequence when terminal has no image support",
);
});
it("new implementation returns true correctly", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Line containing image escape sequence with text before it
const lineWithImageSequence =
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07";
// New implementation should return true (FIX!)
const newResult = isImageLine(lineWithImageSequence);
assert.strictEqual(
newResult,
true,
"Fix: new implementation returns true for line containing image sequence",
);
});
it("new implementation detects Kitty sequences in any position", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
const scenarios = [
"At start: \x1b_Ga=T,f=100,data...\x1b\\",
"Prefix \x1b_Ga=T,data...\x1b\\",
"Suffix text \x1b_Ga=T,data...\x1b\\ suffix",
"Middle \x1b_Ga=T,data...\x1b\\ more text",
// Very long line (simulating 300KB+ crash scenario)
`Text before \x1b_Ga=T,f=100${"A".repeat(300000)} text after`,
];
for (const line of scenarios) {
assert.strictEqual(
isImageLine(line),
true,
`Should detect Kitty sequence in: ${line.slice(0, 50)}...`,
);
}
});
it("new implementation detects iTerm2 sequences in any position", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
const scenarios = [
"At start: \x1b]1337;File=size=100,100:base64...\x07",
"Prefix \x1b]1337;File=inline=1:data==\x07",
"Suffix text \x1b]1337;File=inline=1:data==\x07 suffix",
"Middle \x1b]1337;File=inline=1:data==\x07 more text",
// Very long line (simulating 304KB crash scenario)
`Text before \x1b]1337;File=size=800,600;inline=1:${"B".repeat(300000)} text after`,
];
for (const line of scenarios) {
assert.strictEqual(
isImageLine(line),
true,
`Should detect iTerm2 sequence in: ${line.slice(0, 50)}...`,
);
}
});
});
describe("Integration: Tool execution scenario", () => {
/**
* This simulates what happens when the `read` tool reads an image file.
* The tool result contains both text and image content:
*
* ```typescript
* {
* content: [
* { type: "text", text: "Read image file [image/jpeg]\n800x600" },
* { type: "image", data: "base64...", mimeType: "image/jpeg" }
* ]
* }
* ```
*
* When this is rendered, the image component creates escape sequences.
* If isImageLine() doesn't detect them, TUI crashes.
*/
it("detects image sequences in read tool output", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Simulate output when read tool processes an image
// The line might have text from the read result plus the image escape sequence
const toolOutputLine =
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64image...\x07";
assert.strictEqual(
isImageLine(toolOutputLine),
true,
"Should detect image sequence in tool output line",
);
});
it("detects Kitty sequences from Image component", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Kitty image component creates multi-line output with escape sequences
const kittyLine =
"\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\";
assert.strictEqual(
isImageLine(kittyLine),
true,
"Should detect Kitty image component output",
);
});
it("handles ANSI codes before image sequences", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Line might have styling (error, warning, etc.) before image data
const lines = [
"\x1b[31mError\x1b[0m: \x1b]1337;File=inline=1:base64==\x07",
"\x1b[33mWarning\x1b[0m: \x1b_Ga=T,data...\x1b\\",
"\x1b[1mBold\x1b[0m \x1b]1337;File=:base64==\x07\x1b[0m",
];
for (const line of lines) {
assert.strictEqual(
isImageLine(line),
true,
`Should detect image sequence after ANSI codes: ${line.slice(0, 30)}...`,
);
}
});
});
describe("Crash scenario simulation", () => {
it("does NOT crash on very long lines with image sequences", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
/**
* Simulate the exact crash scenario:
* - Line is 304,401 characters (the crash log showed 58649 > 115)
* - Contains image escape sequence somewhere in the middle
* - Old implementation would return false, causing TUI to do width check
* - New implementation returns true, skipping width check (preventing crash)
*/
const base64Char = "A".repeat(100);
const iterm2Sequence = "\x1b]1337;File=size=800,600;inline=1:";
// Build a line that would cause the crash
const crashLine =
"Output: " +
iterm2Sequence +
base64Char.repeat(3040) + // ~304,000 chars
" end of output";
// Verify line is very long
assert(crashLine.length > 300000, "Test line should be > 300KB");
// New implementation should detect it (prevents crash)
const detected = isImageLine(crashLine);
assert.strictEqual(
detected,
true,
"Should detect image sequence in very long line, preventing TUI crash",
);
});
it("handles lines exactly matching crash log dimensions", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
/**
* Crash log showed: line 58649 chars wide, terminal width 115
* Let's create a line with similar characteristics
*/
const targetWidth = 58649;
const prefix = "Text";
const sequence = "\x1b_Ga=T,f=100";
const suffix = "End";
const padding = "A".repeat(
targetWidth - prefix.length - sequence.length - suffix.length,
);
const line = `${prefix}${sequence}${padding}${suffix}`;
assert.strictEqual(line.length, 58649);
assert.strictEqual(
isImageLine(line),
true,
"Should detect image sequence in 58649-char line",
);
});
});
describe("Negative cases: Don't false positive", () => {
it("does not detect images in regular long text", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Very long line WITHOUT image sequences
const longText = "A".repeat(100000);
assert.strictEqual(
isImageLine(longText),
false,
"Should not detect images in plain long text",
);
});
it("does not detect images in lines with file paths", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
const filePaths = [
"/path/to/1337/image.jpg",
"/usr/local/bin/File_converter",
"~/Documents/1337File_backup.png",
"./_G_test_file.txt",
];
for (const path of filePaths) {
assert.strictEqual(
isImageLine(path),
false,
`Should not falsely detect image sequence in path: ${path}`,
);
}
});
});
});

View file

@ -0,0 +1,137 @@
/**
* Simple chat interface demo using tui.ts
*/
import chalk from "chalk";
import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
import { Editor } from "../src/components/editor.js";
import { Loader } from "../src/components/loader.js";
import { Markdown } from "../src/components/markdown.js";
import { Text } from "../src/components/text.js";
import { ProcessTerminal } from "../src/terminal.js";
import { TUI } from "../src/tui.js";
import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.js";
// Create terminal
const terminal = new ProcessTerminal();
// Create TUI
const tui = new TUI(terminal);
// Create chat container with some initial messages
tui.addChild(
new Text(
"Welcome to Simple Chat!\n\nType your messages below. Type '/' for commands. Press Ctrl+C to exit.",
),
);
// Create editor with autocomplete
const editor = new Editor(tui, defaultEditorTheme);
// Set up autocomplete provider with slash commands and file completion
const autocompleteProvider = new CombinedAutocompleteProvider(
[
{ name: "delete", description: "Delete the last message" },
{ name: "clear", description: "Clear all messages" },
],
process.cwd(),
);
editor.setAutocompleteProvider(autocompleteProvider);
tui.addChild(editor);
// Focus the editor
tui.setFocus(editor);
// Track if we're waiting for bot response
let isResponding = false;
// Handle message submission
editor.onSubmit = (value: string) => {
// Prevent submission if already responding
if (isResponding) {
return;
}
const trimmed = value.trim();
// Handle slash commands
if (trimmed === "/delete") {
const children = tui.children;
// Remove component before editor (if there are any besides the initial text)
if (children.length > 3) {
// children[0] = "Welcome to Simple Chat!"
// children[1] = "Type your messages below..."
// children[2...n-1] = messages
// children[n] = editor
children.splice(children.length - 2, 1);
}
tui.requestRender();
return;
}
if (trimmed === "/clear") {
const children = tui.children;
// Remove all messages but keep the welcome text and editor
children.splice(2, children.length - 3);
tui.requestRender();
return;
}
if (trimmed) {
isResponding = true;
editor.disableSubmit = true;
const userMessage = new Markdown(value, 1, 1, defaultMarkdownTheme);
const children = tui.children;
children.splice(children.length - 1, 0, userMessage);
const loader = new Loader(
tui,
(s) => chalk.cyan(s),
(s) => chalk.dim(s),
"Thinking...",
);
children.splice(children.length - 1, 0, loader);
tui.requestRender();
setTimeout(() => {
tui.removeChild(loader);
// Simulate a response
const responses = [
"That's interesting! Tell me more.",
"I see what you mean.",
"Fascinating perspective!",
"Could you elaborate on that?",
"That makes sense to me.",
"I hadn't thought of it that way.",
"Great point!",
"Thanks for sharing that.",
];
const randomResponse =
responses[Math.floor(Math.random() * responses.length)];
// Add assistant message with no background (transparent)
const botMessage = new Markdown(
randomResponse,
1,
1,
defaultMarkdownTheme,
);
children.splice(children.length - 1, 0, botMessage);
// Re-enable submit
isResponding = false;
editor.disableSubmit = false;
// Request render
tui.requestRender();
}, 1000);
}
};
// Start the TUI
tui.start();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,102 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.js";
describe("fuzzyMatch", () => {
it("empty query matches everything with score 0", () => {
const result = fuzzyMatch("", "anything");
assert.strictEqual(result.matches, true);
assert.strictEqual(result.score, 0);
});
it("query longer than text does not match", () => {
const result = fuzzyMatch("longquery", "short");
assert.strictEqual(result.matches, false);
});
it("exact match has good score", () => {
const result = fuzzyMatch("test", "test");
assert.strictEqual(result.matches, true);
assert.ok(result.score < 0); // Should be negative due to consecutive bonuses
});
it("characters must appear in order", () => {
const matchInOrder = fuzzyMatch("abc", "aXbXc");
assert.strictEqual(matchInOrder.matches, true);
const matchOutOfOrder = fuzzyMatch("abc", "cba");
assert.strictEqual(matchOutOfOrder.matches, false);
});
it("case insensitive matching", () => {
const result = fuzzyMatch("ABC", "abc");
assert.strictEqual(result.matches, true);
const result2 = fuzzyMatch("abc", "ABC");
assert.strictEqual(result2.matches, true);
});
it("consecutive matches score better than scattered matches", () => {
const consecutive = fuzzyMatch("foo", "foobar");
const scattered = fuzzyMatch("foo", "f_o_o_bar");
assert.strictEqual(consecutive.matches, true);
assert.strictEqual(scattered.matches, true);
assert.ok(consecutive.score < scattered.score);
});
it("word boundary matches score better", () => {
const atBoundary = fuzzyMatch("fb", "foo-bar");
const notAtBoundary = fuzzyMatch("fb", "afbx");
assert.strictEqual(atBoundary.matches, true);
assert.strictEqual(notAtBoundary.matches, true);
assert.ok(atBoundary.score < notAtBoundary.score);
});
it("matches swapped alpha numeric tokens", () => {
const result = fuzzyMatch("codex52", "gpt-5.2-codex");
assert.strictEqual(result.matches, true);
});
});
describe("fuzzyFilter", () => {
it("empty query returns all items unchanged", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "", (x: string) => x);
assert.deepStrictEqual(result, items);
});
it("filters out non-matching items", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "an", (x: string) => x);
assert.ok(result.includes("banana"));
assert.ok(!result.includes("apple"));
assert.ok(!result.includes("cherry"));
});
it("sorts results by match quality", () => {
const items = ["a_p_p", "app", "application"];
const result = fuzzyFilter(items, "app", (x: string) => x);
// "app" should be first (exact consecutive match at start)
assert.strictEqual(result[0], "app");
});
it("works with custom getText function", () => {
const items = [
{ name: "foo", id: 1 },
{ name: "bar", id: 2 },
{ name: "foobar", id: 3 },
];
const result = fuzzyFilter(
items,
"foo",
(item: { name: string; id: number }) => item.name,
);
assert.strictEqual(result.length, 2);
assert.ok(result.map((r) => r.name).includes("foo"));
assert.ok(result.map((r) => r.name).includes("foobar"));
});
});

View file

@ -0,0 +1,62 @@
import { readFileSync } from "fs";
import { Image } from "../src/components/image.js";
import { Spacer } from "../src/components/spacer.js";
import { Text } from "../src/components/text.js";
import { ProcessTerminal } from "../src/terminal.js";
import { getCapabilities, getImageDimensions } from "../src/terminal-image.js";
import { TUI } from "../src/tui.js";
const testImagePath = process.argv[2] || "/tmp/test-image.png";
console.log("Terminal capabilities:", getCapabilities());
console.log("Loading image from:", testImagePath);
let imageBuffer: Buffer;
try {
imageBuffer = readFileSync(testImagePath);
} catch (_e) {
console.error(`Failed to load image: ${testImagePath}`);
console.error("Usage: npx tsx test/image-test.ts [path-to-image.png]");
process.exit(1);
}
const base64Data = imageBuffer.toString("base64");
const dims = getImageDimensions(base64Data, "image/png");
console.log("Image dimensions:", dims);
console.log("");
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
tui.addChild(new Text("Image Rendering Test", 1, 1));
tui.addChild(new Spacer(1));
if (dims) {
tui.addChild(
new Image(
base64Data,
"image/png",
{ fallbackColor: (s) => `\x1b[33m${s}\x1b[0m` },
{ maxWidthCells: 60 },
dims,
),
);
} else {
tui.addChild(new Text("Could not parse image dimensions", 1, 0));
}
tui.addChild(new Spacer(1));
tui.addChild(new Text("Press Ctrl+C to exit", 1, 0));
const editor = {
handleInput(data: string) {
if (data.charCodeAt(0) === 3) {
tui.stop();
process.exit(0);
}
},
};
tui.setFocus(editor as any);
tui.start();

View file

@ -0,0 +1,530 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { Input } from "../src/components/input.js";
describe("Input component", () => {
it("submits value including backslash on Enter", () => {
const input = new Input();
let submitted: string | undefined;
input.onSubmit = (value) => {
submitted = value;
};
// Type hello, then backslash, then Enter
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput("\\");
input.handleInput("\r");
// Input is single-line, no backslash+Enter workaround
assert.strictEqual(submitted, "hello\\");
});
it("inserts backslash as regular character", () => {
const input = new Input();
input.handleInput("\\");
input.handleInput("x");
assert.strictEqual(input.getValue(), "\\x");
});
describe("Kill ring", () => {
it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => {
const input = new Input();
input.setValue("foo bar baz");
// Move cursor to end
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "baz"
assert.strictEqual(input.getValue(), "foo bar ");
// Move to beginning and yank
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "bazfoo bar ");
});
it("Ctrl+U saves deleted text to kill ring", () => {
const input = new Input();
input.setValue("hello world");
// Move cursor to after "hello "
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x15"); // Ctrl+U - deletes "hello "
assert.strictEqual(input.getValue(), "world");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello world");
});
it("Ctrl+K saves deleted text to kill ring", () => {
const input = new Input();
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x0b"); // Ctrl+K - deletes "hello world"
assert.strictEqual(input.getValue(), "");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello world");
});
it("Ctrl+Y does nothing when kill ring is empty", () => {
const input = new Input();
input.setValue("test");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "test");
});
it("Alt+Y cycles through kill ring after Ctrl+Y", () => {
const input = new Input();
// Create kill ring with multiple entries
input.setValue("first");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "first"
input.setValue("second");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "second"
input.setValue("third");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "third"
assert.strictEqual(input.getValue(), "");
input.handleInput("\x19"); // Ctrl+Y - yanks "third"
assert.strictEqual(input.getValue(), "third");
input.handleInput("\x1by"); // Alt+Y - cycles to "second"
assert.strictEqual(input.getValue(), "second");
input.handleInput("\x1by"); // Alt+Y - cycles to "first"
assert.strictEqual(input.getValue(), "first");
input.handleInput("\x1by"); // Alt+Y - cycles back to "third"
assert.strictEqual(input.getValue(), "third");
});
it("Alt+Y does nothing if not preceded by yank", () => {
const input = new Input();
input.setValue("test");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "test"
input.setValue("other");
input.handleInput("\x05"); // Ctrl+E
// Type something to break the yank chain
input.handleInput("x");
assert.strictEqual(input.getValue(), "otherx");
input.handleInput("\x1by"); // Alt+Y - should do nothing
assert.strictEqual(input.getValue(), "otherx");
});
it("Alt+Y does nothing if kill ring has one entry", () => {
const input = new Input();
input.setValue("only");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "only"
input.handleInput("\x19"); // Ctrl+Y - yanks "only"
assert.strictEqual(input.getValue(), "only");
input.handleInput("\x1by"); // Alt+Y - should do nothing
assert.strictEqual(input.getValue(), "only");
});
it("consecutive Ctrl+W accumulates into one kill ring entry", () => {
const input = new Input();
input.setValue("one two three");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "three"
input.handleInput("\x17"); // Ctrl+W - deletes "two "
input.handleInput("\x17"); // Ctrl+W - deletes "one "
assert.strictEqual(input.getValue(), "");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "one two three");
});
it("non-delete actions break kill accumulation", () => {
const input = new Input();
input.setValue("foo bar baz");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "baz"
assert.strictEqual(input.getValue(), "foo bar ");
input.handleInput("x"); // Typing breaks accumulation
assert.strictEqual(input.getValue(), "foo bar x");
input.handleInput("\x17"); // Ctrl+W - deletes "x" (separate entry)
assert.strictEqual(input.getValue(), "foo bar ");
input.handleInput("\x19"); // Ctrl+Y - most recent is "x"
assert.strictEqual(input.getValue(), "foo bar x");
input.handleInput("\x1by"); // Alt+Y - cycle to "baz"
assert.strictEqual(input.getValue(), "foo bar baz");
});
it("non-yank actions break Alt+Y chain", () => {
const input = new Input();
input.setValue("first");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W
input.setValue("second");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W
input.setValue("");
input.handleInput("\x19"); // Ctrl+Y - yanks "second"
assert.strictEqual(input.getValue(), "second");
input.handleInput("x"); // Breaks yank chain
assert.strictEqual(input.getValue(), "secondx");
input.handleInput("\x1by"); // Alt+Y - should do nothing
assert.strictEqual(input.getValue(), "secondx");
});
it("kill ring rotation persists after cycling", () => {
const input = new Input();
input.setValue("first");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // deletes "first"
input.setValue("second");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // deletes "second"
input.setValue("third");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // deletes "third"
input.setValue("");
input.handleInput("\x19"); // Ctrl+Y - yanks "third"
input.handleInput("\x1by"); // Alt+Y - cycles to "second"
assert.strictEqual(input.getValue(), "second");
// Break chain and start fresh
input.handleInput("x");
input.setValue("");
// New yank should get "second" (now at end after rotation)
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "second");
});
it("backward deletions prepend, forward deletions append during accumulation", () => {
const input = new Input();
input.setValue("prefix|suffix");
// Position cursor at "|"
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); // Move right 6
input.handleInput("\x0b"); // Ctrl+K - deletes "|suffix" (forward)
assert.strictEqual(input.getValue(), "prefix");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "prefix|suffix");
});
it("Alt+D deletes word forward and saves to kill ring", () => {
const input = new Input();
input.setValue("hello world test");
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x1bd"); // Alt+D - deletes "hello"
assert.strictEqual(input.getValue(), " world test");
input.handleInput("\x1bd"); // Alt+D - deletes " world"
assert.strictEqual(input.getValue(), " test");
// Yank should get accumulated text
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello world test");
});
it("handles yank in middle of text", () => {
const input = new Input();
input.setValue("word");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "word"
input.setValue("hello world");
// Move to middle (after "hello ")
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello wordworld");
});
it("handles yank-pop in middle of text", () => {
const input = new Input();
// Create two kill ring entries
input.setValue("FIRST");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "FIRST"
input.setValue("SECOND");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "SECOND"
// Set up "hello world" and position cursor after "hello "
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x19"); // Ctrl+Y - yanks "SECOND"
assert.strictEqual(input.getValue(), "hello SECONDworld");
input.handleInput("\x1by"); // Alt+Y - replaces with "FIRST"
assert.strictEqual(input.getValue(), "hello FIRSTworld");
});
});
describe("Undo", () => {
it("does nothing when undo stack is empty", () => {
const input = new Input();
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
it("coalesces consecutive word characters into one undo unit", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
assert.strictEqual(input.getValue(), "hello world");
// Undo removes " world"
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello");
// Undo removes "hello"
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
it("undoes spaces one at a time", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput(" ");
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes second " "
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes first " "
assert.strictEqual(input.getValue(), "hello");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes "hello"
assert.strictEqual(input.getValue(), "");
});
it("undoes backspace", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput("\x7f"); // Backspace
assert.strictEqual(input.getValue(), "hell");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello");
});
it("undoes forward delete", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput("\x01"); // Ctrl+A - go to start
input.handleInput("\x1b[C"); // Right arrow
input.handleInput("\x1b[3~"); // Delete key
assert.strictEqual(input.getValue(), "hllo");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello");
});
it("undoes Ctrl+W (delete word backward)", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
assert.strictEqual(input.getValue(), "hello world");
input.handleInput("\x17"); // Ctrl+W
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes Ctrl+K (delete to line end)", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x0b"); // Ctrl+K
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes Ctrl+U (delete to line start)", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x15"); // Ctrl+U
assert.strictEqual(input.getValue(), "world");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes yank", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("\x17"); // Ctrl+W - delete "hello "
input.handleInput("\x19"); // Ctrl+Y - yank
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
it("undoes paste atomically", () => {
const input = new Input();
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 5; i++) input.handleInput("\x1b[C");
// Simulate bracketed paste
input.handleInput("\x1b[200~beep boop\x1b[201~");
assert.strictEqual(input.getValue(), "hellobeep boop world");
// Single undo should restore entire pre-paste state
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes Alt+D (delete word forward)", () => {
const input = new Input();
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x1bd"); // Alt+D - deletes "hello"
assert.strictEqual(input.getValue(), " world");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("cursor movement starts new undo unit", () => {
const input = new Input();
input.handleInput("a");
input.handleInput("b");
input.handleInput("c");
input.handleInput("\x01"); // Ctrl+A - movement breaks coalescing
input.handleInput("\x05"); // Ctrl+E
input.handleInput("d");
input.handleInput("e");
assert.strictEqual(input.getValue(), "abcde");
// Undo removes "de" (typed after movement)
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "abc");
// Undo removes "abc"
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
});
});

113
packages/tui/test/key-tester.ts Executable file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env node
import { matchesKey } from "../src/keys.js";
import { ProcessTerminal } from "../src/terminal.js";
import { type Component, TUI } from "../src/tui.js";
/**
* Simple key code logger component
*/
class KeyLogger implements Component {
private log: string[] = [];
private maxLines = 20;
private tui: TUI;
constructor(tui: TUI) {
this.tui = tui;
}
handleInput(data: string): void {
// Handle Ctrl+C (raw or Kitty protocol) for exit
if (matchesKey(data, "ctrl+c")) {
this.tui.stop();
console.log("\nExiting...");
process.exit(0);
}
// Convert to various representations
const hex = Buffer.from(data).toString("hex");
const charCodes = Array.from(data)
.map((c) => c.charCodeAt(0))
.join(", ");
const repr = data
.replace(/\x1b/g, "\\x1b")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.replace(/\t/g, "\\t")
.replace(/\x7f/g, "\\x7f");
const logLine = `Hex: ${hex.padEnd(20)} | Chars: [${charCodes.padEnd(15)}] | Repr: "${repr}"`;
this.log.push(logLine);
// Keep only last N lines
if (this.log.length > this.maxLines) {
this.log.shift();
}
// Request re-render to show the new log entry
this.tui.requestRender();
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
// Title
lines.push("=".repeat(width));
lines.push(
"Key Code Tester - Press keys to see their codes (Ctrl+C to exit)".padEnd(
width,
),
);
lines.push("=".repeat(width));
lines.push("");
// Log entries
for (const entry of this.log) {
lines.push(entry.padEnd(width));
}
// Fill remaining space
const remaining = Math.max(0, 25 - lines.length);
for (let i = 0; i < remaining; i++) {
lines.push("".padEnd(width));
}
// Footer
lines.push("=".repeat(width));
lines.push("Test these:".padEnd(width));
lines.push(
" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)".padEnd(
width,
),
);
lines.push(" - Alt/Option + Enter".padEnd(width));
lines.push(" - Option/Alt + Backspace".padEnd(width));
lines.push(" - Cmd/Ctrl + Backspace".padEnd(width));
lines.push(" - Regular Backspace".padEnd(width));
lines.push("=".repeat(width));
return lines;
}
}
// Set up TUI
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
const logger = new KeyLogger(tui);
tui.addChild(logger);
tui.setFocus(logger);
// Handle Ctrl+C for clean exit (SIGINT still works for raw mode)
process.on("SIGINT", () => {
tui.stop();
console.log("\nExiting...");
process.exit(0);
});
// Start the TUI
tui.start();

View file

@ -0,0 +1,349 @@
/**
* Tests for keyboard input handling
*/
import assert from "node:assert";
import { describe, it } from "node:test";
import { matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js";
describe("matchesKey", () => {
describe("Kitty protocol with alternate keys (non-Latin layouts)", () => {
// Kitty protocol flag 4 (Report alternate keys) sends:
// CSI codepoint:shifted:base ; modifier:event u
// Where base is the key in standard PC-101 layout
it("should match Ctrl+c when pressing Ctrl+С (Cyrillic) with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'с' = codepoint 1089, Latin 'c' = codepoint 99
// Format: CSI 1089::99;5u (codepoint::base;modifier with ctrl=4, +1=5)
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+c"), true);
setKittyProtocolActive(false);
});
it("should match Ctrl+d when pressing Ctrl+В (Cyrillic) with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'в' = codepoint 1074, Latin 'd' = codepoint 100
const cyrillicCtrlD = "\x1b[1074::100;5u";
assert.strictEqual(matchesKey(cyrillicCtrlD, "ctrl+d"), true);
setKittyProtocolActive(false);
});
it("should match Ctrl+z when pressing Ctrl+Я (Cyrillic) with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'я' = codepoint 1103, Latin 'z' = codepoint 122
const cyrillicCtrlZ = "\x1b[1103::122;5u";
assert.strictEqual(matchesKey(cyrillicCtrlZ, "ctrl+z"), true);
setKittyProtocolActive(false);
});
it("should match Ctrl+Shift+p with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'з' = codepoint 1079, Latin 'p' = codepoint 112
// ctrl=4, shift=1, +1 = 6
const cyrillicCtrlShiftP = "\x1b[1079::112;6u";
assert.strictEqual(matchesKey(cyrillicCtrlShiftP, "ctrl+shift+p"), true);
setKittyProtocolActive(false);
});
it("should still match direct codepoint when no base layout key", () => {
setKittyProtocolActive(true);
// Latin ctrl+c without base layout key (terminal doesn't support flag 4)
const latinCtrlC = "\x1b[99;5u";
assert.strictEqual(matchesKey(latinCtrlC, "ctrl+c"), true);
setKittyProtocolActive(false);
});
it("should handle shifted key in format", () => {
setKittyProtocolActive(true);
// Format with shifted key: CSI codepoint:shifted:base;modifier u
// Latin 'c' with shifted 'C' (67) and base 'c' (99)
const shiftedKey = "\x1b[99:67:99;2u"; // shift modifier = 1, +1 = 2
assert.strictEqual(matchesKey(shiftedKey, "shift+c"), true);
setKittyProtocolActive(false);
});
it("should handle event type in format", () => {
setKittyProtocolActive(true);
// Format with event type: CSI codepoint::base;modifier:event u
// Cyrillic ctrl+c release event (event type 3)
const releaseEvent = "\x1b[1089::99;5:3u";
assert.strictEqual(matchesKey(releaseEvent, "ctrl+c"), true);
setKittyProtocolActive(false);
});
it("should handle full format with shifted key, base key, and event type", () => {
setKittyProtocolActive(true);
// Full format: CSI codepoint:shifted:base;modifier:event u
// Cyrillic 'С' (shifted) with base 'c', Ctrl+Shift pressed, repeat event
// Cyrillic 'с' = 1089, Cyrillic 'С' = 1057, Latin 'c' = 99
// ctrl=4, shift=1, +1 = 6, repeat event = 2
const fullFormat = "\x1b[1089:1057:99;6:2u";
assert.strictEqual(matchesKey(fullFormat, "ctrl+shift+c"), true);
setKittyProtocolActive(false);
});
it("should prefer codepoint for Latin letters even when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
const dvorakCtrlK = "\x1b[107::118;5u";
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+k"), true);
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+v"), false);
setKittyProtocolActive(false);
});
it("should prefer codepoint for symbol keys even when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
const dvorakCtrlSlash = "\x1b[47::91;5u";
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+/"), true);
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+["), false);
setKittyProtocolActive(false);
});
it("should not match wrong key even with base layout", () => {
setKittyProtocolActive(true);
// Cyrillic ctrl+с with base 'c' should NOT match ctrl+d
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+d"), false);
setKittyProtocolActive(false);
});
it("should not match wrong modifiers even with base layout", () => {
setKittyProtocolActive(true);
// Cyrillic ctrl+с should NOT match ctrl+shift+c
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+shift+c"), false);
setKittyProtocolActive(false);
});
});
describe("Legacy key matching", () => {
it("should match legacy Ctrl+c", () => {
setKittyProtocolActive(false);
// Ctrl+c sends ASCII 3 (ETX)
assert.strictEqual(matchesKey("\x03", "ctrl+c"), true);
});
it("should match legacy Ctrl+d", () => {
setKittyProtocolActive(false);
// Ctrl+d sends ASCII 4 (EOT)
assert.strictEqual(matchesKey("\x04", "ctrl+d"), true);
});
it("should match escape key", () => {
assert.strictEqual(matchesKey("\x1b", "escape"), true);
});
it("should match legacy linefeed as enter", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\n", "enter"), true);
assert.strictEqual(parseKey("\n"), "enter");
});
it("should treat linefeed as shift+enter when kitty active", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\n", "shift+enter"), true);
assert.strictEqual(matchesKey("\n", "enter"), false);
assert.strictEqual(parseKey("\n"), "shift+enter");
setKittyProtocolActive(false);
});
it("should parse ctrl+space", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x00", "ctrl+space"), true);
assert.strictEqual(parseKey("\x00"), "ctrl+space");
});
it("should match legacy Ctrl+symbol", () => {
setKittyProtocolActive(false);
// Ctrl+\ sends ASCII 28 (File Separator) in legacy terminals
assert.strictEqual(matchesKey("\x1c", "ctrl+\\"), true);
assert.strictEqual(parseKey("\x1c"), "ctrl+\\");
// Ctrl+] sends ASCII 29 (Group Separator) in legacy terminals
assert.strictEqual(matchesKey("\x1d", "ctrl+]"), true);
assert.strictEqual(parseKey("\x1d"), "ctrl+]");
// Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals
// Ctrl+- is on the same physical key on US keyboards
assert.strictEqual(matchesKey("\x1f", "ctrl+_"), true);
assert.strictEqual(matchesKey("\x1f", "ctrl+-"), true);
assert.strictEqual(parseKey("\x1f"), "ctrl+-");
});
it("should match legacy Ctrl+Alt+symbol", () => {
setKittyProtocolActive(false);
// Ctrl+Alt+[ sends ESC followed by ESC (Ctrl+[ = ESC)
assert.strictEqual(matchesKey("\x1b\x1b", "ctrl+alt+["), true);
assert.strictEqual(parseKey("\x1b\x1b"), "ctrl+alt+[");
// Ctrl+Alt+\ sends ESC followed by ASCII 28
assert.strictEqual(matchesKey("\x1b\x1c", "ctrl+alt+\\"), true);
assert.strictEqual(parseKey("\x1b\x1c"), "ctrl+alt+\\");
// Ctrl+Alt+] sends ESC followed by ASCII 29
assert.strictEqual(matchesKey("\x1b\x1d", "ctrl+alt+]"), true);
assert.strictEqual(parseKey("\x1b\x1d"), "ctrl+alt+]");
// Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals
// Ctrl+- is on the same physical key on US keyboards
assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+_"), true);
assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+-"), true);
assert.strictEqual(parseKey("\x1b\x1f"), "ctrl+alt+-");
});
it("should parse legacy alt-prefixed sequences when kitty inactive", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b ", "alt+space"), true);
assert.strictEqual(parseKey("\x1b "), "alt+space");
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), true);
assert.strictEqual(parseKey("\x1b\x03"), "ctrl+alt+c");
assert.strictEqual(matchesKey("\x1bB", "alt+left"), true);
assert.strictEqual(parseKey("\x1bB"), "alt+left");
assert.strictEqual(matchesKey("\x1bF", "alt+right"), true);
assert.strictEqual(parseKey("\x1bF"), "alt+right");
assert.strictEqual(matchesKey("\x1ba", "alt+a"), true);
assert.strictEqual(parseKey("\x1ba"), "alt+a");
assert.strictEqual(matchesKey("\x1by", "alt+y"), true);
assert.strictEqual(parseKey("\x1by"), "alt+y");
assert.strictEqual(matchesKey("\x1bz", "alt+z"), true);
assert.strictEqual(parseKey("\x1bz"), "alt+z");
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b ", "alt+space"), false);
assert.strictEqual(parseKey("\x1b "), undefined);
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), false);
assert.strictEqual(parseKey("\x1b\x03"), undefined);
assert.strictEqual(matchesKey("\x1bB", "alt+left"), false);
assert.strictEqual(parseKey("\x1bB"), undefined);
assert.strictEqual(matchesKey("\x1bF", "alt+right"), false);
assert.strictEqual(parseKey("\x1bF"), undefined);
assert.strictEqual(matchesKey("\x1ba", "alt+a"), false);
assert.strictEqual(parseKey("\x1ba"), undefined);
assert.strictEqual(matchesKey("\x1by", "alt+y"), false);
assert.strictEqual(parseKey("\x1by"), undefined);
setKittyProtocolActive(false);
});
it("should match arrow keys", () => {
assert.strictEqual(matchesKey("\x1b[A", "up"), true);
assert.strictEqual(matchesKey("\x1b[B", "down"), true);
assert.strictEqual(matchesKey("\x1b[C", "right"), true);
assert.strictEqual(matchesKey("\x1b[D", "left"), true);
});
it("should match SS3 arrows and home/end", () => {
assert.strictEqual(matchesKey("\x1bOA", "up"), true);
assert.strictEqual(matchesKey("\x1bOB", "down"), true);
assert.strictEqual(matchesKey("\x1bOC", "right"), true);
assert.strictEqual(matchesKey("\x1bOD", "left"), true);
assert.strictEqual(matchesKey("\x1bOH", "home"), true);
assert.strictEqual(matchesKey("\x1bOF", "end"), true);
});
it("should match legacy function keys and clear", () => {
assert.strictEqual(matchesKey("\x1bOP", "f1"), true);
assert.strictEqual(matchesKey("\x1b[24~", "f12"), true);
assert.strictEqual(matchesKey("\x1b[E", "clear"), true);
});
it("should match alt+arrows", () => {
assert.strictEqual(matchesKey("\x1bp", "alt+up"), true);
assert.strictEqual(matchesKey("\x1bp", "up"), false);
});
it("should match rxvt modifier sequences", () => {
assert.strictEqual(matchesKey("\x1b[a", "shift+up"), true);
assert.strictEqual(matchesKey("\x1bOa", "ctrl+up"), true);
assert.strictEqual(matchesKey("\x1b[2$", "shift+insert"), true);
assert.strictEqual(matchesKey("\x1b[2^", "ctrl+insert"), true);
assert.strictEqual(matchesKey("\x1b[7$", "shift+home"), true);
});
});
});
describe("parseKey", () => {
describe("Kitty protocol with alternate keys", () => {
it("should return Latin key name when base layout key is present", () => {
setKittyProtocolActive(true);
// Cyrillic ctrl+с with base layout 'c'
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(parseKey(cyrillicCtrlC), "ctrl+c");
setKittyProtocolActive(false);
});
it("should prefer codepoint for Latin letters when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
const dvorakCtrlK = "\x1b[107::118;5u";
assert.strictEqual(parseKey(dvorakCtrlK), "ctrl+k");
setKittyProtocolActive(false);
});
it("should prefer codepoint for symbol keys when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
const dvorakCtrlSlash = "\x1b[47::91;5u";
assert.strictEqual(parseKey(dvorakCtrlSlash), "ctrl+/");
setKittyProtocolActive(false);
});
it("should return key name from codepoint when no base layout", () => {
setKittyProtocolActive(true);
const latinCtrlC = "\x1b[99;5u";
assert.strictEqual(parseKey(latinCtrlC), "ctrl+c");
setKittyProtocolActive(false);
});
it("should ignore Kitty CSI-u with unsupported modifiers", () => {
setKittyProtocolActive(true);
assert.strictEqual(parseKey("\x1b[99;9u"), undefined);
setKittyProtocolActive(false);
});
});
describe("Legacy key parsing", () => {
it("should parse legacy Ctrl+letter", () => {
setKittyProtocolActive(false);
assert.strictEqual(parseKey("\x03"), "ctrl+c");
assert.strictEqual(parseKey("\x04"), "ctrl+d");
});
it("should parse special keys", () => {
assert.strictEqual(parseKey("\x1b"), "escape");
assert.strictEqual(parseKey("\t"), "tab");
assert.strictEqual(parseKey("\r"), "enter");
assert.strictEqual(parseKey("\n"), "enter");
assert.strictEqual(parseKey("\x00"), "ctrl+space");
assert.strictEqual(parseKey(" "), "space");
});
it("should parse arrow keys", () => {
assert.strictEqual(parseKey("\x1b[A"), "up");
assert.strictEqual(parseKey("\x1b[B"), "down");
assert.strictEqual(parseKey("\x1b[C"), "right");
assert.strictEqual(parseKey("\x1b[D"), "left");
});
it("should parse SS3 arrows and home/end", () => {
assert.strictEqual(parseKey("\x1bOA"), "up");
assert.strictEqual(parseKey("\x1bOB"), "down");
assert.strictEqual(parseKey("\x1bOC"), "right");
assert.strictEqual(parseKey("\x1bOD"), "left");
assert.strictEqual(parseKey("\x1bOH"), "home");
assert.strictEqual(parseKey("\x1bOF"), "end");
});
it("should parse legacy function and modifier sequences", () => {
assert.strictEqual(parseKey("\x1bOP"), "f1");
assert.strictEqual(parseKey("\x1b[24~"), "f12");
assert.strictEqual(parseKey("\x1b[E"), "clear");
assert.strictEqual(parseKey("\x1b[2^"), "ctrl+insert");
assert.strictEqual(parseKey("\x1bp"), "alt+up");
});
it("should parse double bracket pageUp", () => {
assert.strictEqual(parseKey("\x1b[[5~"), "pageUp");
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,626 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Component } from "../src/tui.js";
import { TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class StaticOverlay implements Component {
constructor(
private lines: string[],
public requestedWidth?: number,
) {}
render(width: number): string[] {
// Store the width we were asked to render at for verification
this.requestedWidth = width;
return this.lines;
}
invalidate(): void {}
}
class EmptyContent implements Component {
render(): string[] {
return [];
}
invalidate(): void {}
}
async function renderAndFlush(
tui: TUI,
terminal: VirtualTerminal,
): Promise<void> {
tui.requestRender(true);
await new Promise<void>((resolve) => process.nextTick(resolve));
await terminal.flush();
}
describe("TUI overlay options", () => {
describe("width overflow protection", () => {
it("should truncate overlay lines that exceed declared width", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Overlay declares width 20 but renders lines much wider
const overlay = new StaticOverlay(["X".repeat(100)]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 20 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash, and no line should exceed terminal width
const viewport = terminal.getViewport();
for (const line of viewport) {
// visibleWidth not available here, but line length is a rough check
// The important thing is it didn't crash
assert.ok(line !== undefined);
}
tui.stop();
});
it("should handle overlay with complex ANSI sequences without crashing", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Simulate complex ANSI content like the crash log showed
const complexLine =
"\x1b[48;2;40;50;40m \x1b[38;2;128;128;128mSome styled content\x1b[39m\x1b[49m" +
"\x1b]8;;http://example.com\x07link\x1b]8;;\x07" +
" more content ".repeat(10);
const overlay = new StaticOverlay([
complexLine,
complexLine,
complexLine,
]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 60 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
it("should handle overlay composited on styled base content", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Base content with styling
class StyledContent implements Component {
render(width: number): string[] {
const styledLine = `\x1b[1m\x1b[38;2;255;0;0m${"X".repeat(width)}\x1b[0m`;
return [styledLine, styledLine, styledLine];
}
invalidate(): void {}
}
const overlay = new StaticOverlay(["OVERLAY"]);
tui.addChild(new StyledContent());
tui.showOverlay(overlay, { width: 20, anchor: "center" });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash and overlay should be visible
const viewport = terminal.getViewport();
const hasOverlay = viewport.some((line) => line?.includes("OVERLAY"));
assert.ok(hasOverlay, "Overlay should be visible");
tui.stop();
});
it("should handle wide characters at overlay boundary", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Wide chars (each takes 2 columns) at the edge of declared width
const wideCharLine = "中文日本語한글テスト漢字"; // Mix of CJK chars
const overlay = new StaticOverlay([wideCharLine]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 15 }); // Odd width to potentially hit boundary
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
it("should handle overlay positioned at terminal edge", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Overlay positioned at right edge with content that exceeds declared width
const overlay = new StaticOverlay(["X".repeat(50)]);
tui.addChild(new EmptyContent());
// Position at col 60 with width 20 - should fit exactly at right edge
tui.showOverlay(overlay, { col: 60, width: 20 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
it("should handle overlay on base content with OSC sequences", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Base content with OSC 8 hyperlinks (like file paths in agent output)
class HyperlinkContent implements Component {
render(width: number): string[] {
const link = `\x1b]8;;file:///path/to/file.ts\x07file.ts\x1b]8;;\x07`;
const line = `See ${link} for details ${"X".repeat(width - 30)}`;
return [line, line, line];
}
invalidate(): void {}
}
const overlay = new StaticOverlay(["OVERLAY-TEXT"]);
tui.addChild(new HyperlinkContent());
tui.showOverlay(overlay, { anchor: "center", width: 20 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash - this was the original bug scenario
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
});
describe("width percentage", () => {
it("should render overlay at percentage of terminal width", async () => {
const terminal = new VirtualTerminal(100, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["test"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: "50%" });
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(overlay.requestedWidth, 50);
tui.stop();
});
it("should respect minWidth when widthPercent results in smaller width", async () => {
const terminal = new VirtualTerminal(100, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["test"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: "10%", minWidth: 30 });
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(overlay.requestedWidth, 30);
tui.stop();
});
});
describe("anchor positioning", () => {
it("should position overlay at top-left", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["TOP-LEFT"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "top-left", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.startsWith("TOP-LEFT"),
`Expected TOP-LEFT at start, got: ${viewport[0]}`,
);
tui.stop();
});
it("should position overlay at bottom-right", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["BTM-RIGHT"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "bottom-right", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be on last row, ending at last column
const lastRow = viewport[23];
assert.ok(
lastRow?.includes("BTM-RIGHT"),
`Expected BTM-RIGHT on last row, got: ${lastRow}`,
);
assert.ok(
lastRow?.trimEnd().endsWith("BTM-RIGHT"),
`Expected BTM-RIGHT at end, got: ${lastRow}`,
);
tui.stop();
});
it("should position overlay at top-center", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["CENTERED"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "top-center", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be on first row, centered horizontally
const firstRow = viewport[0];
assert.ok(
firstRow?.includes("CENTERED"),
`Expected CENTERED on first row, got: ${firstRow}`,
);
// Check it's roughly centered (col 35 for width 10 in 80 col terminal)
const colIndex = firstRow?.indexOf("CENTERED") ?? -1;
assert.ok(
colIndex >= 30 && colIndex <= 40,
`Expected centered, got col ${colIndex}`,
);
tui.stop();
});
});
describe("margin", () => {
it("should clamp negative margins to zero", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["NEG-MARGIN"]);
tui.addChild(new EmptyContent());
// Negative margins should be treated as 0
tui.showOverlay(overlay, {
anchor: "top-left",
width: 12,
margin: { top: -5, left: -10, right: 0, bottom: 0 },
});
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be at row 0, col 0 (negative margins clamped to 0)
assert.ok(
viewport[0]?.startsWith("NEG-MARGIN"),
`Expected NEG-MARGIN at start of row 0, got: ${viewport[0]}`,
);
tui.stop();
});
it("should respect margin as number", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["MARGIN"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "top-left", width: 10, margin: 5 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be on row 5 (not 0) due to margin
assert.ok(!viewport[0]?.includes("MARGIN"), "Should not be on row 0");
assert.ok(!viewport[4]?.includes("MARGIN"), "Should not be on row 4");
assert.ok(
viewport[5]?.includes("MARGIN"),
`Expected MARGIN on row 5, got: ${viewport[5]}`,
);
// Should start at col 5 (not 0)
const colIndex = viewport[5]?.indexOf("MARGIN") ?? -1;
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
tui.stop();
});
it("should respect margin object", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["MARGIN"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, {
anchor: "top-left",
width: 10,
margin: { top: 2, left: 3, right: 0, bottom: 0 },
});
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[2]?.includes("MARGIN"),
`Expected MARGIN on row 2, got: ${viewport[2]}`,
);
const colIndex = viewport[2]?.indexOf("MARGIN") ?? -1;
assert.strictEqual(colIndex, 3, `Expected col 3, got ${colIndex}`);
tui.stop();
});
});
describe("offset", () => {
it("should apply offsetX and offsetY from anchor position", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["OFFSET"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, {
anchor: "top-left",
width: 10,
offsetX: 10,
offsetY: 5,
});
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[5]?.includes("OFFSET"),
`Expected OFFSET on row 5, got: ${viewport[5]}`,
);
const colIndex = viewport[5]?.indexOf("OFFSET") ?? -1;
assert.strictEqual(colIndex, 10, `Expected col 10, got ${colIndex}`);
tui.stop();
});
});
describe("percentage positioning", () => {
it("should position with rowPercent and colPercent", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["PCT"]);
tui.addChild(new EmptyContent());
// 50% should center both ways
tui.showOverlay(overlay, { width: 10, row: "50%", col: "50%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Find the row with PCT
let foundRow = -1;
for (let i = 0; i < viewport.length; i++) {
if (viewport[i]?.includes("PCT")) {
foundRow = i;
break;
}
}
// Should be roughly centered vertically (row ~11-12 for 24 row terminal)
assert.ok(
foundRow >= 10 && foundRow <= 13,
`Expected centered row, got ${foundRow}`,
);
tui.stop();
});
it("rowPercent 0 should position at top", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["TOP"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 10, row: "0%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("TOP"),
`Expected TOP on row 0, got: ${viewport[0]}`,
);
tui.stop();
});
it("rowPercent 100 should position at bottom", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["BOTTOM"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 10, row: "100%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[23]?.includes("BOTTOM"),
`Expected BOTTOM on last row, got: ${viewport[23]}`,
);
tui.stop();
});
});
describe("maxHeight", () => {
it("should truncate overlay to maxHeight", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay([
"Line 1",
"Line 2",
"Line 3",
"Line 4",
"Line 5",
]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { maxHeight: 3 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
const content = viewport.join("\n");
assert.ok(content.includes("Line 1"), "Should include Line 1");
assert.ok(content.includes("Line 2"), "Should include Line 2");
assert.ok(content.includes("Line 3"), "Should include Line 3");
assert.ok(!content.includes("Line 4"), "Should NOT include Line 4");
assert.ok(!content.includes("Line 5"), "Should NOT include Line 5");
tui.stop();
});
it("should truncate overlay to maxHeightPercent", async () => {
const terminal = new VirtualTerminal(80, 10);
const tui = new TUI(terminal);
// 10 lines in a 10 row terminal with 50% maxHeight should show 5 lines
const overlay = new StaticOverlay([
"L1",
"L2",
"L3",
"L4",
"L5",
"L6",
"L7",
"L8",
"L9",
"L10",
]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { maxHeight: "50%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
const content = viewport.join("\n");
assert.ok(content.includes("L1"), "Should include L1");
assert.ok(content.includes("L5"), "Should include L5");
assert.ok(!content.includes("L6"), "Should NOT include L6");
tui.stop();
});
});
describe("absolute positioning", () => {
it("row and col should override anchor", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["ABSOLUTE"]);
tui.addChild(new EmptyContent());
// Even with bottom-right anchor, row/col should win
tui.showOverlay(overlay, {
anchor: "bottom-right",
row: 3,
col: 5,
width: 10,
});
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[3]?.includes("ABSOLUTE"),
`Expected ABSOLUTE on row 3, got: ${viewport[3]}`,
);
const colIndex = viewport[3]?.indexOf("ABSOLUTE") ?? -1;
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
tui.stop();
});
});
describe("stacked overlays", () => {
it("should render multiple overlays with later ones on top", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
// First overlay at top-left
const overlay1 = new StaticOverlay(["FIRST-OVERLAY"]);
tui.showOverlay(overlay1, { anchor: "top-left", width: 20 });
// Second overlay at top-left (should cover part of first)
const overlay2 = new StaticOverlay(["SECOND"]);
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Second overlay should be visible (on top)
assert.ok(
viewport[0]?.includes("SECOND"),
`Expected SECOND on row 0, got: ${viewport[0]}`,
);
// Part of first overlay might still be visible after SECOND
// FIRST-OVERLAY is 13 chars, SECOND is 6 chars, so "OVERLAY" part might show
tui.stop();
});
it("should handle overlays at different positions without interference", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
// Overlay at top-left
const overlay1 = new StaticOverlay(["TOP-LEFT"]);
tui.showOverlay(overlay1, { anchor: "top-left", width: 15 });
// Overlay at bottom-right
const overlay2 = new StaticOverlay(["BTM-RIGHT"]);
tui.showOverlay(overlay2, { anchor: "bottom-right", width: 15 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Both should be visible
assert.ok(
viewport[0]?.includes("TOP-LEFT"),
`Expected TOP-LEFT on row 0, got: ${viewport[0]}`,
);
assert.ok(
viewport[23]?.includes("BTM-RIGHT"),
`Expected BTM-RIGHT on row 23, got: ${viewport[23]}`,
);
tui.stop();
});
it("should properly hide overlays in stack order", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
// Show two overlays
const overlay1 = new StaticOverlay(["FIRST"]);
tui.showOverlay(overlay1, { anchor: "top-left", width: 10 });
const overlay2 = new StaticOverlay(["SECOND"]);
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
// Second should be visible
let viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("SECOND"),
"SECOND should be visible initially",
);
// Hide top overlay
tui.hideOverlay();
await renderAndFlush(tui, terminal);
// First should now be visible
viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("FIRST"),
"FIRST should be visible after hiding SECOND",
);
tui.stop();
});
});
});

View file

@ -0,0 +1,60 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class SimpleContent implements Component {
constructor(private lines: string[]) {}
render(): string[] {
return this.lines;
}
invalidate() {}
}
class SimpleOverlay implements Component {
render(): string[] {
return ["OVERLAY_TOP", "OVERLAY_MID", "OVERLAY_BOT"];
}
invalidate() {}
}
describe("TUI overlay with short content", () => {
it("should render overlay when content is shorter than terminal height", async () => {
// Terminal has 24 rows, but content only has 3 lines
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Only 3 lines of content
tui.addChild(new SimpleContent(["Line 1", "Line 2", "Line 3"]));
// Show overlay centered - should be around row 10 in a 24-row terminal
const overlay = new SimpleOverlay();
tui.showOverlay(overlay);
// Trigger render
tui.start();
await new Promise((r) => process.nextTick(r));
await terminal.flush();
const viewport = terminal.getViewport();
const hasOverlay = viewport.some((line) => line.includes("OVERLAY"));
console.log("Terminal rows:", terminal.rows);
console.log("Content lines: 3");
console.log("Overlay visible:", hasOverlay);
if (!hasOverlay) {
console.log("\nViewport contents:");
for (let i = 0; i < viewport.length; i++) {
console.log(` [${i}]: "${viewport[i]}"`);
}
}
assert.ok(
hasOverlay,
"Overlay should be visible when content is shorter than terminal",
);
tui.stop();
});
});

View file

@ -0,0 +1,60 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js";
describe("regional indicator width regression", () => {
it("treats partial flag grapheme as full-width to avoid streaming render drift", () => {
// Repro context:
// During streaming, "🇨🇳" often appears as an intermediate "🇨" first.
// If "🇨" is measured as width 1 while terminal renders it as width 2,
// differential rendering can drift and leave stale characters on screen.
const partialFlag = "🇨";
const listLine = " - 🇨";
assert.strictEqual(visibleWidth(partialFlag), 2);
assert.strictEqual(visibleWidth(listLine), 10);
});
it("wraps intermediate partial-flag list line before overflow", () => {
// Width 9 cannot fit " - 🇨" if 🇨 is width 2 (8 + 2 = 10).
// This must wrap to avoid terminal auto-wrap mismatch.
const wrapped = wrapTextWithAnsi(" - 🇨", 9);
assert.strictEqual(wrapped.length, 2);
assert.strictEqual(visibleWidth(wrapped[0] || ""), 7);
assert.strictEqual(visibleWidth(wrapped[1] || ""), 2);
});
it("treats all regional-indicator singleton graphemes as width 2", () => {
for (let cp = 0x1f1e6; cp <= 0x1f1ff; cp++) {
const regionalIndicator = String.fromCodePoint(cp);
assert.strictEqual(
visibleWidth(regionalIndicator),
2,
`Expected ${regionalIndicator} (U+${cp.toString(16).toUpperCase()}) to be width 2`,
);
}
});
it("keeps full flag pairs at width 2", () => {
const samples = ["🇯🇵", "🇺🇸", "🇬🇧", "🇨🇳", "🇩🇪", "🇫🇷"];
for (const flag of samples) {
assert.strictEqual(
visibleWidth(flag),
2,
`Expected ${flag} to be width 2`,
);
}
});
it("keeps common streaming emoji intermediates at stable width", () => {
const samples = ["👍", "👍🏻", "✅", "⚡", "⚡️", "👨", "👨‍💻", "🏳️‍🌈"];
for (const sample of samples) {
assert.strictEqual(
visibleWidth(sample),
2,
`Expected ${sample} to be width 2`,
);
}
});
});

View file

@ -0,0 +1,30 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { SelectList } from "../src/components/select-list.js";
const testTheme = {
selectedPrefix: (text: string) => text,
selectedText: (text: string) => text,
description: (text: string) => text,
scrollInfo: (text: string) => text,
noMatch: (text: string) => text,
};
describe("SelectList", () => {
it("normalizes multiline descriptions to single line", () => {
const items = [
{
value: "test",
label: "test",
description: "Line one\nLine two\nLine three",
},
];
const list = new SelectList(items, 5, testTheme);
const rendered = list.render(100);
assert.ok(rendered.length > 0);
assert.ok(!rendered[0].includes("\n"));
assert.ok(rendered[0].includes("Line one Line two Line three"));
});
});

View file

@ -0,0 +1,450 @@
/**
* Tests for StdinBuffer
*
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
* MIT License - Copyright (c) 2025 opentui
*/
import assert from "node:assert";
import { beforeEach, describe, it } from "node:test";
import { StdinBuffer } from "../src/stdin-buffer.js";
describe("StdinBuffer", () => {
let buffer: StdinBuffer;
let emittedSequences: string[];
beforeEach(() => {
buffer = new StdinBuffer({ timeout: 10 });
// Collect emitted sequences
emittedSequences = [];
buffer.on("data", (sequence) => {
emittedSequences.push(sequence);
});
});
// Helper to process data through the buffer
function processInput(data: string | Buffer): void {
buffer.process(data);
}
// Helper to wait for async operations
async function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("Regular Characters", () => {
it("should pass through regular characters immediately", () => {
processInput("a");
assert.deepStrictEqual(emittedSequences, ["a"]);
});
it("should pass through multiple regular characters", () => {
processInput("abc");
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
});
it("should handle unicode characters", () => {
processInput("hello 世界");
assert.deepStrictEqual(emittedSequences, [
"h",
"e",
"l",
"l",
"o",
" ",
"世",
"界",
]);
});
});
describe("Complete Escape Sequences", () => {
it("should pass through complete mouse SGR sequences", () => {
const mouseSeq = "\x1b[<35;20;5m";
processInput(mouseSeq);
assert.deepStrictEqual(emittedSequences, [mouseSeq]);
});
it("should pass through complete arrow key sequences", () => {
const upArrow = "\x1b[A";
processInput(upArrow);
assert.deepStrictEqual(emittedSequences, [upArrow]);
});
it("should pass through complete function key sequences", () => {
const f1 = "\x1b[11~";
processInput(f1);
assert.deepStrictEqual(emittedSequences, [f1]);
});
it("should pass through meta key sequences", () => {
const metaA = "\x1ba";
processInput(metaA);
assert.deepStrictEqual(emittedSequences, [metaA]);
});
it("should pass through SS3 sequences", () => {
const ss3 = "\x1bOA";
processInput(ss3);
assert.deepStrictEqual(emittedSequences, [ss3]);
});
});
describe("Partial Escape Sequences", () => {
it("should buffer incomplete mouse SGR sequence", async () => {
processInput("\x1b");
assert.deepStrictEqual(emittedSequences, []);
assert.strictEqual(buffer.getBuffer(), "\x1b");
processInput("[<35");
assert.deepStrictEqual(emittedSequences, []);
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
processInput(";20;5m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
assert.strictEqual(buffer.getBuffer(), "");
});
it("should buffer incomplete CSI sequence", () => {
processInput("\x1b[");
assert.deepStrictEqual(emittedSequences, []);
processInput("1;");
assert.deepStrictEqual(emittedSequences, []);
processInput("5H");
assert.deepStrictEqual(emittedSequences, ["\x1b[1;5H"]);
});
it("should buffer split across many chunks", () => {
processInput("\x1b");
processInput("[");
processInput("<");
processInput("3");
processInput("5");
processInput(";");
processInput("2");
processInput("0");
processInput(";");
processInput("5");
processInput("m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
});
it("should flush incomplete sequence after timeout", async () => {
processInput("\x1b[<35");
assert.deepStrictEqual(emittedSequences, []);
// Wait for timeout
await wait(15);
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
});
});
describe("Mixed Content", () => {
it("should handle characters followed by escape sequence", () => {
processInput("abc\x1b[A");
assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[A"]);
});
it("should handle escape sequence followed by characters", () => {
processInput("\x1b[Aabc");
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "a", "b", "c"]);
});
it("should handle multiple complete sequences", () => {
processInput("\x1b[A\x1b[B\x1b[C");
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "\x1b[B", "\x1b[C"]);
});
it("should handle partial sequence with preceding characters", () => {
processInput("abc\x1b[<35");
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
processInput(";20;5m");
assert.deepStrictEqual(emittedSequences, [
"a",
"b",
"c",
"\x1b[<35;20;5m",
]);
});
});
describe("Kitty Keyboard Protocol", () => {
it("should handle Kitty CSI u press events", () => {
// Press 'a' in Kitty protocol
processInput("\x1b[97u");
assert.deepStrictEqual(emittedSequences, ["\x1b[97u"]);
});
it("should handle Kitty CSI u release events", () => {
// Release 'a' in Kitty protocol
processInput("\x1b[97;1:3u");
assert.deepStrictEqual(emittedSequences, ["\x1b[97;1:3u"]);
});
it("should handle batched Kitty press and release", () => {
// Press 'a', release 'a' batched together (common over SSH)
processInput("\x1b[97u\x1b[97;1:3u");
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u"]);
});
it("should handle multiple batched Kitty events", () => {
// Press 'a', release 'a', press 'b', release 'b'
processInput("\x1b[97u\x1b[97;1:3u\x1b[98u\x1b[98;1:3u");
assert.deepStrictEqual(emittedSequences, [
"\x1b[97u",
"\x1b[97;1:3u",
"\x1b[98u",
"\x1b[98;1:3u",
]);
});
it("should handle Kitty arrow keys with event type", () => {
// Up arrow press with event type
processInput("\x1b[1;1:1A");
assert.deepStrictEqual(emittedSequences, ["\x1b[1;1:1A"]);
});
it("should handle Kitty functional keys with event type", () => {
// Delete key release
processInput("\x1b[3;1:3~");
assert.deepStrictEqual(emittedSequences, ["\x1b[3;1:3~"]);
});
it("should handle plain characters mixed with Kitty sequences", () => {
// Plain 'a' followed by Kitty release
processInput("a\x1b[97;1:3u");
assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]);
});
it("should handle Kitty sequence followed by plain characters", () => {
processInput("\x1b[97ua");
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "a"]);
});
it("should handle rapid typing simulation with Kitty protocol", () => {
// Simulates typing "hi" quickly with releases interleaved
processInput("\x1b[104u\x1b[104;1:3u\x1b[105u\x1b[105;1:3u");
assert.deepStrictEqual(emittedSequences, [
"\x1b[104u",
"\x1b[104;1:3u",
"\x1b[105u",
"\x1b[105;1:3u",
]);
});
});
describe("Mouse Events", () => {
it("should handle mouse press event", () => {
processInput("\x1b[<0;10;5M");
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5M"]);
});
it("should handle mouse release event", () => {
processInput("\x1b[<0;10;5m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5m"]);
});
it("should handle mouse move event", () => {
processInput("\x1b[<35;20;5m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
});
it("should handle split mouse events", () => {
processInput("\x1b[<3");
processInput("5;1");
processInput("5;");
processInput("10m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;15;10m"]);
});
it("should handle multiple mouse events", () => {
processInput("\x1b[<35;1;1m\x1b[<35;2;2m\x1b[<35;3;3m");
assert.deepStrictEqual(emittedSequences, [
"\x1b[<35;1;1m",
"\x1b[<35;2;2m",
"\x1b[<35;3;3m",
]);
});
it("should handle old-style mouse sequence (ESC[M + 3 bytes)", () => {
processInput("\x1b[M abc");
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab", "c"]);
});
it("should buffer incomplete old-style mouse sequence", () => {
processInput("\x1b[M");
assert.strictEqual(buffer.getBuffer(), "\x1b[M");
processInput(" a");
assert.strictEqual(buffer.getBuffer(), "\x1b[M a");
processInput("b");
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab"]);
});
});
describe("Edge Cases", () => {
it("should handle empty input", () => {
processInput("");
// Empty string emits an empty data event
assert.deepStrictEqual(emittedSequences, [""]);
});
it("should handle lone escape character with timeout", async () => {
processInput("\x1b");
assert.deepStrictEqual(emittedSequences, []);
// After timeout, should emit
await wait(15);
assert.deepStrictEqual(emittedSequences, ["\x1b"]);
});
it("should handle lone escape character with explicit flush", () => {
processInput("\x1b");
assert.deepStrictEqual(emittedSequences, []);
const flushed = buffer.flush();
assert.deepStrictEqual(flushed, ["\x1b"]);
});
it("should handle buffer input", () => {
processInput(Buffer.from("\x1b[A"));
assert.deepStrictEqual(emittedSequences, ["\x1b[A"]);
});
it("should handle very long sequences", () => {
const longSeq = `\x1b[${"1;".repeat(50)}H`;
processInput(longSeq);
assert.deepStrictEqual(emittedSequences, [longSeq]);
});
});
describe("Flush", () => {
it("should flush incomplete sequences", () => {
processInput("\x1b[<35");
const flushed = buffer.flush();
assert.deepStrictEqual(flushed, ["\x1b[<35"]);
assert.strictEqual(buffer.getBuffer(), "");
});
it("should return empty array if nothing to flush", () => {
const flushed = buffer.flush();
assert.deepStrictEqual(flushed, []);
});
it("should emit flushed data via timeout", async () => {
processInput("\x1b[<35");
assert.deepStrictEqual(emittedSequences, []);
// Wait for timeout to flush
await wait(15);
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
});
});
describe("Clear", () => {
it("should clear buffered content without emitting", () => {
processInput("\x1b[<35");
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
buffer.clear();
assert.strictEqual(buffer.getBuffer(), "");
assert.deepStrictEqual(emittedSequences, []);
});
});
describe("Bracketed Paste", () => {
let emittedPaste: string[] = [];
beforeEach(() => {
buffer = new StdinBuffer({ timeout: 10 });
// Collect emitted sequences
emittedSequences = [];
buffer.on("data", (sequence) => {
emittedSequences.push(sequence);
});
// Collect paste events
emittedPaste = [];
buffer.on("paste", (data) => {
emittedPaste.push(data);
});
});
it("should emit paste event for complete bracketed paste", () => {
const pasteStart = "\x1b[200~";
const pasteEnd = "\x1b[201~";
const content = "hello world";
processInput(pasteStart + content + pasteEnd);
assert.deepStrictEqual(emittedPaste, ["hello world"]);
assert.deepStrictEqual(emittedSequences, []); // No data events during paste
});
it("should handle paste arriving in chunks", () => {
processInput("\x1b[200~");
assert.deepStrictEqual(emittedPaste, []);
processInput("hello ");
assert.deepStrictEqual(emittedPaste, []);
processInput("world\x1b[201~");
assert.deepStrictEqual(emittedPaste, ["hello world"]);
assert.deepStrictEqual(emittedSequences, []);
});
it("should handle paste with input before and after", () => {
processInput("a");
processInput("\x1b[200~pasted\x1b[201~");
processInput("b");
assert.deepStrictEqual(emittedSequences, ["a", "b"]);
assert.deepStrictEqual(emittedPaste, ["pasted"]);
});
it("should handle paste with newlines", () => {
processInput("\x1b[200~line1\nline2\nline3\x1b[201~");
assert.deepStrictEqual(emittedPaste, ["line1\nline2\nline3"]);
assert.deepStrictEqual(emittedSequences, []);
});
it("should handle paste with unicode", () => {
processInput("\x1b[200~Hello 世界 🎉\x1b[201~");
assert.deepStrictEqual(emittedPaste, ["Hello 世界 🎉"]);
assert.deepStrictEqual(emittedSequences, []);
});
});
describe("Destroy", () => {
it("should clear buffer on destroy", () => {
processInput("\x1b[<35");
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
buffer.destroy();
assert.strictEqual(buffer.getBuffer(), "");
});
it("should clear pending timeouts on destroy", async () => {
processInput("\x1b[<35");
buffer.destroy();
// Wait longer than timeout
await wait(15);
// Should not have emitted anything
assert.deepStrictEqual(emittedSequences, []);
});
});
});

View file

@ -0,0 +1,167 @@
/**
* Tests for terminal image detection and line handling
*/
import assert from "node:assert";
import { describe, it } from "node:test";
import { isImageLine } from "../src/terminal-image.js";
describe("isImageLine", () => {
describe("iTerm2 image protocol", () => {
it("should detect iTerm2 image escape sequence at start of line", () => {
// iTerm2 image escape sequence: ESC ]1337;File=...
const iterm2ImageLine =
"\x1b]1337;File=size=100,100;inline=1:base64encodeddata==\x07";
assert.strictEqual(isImageLine(iterm2ImageLine), true);
});
it("should detect iTerm2 image escape sequence with text before it", () => {
// Simulating a line that has text then image data (bug scenario)
const lineWithTextAndImage =
"Some text \x1b]1337;File=size=100,100;inline=1:base64data==\x07 more text";
assert.strictEqual(isImageLine(lineWithTextAndImage), true);
});
it("should detect iTerm2 image escape sequence in middle of long line", () => {
// Simulate a very long line with image data in the middle
const longLineWithImage =
"Text before image..." +
"\x1b]1337;File=inline=1:verylongbase64data==" +
"...text after";
assert.strictEqual(isImageLine(longLineWithImage), true);
});
it("should detect iTerm2 image escape sequence at end of line", () => {
const lineWithImageAtEnd =
"Regular text ending with \x1b]1337;File=inline=1:base64data==\x07";
assert.strictEqual(isImageLine(lineWithImageAtEnd), true);
});
it("should detect minimal iTerm2 image escape sequence", () => {
const minimalImageLine = "\x1b]1337;File=:\x07";
assert.strictEqual(isImageLine(minimalImageLine), true);
});
});
describe("Kitty image protocol", () => {
it("should detect Kitty image escape sequence at start of line", () => {
// Kitty image escape sequence: ESC _G
const kittyImageLine =
"\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\";
assert.strictEqual(isImageLine(kittyImageLine), true);
});
it("should detect Kitty image escape sequence with text before it", () => {
// Bug scenario: text + image data in same line
const lineWithTextAndKittyImage =
"Output: \x1b_Ga=T,f=100;data...\x1b\\\x1b_Gm=i=1;\x1b\\";
assert.strictEqual(isImageLine(lineWithTextAndKittyImage), true);
});
it("should detect Kitty image escape sequence with padding", () => {
// Kitty protocol adds padding to escape sequences
const kittyWithPadding = " \x1b_Ga=T,f=100...\x1b\\\x1b_Gm=i=1;\x1b\\ ";
assert.strictEqual(isImageLine(kittyWithPadding), true);
});
});
describe("Bug regression tests", () => {
it("should detect image sequences in very long lines (304k+ chars)", () => {
// This simulates the crash scenario: a line with 304,401 chars
// containing image escape sequences somewhere
const base64Char = "A".repeat(100); // 100 chars of base64-like data
const imageSequence = "\x1b]1337;File=size=800,600;inline=1:";
// Build a long line with image sequence
const longLine =
"Text prefix " +
imageSequence +
base64Char.repeat(3000) + // ~300,000 chars
" suffix";
assert.strictEqual(longLine.length > 300000, true);
assert.strictEqual(isImageLine(longLine), true);
});
it("should detect image sequences when terminal doesn't support images", () => {
// The bug occurred when getImageEscapePrefix() returned null
// isImageLine should still detect image sequences regardless
const lineWithImage =
"Read image file [image/jpeg]\x1b]1337;File=inline=1:base64data==\x07";
assert.strictEqual(isImageLine(lineWithImage), true);
});
it("should detect image sequences with ANSI codes before them", () => {
// Text might have ANSI styling before image data
const lineWithAnsiAndImage =
"\x1b[31mError output \x1b]1337;File=inline=1:image==\x07";
assert.strictEqual(isImageLine(lineWithAnsiAndImage), true);
});
it("should detect image sequences with ANSI codes after them", () => {
const lineWithImageAndAnsi =
"\x1b_Ga=T,f=100:data...\x1b\\\x1b_Gm=i=1;\x1b\\\x1b[0m reset";
assert.strictEqual(isImageLine(lineWithImageAndAnsi), true);
});
});
describe("Negative cases - lines without images", () => {
it("should not detect images in plain text lines", () => {
const plainText =
"This is just a regular text line without any escape sequences";
assert.strictEqual(isImageLine(plainText), false);
});
it("should not detect images in lines with only ANSI codes", () => {
const ansiText = "\x1b[31mRed text\x1b[0m and \x1b[32mgreen text\x1b[0m";
assert.strictEqual(isImageLine(ansiText), false);
});
it("should not detect images in lines with cursor movement codes", () => {
const cursorCodes = "\x1b[1A\x1b[2KLine cleared and moved up";
assert.strictEqual(isImageLine(cursorCodes), false);
});
it("should not detect images in lines with partial iTerm2 sequences", () => {
// Similar prefix but missing the complete sequence
const partialSequence =
"Some text with ]1337;File but missing ESC at start";
assert.strictEqual(isImageLine(partialSequence), false);
});
it("should not detect images in lines with partial Kitty sequences", () => {
// Similar prefix but missing the complete sequence
const partialSequence = "Some text with _G but missing ESC at start";
assert.strictEqual(isImageLine(partialSequence), false);
});
it("should not detect images in empty lines", () => {
assert.strictEqual(isImageLine(""), false);
});
it("should not detect images in lines with newlines only", () => {
assert.strictEqual(isImageLine("\n"), false);
assert.strictEqual(isImageLine("\n\n"), false);
});
});
describe("Mixed content scenarios", () => {
it("should detect images when line has both Kitty and iTerm2 sequences", () => {
const mixedLine =
"Kitty: \x1b_Ga=T...\x1b\\\x1b_Gm=i=1;\x1b\\ iTerm2: \x1b]1337;File=inline=1:data==\x07";
assert.strictEqual(isImageLine(mixedLine), true);
});
it("should detect image in line with multiple text and image segments", () => {
const complexLine =
"Start \x1b]1337;File=img1==\x07 middle \x1b]1337;File=img2==\x07 end";
assert.strictEqual(isImageLine(complexLine), true);
});
it("should not falsely detect image in line with file path containing keywords", () => {
// File path might contain "1337" or "File" but without escape sequences
const filePathLine = "/path/to/File_1337_backup/image.jpg";
assert.strictEqual(isImageLine(filePathLine), false);
});
});
});

View file

@ -0,0 +1,42 @@
/**
* Default themes for TUI tests using chalk
*/
import { Chalk } from "chalk";
import type {
EditorTheme,
MarkdownTheme,
SelectListTheme,
} from "../src/index.js";
const chalk = new Chalk({ level: 3 });
export const defaultSelectListTheme: SelectListTheme = {
selectedPrefix: (text: string) => chalk.blue(text),
selectedText: (text: string) => chalk.bold(text),
description: (text: string) => chalk.dim(text),
scrollInfo: (text: string) => chalk.dim(text),
noMatch: (text: string) => chalk.dim(text),
};
export const defaultMarkdownTheme: MarkdownTheme = {
heading: (text: string) => chalk.bold.cyan(text),
link: (text: string) => chalk.blue(text),
linkUrl: (text: string) => chalk.dim(text),
code: (text: string) => chalk.yellow(text),
codeBlock: (text: string) => chalk.green(text),
codeBlockBorder: (text: string) => chalk.dim(text),
quote: (text: string) => chalk.italic(text),
quoteBorder: (text: string) => chalk.dim(text),
hr: (text: string) => chalk.dim(text),
listBullet: (text: string) => chalk.cyan(text),
bold: (text: string) => chalk.bold(text),
italic: (text: string) => chalk.italic(text),
strikethrough: (text: string) => chalk.strikethrough(text),
underline: (text: string) => chalk.underline(text),
};
export const defaultEditorTheme: EditorTheme = {
borderColor: (text: string) => chalk.dim(text),
selectList: defaultSelectListTheme,
};

View file

@ -0,0 +1,133 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { Chalk } from "chalk";
import { TruncatedText } from "../src/components/truncated-text.js";
import { visibleWidth } from "../src/utils.js";
// Force full color in CI so ANSI assertions are deterministic
const chalk = new Chalk({ level: 3 });
describe("TruncatedText component", () => {
it("pads output lines to exactly match width", () => {
const text = new TruncatedText("Hello world", 1, 0);
const lines = text.render(50);
// Should have exactly one content line (no vertical padding)
assert.strictEqual(lines.length, 1);
// Line should be exactly 50 visible characters
const visibleLen = visibleWidth(lines[0]);
assert.strictEqual(visibleLen, 50);
});
it("pads output with vertical padding lines to width", () => {
const text = new TruncatedText("Hello", 0, 2);
const lines = text.render(40);
// Should have 2 padding lines + 1 content line + 2 padding lines = 5 total
assert.strictEqual(lines.length, 5);
// All lines should be exactly 40 characters
for (const line of lines) {
assert.strictEqual(visibleWidth(line), 40);
}
});
it("truncates long text and pads to width", () => {
const longText =
"This is a very long piece of text that will definitely exceed the available width";
const text = new TruncatedText(longText, 1, 0);
const lines = text.render(30);
assert.strictEqual(lines.length, 1);
// Should be exactly 30 characters
assert.strictEqual(visibleWidth(lines[0]), 30);
// Should contain ellipsis
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
assert.ok(stripped.includes("..."));
});
it("preserves ANSI codes in output and pads correctly", () => {
const styledText = `${chalk.red("Hello")} ${chalk.blue("world")}`;
const text = new TruncatedText(styledText, 1, 0);
const lines = text.render(40);
assert.strictEqual(lines.length, 1);
// Should be exactly 40 visible characters (ANSI codes don't count)
assert.strictEqual(visibleWidth(lines[0]), 40);
// Should preserve the color codes
assert.ok(lines[0].includes("\x1b["));
});
it("truncates styled text and adds reset code before ellipsis", () => {
const longStyledText = chalk.red(
"This is a very long red text that will be truncated",
);
const text = new TruncatedText(longStyledText, 1, 0);
const lines = text.render(20);
assert.strictEqual(lines.length, 1);
// Should be exactly 20 visible characters
assert.strictEqual(visibleWidth(lines[0]), 20);
// Should contain reset code before ellipsis
assert.ok(lines[0].includes("\x1b[0m..."));
});
it("handles text that fits exactly", () => {
// With paddingX=1, available width is 30-2=28
// "Hello world" is 11 chars, fits comfortably
const text = new TruncatedText("Hello world", 1, 0);
const lines = text.render(30);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 30);
// Should NOT contain ellipsis
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
assert.ok(!stripped.includes("..."));
});
it("handles empty text", () => {
const text = new TruncatedText("", 1, 0);
const lines = text.render(30);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 30);
});
it("stops at newline and only shows first line", () => {
const multilineText = "First line\nSecond line\nThird line";
const text = new TruncatedText(multilineText, 1, 0);
const lines = text.render(40);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 40);
// Should only contain "First line"
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "").trim();
assert.ok(stripped.includes("First line"));
assert.ok(!stripped.includes("Second line"));
assert.ok(!stripped.includes("Third line"));
});
it("truncates first line even with newlines in text", () => {
const longMultilineText =
"This is a very long first line that needs truncation\nSecond line";
const text = new TruncatedText(longMultilineText, 1, 0);
const lines = text.render(25);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 25);
// Should contain ellipsis and not second line
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
assert.ok(stripped.includes("..."));
assert.ok(!stripped.includes("Second line"));
});
});

View file

@ -0,0 +1,79 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class StaticLines implements Component {
constructor(private readonly lines: string[]) {}
render(): string[] {
return this.lines;
}
invalidate(): void {}
}
class StaticOverlay implements Component {
constructor(private readonly line: string) {}
render(): string[] {
return [this.line];
}
invalidate(): void {}
}
function getCellItalic(
terminal: VirtualTerminal,
row: number,
col: number,
): number {
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
const buffer = xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
assert.ok(line, `Missing buffer line at row ${row}`);
const cell = line.getCell(col);
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
return cell.isItalic();
}
async function renderAndFlush(
tui: TUI,
terminal: VirtualTerminal,
): Promise<void> {
tui.requestRender(true);
await new Promise<void>((resolve) => process.nextTick(resolve));
await terminal.flush();
}
describe("TUI overlay compositing", () => {
it("should not leak styles when a trailing reset sits beyond the last visible column (no overlay)", async () => {
const width = 20;
const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`;
const terminal = new VirtualTerminal(width, 6);
const tui = new TUI(terminal);
tui.addChild(new StaticLines([baseLine, "INPUT"]));
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
tui.stop();
});
it("should not leak styles when overlay slicing drops trailing SGR resets", async () => {
const width = 20;
const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`;
const terminal = new VirtualTerminal(width, 6);
const tui = new TUI(terminal);
tui.addChild(new StaticLines([baseLine, "INPUT"]));
tui.showOverlay(new StaticOverlay("OVR"), { row: 0, col: 5, width: 3 });
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
tui.stop();
});
});

View file

@ -0,0 +1,409 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class TestComponent implements Component {
lines: string[] = [];
render(_width: number): string[] {
return this.lines;
}
invalidate(): void {}
}
function getCellItalic(
terminal: VirtualTerminal,
row: number,
col: number,
): number {
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
const buffer = xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
assert.ok(line, `Missing buffer line at row ${row}`);
const cell = line.getCell(col);
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
return cell.isItalic();
}
describe("TUI resize handling", () => {
it("triggers full re-render when terminal height changes", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
const initialRedraws = tui.fullRedraws;
// Resize height
terminal.resize(40, 15);
await terminal.flush();
// Should have triggered a full redraw
assert.ok(
tui.fullRedraws > initialRedraws,
"Height change should trigger full redraw",
);
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("Line 0"),
"Content preserved after height change",
);
tui.stop();
});
it("triggers full re-render when terminal width changes", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
const initialRedraws = tui.fullRedraws;
// Resize width
terminal.resize(60, 10);
await terminal.flush();
// Should have triggered a full redraw
assert.ok(
tui.fullRedraws > initialRedraws,
"Width change should trigger full redraw",
);
tui.stop();
});
});
describe("TUI content shrinkage", () => {
it("clears empty rows when content shrinks significantly", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);
// Start with many lines
component.lines = [
"Line 0",
"Line 1",
"Line 2",
"Line 3",
"Line 4",
"Line 5",
];
tui.start();
await terminal.flush();
const initialRedraws = tui.fullRedraws;
// Shrink to fewer lines
component.lines = ["Line 0", "Line 1"];
tui.requestRender();
await terminal.flush();
// Should have triggered a full redraw to clear empty rows
assert.ok(
tui.fullRedraws > initialRedraws,
"Content shrinkage should trigger full redraw",
);
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), "First line preserved");
assert.ok(viewport[1]?.includes("Line 1"), "Second line preserved");
// Lines below should be empty (cleared)
assert.strictEqual(viewport[2]?.trim(), "", "Line 2 should be cleared");
assert.strictEqual(viewport[3]?.trim(), "", "Line 3 should be cleared");
tui.stop();
});
it("handles shrink to single line", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.flush();
// Shrink to single line
component.lines = ["Only line"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Only line"), "Single line rendered");
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
tui.stop();
});
it("handles shrink to empty", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
// Shrink to empty
component.lines = [];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
// All lines should be empty
assert.strictEqual(viewport[0]?.trim(), "", "Line 0 should be cleared");
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
tui.stop();
});
});
describe("TUI differential rendering", () => {
it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
// Initial render: 5 identical lines
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
tui.start();
await terminal.flush();
// Shrink to 3 lines, all identical to before (no content changes in remaining lines)
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.requestRender();
await terminal.flush();
// cursorRow should be 2 (last line of new content)
// Verify by doing another render with a change on line 1
component.lines = ["Line 0", "CHANGED", "Line 2"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
// Line 1 should show "CHANGED", proving cursor tracking was correct
assert.ok(
viewport[1]?.includes("CHANGED"),
`Expected "CHANGED" on line 1, got: ${viewport[1]}`,
);
tui.stop();
});
it("renders correctly when only a middle line changes (spinner case)", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
// Initial render
component.lines = ["Header", "Working...", "Footer"];
tui.start();
await terminal.flush();
// Simulate spinner animation - only middle line changes
const spinnerFrames = ["|", "/", "-", "\\"];
for (const frame of spinnerFrames) {
component.lines = ["Header", `Working ${frame}`, "Footer"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("Header"),
`Header preserved: ${viewport[0]}`,
);
assert.ok(
viewport[1]?.includes(`Working ${frame}`),
`Spinner updated: ${viewport[1]}`,
);
assert.ok(
viewport[2]?.includes("Footer"),
`Footer preserved: ${viewport[2]}`,
);
}
tui.stop();
});
it("resets styles after each rendered line", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["\x1b[3mItalic", "Plain"];
tui.start();
await terminal.flush();
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
tui.stop();
});
it("renders correctly when first line changes but rest stays same", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.flush();
// Change only first line
component.lines = ["CHANGED", "Line 1", "Line 2", "Line 3"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("CHANGED"),
`First line changed: ${viewport[0]}`,
);
assert.ok(
viewport[1]?.includes("Line 1"),
`Line 1 preserved: ${viewport[1]}`,
);
assert.ok(
viewport[2]?.includes("Line 2"),
`Line 2 preserved: ${viewport[2]}`,
);
assert.ok(
viewport[3]?.includes("Line 3"),
`Line 3 preserved: ${viewport[3]}`,
);
tui.stop();
});
it("renders correctly when last line changes but rest stays same", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.flush();
// Change only last line
component.lines = ["Line 0", "Line 1", "Line 2", "CHANGED"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("Line 0"),
`Line 0 preserved: ${viewport[0]}`,
);
assert.ok(
viewport[1]?.includes("Line 1"),
`Line 1 preserved: ${viewport[1]}`,
);
assert.ok(
viewport[2]?.includes("Line 2"),
`Line 2 preserved: ${viewport[2]}`,
);
assert.ok(
viewport[3]?.includes("CHANGED"),
`Last line changed: ${viewport[3]}`,
);
tui.stop();
});
it("renders correctly when multiple non-adjacent lines change", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
tui.start();
await terminal.flush();
// Change lines 1 and 3, keep 0, 2, 4 the same
component.lines = ["Line 0", "CHANGED 1", "Line 2", "CHANGED 3", "Line 4"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("Line 0"),
`Line 0 preserved: ${viewport[0]}`,
);
assert.ok(
viewport[1]?.includes("CHANGED 1"),
`Line 1 changed: ${viewport[1]}`,
);
assert.ok(
viewport[2]?.includes("Line 2"),
`Line 2 preserved: ${viewport[2]}`,
);
assert.ok(
viewport[3]?.includes("CHANGED 3"),
`Line 3 changed: ${viewport[3]}`,
);
assert.ok(
viewport[4]?.includes("Line 4"),
`Line 4 preserved: ${viewport[4]}`,
);
tui.stop();
});
it("handles transition from content to empty and back to content", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
// Start with content
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
let viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), "Initial content rendered");
// Clear to empty
component.lines = [];
tui.requestRender();
await terminal.flush();
// Add content back - this should work correctly even after empty state
component.lines = ["New Line 0", "New Line 1"];
tui.requestRender();
await terminal.flush();
viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("New Line 0"),
`New content rendered: ${viewport[0]}`,
);
assert.ok(
viewport[1]?.includes("New Line 1"),
`New content line 1: ${viewport[1]}`,
);
tui.stop();
});
});

View file

@ -0,0 +1,113 @@
/**
* TUI viewport overwrite repro
*
* Place this file at: packages/tui/test/viewport-overwrite-repro.ts
* Run from repo root: npx tsx packages/tui/test/viewport-overwrite-repro.ts
*
* For reliable repro, run in a small terminal (8-12 rows) or a tmux session:
* tmux new-session -d -s tui-bug -x 80 -y 12
* tmux send-keys -t tui-bug "npx tsx packages/tui/test/viewport-overwrite-repro.ts" Enter
* tmux attach -t tui-bug
*
* Expected behavior:
* - PRE-TOOL lines remain visible above tool output.
* - POST-TOOL lines append after tool output without overwriting earlier content.
*
* Actual behavior (bug):
* - When content exceeds the viewport and new lines arrive after a tool-call pause,
* some earlier PRE-TOOL lines near the bottom are overwritten by POST-TOOL lines.
*/
import { ProcessTerminal } from "../src/terminal.js";
import { type Component, TUI } from "../src/tui.js";
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
class Lines implements Component {
private lines: string[] = [];
set(lines: string[]): void {
this.lines = lines;
}
append(lines: string[]): void {
this.lines.push(...lines);
}
render(width: number): string[] {
return this.lines.map((line) => {
if (line.length > width) return line.slice(0, width);
return line.padEnd(width, " ");
});
}
invalidate(): void {}
}
async function streamLines(
buffer: Lines,
label: string,
count: number,
delayMs: number,
ui: TUI,
): Promise<void> {
for (let i = 1; i <= count; i += 1) {
buffer.append([`${label} ${String(i).padStart(2, "0")}`]);
ui.requestRender();
await sleep(delayMs);
}
}
async function main(): Promise<void> {
const ui = new TUI(new ProcessTerminal());
const buffer = new Lines();
ui.addChild(buffer);
ui.start();
const height = ui.terminal.rows;
const preCount = height + 8; // Ensure content exceeds viewport
const toolCount = height + 12; // Tool output pushes further into scrollback
const postCount = 6;
buffer.set([
"TUI viewport overwrite repro",
`Viewport rows detected: ${height}`,
"(Resize to ~8-12 rows for best repro)",
"",
"=== PRE-TOOL STREAM ===",
]);
ui.requestRender();
await sleep(300);
// Phase 1: Stream pre-tool text until viewport is exceeded.
await streamLines(buffer, "PRE-TOOL LINE", preCount, 30, ui);
// Phase 2: Simulate tool call pause and tool output.
buffer.append(["", "--- TOOL CALL START ---", "(pause...)", ""]);
ui.requestRender();
await sleep(700);
await streamLines(buffer, "TOOL OUT", toolCount, 20, ui);
// Phase 3: Post-tool streaming. This is where overwrite often appears.
buffer.append(["", "=== POST-TOOL STREAM ==="]);
ui.requestRender();
await sleep(300);
await streamLines(buffer, "POST-TOOL LINE", postCount, 40, ui);
// Leave the output visible briefly, then restore terminal state.
await sleep(1500);
ui.stop();
}
main().catch((error) => {
// Ensure terminal is restored if something goes wrong.
try {
const ui = new TUI(new ProcessTerminal());
ui.stop();
} catch {
// Ignore restore errors.
}
process.stderr.write(`${String(error)}\n`);
process.exitCode = 1;
});

View file

@ -0,0 +1,209 @@
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import xterm from "@xterm/headless";
import type { Terminal } from "../src/terminal.js";
// Extract Terminal class from the module
const XtermTerminal = xterm.Terminal;
/**
* Virtual terminal for testing using xterm.js for accurate terminal emulation
*/
export class VirtualTerminal implements Terminal {
private xterm: XtermTerminalType;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
private _columns: number;
private _rows: number;
constructor(columns = 80, rows = 24) {
this._columns = columns;
this._rows = rows;
// Create xterm instance with specified dimensions
this.xterm = new XtermTerminal({
cols: columns,
rows: rows,
// Disable all interactive features for testing
disableStdin: true,
allowProposedApi: true,
});
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.inputHandler = onInput;
this.resizeHandler = onResize;
// Enable bracketed paste mode for consistency with ProcessTerminal
this.xterm.write("\x1b[?2004h");
}
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {
// No-op for virtual terminal - no stdin to drain
}
stop(): void {
// Disable bracketed paste mode
this.xterm.write("\x1b[?2004l");
this.inputHandler = undefined;
this.resizeHandler = undefined;
}
write(data: string): void {
this.xterm.write(data);
}
get columns(): number {
return this._columns;
}
get rows(): number {
return this._rows;
}
get kittyProtocolActive(): boolean {
// Virtual terminal always reports Kitty protocol as active for testing
return true;
}
moveBy(lines: number): void {
if (lines > 0) {
// Move down
this.xterm.write(`\x1b[${lines}B`);
} else if (lines < 0) {
// Move up
this.xterm.write(`\x1b[${-lines}A`);
}
// lines === 0: no movement
}
hideCursor(): void {
this.xterm.write("\x1b[?25l");
}
showCursor(): void {
this.xterm.write("\x1b[?25h");
}
clearLine(): void {
this.xterm.write("\x1b[K");
}
clearFromCursor(): void {
this.xterm.write("\x1b[J");
}
clearScreen(): void {
this.xterm.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
}
setTitle(title: string): void {
// OSC 0;title BEL - set terminal window title
this.xterm.write(`\x1b]0;${title}\x07`);
}
// Test-specific methods not in Terminal interface
/**
* Simulate keyboard input
*/
sendInput(data: string): void {
if (this.inputHandler) {
this.inputHandler(data);
}
}
/**
* Resize the terminal
*/
resize(columns: number, rows: number): void {
this._columns = columns;
this._rows = rows;
this.xterm.resize(columns, rows);
if (this.resizeHandler) {
this.resizeHandler();
}
}
/**
* Wait for all pending writes to complete. Viewport and scroll buffer will be updated.
*/
async flush(): Promise<void> {
// Write an empty string to ensure all previous writes are flushed
return new Promise<void>((resolve) => {
this.xterm.write("", () => resolve());
});
}
/**
* Flush and get viewport - convenience method for tests
*/
async flushAndGetViewport(): Promise<string[]> {
await this.flush();
return this.getViewport();
}
/**
* Get the visible viewport (what's currently on screen)
* Note: You should use getViewportAfterWrite() for testing after writing data
*/
getViewport(): string[] {
const lines: string[] = [];
const buffer = this.xterm.buffer.active;
// Get only the visible lines (viewport)
for (let i = 0; i < this.xterm.rows; i++) {
const line = buffer.getLine(buffer.viewportY + i);
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push("");
}
}
return lines;
}
/**
* Get the entire scroll buffer
*/
getScrollBuffer(): string[] {
const lines: string[] = [];
const buffer = this.xterm.buffer.active;
// Get all lines in the buffer (including scrollback)
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push("");
}
}
return lines;
}
/**
* Clear the terminal viewport
*/
clear(): void {
this.xterm.clear();
}
/**
* Reset the terminal completely
*/
reset(): void {
this.xterm.reset();
}
/**
* Get cursor position
*/
getCursorPosition(): { x: number; y: number } {
const buffer = this.xterm.buffer.active;
return {
x: buffer.cursorX,
y: buffer.cursorY,
};
}
}

View file

@ -0,0 +1,158 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js";
describe("wrapTextWithAnsi", () => {
describe("underline styling", () => {
it("should not apply underline style before the styled text", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const url = "https://example.com/very/long/path/that/will/wrap";
const text = `read this thread ${underlineOn}${url}${underlineOff}`;
const wrapped = wrapTextWithAnsi(text, 40);
// First line should NOT contain underline code - it's just "read this thread"
assert.strictEqual(wrapped[0], "read this thread");
// Second line should start with underline, have URL content
assert.strictEqual(wrapped[1].startsWith(underlineOn), true);
assert.ok(wrapped[1].includes("https://"));
});
it("should not have whitespace before underline reset code", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const textWithUnderlinedTrailingSpace = `${underlineOn}underlined text here ${underlineOff}more`;
const wrapped = wrapTextWithAnsi(textWithUnderlinedTrailingSpace, 18);
assert.ok(!wrapped[0].includes(` ${underlineOff}`));
});
it("should not bleed underline to padding - each line should end with reset for underline only", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const url =
"https://example.com/very/long/path/that/will/definitely/wrap";
const text = `prefix ${underlineOn}${url}${underlineOff} suffix`;
const wrapped = wrapTextWithAnsi(text, 30);
// Middle lines (with underlined content) should end with underline-off, not full reset
// Line 1 and 2 contain underlined URL parts
for (let i = 1; i < wrapped.length - 1; i++) {
const line = wrapped[i];
if (line.includes(underlineOn)) {
// Should end with underline off, NOT full reset
assert.strictEqual(line.endsWith(underlineOff), true);
assert.strictEqual(line.endsWith("\x1b[0m"), false);
}
}
});
});
describe("background color preservation", () => {
it("should preserve background color across wrapped lines without full reset", () => {
const bgBlue = "\x1b[44m";
const reset = "\x1b[0m";
const text = `${bgBlue}hello world this is blue background text${reset}`;
const wrapped = wrapTextWithAnsi(text, 15);
// Each line should have background color
for (const line of wrapped) {
assert.ok(line.includes(bgBlue));
}
// Middle lines should NOT end with full reset (kills background for padding)
for (let i = 0; i < wrapped.length - 1; i++) {
assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false);
}
});
it("should reset underline but preserve background when wrapping underlined text inside background", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const reset = "\x1b[0m";
const text = `\x1b[41mprefix ${underlineOn}UNDERLINED_CONTENT_THAT_WRAPS${underlineOff} suffix${reset}`;
const wrapped = wrapTextWithAnsi(text, 20);
// All lines should have background color 41 (either as \x1b[41m or combined like \x1b[4;41m)
for (const line of wrapped) {
const hasBgColor =
line.includes("[41m") ||
line.includes(";41m") ||
line.includes("[41;");
assert.ok(hasBgColor);
}
// Lines with underlined content should use underline-off at end, not full reset
for (let i = 0; i < wrapped.length - 1; i++) {
const line = wrapped[i];
// If this line has underline on, it should end with underline off (not full reset)
if (
(line.includes("[4m") ||
line.includes("[4;") ||
line.includes(";4m")) &&
!line.includes(underlineOff)
) {
assert.strictEqual(line.endsWith(underlineOff), true);
assert.strictEqual(line.endsWith("\x1b[0m"), false);
}
}
});
});
describe("basic wrapping", () => {
it("should wrap plain text correctly", () => {
const text = "hello world this is a test";
const wrapped = wrapTextWithAnsi(text, 10);
assert.ok(wrapped.length > 1);
for (const line of wrapped) {
assert.ok(visibleWidth(line) <= 10);
}
});
it("should ignore OSC 133 semantic markers in visible width", () => {
const text = "\x1b]133;A\x07hello\x1b]133;B\x07";
assert.strictEqual(visibleWidth(text), 5);
});
it("should ignore OSC sequences terminated with ST in visible width", () => {
const text = "\x1b]133;A\x1b\\hello\x1b]133;B\x1b\\";
assert.strictEqual(visibleWidth(text), 5);
});
it("should treat isolated regional indicators as width 2", () => {
assert.strictEqual(visibleWidth("🇨"), 2);
assert.strictEqual(visibleWidth("🇨🇳"), 2);
});
it("should truncate trailing whitespace that exceeds width", () => {
const twoSpacesWrappedToWidth1 = wrapTextWithAnsi(" ", 1);
assert.ok(visibleWidth(twoSpacesWrappedToWidth1[0]) <= 1);
});
it("should preserve color codes across wraps", () => {
const red = "\x1b[31m";
const reset = "\x1b[0m";
const text = `${red}hello world this is red${reset}`;
const wrapped = wrapTextWithAnsi(text, 10);
// Each continuation line should start with red code
for (let i = 1; i < wrapped.length; i++) {
assert.strictEqual(wrapped[i].startsWith(red), true);
}
// Middle lines should not end with full reset
for (let i = 0; i < wrapped.length - 1; i++) {
assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false);
}
});
});
});

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/wrap-ansi.test.ts"],
},
});