mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-17 06:04:52 +00:00
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:
commit
0250f72976
579 changed files with 206942 additions and 0 deletions
536
packages/tui/CHANGELOG.md
Normal file
536
packages/tui/CHANGELOG.md
Normal 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
806
packages/tui/README.md
Normal 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
52
packages/tui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
825
packages/tui/src/autocomplete.ts
Normal file
825
packages/tui/src/autocomplete.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
141
packages/tui/src/components/box.ts
Normal file
141
packages/tui/src/components/box.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
40
packages/tui/src/components/cancellable-loader.ts
Normal file
40
packages/tui/src/components/cancellable-loader.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
2150
packages/tui/src/components/editor.ts
Normal file
2150
packages/tui/src/components/editor.ts
Normal file
File diff suppressed because it is too large
Load diff
116
packages/tui/src/components/image.ts
Normal file
116
packages/tui/src/components/image.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
562
packages/tui/src/components/input.ts
Normal file
562
packages/tui/src/components/input.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
57
packages/tui/src/components/loader.ts
Normal file
57
packages/tui/src/components/loader.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
913
packages/tui/src/components/markdown.ts
Normal file
913
packages/tui/src/components/markdown.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
234
packages/tui/src/components/select-list.ts
Normal file
234
packages/tui/src/components/select-list.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
282
packages/tui/src/components/settings-list.ts
Normal file
282
packages/tui/src/components/settings-list.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
packages/tui/src/components/spacer.ts
Normal file
28
packages/tui/src/components/spacer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
123
packages/tui/src/components/text.ts
Normal file
123
packages/tui/src/components/text.ts
Normal 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 : [""];
|
||||
}
|
||||
}
|
||||
65
packages/tui/src/components/truncated-text.ts
Normal file
65
packages/tui/src/components/truncated-text.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
74
packages/tui/src/editor-component.ts
Normal file
74
packages/tui/src/editor-component.ts
Normal 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
145
packages/tui/src/fuzzy.ts
Normal 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
117
packages/tui/src/index.ts
Normal 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";
|
||||
183
packages/tui/src/keybindings.ts
Normal file
183
packages/tui/src/keybindings.ts
Normal 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
1309
packages/tui/src/keys.ts
Normal file
File diff suppressed because it is too large
Load diff
46
packages/tui/src/kill-ring.ts
Normal file
46
packages/tui/src/kill-ring.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
397
packages/tui/src/stdin-buffer.ts
Normal file
397
packages/tui/src/stdin-buffer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
405
packages/tui/src/terminal-image.ts
Normal file
405
packages/tui/src/terminal-image.ts
Normal 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(" ")}]`;
|
||||
}
|
||||
332
packages/tui/src/terminal.ts
Normal file
332
packages/tui/src/terminal.ts
Normal 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
1328
packages/tui/src/tui.ts
Normal file
File diff suppressed because it is too large
Load diff
28
packages/tui/src/undo-stack.ts
Normal file
28
packages/tui/src/undo-stack.ts
Normal 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
933
packages/tui/src/utils.ts
Normal 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 };
|
||||
}
|
||||
521
packages/tui/test/autocomplete.test.ts
Normal file
521
packages/tui/test/autocomplete.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
137
packages/tui/test/chat-simple.ts
Normal file
137
packages/tui/test/chat-simple.ts
Normal 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();
|
||||
2748
packages/tui/test/editor.test.ts
Normal file
2748
packages/tui/test/editor.test.ts
Normal file
File diff suppressed because it is too large
Load diff
102
packages/tui/test/fuzzy.test.ts
Normal file
102
packages/tui/test/fuzzy.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
62
packages/tui/test/image-test.ts
Normal file
62
packages/tui/test/image-test.ts
Normal 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();
|
||||
530
packages/tui/test/input.test.ts
Normal file
530
packages/tui/test/input.test.ts
Normal 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
113
packages/tui/test/key-tester.ts
Executable 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();
|
||||
349
packages/tui/test/keys.test.ts
Normal file
349
packages/tui/test/keys.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
1223
packages/tui/test/markdown.test.ts
Normal file
1223
packages/tui/test/markdown.test.ts
Normal file
File diff suppressed because it is too large
Load diff
626
packages/tui/test/overlay-options.test.ts
Normal file
626
packages/tui/test/overlay-options.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
60
packages/tui/test/overlay-short-content.test.ts
Normal file
60
packages/tui/test/overlay-short-content.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
30
packages/tui/test/select-list.test.ts
Normal file
30
packages/tui/test/select-list.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
450
packages/tui/test/stdin-buffer.test.ts
Normal file
450
packages/tui/test/stdin-buffer.test.ts
Normal 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, []);
|
||||
});
|
||||
});
|
||||
});
|
||||
167
packages/tui/test/terminal-image.test.ts
Normal file
167
packages/tui/test/terminal-image.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
packages/tui/test/test-themes.ts
Normal file
42
packages/tui/test/test-themes.ts
Normal 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,
|
||||
};
|
||||
133
packages/tui/test/truncated-text.test.ts
Normal file
133
packages/tui/test/truncated-text.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
79
packages/tui/test/tui-overlay-style-leak.test.ts
Normal file
79
packages/tui/test/tui-overlay-style-leak.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
409
packages/tui/test/tui-render.test.ts
Normal file
409
packages/tui/test/tui-render.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
113
packages/tui/test/viewport-overwrite-repro.ts
Normal file
113
packages/tui/test/viewport-overwrite-repro.ts
Normal 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;
|
||||
});
|
||||
209
packages/tui/test/virtual-terminal.ts
Normal file
209
packages/tui/test/virtual-terminal.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
158
packages/tui/test/wrap-ansi.test.ts
Normal file
158
packages/tui/test/wrap-ansi.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
9
packages/tui/tsconfig.build.json
Normal file
9
packages/tui/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
7
packages/tui/vitest.config.ts
Normal file
7
packages/tui/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/wrap-ansi.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue