From 07586a9b448b9853587abfa579673fc66db25b55 Mon Sep 17 00:00:00 2001 From: David Schovanec Date: Wed, 25 Feb 2026 20:51:57 +0100 Subject: [PATCH] refactor: replace internal/ansi with charmbracelet/x/vt emulator The hand-rolled ANSI handling (regex stripping, virtual 2D grid, heuristic frame detection, terminal responder) breaks on complex TUI apps. Replace the entire internal/ansi package with a proper VT terminal emulator. - TUI sessions now use vterm.Screen wrapping a thread-safe VT emulator; PTY output feeds the emulator directly, no raw byte storage needed - Non-TUI ANSI stripping uses vterm.Strip with two paths: fast regex for simple output, temporary VT emulator for cursor-positioned content - Terminal query responses (DA1/DA2/DSR) handled by emulator natively via ReadResponses bridge, replacing hand-rolled TerminalResponder - Atomic version counter replaces byte-count change detection for TUI - wait.ForOutput gains FullOutput flag for TUI-aware pattern matching - Delete internal/ansi/ entirely (5 files, ~2900 lines removed) --- CLAUDE.md | 36 +- cmd/exec.go | 4 +- cmd/read.go | 8 +- cmd/search.go | 8 +- docs/TUI.md | 234 ++-- go.mod | 18 + go.sum | 36 + internal/ansi/clear.go | 520 -------- internal/ansi/clear_test.go | 1505 ------------------------ internal/ansi/responder.go | 134 --- internal/ansi/responder_test.go | 248 ---- internal/ansi/strip.go | 508 -------- internal/daemon/client.go | 1 + internal/daemon/server.go | 245 ++-- internal/mcp/tools.go | 12 +- internal/vterm/screen.go | 138 +++ internal/vterm/screen_test.go | 113 ++ internal/vterm/strip.go | 102 ++ internal/{ansi => vterm}/strip_test.go | 97 +- internal/wait/wait.go | 25 +- 20 files changed, 729 insertions(+), 3263 deletions(-) delete mode 100644 internal/ansi/clear.go delete mode 100644 internal/ansi/clear_test.go delete mode 100644 internal/ansi/responder.go delete mode 100644 internal/ansi/responder_test.go delete mode 100644 internal/ansi/strip.go create mode 100644 internal/vterm/screen.go create mode 100644 internal/vterm/screen_test.go create mode 100644 internal/vterm/strip.go rename internal/{ansi => vterm}/strip_test.go (81%) diff --git a/CLAUDE.md b/CLAUDE.md index 6cdf495..28c6898 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,11 +44,10 @@ shelli provides persistent interactive shell sessions via PTY-backed processes m - Commands: create, exec, send, read, list, stop, kill, search, cursor, version, daemon **Utilities** (`internal/`) -- `wait/`: Output polling with settle-time and pattern-matching modes -- `ansi/`: ANSI escape code stripping, TUI frame detection, terminal query responses (see `docs/TUI.md` for details) - - `strip.go`: ANSI escape code removal with rune-based virtual screen buffer supporting cursor positioning, relative movement (A/B/C/D), erase line (K), DEC Special Graphics charset, and newline-based grid sizing - - `clear.go`: `FrameDetector` for TUI mode (screen clear, sync mode, cursor home with cooldown, CursorJumpTop with look-ahead, size cap). Snapshot mode suppresses ALL truncation strategies. - - `responder.go`: `TerminalResponder` intercepts DA1/DA2/Kitty queries and writes responses to PTY +- `wait/`: Output polling with settle-time and pattern-matching modes. Supports `FullOutput` flag for TUI sessions where output is full screen content rather than a growing buffer. +- `vterm/`: VT terminal emulator wrapper using `charmbracelet/x/vt` (see `docs/TUI.md` for details) + - `screen.go`: `Screen` wraps a thread-safe VT emulator with atomic version counter and terminal query response bridge. Used for TUI sessions (replaces raw byte storage + frame detection + terminal responder). + - `strip.go`: ANSI escape code removal. Detects cursor positioning sequences and uses a temporary VT emulator for correct rendering; falls back to fast regex stripping for simple output. - `escape/`: Escape sequence interpretation for raw mode ### Data Flow @@ -56,12 +55,19 @@ shelli provides persistent interactive shell sessions via PTY-backed processes m ``` CLI/MCP → daemon.Client → Unix socket → daemon.Server → PTY → subprocess ↓ - OutputStorage - ├─ MemoryStorage (default) - └─ FileStorage (persistent) + ┌─── TUI sessions ───┐ + │ vterm.Screen │ + │ (VT emulator IS │ + │ the screen state) │ + └─────────────────────┘ + ┌─── Non-TUI sessions ┐ + │ OutputStorage │ + │ ├─ MemoryStorage │ + │ └─ FileStorage │ + └──────────────────────┘ ``` -PTY sessions accessible via both MCP and CLI, with optional size-based poll optimization. Additional endpoints: `size` (lightweight buffer size check for poll optimization) +PTY sessions accessible via both MCP and CLI, with optional size-based poll optimization. Additional endpoints: `size` (returns version counter for TUI, byte count for non-TUI) ### Key Design Decisions @@ -73,11 +79,11 @@ PTY sessions accessible via both MCP and CLI, with optional size-based poll opti - **Stop vs Kill**: `stop` terminates process but keeps output accessible; `kill` deletes everything - **Session states**: Sessions can be "running" or "stopped" with timestamp tracking - **TTL cleanup**: Optional auto-deletion of stopped sessions via `--stopped-ttl` -- **TUI mode**: `--tui` flag enables frame detection with multiple strategies (screen clear, sync mode, cursor home, size cap) to auto-truncate buffer for TUI apps -- **Snapshot read**: `--snapshot` on read clears storage and resets the frame detector, then triggers a resize cycle (SIGWINCH) to force a full TUI redraw, waits for settle, then reads the clean frame. Pre-clearing prevents races between captureOutput and the settle loop. Requires TUI mode. -- **Terminal responder**: TUI sessions get a `TerminalResponder` that intercepts terminal capability queries (DA1, DA2, Kitty keyboard, DECRPM) in PTY output and writes responses to PTY input. Unblocks apps like yazi that block on unanswered queries. -- **Per-consumer cursors**: Optional `cursor` parameter on read operations. Each named cursor tracks its own read position, allowing multiple consumers to tail the same session independently. Without a cursor, the global `ReadPos` is used (backward compatible). -- **Size endpoint**: Lightweight `size` action returns output buffer size without transferring content. Used by wait polling to skip expensive full reads when nothing changed. +- **TUI mode with VT emulator**: `--tui` flag creates a `vterm.Screen` (VT emulator) for the session. PTY output feeds the emulator directly; no raw byte storage needed. The emulator handles all cursor positioning, screen clearing, and character rendering natively. Reads return the current screen state via `Render()` (ANSI) or `String()` (plain text). +- **VT emulator response bridge**: The emulator automatically handles terminal capability queries (DA1, DA2, DSR, etc.) and writes responses to its internal pipe. A `ReadResponses` goroutine bridges these to the PTY master, unblocking apps like yazi. +- **Snapshot read**: `--snapshot` triggers a resize cycle (SIGWINCH) to force a full TUI redraw, waits for the emulator version to settle, then reads `screen.String()` (plain text). No storage clearing or frame detection needed. +- **Per-consumer cursors**: Optional `cursor` parameter on read operations. Each named cursor tracks its own read position (byte offset for non-TUI, version counter for TUI), allowing multiple consumers to tail the same session independently. Without a cursor, the global `ReadPos` is used (backward compatible). +- **Size endpoint**: Lightweight `size` action returns version counter (TUI) or buffer byte count (non-TUI). Used by wait polling to skip expensive full reads when nothing changed. ## Claude Plugin @@ -93,7 +99,7 @@ Skills in `.claude/skills/`: - **Linting**: `.golangci.yml` - golangci-lint config with gosec, gocritic, revive - **CI/CD**: `.github/workflows/ci.yml` - lint, test, build, security on push/PR - **Releases**: `.goreleaser.yml` - multi-platform binaries, Homebrew tap update on tags -- **Tests**: `internal/ansi/strip_test.go`, `internal/ansi/clear_test.go`, `internal/wait/wait_test.go`, `internal/daemon/limitlines_test.go` +- **Tests**: `internal/vterm/strip_test.go`, `internal/vterm/screen_test.go`, `internal/wait/wait_test.go`, `internal/daemon/limitlines_test.go` - **Version**: `shelli version` - build info injected by goreleaser ## Documentation Sync Rules diff --git a/cmd/exec.go b/cmd/exec.go index 5f3d4cf..ad474a6 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -6,7 +6,7 @@ import ( "os" "strings" - "github.com/schovi/shelli/internal/ansi" + "github.com/schovi/shelli/internal/vterm" "github.com/schovi/shelli/internal/daemon" "github.com/spf13/cobra" ) @@ -83,7 +83,7 @@ func runExec(cmd *cobra.Command, args []string) error { output := result.Output if execStripAnsiFlag { - output = ansi.Strip(output) + output = vterm.StripDefault(output) } if execJsonFlag { diff --git a/cmd/read.go b/cmd/read.go index d765f28..9ddfd55 100644 --- a/cmd/read.go +++ b/cmd/read.go @@ -9,7 +9,7 @@ import ( "syscall" "time" - "github.com/schovi/shelli/internal/ansi" + "github.com/schovi/shelli/internal/vterm" "github.com/schovi/shelli/internal/daemon" "github.com/schovi/shelli/internal/wait" "github.com/spf13/cobra" @@ -166,7 +166,7 @@ func runRead(cmd *cobra.Command, args []string) error { } if readStripAnsiFlag { - output = ansi.Strip(output) + output = vterm.StripDefault(output) } if readJsonFlag { @@ -199,7 +199,7 @@ func runReadSnapshot(name string) error { } if readStripAnsiFlag { - output = ansi.Strip(output) + output = vterm.StripDefault(output) } if readJsonFlag { @@ -253,7 +253,7 @@ func runReadFollow(name string) error { } if output != "" { if readStripAnsiFlag { - output = ansi.Strip(output) + output = vterm.StripDefault(output) } fmt.Print(output) } diff --git a/cmd/search.go b/cmd/search.go index 9b40879..c293a41 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/schovi/shelli/internal/ansi" + "github.com/schovi/shelli/internal/vterm" "github.com/schovi/shelli/internal/daemon" "github.com/spf13/cobra" ) @@ -97,21 +97,21 @@ func runSearch(cmd *cobra.Command, args []string) error { for j, line := range match.Before { display := line if searchStripAnsiFlag { - display = ansi.Strip(line) + display = vterm.StripDefault(line) } fmt.Printf("%4d: %s\n", startLine+j, display) } display := match.Line if searchStripAnsiFlag { - display = ansi.Strip(match.Line) + display = vterm.StripDefault(match.Line) } fmt.Printf(">%3d: %s\n", match.LineNumber, display) for j, line := range match.After { display := line if searchStripAnsiFlag { - display = ansi.Strip(line) + display = vterm.StripDefault(line) } fmt.Printf("%4d: %s\n", match.LineNumber+1+j, display) } diff --git a/docs/TUI.md b/docs/TUI.md index 19caec9..bc002e4 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -1,76 +1,63 @@ # TUI Mode -TUI mode enables shelli to work with full-screen terminal applications (htop, vim, lazygit, etc.) by detecting frame boundaries and managing buffer truncation. +TUI mode enables shelli to work with full-screen terminal applications (htop, vim, lazygit, etc.) using a proper VT terminal emulator. ## Overview Without TUI mode, shelli's output buffer grows indefinitely as TUI apps repaint the screen. Each repaint appends new escape sequences and content, making the buffer unreadable and eventually hitting memory limits. -TUI mode solves this by: -1. Detecting when a new frame starts (via frame detection strategies) -2. Truncating the buffer at frame boundaries, keeping only the latest frame -3. Providing snapshot reads that force a clean redraw +TUI mode solves this by replacing raw byte storage with a VT terminal emulator (`charmbracelet/x/vt`). The emulator IS the screen state, so reads always return the current screen content regardless of how many repaints have occurred. Enable with `--tui` on session creation: ```bash shelli create myapp --cmd htop --tui ``` -## Frame Detection +## Architecture -The `FrameDetector` (`internal/ansi/clear.go`) processes PTY output chunks and identifies frame boundaries using five strategies, checked in priority order. - -### Strategy 1: Screen Clear (`screen_clear`) - -**Trigger**: `ESC[2J` (clear screen), `ESC[?1049h` (alt buffer), `ESC c` (terminal reset) - -**Behavior**: Unconditional truncation. Everything before the sequence is discarded. - -**Apps**: vim (alt buffer), less (alt buffer), most TUI apps at startup - -### Strategy 2: Sync Mode (`sync_mode`) - -**Trigger**: `ESC[?2026h` (synchronized update begin) - -**Behavior**: Truncation on frame START. Content between sync begin and sync end forms one frame. Suppressed during snapshot mode to allow partial redraws to accumulate. - -**Apps**: lazygit, Claude Code, modern terminals - -### Strategy 3: Cursor Home (`cursor_home`) - -**Trigger**: `ESC[1;1H` or `ESC[H` (cursor to position 1,1) - -**Behavior**: Only fires when preceded by a heuristic marker within 20 bytes: -- `ESC[0m` or `ESC[m` (attribute reset) -- `ESC[?25l` (hide cursor) +### How it works -Additionally uses look-ahead to distinguish real frame boundaries from cursor repositioning (e.g., vim/micro editing cursor returning to row 1): -- If printable content follows within 50 bytes: truncate (real frame) -- If only cursor control sequences follow (`ESC[?25h`, `ESC[?12l`, etc.): skip -- If at end of chunk (ambiguous): defer decision to next chunk +Each TUI session gets a `vterm.Screen` wrapper around a thread-safe VT emulator: -A within-chunk cooldown of 4096 bytes prevents double-firing when apps send multiple cursor_home sequences within a single render pass. +``` +PTY output → screen.Write() (feeds VT emulator) + screen.Render() → ANSI-styled screen content (for reads) + screen.String() → plain text screen content (for snapshots/strip) + screen.ReadResponses(ptmx) → bridges terminal query responses to PTY +``` -**Apps**: k9s, htop, nnn +No raw byte storage is used for TUI sessions. The emulator handles: +- Cursor positioning (absolute, relative, home) +- Screen clearing (ESC[2J, ESC[J, etc.) +- Line erasing (ESC[K, etc.) +- Alt screen buffer (ESC[?1049h/l) +- Synchronized updates (ESC[?2026h/l) +- DEC Special Graphics charset +- Color and text attributes (SGR) +- Terminal capability queries (DA1, DA2, DSR, etc.) -### Strategy 4: Cursor Jump to Top (`CursorJumpTop`) +### Version counter -**Trigger**: `ESC[row;colH` where `row <= 2` and `maxRowSeen >= 10` +An atomic version counter increments on every `Write()`. This replaces byte-count-based change detection: +- `handleSize` returns the version counter for TUI sessions +- `handleRead` with `ReadModeNew` compares version against stored read position +- Wait/settle loops poll the version counter -**Behavior**: Detects when cursor jumps from a high row back to the top of the screen, indicating a full screen redraw. Uses look-ahead to distinguish real frame boundaries from cursor repositioning: -- If printable content follows (possibly after color/mode sequences): truncate -- If only cursor control sequences follow (`ESC[?25h`, `ESC[?12l`, etc.): skip -- If at end of chunk (ambiguous): defer decision to next chunk +### Non-TUI sessions -**Apps**: htop, glances, apps that draw rows sequentially +Non-TUI sessions are unchanged: raw byte storage with the existing OutputStorage interface. -### Strategy 5: Size Cap (`MaxSize`) +## Terminal Query Responses -**Trigger**: Buffer exceeds `MaxSize` (default 100KB) +The VT emulator handles terminal capability queries internally. When an app sends a query (e.g., DA1 `ESC[c`), the emulator generates a response and writes it to an internal pipe. A `ReadResponses` goroutine reads from this pipe and writes to the PTY master, appearing as terminal input to the subprocess. -**Behavior**: Only fires if a frame boundary was detected recently (within `MaxSize * 2` bytes). This prevents breaking hybrid apps (like btm) that send one frame at startup then switch to incremental updates. +Handled queries include: +- DA1 (Primary Device Attributes): `ESC[c` / `ESC[0c` +- DA2 (Secondary Device Attributes): `ESC[>c` / `ESC[>0c` +- DSR (Device Status Report): `ESC[5n`, `ESC[6n` +- Cursor Position Report -**Apps**: Safety net for any app with recent frame detection +This replaces the old hand-rolled `TerminalResponder` with the emulator's built-in handlers. ## Snapshot Mechanism @@ -78,122 +65,55 @@ Snapshot (`--snapshot` on read) provides a clean, current frame by forcing a ful ### Flow -1. **Cold start wait**: If storage is empty, wait up to 2s for initial content (handles slow-starting apps) -2. **Clear storage** and reset frame detector -3. **Enable snapshot mode** (suppresses ALL truncation strategies) -4. **Resize cycle**: Set terminal to (cols+1, rows+1), send SIGWINCH, pause 200ms, restore original size, send SIGWINCH -5. **Settle loop**: Poll storage every 25ms until content stops changing for `settle_ms` (default 300ms) -6. **Retry**: If output is still empty, send another SIGWINCH with 2x settle time -7. **Disable snapshot mode** and return output +1. **Cold start wait**: If `screen.Version() == 0`, wait up to 2s for initial content +2. **Resize cycle**: Set terminal to (cols+1, rows+1) and resize emulator to match, send SIGWINCH, pause 200ms, restore original size, send SIGWINCH +3. **Settle loop**: Poll `screen.Version()` every 25ms until stable for `settle_ms` (default 300ms) +4. **Retry**: If output is still empty, send another SIGWINCH with 2x settle time +5. Return `screen.String()` (plain text) ### Why resize? -TUI apps listen for SIGWINCH (window size change) and perform a full redraw. By temporarily changing the size and changing it back, we trigger two redraws. The frame detector captures the clean output from the final redraw. +TUI apps listen for SIGWINCH (window size change) and perform a full redraw. The emulator is also resized to match, so it correctly interprets the redrawn content at the right dimensions. -## Virtual Screen Buffer +## ANSI Stripping -The virtual screen buffer (`internal/ansi/strip.go`) converts cursor-positioned terminal output into readable linear text. Used when `--strip-ansi` is applied to read output. +The `vterm.Strip()` function (`internal/vterm/strip.go`) removes ANSI escape sequences from text. -### How it works +### Two paths -1. Pre-scan all cursor positioning sequences to determine grid dimensions -2. Allocate a rune-based grid (supports multi-byte UTF-8: box-drawing, emoji, CJK) -3. Process the string, executing cursor movements and writing characters to grid cells -4. Output: join grid rows, right-trim trailing spaces, remove trailing empty rows - -### Grid clearing on redraw - -During snapshot reads, the resize cycle (SIGWINCH) triggers two full redraws that accumulate in the buffer. The second redraw moves the cursor back to (0,0) and rewrites the screen. If lines in the second redraw are shorter than the first, stale characters remain because the grid isn't cleared between redraws. - -Two mechanisms handle this: - -1. **ESC[2J (erase display)**: Apps like ncdu send explicit screen clear sequences. The virtual buffer now executes these, clearing the grid unconditionally. - -2. **Cursor home with look-ahead**: When cursor positioning moves to (0,0) from row 10+ (0-based), the buffer checks if printable content follows within 100 bytes. If yes (real redraw), the grid is cleared. If only escape sequences or end-of-string follow (cursor parking), the grid is preserved. This prevents apps like newsboat from having their output wiped by trailing cursor-home sequences used for cursor positioning after rendering. - -### Supported sequences - -| Sequence | Name | Behavior | -|----------|------|----------| -| `ESC[row;colH` / `ESC[row;colF` | Cursor Position | Move to absolute row, col | -| `ESC[nH` / `ESC[H` | Cursor Row / Home | Move to row n (or 1,1) | -| `ESC[nG` | Cursor Column Absolute | Move to column n, keep row | -| `ESC[nd` | Cursor Row Absolute | Move to row n, keep column | -| `ESC[nA` | Cursor Up | Move up n rows | -| `ESC[nB` | Cursor Down | Move down n rows | -| `ESC[nC` | Cursor Right | Move right n columns | -| `ESC[nD` | Cursor Left | Move left n columns | -| `ESC[K` / `ESC[0K` | Erase to End | Clear from cursor to end of line | -| `ESC[1K` | Erase to Start | Clear from start of line to cursor | -| `ESC[2K` | Erase Full Line | Clear entire line | -| `ESC[J` / `ESC[0J` | Erase to End of Display | Clear from cursor to end of display | -| `ESC[1J` | Erase to Start of Display | Clear from start of display to cursor | -| `ESC[2J` | Erase Full Display | Clear entire display | -| `ESC(0` | DEC Graphics On | Activate DEC Special Graphics charset | -| `ESC(B` | DEC Graphics Off | Deactivate, return to ASCII | - -### DEC Special Graphics - -When `ESC(0` is active, ASCII characters are mapped to box-drawing glyphs: - -| Input | Output | Description | -|-------|--------|-------------| -| `q` | `─` | Horizontal line | -| `x` | `│` | Vertical line | -| `l` | `┌` | Top-left corner | -| `k` | `┐` | Top-right corner | -| `m` | `└` | Bottom-left corner | -| `j` | `┘` | Bottom-right corner | -| `n` | `┼` | Cross | -| `t` | `├` | Left tee | -| `u` | `┤` | Right tee | -| `v` | `┴` | Bottom tee | -| `w` | `┬` | Top tee | - -## Terminal Responder - -The `TerminalResponder` (`internal/ansi/responder.go`) intercepts terminal capability queries in PTY output and writes responses back to the PTY input. This unblocks apps that wait for query responses before rendering. - -### Intercepted queries - -| Query | Sequence | Response | -|-------|----------|----------| -| DA1 (Primary Device Attributes) | `ESC[c` / `ESC[0c` | `ESC[?62;22c` (VT220 with ANSI color) | -| DA2 (Secondary Device Attributes) | `ESC[>c` / `ESC[>0c` | `ESC[>1;1;0c` (VT220, version 1) | -| Kitty Keyboard Query | `ESC[?u` | `ESC[?0u` (not supported) | -| DECRPM (Mode Report) | `ESC[?{n}$p` | `ESC[?{n};0$y` (not recognized) | +1. **Fast path (no cursor sequences)**: Regex-based stripping of CSI, OSC, charset, keypad, DEC private mode, and ESC+letter sequences. -## App Compatibility +2. **Emulator path (has cursor sequences)**: Creates a temporary VT emulator, writes the content, reads back `String()` (plain text). Handles all cursor positioning, erasing, and character rendering correctly. -| App | Frame Detection | Snapshot | Strip-ANSI | Score | Notes | -|-----|----------------|----------|------------|-------|-------| -| btop | cursor_home | Clean | Good | 9/9 | HVP cursor (CSI f) support | -| htop | screen_clear | Clean | Good | 9/9 | Reference-quality TUI support | -| glances | CursorJumpTop | Clean | Good | 9/9 | Needs --settle 3000 for consistency | -| k9s | cursor_home | Clean | Partial | 9/9 | Left columns truncated in strip-ansi | -| ranger | CursorJumpTop | Clean | Good | 9/9* | Fixed by snapshot truncation suppression | -| nnn | cursor_home | Clean | Good | 9/9 | | -| yazi | screen_clear | Clean | Partial | 9/9 | Only right pane in strip-ansi | -| vifm | cursor_home | Clean | Good | 9/9 | | -| lazygit | sync_mode | Clean | Good | 9/9 | | -| tig | screen_clear | Clean | Good | 9/9 | | -| vim | screen_clear + cursor_home | Clean | Good | 9/9 | Fixed by cursor_home look-ahead + snapshot suppression | -| less | screen_clear | Clean | Good | 9/9* | Fixed by newline grid sizing | -| micro | CursorJumpTop + cursor_home | Clean | Good | 9/9 | Fixed by cursor_home look-ahead + snapshot suppression | -| weechat | cursor_home | Clean | Good | 9/9 | Timing-dependent at large sizes | -| irssi | screen_clear | Clean | Good | 9/9 | | -| newsboat | screen_clear | Clean | Good | 9/9 | Fixed by grid clearing on redraw | -| mc | cursor_home | Clean | Good | 9/9 | Heavy box drawing renders well | -| bat | screen_clear | Clean | Good | 9/9* | Fixed by newline grid sizing | -| ncdu | screen_clear | Clean | Good | 9/9 | Fixed by erase display support | - -*= fixed by snapshot truncation suppression, newline grid sizing, or grid clearing on redraw +Detection: checks for cursor positioning patterns (`ESC[n;nH`, `ESC[nG`, `ESC[nd`, `ESC[nA/B/C/D`). -## Known Limitations +Standalone `\n` (not preceded by `\r`) is converted to `\r\n` before feeding to the emulator, matching what a real terminal driver does with ONLCR. -### Scroll Regions (DECSTBM) +## App Compatibility -`ESC[top;bottomr` sets a scroll region. The virtual screen buffer does not track scroll regions, so apps that use scrolling within a region (e.g., some panels in mc) may have degraded strip-ansi output. +| App | Snapshot | Strip-ANSI | Notes | +|-----|----------|------------|-------| +| btop | Clean | Good | HVP cursor (CSI f) support | +| htop | Clean | Good | Reference-quality TUI support | +| glances | Clean | Good | Needs --settle 3000 for consistency | +| k9s | Clean | Partial | Left columns truncated in strip-ansi | +| ranger | Clean | Good | | +| nnn | Clean | Good | | +| yazi | Clean | Partial | Only right pane in strip-ansi | +| vifm | Clean | Good | | +| lazygit | Clean | Good | | +| tig | Clean | Good | | +| vim | Clean | Good | | +| less | Clean | Good | | +| micro | Clean | Good | | +| weechat | Clean | Good | Timing-dependent at large sizes | +| irssi | Clean | Good | | +| newsboat | Clean | Good | | +| mc | Clean | Good | Heavy box drawing renders well | +| bat | Clean | Good | | +| ncdu | Clean | Good | | + +## Known Limitations ### Direct /dev/tty Access @@ -201,7 +121,7 @@ Some apps open `/dev/tty` directly instead of using stdin/stdout. These bypass t ### Wide Characters -CJK characters and some emoji occupy two terminal cells but one grid position. The virtual screen buffer currently treats each character as one cell, which may cause alignment issues with double-width characters. +CJK characters and some emoji occupy two terminal cells. The VT emulator handles display width correctly via the `displaywidth` package, but some edge cases with complex grapheme clusters may still cause alignment issues. ### Partial Strip-ANSI for Complex Layouts @@ -213,14 +133,6 @@ Some apps with complex multi-pane layouts produce partial strip-ansi output: | Constant | Value | Location | Purpose | |----------|-------|----------|---------| -| `maxSequenceLen` | 12 | `clear.go` | Max escape sequence length for cross-chunk buffering | -| `cursorHomeLookback` | 20 bytes | `clear.go` | Lookback window for cursor_home heuristic markers | -| `cursorJumpTopThreshold` | 10 rows | `clear.go` | Minimum maxRowSeen before jump-to-top triggers | -| `jumpLookAheadLen` | 50 bytes | `clear.go` | Bytes to scan forward after cursor jump | -| `cursorHomeCooldownBytes` | 4096 bytes | `clear.go` | Within-chunk cooldown after cursor_home fires | -| `MaxSize` (default) | 100KB | `clear.go` | Size cap fallback threshold | -| `maxGridCols` | 500 | `strip.go` | Maximum virtual screen buffer columns | -| `maxGridRows` | 500 | `strip.go` | Maximum virtual screen buffer rows | | `DefaultSnapshotSettleMs` | 300ms | `constants.go` | Default settle time for snapshot | | `SnapshotPollInterval` | 25ms | `constants.go` | Polling interval during snapshot settle | | `SnapshotResizePause` | 200ms | `constants.go` | Pause between resize steps | diff --git a/go.mod b/go.mod index cdaa356..519e5a0 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,24 @@ require ( ) require ( + github.com/charmbracelet/colorprofile v0.3.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251106193841-7889546fc720 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/vt v0.0.0-20260223200540-d6a276319c45 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 2b2b1af..dc905ed 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,48 @@ +github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= +github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= +github.com/charmbracelet/ultraviolet v0.0.0-20251106193841-7889546fc720 h1:Pny/vp+ySKst82CWEME1oP6YEFs/17tlH+QOjqW7VUY= +github.com/charmbracelet/ultraviolet v0.0.0-20251106193841-7889546fc720/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/vt v0.0.0-20260223200540-d6a276319c45 h1:t6Zyc1xFQ7ekIZhGI6+zqUkraGk4eSl5V7RFaP6zIZU= +github.com/charmbracelet/x/vt v0.0.0-20260223200540-d6a276319c45/go.mod h1:Hp1jdqPepU/Ngj8zRXijB9j4HI9fEN7+Lay2sssQ+5o= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/ansi/clear.go b/internal/ansi/clear.go deleted file mode 100644 index fb07610..0000000 --- a/internal/ansi/clear.go +++ /dev/null @@ -1,520 +0,0 @@ -package ansi - -import ( - "bytes" - "sync" -) - -// TruncationStrategy defines which detection methods are enabled for TUI mode. -type TruncationStrategy struct { - ScreenClear bool // ESC[2J, ESC[?1049h, ESC c - SyncMode bool // ESC[?2026h (sync begin) - CursorHome bool // ESC[1;1H with heuristics - CursorJumpTop bool // ESC[row;colH jump from high row to row<=2 - MaxSize int // Size cap in bytes (0 = disabled) -} - -// DefaultTUIStrategy returns the recommended settings for TUI mode. -func DefaultTUIStrategy() TruncationStrategy { - return TruncationStrategy{ - ScreenClear: true, - SyncMode: true, - CursorHome: true, - CursorJumpTop: true, - MaxSize: 100 * 1024, // 100KB fallback - } -} - -// pendingTruncation tracks a deferred truncation that needs look-ahead -// resolution in the next chunk (used by CursorJumpTop and CursorHome). -type pendingTruncation struct { - active bool - truncEnd int -} - -// FrameDetector detects frame boundaries in PTY output for TUI applications. -// Handles cross-chunk detection via pending buffer. Thread-safe. -type FrameDetector struct { - mu sync.Mutex - strategy TruncationStrategy - pending []byte - heuristicTrail []byte // trailing bytes from previous chunk for cross-chunk cursor-home lookback - bufferSize int // track accumulated size for MaxSize check - bytesSinceLastFrame int // bytes processed since last frame boundary (-1 = never seen) - seenFrame bool // true if we've ever seen a frame boundary - maxRowSeen int // highest row seen in cursor position sequences (for CursorJumpTop) - snapshotMode bool // when true, all truncation is suppressed - pendingJump pendingTruncation // deferred CursorJumpTop needing look-ahead in next chunk - pendingHome pendingTruncation // deferred cursor_home needing look-ahead in next chunk - lastCursorHomePos int // byte position of last cursor_home truncation within current Process() call (-1 = none) -} - -// DetectResult contains the result of processing a chunk. -type DetectResult struct { - Truncate bool - DataAfter []byte -} - -// NewFrameDetector creates a new detector with the given strategy. -func NewFrameDetector(strategy TruncationStrategy) *FrameDetector { - return &FrameDetector{strategy: strategy} -} - -// strategyGroup groups truncation sequences by the strategy flag that enables them. -type strategyGroup struct { - enabled func(s *TruncationStrategy, snapshotMode bool) bool - seqs [][]byte - cursorHome bool // requires heuristic + cooldown + look-ahead -} - -var truncationGroups = []strategyGroup{ - { - enabled: func(s *TruncationStrategy, snap bool) bool { return !snap && s.ScreenClear }, - seqs: [][]byte{ - {0x1B, '[', '2', 'J'}, // ESC[2J - clear entire screen - {0x1B, '[', '?', '1', '0', '4', '9', 'h'}, // ESC[?1049h - alt buffer on - {0x1B, 'c'}, // ESC c - terminal reset - }, - }, - { - enabled: func(s *TruncationStrategy, snap bool) bool { return !snap && s.SyncMode }, - seqs: [][]byte{ - {0x1B, '[', '?', '2', '0', '2', '6', 'h'}, // ESC[?2026h - sync begin - }, - }, - { - enabled: func(s *TruncationStrategy, snap bool) bool { return !snap && s.CursorHome }, - cursorHome: true, - seqs: [][]byte{ - {0x1B, '[', '1', ';', '1', 'H'}, // ESC[1;1H - {0x1B, '[', 'H'}, // ESC[H (short form) - }, - }, -} - -// cursor home heuristic sequences (must appear within lookback window) -var cursorHomeHeuristics = [][]byte{ - {0x1B, '[', '0', 'm'}, // ESC[0m - reset attributes - {0x1B, '[', 'm'}, // ESC[m - reset attributes (short) - {0x1B, '[', '?', '2', '5', 'l'}, // ESC[?25l - hide cursor -} - -const ( - maxSequenceLen = 12 - cursorHomeLookback = 20 - cursorJumpTopThreshold = 10 // minimum maxRowSeen before a jump to row<=2 counts as truncation - jumpLookAheadLen = 50 // bytes to scan forward after a cursor jump to check for content - cursorHomeCooldownBytes = 4096 // bytes to suppress cursor_home after it fires -) - -// Process analyzes a chunk and returns whether truncation should occur -// and the data to store (everything after the last truncation point). -func (d *FrameDetector) Process(chunk []byte) DetectResult { - d.mu.Lock() - defer d.mu.Unlock() - - // Combine pending bytes with new chunk - data := chunk - if len(d.pending) > 0 { - data = make([]byte, len(d.pending)+len(chunk)) - copy(data, d.pending) - copy(data[len(d.pending):], chunk) - d.pending = nil - } - - // Track cursor_home cooldown within this Process() call - d.lastCursorHomePos = -1 - - lastTruncEnd := d.resolvePending(data) - - // Find the last truncation sequence position - for i := 0; i < len(data); i++ { - for _, g := range truncationGroups { - if !g.enabled(&d.strategy, d.snapshotMode) { - continue - } - for _, seq := range g.seqs { - if i+len(seq) > len(data) || !bytes.Equal(data[i:i+len(seq)], seq) { - continue - } - if g.cursorHome { - if d.lastCursorHomePos >= 0 && i-d.lastCursorHomePos < cursorHomeCooldownBytes { - continue - } - if !d.checkCursorHomeHeuristic(data, i) { - continue - } - end := i + len(seq) - switch { - case d.hasContentAfterCursor(data, end): - d.lastCursorHomePos = i - case end >= len(data): - d.pendingHome = pendingTruncation{active: true, truncEnd: end} - continue - default: - continue - } - } - lastTruncEnd = i + len(seq) - d.maxRowSeen = 0 - } - } - - // Cursor jump-to-top detection with look-ahead - if d.strategy.CursorJumpTop && !d.snapshotMode && data[i] == 0x1B { - if row, end, ok := parseCursorRow(data, i); ok { - if row <= 2 && d.maxRowSeen >= cursorJumpTopThreshold { - if d.hasContentAfterCursor(data, end) { - lastTruncEnd = end - d.maxRowSeen = 0 - } else if end >= len(data)-maxSequenceLen { - d.pendingJump = pendingTruncation{active: true, truncEnd: end} - } - } else if row > d.maxRowSeen { - d.maxRowSeen = row - } - } - } - } - - data = d.bufferTrailingBytes(data) - - if d.strategy.CursorHome { - if len(data) >= cursorHomeLookback { - d.heuristicTrail = make([]byte, cursorHomeLookback) - copy(d.heuristicTrail, data[len(data)-cursorHomeLookback:]) - } else if len(data) > 0 { - d.heuristicTrail = make([]byte, len(data)) - copy(d.heuristicTrail, data) - } - } - - // Check for recent frame BEFORE updating (to catch size cap before recency expires) - hadRecentFrame := d.seenFrame && d.bytesSinceLastFrame < d.frameRecencyWindow() - - // Update buffer size tracking and frame recency - if lastTruncEnd == -1 { - d.bufferSize += len(data) - d.bytesSinceLastFrame += len(data) - } else { - d.seenFrame = true - d.bytesSinceLastFrame = len(data) - lastTruncEnd - d.bufferSize = d.bytesSinceLastFrame - } - - if result := d.checkSizeCap(data, hadRecentFrame); result != nil { - return *result - } - - if lastTruncEnd == -1 { - return DetectResult{Truncate: false, DataAfter: data} - } - - return DetectResult{Truncate: true, DataAfter: data[lastTruncEnd:]} -} - -// resolvePending checks for deferred truncations from the previous chunk. -// If the deferred jump/home had content following in this new chunk, -// the truncation is confirmed (returns 0). Otherwise returns -1 (no truncation). -func (d *FrameDetector) resolvePending(data []byte) int { - lastTruncEnd := -1 - if d.pendingJump.active { - d.pendingJump = pendingTruncation{} - if d.hasContentAfterCursor(data, 0) { - lastTruncEnd = 0 - d.maxRowSeen = 0 - } - } - if d.pendingHome.active { - d.pendingHome = pendingTruncation{} - if d.hasContentAfterCursor(data, 0) { - lastTruncEnd = 0 - } - } - return lastTruncEnd -} - -// bufferTrailingBytes moves trailing bytes that could be the start of an -// incomplete escape sequence into d.pending for the next Process() call. -// Returns data with those trailing bytes removed. -func (d *FrameDetector) bufferTrailingBytes(data []byte) []byte { - pendingStart := len(data) - if len(data) > 0 { - for i := max(0, len(data)-maxSequenceLen); i < len(data); i++ { - if data[i] == 0x1B { - remaining := data[i:] - for _, g := range truncationGroups { - if !g.enabled(&d.strategy, d.snapshotMode) { - continue - } - for _, seq := range g.seqs { - if isPrefixOf(remaining, seq) && len(remaining) < len(seq) { - pendingStart = i - break - } - } - if pendingStart != len(data) { - break - } - } - if pendingStart == len(data) && d.strategy.CursorJumpTop { - if isPartialCursorPosition(remaining) { - pendingStart = i - } - } - if pendingStart != len(data) { - break - } - } - } - } - if pendingStart < len(data) { - d.pending = make([]byte, len(data)-pendingStart) - copy(d.pending, data[pendingStart:]) - data = data[:pendingStart] - } - return data -} - -// checkSizeCap applies the MaxSize truncation strategy. -// Only fires when a frame boundary was seen recently (prevents breaking hybrid TUI apps -// that send frames once at startup then switch to incremental updates). -func (d *FrameDetector) checkSizeCap(data []byte, hadRecentFrame bool) *DetectResult { - if d.strategy.MaxSize > 0 && !d.snapshotMode && hadRecentFrame && d.bufferSize > d.strategy.MaxSize { - d.bufferSize = len(data) - d.bytesSinceLastFrame = len(data) - return &DetectResult{Truncate: true, DataAfter: data} - } - return nil -} - -// SetSnapshotMode enables or disables snapshot mode. -// When enabled, ALL truncation strategies are suppressed (screen_clear, -// sync_mode, cursor_home, CursorJumpTop, MaxSize). The settle timer -// determines frame completion during snapshot, not frame detection. -func (d *FrameDetector) SetSnapshotMode(enabled bool) { - d.mu.Lock() - defer d.mu.Unlock() - d.snapshotMode = enabled -} - -// checkCursorHomeHeuristic returns true if cursor home should trigger truncation. -// Only triggers if preceded by reset or hide cursor within lookback window. -// Uses heuristicTrail from the previous chunk when the lookback window extends -// beyond the start of the current data. -func (d *FrameDetector) checkCursorHomeHeuristic(data []byte, pos int) bool { - var window []byte - switch { - case pos >= cursorHomeLookback: - window = data[pos-cursorHomeLookback : pos] - case len(d.heuristicTrail) > 0: - // Extend lookback into previous chunk's trailing bytes - need := cursorHomeLookback - pos - trail := d.heuristicTrail - if need > len(trail) { - need = len(trail) - } - window = make([]byte, need+pos) - copy(window, trail[len(trail)-need:]) - copy(window[need:], data[:pos]) - default: - window = data[:pos] - } - - for _, heuristic := range cursorHomeHeuristics { - for i := 0; i <= len(window)-len(heuristic); i++ { - if bytes.Equal(window[i:i+len(heuristic)], heuristic) { - return true - } - } - } - return false -} - -// hasContentAfterCursor scans forward from pos in data (up to jumpLookAheadLen bytes) -// to determine if printable content follows. Skips escape sequences (colors, modes, -// cursor control). Returns true if printable content is found, false if only -// cursor control / DEC private mode sequences follow. -func (d *FrameDetector) hasContentAfterCursor(data []byte, pos int) bool { - limit := pos + jumpLookAheadLen - if limit > len(data) { - limit = len(data) - } - j := pos - for j < limit { - if data[j] == 0x1B { - // Skip escape sequences - if j+1 < limit && data[j+1] == '[' { - // CSI sequence: ESC[ ... terminator - k := j + 2 - for k < limit && !isCSITerminator(data[k]) { - k++ - } - if k < limit { - k++ // skip terminator - } - j = k - continue - } - // Other ESC sequences (ESC + one char, or ESC( / ESC)) - if j+2 < limit && (data[j+1] == '(' || data[j+1] == ')' || data[j+1] == '#') { - j += 3 - continue - } - j += 2 - continue - } - // Printable character found (not ESC, not control) - if data[j] >= 0x20 && data[j] != 0x7F { - return true - } - j++ - } - return false -} - -// Reset clears all detector state (pending buffer, heuristics, counters). -// Use before triggering a fresh TUI redraw (e.g., snapshot resize cycle). -func (d *FrameDetector) Reset() { - d.mu.Lock() - defer d.mu.Unlock() - d.pending = nil - d.heuristicTrail = nil - d.bufferSize = 0 - d.bytesSinceLastFrame = 0 - d.seenFrame = false - d.maxRowSeen = 0 - d.pendingJump = pendingTruncation{} - d.pendingHome = pendingTruncation{} - d.lastCursorHomePos = -1 -} - -// ResetBufferSize resets the buffer size tracker (call after external truncation). -func (d *FrameDetector) ResetBufferSize() { - d.bufferSize = 0 -} - -// BufferSize returns the current tracked buffer size. -func (d *FrameDetector) BufferSize() int { - return d.bufferSize -} - -// HasRecentFrame returns true if a frame boundary was detected recently -// (within the last frameRecencyWindow bytes). -func (d *FrameDetector) HasRecentFrame() bool { - return d.seenFrame && d.bytesSinceLastFrame < d.frameRecencyWindow() -} - -// frameRecencyWindow returns MaxSize * 2, ensuring the recency window is always -// large enough for size-cap to fire before recency expires. -func (d *FrameDetector) frameRecencyWindow() int { - return d.strategy.MaxSize * 2 -} - -// BytesSinceLastFrame returns bytes processed since the last frame boundary. -func (d *FrameDetector) BytesSinceLastFrame() int { - return d.bytesSinceLastFrame -} - -// Flush returns any pending bytes (call when session ends). -func (d *FrameDetector) Flush() []byte { - d.mu.Lock() - defer d.mu.Unlock() - pending := d.pending - d.pending = nil - return pending -} - -// isPartialCursorPosition returns true if data looks like the start of an -// incomplete ESC[row;colH or ESC[row;colF sequence. -func isPartialCursorPosition(data []byte) bool { - if len(data) < 1 || data[0] != 0x1B { - return false - } - if len(data) < 2 { - return true // just ESC, could be anything - } - if data[1] != '[' { - return false - } - // ESC[ followed by digits, optional semicolons, and more digits (but no terminator yet) - for j := 2; j < len(data); j++ { - if data[j] >= '0' && data[j] <= '9' { - continue - } - if data[j] == ';' { - continue - } - // Hit a non-digit, non-semicolon: this is a complete (or invalid) sequence - return false - } - // Ended without terminator: partial - return len(data) > 2 -} - -// parseCursorRow parses ESC[row;colH or ESC[row;colF at position i in data. -// Returns the row number, the byte index after the sequence, and whether parsing succeeded. -// Expects data[i] == 0x1B and data[i+1] == '['. -func parseCursorRow(data []byte, i int) (row int, endIndex int, ok bool) { - if i+2 >= len(data) || data[i] != 0x1B || data[i+1] != '[' { - return 0, 0, false - } - - j := i + 2 - row = 0 - hasRow := false - - // Parse row number (cap at 5 digits to prevent overflow) - digits := 0 - for j < len(data) && data[j] >= '0' && data[j] <= '9' { - digits++ - if digits > 5 { - return 0, 0, false - } - row = row*10 + int(data[j]-'0') - hasRow = true - j++ - } - - if j >= len(data) { - return 0, 0, false - } - - // After row, expect ';' then col then 'H'/'F', OR just 'H'/'F' (row-only form) - if data[j] == ';' { - j++ // skip ';' - // Parse col number (we don't need the value, cap at 5 digits) - colDigits := 0 - for j < len(data) && data[j] >= '0' && data[j] <= '9' { - colDigits++ - if colDigits > 5 { - return 0, 0, false - } - j++ - } - if j >= len(data) { - return 0, 0, false - } - } - - if data[j] == 'H' || data[j] == 'F' || data[j] == 'f' { - if !hasRow { - row = 1 // ESC[H defaults to row 1 - } - return row, j + 1, true - } - - return 0, 0, false -} - -func isPrefixOf(data, seq []byte) bool { - if len(data) > len(seq) { - return false - } - for i := range data { - if data[i] != seq[i] { - return false - } - } - return true -} diff --git a/internal/ansi/clear_test.go b/internal/ansi/clear_test.go deleted file mode 100644 index 6b8a62a..0000000 --- a/internal/ansi/clear_test.go +++ /dev/null @@ -1,1505 +0,0 @@ -package ansi - -import ( - "bytes" - "fmt" - "sync" - "testing" -) - -func TestScreenClearDetector(t *testing.T) { - tests := []struct { - name string - chunks [][]byte - wantClears []bool - wantData [][]byte - }{ - { - name: "no clear sequence", - chunks: [][]byte{[]byte("hello world")}, - wantClears: []bool{false}, - wantData: [][]byte{[]byte("hello world")}, - }, - { - name: "ESC[2J clears screen", - chunks: [][]byte{[]byte("old\x1b[2Jnew")}, - wantClears: []bool{true}, - wantData: [][]byte{[]byte("new")}, - }, - { - name: "ESC[?1049h alternate buffer", - chunks: [][]byte{[]byte("old\x1b[?1049hnew")}, - wantClears: []bool{true}, - wantData: [][]byte{[]byte("new")}, - }, - { - name: "ESC c terminal reset", - chunks: [][]byte{[]byte("old\x1bcnew")}, - wantClears: []bool{true}, - wantData: [][]byte{[]byte("new")}, - }, - { - name: "multiple clears - last one wins", - chunks: [][]byte{[]byte("first\x1b[2Jsecond\x1b[2Jthird")}, - wantClears: []bool{true}, - wantData: [][]byte{[]byte("third")}, - }, - { - name: "clear at end of chunk", - chunks: [][]byte{[]byte("old content\x1b[2J")}, - wantClears: []bool{true}, - wantData: [][]byte{[]byte{}}, - }, - { - name: "clear at start of chunk", - chunks: [][]byte{[]byte("\x1b[2Jnew content")}, - wantClears: []bool{true}, - wantData: [][]byte{[]byte("new content")}, - }, - { - name: "cross-chunk ESC[2J - ESC at end", - chunks: [][]byte{ - []byte("old\x1b"), - []byte("[2Jnew"), - }, - wantClears: []bool{false, true}, - wantData: [][]byte{[]byte("old"), []byte("new")}, - }, - { - name: "cross-chunk ESC[2J - ESC[ at end", - chunks: [][]byte{ - []byte("old\x1b["), - []byte("2Jnew"), - }, - wantClears: []bool{false, true}, - wantData: [][]byte{[]byte("old"), []byte("new")}, - }, - { - name: "cross-chunk ESC[2J - ESC[2 at end", - chunks: [][]byte{ - []byte("old\x1b[2"), - []byte("Jnew"), - }, - wantClears: []bool{false, true}, - wantData: [][]byte{[]byte("old"), []byte("new")}, - }, - { - name: "cross-chunk alternate buffer", - chunks: [][]byte{ - []byte("old\x1b[?104"), - []byte("9hnew"), - }, - wantClears: []bool{false, true}, - wantData: [][]byte{[]byte("old"), []byte("new")}, - }, - { - name: "no clear across multiple chunks", - chunks: [][]byte{ - []byte("hello "), - []byte("world "), - []byte("test"), - }, - wantClears: []bool{false, false, false}, - wantData: [][]byte{[]byte("hello "), []byte("world "), []byte("test")}, - }, - { - name: "empty chunk", - chunks: [][]byte{[]byte{}}, - wantClears: []bool{false}, - wantData: [][]byte{[]byte{}}, - }, - { - name: "partial ESC not a clear", - chunks: [][]byte{ - []byte("hello\x1b[31mred"), - }, - wantClears: []bool{false}, - wantData: [][]byte{[]byte("hello\x1b[31mred")}, - }, - { - name: "ESC alone then regular text", - chunks: [][]byte{ - []byte("data\x1b"), - []byte("regular text"), - }, - wantClears: []bool{false, false}, - wantData: [][]byte{[]byte("data"), []byte("\x1bregular text")}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{ScreenClear: true}) - - for i, chunk := range tt.chunks { - result := d.Process(chunk) - - if result.Truncate != tt.wantClears[i] { - t.Errorf("chunk %d: Truncate = %v, want %v", i, result.Truncate, tt.wantClears[i]) - } - - if !bytes.Equal(result.DataAfter, tt.wantData[i]) { - t.Errorf("chunk %d: DataAfter = %q, want %q", i, result.DataAfter, tt.wantData[i]) - } - } - }) - } -} - -func TestScreenClearDetector_Flush(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{ScreenClear: true}) - - // Send chunk ending with partial escape sequence - result := d.Process([]byte("data\x1b")) - if result.Truncate { - t.Error("expected no clear") - } - if !bytes.Equal(result.DataAfter, []byte("data")) { - t.Errorf("DataAfter = %q, want %q", result.DataAfter, "data") - } - - // Flush should return pending bytes - pending := d.Flush() - if !bytes.Equal(pending, []byte("\x1b")) { - t.Errorf("Flush() = %q, want %q", pending, "\x1b") - } - - // Second flush should return nil - pending = d.Flush() - if pending != nil { - t.Errorf("second Flush() = %q, want nil", pending) - } -} - -func TestScreenClearDetector_RealWorldVim(t *testing.T) { - // Simulate vim startup which uses alternate buffer - d := NewFrameDetector(TruncationStrategy{ScreenClear: true}) - - // vim typically sends: ESC[?1049h to enter alternate buffer - chunks := [][]byte{ - []byte("normal shell output\n"), - []byte("$ vim file.txt\n"), - []byte("\x1b[?1049h"), // enter alternate buffer - []byte("\x1b[2J"), // clear screen - []byte("file contents here\n"), - } - - var totalClears int - var lastData []byte - - for _, chunk := range chunks { - result := d.Process(chunk) - if result.Truncate { - totalClears++ - } - lastData = result.DataAfter - } - - // Should have detected clears - if totalClears < 2 { - t.Errorf("expected at least 2 clears, got %d", totalClears) - } - - // Last data should be the file contents - if !bytes.Equal(lastData, []byte("file contents here\n")) { - t.Errorf("last data = %q, want %q", lastData, "file contents here\n") - } -} - -func TestFrameDetector_SyncMode(t *testing.T) { - tests := []struct { - name string - chunks [][]byte - wantTrunc []bool - wantData [][]byte - }{ - { - name: "sync mode begin truncates (new frame starts)", - chunks: [][]byte{[]byte("oldframe\x1b[?2026hnewframe")}, - wantTrunc: []bool{true}, - wantData: [][]byte{[]byte("newframe")}, - }, - { - name: "sync mode end does not truncate", - chunks: [][]byte{[]byte("frame\x1b[?2026lafter")}, - wantTrunc: []bool{false}, - wantData: [][]byte{[]byte("frame\x1b[?2026lafter")}, - }, - { - name: "multiple sync frames - last begin wins", - chunks: [][]byte{[]byte("\x1b[?2026hf1\x1b[?2026l\x1b[?2026hf2\x1b[?2026l")}, - wantTrunc: []bool{true}, - wantData: [][]byte{[]byte("f2\x1b[?2026l")}, - }, - { - name: "cross-chunk sync mode begin", - chunks: [][]byte{ - []byte("old\x1b[?202"), - []byte("6hnew"), - }, - wantTrunc: []bool{false, true}, - wantData: [][]byte{[]byte("old"), []byte("new")}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{ - SyncMode: true, - }) - - for i, chunk := range tt.chunks { - result := d.Process(chunk) - - if result.Truncate != tt.wantTrunc[i] { - t.Errorf("chunk %d: Truncate = %v, want %v", i, result.Truncate, tt.wantTrunc[i]) - } - - if !bytes.Equal(result.DataAfter, tt.wantData[i]) { - t.Errorf("chunk %d: DataAfter = %q, want %q", i, result.DataAfter, tt.wantData[i]) - } - } - }) - } -} - -func TestFrameDetector_CursorHome(t *testing.T) { - tests := []struct { - name string - chunks [][]byte - wantTrunc []bool - wantData [][]byte - }{ - { - name: "cursor home with reset truncates", - chunks: [][]byte{[]byte("old\x1b[0m\x1b[1;1Hnew")}, - wantTrunc: []bool{true}, - wantData: [][]byte{[]byte("new")}, - }, - { - name: "cursor home with short reset truncates", - chunks: [][]byte{[]byte("old\x1b[m\x1b[1;1Hnew")}, - wantTrunc: []bool{true}, - wantData: [][]byte{[]byte("new")}, - }, - { - name: "cursor home with hide cursor truncates", - chunks: [][]byte{[]byte("old\x1b[?25l\x1b[1;1Hnew")}, - wantTrunc: []bool{true}, - wantData: [][]byte{[]byte("new")}, - }, - { - name: "cursor home without heuristic does not truncate", - chunks: [][]byte{[]byte("old\x1b[1;1Hnew")}, - wantTrunc: []bool{false}, - wantData: [][]byte{[]byte("old\x1b[1;1Hnew")}, - }, - { - name: "short cursor home with reset truncates", - chunks: [][]byte{[]byte("old\x1b[0m\x1b[Hnew")}, - wantTrunc: []bool{true}, - wantData: [][]byte{[]byte("new")}, - }, - { - name: "short cursor home without heuristic does not truncate", - chunks: [][]byte{[]byte("old\x1b[Hnew")}, - wantTrunc: []bool{false}, - wantData: [][]byte{[]byte("old\x1b[Hnew")}, - }, - { - name: "heuristic too far away does not truncate", - chunks: [][]byte{[]byte("old\x1b[0m" + string(make([]byte, 25)) + "\x1b[1;1Hnew")}, - wantTrunc: []bool{false}, - wantData: [][]byte{[]byte("old\x1b[0m" + string(make([]byte, 25)) + "\x1b[1;1Hnew")}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{ - CursorHome: true, - }) - - for i, chunk := range tt.chunks { - result := d.Process(chunk) - - if result.Truncate != tt.wantTrunc[i] { - t.Errorf("chunk %d: Truncate = %v, want %v", i, result.Truncate, tt.wantTrunc[i]) - } - - if !bytes.Equal(result.DataAfter, tt.wantData[i]) { - t.Errorf("chunk %d: DataAfter = %q, want %q", i, result.DataAfter, tt.wantData[i]) - } - } - }) - } -} - -func TestFrameDetector_SizeCap(t *testing.T) { - tests := []struct { - name string - maxSize int - strategy TruncationStrategy - chunks [][]byte - wantTrunc []bool - wantMaxLen []int // max length of DataAfter - }{ - { - name: "under cap no truncation", - maxSize: 1000, - strategy: TruncationStrategy{ScreenClear: true, MaxSize: 1000}, - chunks: [][]byte{[]byte("\x1b[2Jhello world")}, - wantTrunc: []bool{true}, - wantMaxLen: []int{100}, - }, - { - name: "exceeds cap truncates after frame boundary", - maxSize: 50, - strategy: TruncationStrategy{ScreenClear: true, MaxSize: 50}, - chunks: [][]byte{[]byte("\x1b[2J"), make([]byte, 100)}, - wantTrunc: []bool{true, true}, - wantMaxLen: []int{0, 100}, - }, - { - name: "accumulated size triggers cap after frame", - maxSize: 50, - strategy: TruncationStrategy{ScreenClear: true, MaxSize: 50}, - chunks: [][]byte{[]byte("\x1b[2J"), make([]byte, 30), make([]byte, 30)}, - wantTrunc: []bool{true, false, true}, - wantMaxLen: []int{0, 30, 30}, - }, - { - name: "disabled cap (0) never truncates on size", - maxSize: 0, - strategy: TruncationStrategy{MaxSize: 0}, - chunks: [][]byte{make([]byte, 1000)}, - wantTrunc: []bool{false}, - wantMaxLen: []int{1000}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d := NewFrameDetector(tt.strategy) - - for i, chunk := range tt.chunks { - result := d.Process(chunk) - - if result.Truncate != tt.wantTrunc[i] { - t.Errorf("chunk %d: Truncate = %v, want %v", i, result.Truncate, tt.wantTrunc[i]) - } - - if len(result.DataAfter) > tt.wantMaxLen[i] { - t.Errorf("chunk %d: DataAfter len = %d, want <= %d", i, len(result.DataAfter), tt.wantMaxLen[i]) - } - } - }) - } -} - -func TestFrameDetector_AdaptiveTUI(t *testing.T) { - t.Run("incremental app without frame boundaries - no size cap truncation", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - - // Simulate incremental TUI: positional updates only, no frame sequences - chunks := [][]byte{ - []byte("initial full screen draw\n"), - []byte("\x1b[5;10Hupdate cell"), // positional update (row 5, col 10) - []byte("\x1b[10;20Hanother cell"), // another positional update - } - - // Send lots of data to exceed size cap - bigChunk := make([]byte, 150*1024) // 150KB, exceeds 100KB cap - for i := range bigChunk { - bigChunk[i] = 'x' - } - chunks = append(chunks, bigChunk) - - for i, chunk := range chunks { - result := d.Process(chunk) - if result.Truncate { - t.Errorf("chunk %d: unexpected truncation for incremental app", i) - } - } - - if d.HasRecentFrame() { - t.Error("HasRecentFrame should be false for app with no frames") - } - }) - - t.Run("full-redraw app with frame boundaries - size cap works", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - - // First frame boundary - result := d.Process([]byte("\x1b[2Jframe content")) - if !result.Truncate { - t.Error("expected truncation on screen clear") - } - - if !d.HasRecentFrame() { - t.Error("HasRecentFrame should be true after screen clear") - } - - // Size cap should work because frame was recent - bigChunk := make([]byte, 150*1024) - result = d.Process(bigChunk) - if !result.Truncate { - t.Error("expected size cap truncation after recent frame") - } - }) - - t.Run("hybrid app - frame at startup then incremental - size cap disabled after threshold", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - - // App starts with frame boundary (like btm) - d.Process([]byte("\x1b[2Jinitial")) - - if !d.HasRecentFrame() { - t.Error("HasRecentFrame should be true after frame") - } - - // Send 210KB of incremental updates (exceeds 200KB recency window = MaxSize*2) - chunk := make([]byte, 210*1024) - d.Process(chunk) - - if d.HasRecentFrame() { - t.Error("HasRecentFrame should be false after 210KB without frames") - } - - // Now size cap should NOT apply even though buffer exceeds MaxSize - bigChunk := make([]byte, 150*1024) - result := d.Process(bigChunk) - if result.Truncate { - t.Error("size cap should NOT truncate when no recent frames") - } - }) - - t.Run("continuous frames - size cap applies", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - - // Simulate app that sends frames frequently (like vim, htop) - for i := 0; i < 5; i++ { - d.Process([]byte("\x1b[2Jframe")) - d.Process(make([]byte, 10*1024)) // 10KB between frames - } - - if !d.HasRecentFrame() { - t.Error("HasRecentFrame should be true with frequent frames") - } - - // Size cap should work - bigChunk := make([]byte, 150*1024) - result := d.Process(bigChunk) - if !result.Truncate { - t.Error("expected size cap truncation with recent frames") - } - }) - - t.Run("sync mode app - frame boundary detected on sync begin", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - - result := d.Process([]byte("old\x1b[?2026hnew")) - if !result.Truncate { - t.Error("expected truncation on sync begin") - } - - if !d.HasRecentFrame() { - t.Error("HasRecentFrame should be true after sync begin") - } - }) -} - -func TestFrameDetector_Combined(t *testing.T) { - tests := []struct { - name string - strategy TruncationStrategy - chunks [][]byte - wantTrunc []bool - wantData [][]byte - }{ - { - name: "screen clear wins in same chunk", - strategy: DefaultTUIStrategy(), - chunks: [][]byte{[]byte("old\x1b[0m\x1b[1;1H\x1b[2Jnew")}, - wantTrunc: []bool{true}, - wantData: [][]byte{[]byte("new")}, - }, - { - name: "sync and screen clear - last wins", - strategy: DefaultTUIStrategy(), - chunks: [][]byte{[]byte("old\x1b[?2026h\x1b[2Jnew")}, - wantTrunc: []bool{true}, - wantData: [][]byte{[]byte("new")}, - }, - { - name: "all strategies combined", - strategy: DefaultTUIStrategy(), - chunks: [][]byte{[]byte("a\x1b[2Jb\x1b[?2026hc\x1b[0m\x1b[1;1Hd")}, - wantTrunc: []bool{true}, - wantData: [][]byte{[]byte("d")}, - }, - { - name: "disabled screen clear", - strategy: TruncationStrategy{ - ScreenClear: false, - SyncMode: true, - }, - chunks: [][]byte{[]byte("old\x1b[2Jnew")}, - wantTrunc: []bool{false}, - wantData: [][]byte{[]byte("old\x1b[2Jnew")}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d := NewFrameDetector(tt.strategy) - - for i, chunk := range tt.chunks { - result := d.Process(chunk) - - if result.Truncate != tt.wantTrunc[i] { - t.Errorf("chunk %d: Truncate = %v, want %v", i, result.Truncate, tt.wantTrunc[i]) - } - - if !bytes.Equal(result.DataAfter, tt.wantData[i]) { - t.Errorf("chunk %d: DataAfter = %q, want %q", i, result.DataAfter, tt.wantData[i]) - } - } - }) - } -} - -func TestFrameDetector_BufferSize(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{ - ScreenClear: true, - MaxSize: 0, - }) - - // Initial buffer size is 0 - if d.BufferSize() != 0 { - t.Errorf("initial BufferSize() = %d, want 0", d.BufferSize()) - } - - // Process some data - d.Process([]byte("hello")) - if d.BufferSize() != 5 { - t.Errorf("after 'hello': BufferSize() = %d, want 5", d.BufferSize()) - } - - // Process more data - d.Process([]byte(" world")) - if d.BufferSize() != 11 { - t.Errorf("after ' world': BufferSize() = %d, want 11", d.BufferSize()) - } - - // Clear resets size to data after clear - d.Process([]byte("\x1b[2Jnew")) - if d.BufferSize() != 3 { - t.Errorf("after clear: BufferSize() = %d, want 3", d.BufferSize()) - } - - // Reset buffer size - d.ResetBufferSize() - if d.BufferSize() != 0 { - t.Errorf("after reset: BufferSize() = %d, want 0", d.BufferSize()) - } -} - -func TestFrameDetector_Reset(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - - // Build up state - d.Process([]byte("\x1b[2Jframe content")) - d.Process([]byte("more data")) - - if d.BufferSize() == 0 { - t.Error("BufferSize should be > 0 before Reset") - } - if !d.HasRecentFrame() { - t.Error("HasRecentFrame should be true before Reset") - } - - d.Reset() - - if d.BufferSize() != 0 { - t.Errorf("after Reset: BufferSize() = %d, want 0", d.BufferSize()) - } - if d.HasRecentFrame() { - t.Error("after Reset: HasRecentFrame should be false") - } - if d.BytesSinceLastFrame() != 0 { - t.Errorf("after Reset: BytesSinceLastFrame() = %d, want 0", d.BytesSinceLastFrame()) - } - - // Flush should return nil (pending cleared) - if pending := d.Flush(); pending != nil { - t.Errorf("after Reset: Flush() = %q, want nil", pending) - } - - // Detector should work normally after reset - result := d.Process([]byte("\x1b[2Jnew frame")) - if !result.Truncate { - t.Error("after Reset: should detect truncation normally") - } - if string(result.DataAfter) != "new frame" { - t.Errorf("after Reset: DataAfter = %q, want %q", result.DataAfter, "new frame") - } -} - -func TestDefaultTUIStrategy(t *testing.T) { - s := DefaultTUIStrategy() - - if !s.ScreenClear { - t.Error("ScreenClear should be enabled") - } - if !s.SyncMode { - t.Error("SyncMode should be enabled") - } - if !s.CursorHome { - t.Error("CursorHome should be enabled") - } - if !s.CursorJumpTop { - t.Error("CursorJumpTop should be enabled") - } - if s.MaxSize != 100*1024 { - t.Errorf("MaxSize = %d, want %d", s.MaxSize, 100*1024) - } -} - -func TestFrameDetector_RealWorldK9s(t *testing.T) { - // k9s typically uses cursor home with reset for each frame - d := NewFrameDetector(DefaultTUIStrategy()) - - chunks := [][]byte{ - []byte("frame1 content\n"), - []byte("\x1b[0m\x1b[1;1H"), // reset + cursor home - []byte("frame2 content\n"), - []byte("\x1b[0m\x1b[1;1H"), // reset + cursor home - []byte("frame3 content\n"), - } - - var lastData []byte - truncCount := 0 - - for _, chunk := range chunks { - result := d.Process(chunk) - if result.Truncate { - truncCount++ - } - lastData = result.DataAfter - } - - if truncCount != 2 { - t.Errorf("truncCount = %d, want 2", truncCount) - } - - if !bytes.Equal(lastData, []byte("frame3 content\n")) { - t.Errorf("lastData = %q, want %q", lastData, "frame3 content\n") - } -} - -func TestFrameDetector_RealWorldClaudeCode(t *testing.T) { - // Claude Code uses sync mode for updates - // Truncation happens on sync BEGIN (h), keeping the frame content - d := NewFrameDetector(DefaultTUIStrategy()) - - chunks := [][]byte{ - []byte("old"), - []byte("\x1b[?2026h"), // sync start - truncate here (new frame) - []byte("frame1"), - []byte("\x1b[?2026l"), // sync end - no truncate - []byte("\x1b[?2026h"), // sync start - truncate here (new frame) - []byte("frame2"), - []byte("\x1b[?2026l"), // sync end - no truncate - } - - var lastData []byte - truncCount := 0 - - for _, chunk := range chunks { - result := d.Process(chunk) - if result.Truncate { - truncCount++ - } - lastData = result.DataAfter - } - - if truncCount != 2 { - t.Errorf("truncCount = %d, want 2", truncCount) - } - - // Last data should be frame2 content plus the sync end - if !bytes.Equal(lastData, []byte("\x1b[?2026l")) { - t.Errorf("lastData = %q, want %q", lastData, "\x1b[?2026l") - } -} - -func TestFrameDetector_SizeCapWith4KBChunks(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - - // Send a frame boundary first - result := d.Process([]byte("\x1b[2Jframe")) - if !result.Truncate { - t.Fatal("expected truncation on screen clear") - } - - // Send ~30 x 4KB chunks (120KB total, exceeds 100KB MaxSize) - // With recency window = MaxSize*2 = 200KB, the frame is still recent - truncated := false - for i := 0; i < 30; i++ { - chunk := make([]byte, 4*1024) - result := d.Process(chunk) - if result.Truncate { - truncated = true - break - } - } - - if !truncated { - t.Error("size cap should fire with default settings and 4KB chunks") - } -} - -func TestFrameDetector_RepeatedGrowthStaysBounded(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - - // Send initial frame - d.Process([]byte("\x1b[2Jstart")) - - // First growth cycle: exceed MaxSize (100KB) - firstCapFired := false - for i := 0; i < 30; i++ { - result := d.Process(make([]byte, 4*1024)) - if result.Truncate { - firstCapFired = true - break - } - } - if !firstCapFired { - t.Fatal("first size cap should have fired") - } - - // Second growth cycle: after re-anchoring, cap should fire again - secondCapFired := false - for i := 0; i < 30; i++ { - result := d.Process(make([]byte, 4*1024)) - if result.Truncate { - secondCapFired = true - break - } - } - if !secondCapFired { - t.Error("second size cap should fire after re-anchoring") - } -} - -func TestFrameDetector_CrossChunkCursorHomeHeuristic(t *testing.T) { - t.Run("reset at end of chunk 1, cursor home at start of chunk 2", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - // Chunk 1 ends with ESC[0m (reset) - result := d.Process([]byte("old content\x1b[0m")) - if result.Truncate { - t.Error("chunk 1 should not truncate") - } - - // Chunk 2 starts with ESC[1;1H (cursor home) - result = d.Process([]byte("\x1b[1;1Hnew content")) - if !result.Truncate { - t.Error("chunk 2 should truncate (cross-chunk heuristic)") - } - if !bytes.Equal(result.DataAfter, []byte("new content")) { - t.Errorf("DataAfter = %q, want %q", result.DataAfter, "new content") - } - }) - - t.Run("hide cursor at end of chunk 1, cursor home at start of chunk 2", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - // Chunk 1 ends with ESC[?25l (hide cursor) - result := d.Process([]byte("old content\x1b[?25l")) - if result.Truncate { - t.Error("chunk 1 should not truncate") - } - - // Chunk 2 starts with ESC[1;1H (cursor home) - result = d.Process([]byte("\x1b[1;1Hnew content")) - if !result.Truncate { - t.Error("chunk 2 should truncate (cross-chunk heuristic)") - } - }) - - t.Run("heuristic marker too far back in previous chunk", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - // Chunk 1: reset followed by >20 bytes of padding - padding := make([]byte, 25) - for i := range padding { - padding[i] = 'x' - } - chunk1 := append([]byte("old\x1b[0m"), padding...) - result := d.Process(chunk1) - if result.Truncate { - t.Error("chunk 1 should not truncate") - } - - // Chunk 2: cursor home at start - heuristic marker is >20 bytes back, should NOT trigger - result = d.Process([]byte("\x1b[1;1Hnew content")) - if result.Truncate { - t.Error("should NOT truncate when heuristic marker is beyond lookback window") - } - }) -} - -func TestFrameDetector_SnapshotMode(t *testing.T) { - t.Run("sync mode suppressed during snapshot mode", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - d.SetSnapshotMode(true) - - result := d.Process([]byte("old\x1b[?2026hnew\x1b[?2026l")) - if result.Truncate { - t.Error("sync mode should NOT truncate during snapshot mode") - } - if !bytes.Equal(result.DataAfter, []byte("old\x1b[?2026hnew\x1b[?2026l")) { - t.Errorf("DataAfter = %q, want full data", result.DataAfter) - } - }) - - t.Run("screen clear suppressed during snapshot mode", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - d.SetSnapshotMode(true) - - result := d.Process([]byte("old\x1b[2Jnew")) - if result.Truncate { - t.Error("screen clear should NOT truncate during snapshot mode") - } - expected := []byte("old\x1b[2Jnew") - if !bytes.Equal(result.DataAfter, expected) { - t.Errorf("DataAfter = %q, want full data", result.DataAfter) - } - }) - - t.Run("cursor home suppressed during snapshot mode", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - d.SetSnapshotMode(true) - - result := d.Process([]byte("old\x1b[0m\x1b[1;1Hnew")) - if result.Truncate { - t.Error("cursor_home should NOT truncate during snapshot mode") - } - }) - - t.Run("CursorJumpTop suppressed during snapshot mode", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - - // Build up rows to exceed threshold - var buf []byte - for r := 1; r <= 30; r++ { - buf = append(buf, []byte(fmt.Sprintf("\x1b[%d;1Hline content", r))...) - } - d.Process(buf) - d.SetSnapshotMode(true) - - result := d.Process([]byte("\x1b[1;1Hnew frame")) - if result.Truncate { - t.Error("CursorJumpTop should NOT truncate during snapshot mode") - } - }) - - t.Run("MaxSize suppressed during snapshot mode", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - - // Trigger a frame boundary first (outside snapshot mode) - d.Process([]byte("\x1b[2Jframe")) - d.SetSnapshotMode(true) - - // Send data exceeding MaxSize (100KB) - bigChunk := make([]byte, 150*1024) - result := d.Process(bigChunk) - if result.Truncate { - t.Error("MaxSize should NOT truncate during snapshot mode") - } - }) - - t.Run("vim redraw pattern: alt screen + clear + cursor home all suppressed in snapshot", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - d.SetSnapshotMode(true) - - // vim sends all three in one redraw sequence - result := d.Process([]byte("\x1b[?1049h\x1b[2J\x1b[Hfile contents here")) - if result.Truncate { - t.Error("vim redraw pattern should NOT truncate during snapshot mode") - } - expected := []byte("\x1b[?1049h\x1b[2J\x1b[Hfile contents here") - if !bytes.Equal(result.DataAfter, expected) { - t.Errorf("DataAfter = %q, want full data", result.DataAfter) - } - }) - - t.Run("sync mode resumes after snapshot mode disabled", func(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - d.SetSnapshotMode(true) - - d.Process([]byte("data\x1b[?2026hmore")) - d.SetSnapshotMode(false) - - result := d.Process([]byte("old\x1b[?2026hnew")) - if !result.Truncate { - t.Error("sync mode should truncate after snapshot mode disabled") - } - if !bytes.Equal(result.DataAfter, []byte("new")) { - t.Errorf("DataAfter = %q, want %q", result.DataAfter, "new") - } - }) -} - -func TestFrameDetector_CursorJumpTop(t *testing.T) { - // Helper: build cursor position sequence ESC[row;1H - cursorPos := func(row int) string { - return fmt.Sprintf("\x1b[%d;1H", row) - } - - t.Run("jump from row 30 to row 1 triggers truncation", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - // Build up rows to exceed threshold (10) - var buf []byte - for r := 1; r <= 30; r++ { - buf = append(buf, []byte(cursorPos(r)+"line content")...) - } - result := d.Process(buf) - if result.Truncate { - t.Error("building up rows should not truncate") - } - - // Now jump back to row 1 - result = d.Process([]byte(cursorPos(1) + "new frame")) - if !result.Truncate { - t.Error("jump from row 30 to row 1 should truncate") - } - if !bytes.Equal(result.DataAfter, []byte("new frame")) { - t.Errorf("DataAfter = %q, want %q", result.DataAfter, "new frame") - } - }) - - t.Run("jump to row 2 also triggers truncation", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - var buf []byte - for r := 1; r <= 20; r++ { - buf = append(buf, []byte(cursorPos(r)+"content")...) - } - d.Process(buf) - - result := d.Process([]byte(cursorPos(2) + "new frame")) - if !result.Truncate { - t.Error("jump to row 2 should also truncate") - } - }) - - t.Run("sequential rows do not trigger truncation", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - // Write rows 1..20 sequentially in one chunk - var buf []byte - for r := 1; r <= 20; r++ { - buf = append(buf, []byte(cursorPos(r)+"content")...) - } - result := d.Process(buf) - if result.Truncate { - t.Error("sequential rows should not truncate") - } - }) - - t.Run("max row below threshold does not truncate even on jump", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - // Only go up to row 5 (below threshold of 10) - var buf []byte - for r := 1; r <= 5; r++ { - buf = append(buf, []byte(cursorPos(r)+"content")...) - } - d.Process(buf) - - result := d.Process([]byte(cursorPos(1) + "new frame")) - if result.Truncate { - t.Error("should not truncate when maxRowSeen < threshold") - } - }) - - t.Run("strategy disabled does not truncate", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: false}) - - var buf []byte - for r := 1; r <= 30; r++ { - buf = append(buf, []byte(cursorPos(r)+"content")...) - } - d.Process(buf) - - result := d.Process([]byte(cursorPos(1) + "new frame")) - if result.Truncate { - t.Error("should not truncate when CursorJumpTop is disabled") - } - }) - - t.Run("cross-chunk row tracking persists", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - // Chunk 1: rows 1..15 - var buf1 []byte - for r := 1; r <= 15; r++ { - buf1 = append(buf1, []byte(cursorPos(r)+"content")...) - } - result := d.Process(buf1) - if result.Truncate { - t.Error("chunk 1 should not truncate") - } - - // Chunk 2: rows 16..25 - var buf2 []byte - for r := 16; r <= 25; r++ { - buf2 = append(buf2, []byte(cursorPos(r)+"content")...) - } - result = d.Process(buf2) - if result.Truncate { - t.Error("chunk 2 should not truncate") - } - - // Chunk 3: jump back to row 1 - result = d.Process([]byte(cursorPos(1) + "new frame")) - if !result.Truncate { - t.Error("cross-chunk jump should truncate") - } - }) - - t.Run("cross-chunk partial cursor position sequence", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - // Build up rows first - var buf []byte - for r := 1; r <= 20; r++ { - buf = append(buf, []byte(cursorPos(r)+"content")...) - } - d.Process(buf) - - // Send partial sequence: ESC[1;1 (missing H) - result := d.Process([]byte("data\x1b[1;1")) - if result.Truncate { - t.Error("partial sequence should not truncate yet") - } - - // Complete the sequence in next chunk - result = d.Process([]byte("Hnew frame")) - if !result.Truncate { - t.Error("completed cross-chunk cursor position should truncate") - } - }) -} - -func TestFrameDetector_CursorHomeCooldown(t *testing.T) { - t.Run("vim pattern: two cursor_home within 4096 bytes - only first fires", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - // Single chunk with two cursor_home sequences close together - // First: reset + cursor_home - // Then some content (< 4096 bytes) - // Second: reset + cursor_home - padding := make([]byte, 200) - for i := range padding { - padding[i] = 'x' - } - var chunk []byte - chunk = append(chunk, []byte("old\x1b[?25l\x1b[1;1H")...) // first cursor_home - chunk = append(chunk, padding...) - chunk = append(chunk, []byte("\x1b[0m\x1b[1;1H")...) // second cursor_home (within cooldown) - chunk = append(chunk, []byte("final content")...) - - result := d.Process(chunk) - if !result.Truncate { - t.Error("should truncate (first cursor_home)") - } - // The truncation should be at the first cursor_home, not the second - // (second is suppressed by cooldown) - expected := append([]byte{}, padding...) - expected = append(expected, []byte("\x1b[0m\x1b[1;1Hfinal content")...) - if !bytes.Equal(result.DataAfter, expected) { - t.Errorf("DataAfter should include data after first cursor_home (second suppressed)") - } - }) - - t.Run("normal multi-frame: two cursor_home 5000+ bytes apart - both fire", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - padding := make([]byte, 5000) - for i := range padding { - padding[i] = 'x' - } - var chunk []byte - chunk = append(chunk, []byte("old\x1b[0m\x1b[1;1H")...) // first cursor_home - chunk = append(chunk, padding...) - chunk = append(chunk, []byte("\x1b[0m\x1b[1;1H")...) // second cursor_home (beyond cooldown) - chunk = append(chunk, []byte("final")...) - - result := d.Process(chunk) - if !result.Truncate { - t.Error("should truncate") - } - // Both should fire, so DataAfter should be after the second cursor_home - if !bytes.Equal(result.DataAfter, []byte("final")) { - t.Errorf("DataAfter = %q, want %q", result.DataAfter, "final") - } - }) - - t.Run("separate chunks: cursor_home in different Process calls both fire", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - result := d.Process([]byte("old\x1b[0m\x1b[1;1Hframe1")) - if !result.Truncate { - t.Error("first chunk should truncate") - } - - result = d.Process([]byte("data\x1b[0m\x1b[1;1Hframe2")) - if !result.Truncate { - t.Error("second chunk should truncate (cooldown resets between Process calls)") - } - }) - - t.Run("Reset clears cooldown state", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - d.Process([]byte("old\x1b[0m\x1b[1;1Hcontent")) - d.Reset() - - result := d.Process([]byte("new\x1b[0m\x1b[1;1Hcontent")) - if !result.Truncate { - t.Error("after Reset, cursor_home should work normally") - } - }) -} - -func TestFrameDetector_CursorJumpTopLookAhead(t *testing.T) { - cursorPos := func(row int) string { - return fmt.Sprintf("\x1b[%d;1H", row) - } - - buildRows := func(from, to int) []byte { - var buf []byte - for r := from; r <= to; r++ { - buf = append(buf, []byte(cursorPos(r)+"line content")...) - } - return buf - } - - t.Run("micro pattern: jump to top followed by cursor control only - no truncation", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - d.Process(buildRows(1, 40)) - - // Jump to row 1 followed by cursor control sequences only (no printable content) - result := d.Process([]byte(cursorPos(1) + "\x1b[?12l\x1b[?25h")) - if result.Truncate { - t.Error("should NOT truncate when only cursor control follows jump") - } - }) - - t.Run("htop pattern: jump to top followed by content - truncation fires", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - d.Process(buildRows(1, 30)) - - result := d.Process([]byte(cursorPos(1) + "CPU usage 50%")) - if !result.Truncate { - t.Error("should truncate when content follows jump") - } - if !bytes.Equal(result.DataAfter, []byte("CPU usage 50%")) { - t.Errorf("DataAfter = %q, want %q", result.DataAfter, "CPU usage 50%") - } - }) - - t.Run("jump followed by color then content - truncation fires", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - d.Process(buildRows(1, 20)) - - result := d.Process([]byte(cursorPos(1) + "\x1b[32mgreen text")) - if !result.Truncate { - t.Error("should truncate when content follows after color sequences") - } - }) - - t.Run("cross-chunk: jump at end of chunk, content in next - truncation", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - d.Process(buildRows(1, 25)) - - // Jump at very end of chunk (near boundary) - result := d.Process([]byte(cursorPos(1))) - if result.Truncate { - t.Error("should not truncate yet (waiting for next chunk)") - } - - // Next chunk has content - result = d.Process([]byte("new frame content")) - if !result.Truncate { - t.Error("should truncate when content arrives in next chunk") - } - }) - - t.Run("cross-chunk: jump at end of chunk, cursor control in next - no truncation", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - d.Process(buildRows(1, 25)) - - // Jump at end of chunk - result := d.Process([]byte(cursorPos(1))) - if result.Truncate { - t.Error("should not truncate yet") - } - - // Next chunk has only cursor control - result = d.Process([]byte("\x1b[?25h\x1b[?12l")) - if result.Truncate { - t.Error("should NOT truncate when only cursor control in next chunk") - } - }) - - t.Run("jump followed by another cursor positioning - no truncation", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - d.Process(buildRows(1, 20)) - - // Jump to row 1 followed by another cursor position (row 5) with no content between - result := d.Process([]byte("\x1b[1;3H\x1b[?12l\x1b[?25h")) - if result.Truncate { - t.Error("should NOT truncate when only cursor positioning/control follows") - } - }) - - t.Run("jump followed by tilde-terminated CSI then content - truncation fires", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorJumpTop: true}) - - d.Process(buildRows(1, 20)) - - result := d.Process([]byte(cursorPos(1) + "\x1b[15~hello world")) - if !result.Truncate { - t.Error("should truncate when content follows after tilde-terminated sequence") - } - }) -} - -func TestFrameDetector_CursorHomeLookAhead(t *testing.T) { - t.Run("vim pattern: second cursor_home followed by control only - no truncation", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - // First cursor_home with content after (legitimate frame start) - // Then ~5000 bytes of row content - // Then second cursor_home followed by only cursor control (editing cursor return) - padding := make([]byte, 5000) - for i := range padding { - padding[i] = 'x' - } - var chunk []byte - chunk = append(chunk, []byte("\x1b[?25l\x1b[1;1H")...) // first cursor_home (frame start) - chunk = append(chunk, padding...) - chunk = append(chunk, []byte("\x1b[0m\x1b[1;1H")...) // second cursor_home (editing cursor) - chunk = append(chunk, []byte("\x1b[?12l\x1b[?25h")...) // cursor control only - - result := d.Process(chunk) - if !result.Truncate { - t.Error("should truncate (first cursor_home)") - } - // DataAfter should be from first cursor_home, second should be suppressed - wantLen := 5000 + len("\x1b[0m\x1b[1;1H") + len("\x1b[?12l\x1b[?25h") - if len(result.DataAfter) != wantLen { - t.Errorf("DataAfter len = %d, want %d (second cursor_home should be suppressed)", len(result.DataAfter), wantLen) - } - }) - - t.Run("micro pattern: cursor_home followed by cursor control only - no truncation", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - // cursor_home with heuristic, followed by only cursor control sequences - result := d.Process([]byte("content\x1b[0m\x1b[1;1H\x1b[?12l\x1b[?25h")) - if result.Truncate { - t.Error("should NOT truncate when only cursor control follows cursor_home") - } - }) - - t.Run("multi-frame with content still works: both fire", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - padding := make([]byte, 5000) - for i := range padding { - padding[i] = 'x' - } - var chunk []byte - chunk = append(chunk, []byte("old\x1b[0m\x1b[1;1H")...) // first cursor_home - chunk = append(chunk, padding...) - chunk = append(chunk, []byte("\x1b[0m\x1b[1;1H")...) // second cursor_home (beyond cooldown) - chunk = append(chunk, []byte("final")...) // content after second - - result := d.Process(chunk) - if !result.Truncate { - t.Error("should truncate") - } - // Both should fire, DataAfter from second cursor_home - if !bytes.Equal(result.DataAfter, []byte("final")) { - t.Errorf("DataAfter = %q, want %q", result.DataAfter, "final") - } - }) - - t.Run("cross-chunk deferred: content follows - truncation fires", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - // cursor_home at the very end of chunk (no bytes after) - result := d.Process([]byte("old\x1b[0m\x1b[1;1H")) - if result.Truncate { - t.Error("should not truncate yet (deferred)") - } - - // Next chunk has content - result = d.Process([]byte("new frame content")) - if !result.Truncate { - t.Error("should truncate when content arrives in next chunk") - } - if !bytes.Equal(result.DataAfter, []byte("new frame content")) { - t.Errorf("DataAfter = %q, want %q", result.DataAfter, "new frame content") - } - }) - - t.Run("cross-chunk deferred: control only - no truncation", func(t *testing.T) { - d := NewFrameDetector(TruncationStrategy{CursorHome: true}) - - // cursor_home at the very end of chunk - result := d.Process([]byte("old\x1b[0m\x1b[1;1H")) - if result.Truncate { - t.Error("should not truncate yet (deferred)") - } - - // Next chunk has only cursor control - result = d.Process([]byte("\x1b[?25h\x1b[?12l")) - if result.Truncate { - t.Error("should NOT truncate when only cursor control in next chunk") - } - }) -} - -func TestFrameDetector_ConcurrentAccess(t *testing.T) { - d := NewFrameDetector(DefaultTUIStrategy()) - var wg sync.WaitGroup - - wg.Add(3) - - // Goroutine 1: continuous Process calls - go func() { - defer wg.Done() - for i := 0; i < 1000; i++ { - d.Process([]byte("\x1b[2Jframe data")) - } - }() - - // Goroutine 2: SetSnapshotMode toggles - go func() { - defer wg.Done() - for i := 0; i < 1000; i++ { - d.SetSnapshotMode(true) - d.SetSnapshotMode(false) - } - }() - - // Goroutine 3: Reset calls - go func() { - defer wg.Done() - for i := 0; i < 1000; i++ { - d.Reset() - } - }() - - wg.Wait() -} - -func TestParseCursorRow_EdgeCases(t *testing.T) { - tests := []struct { - name string - data []byte - pos int - wantRow int - wantEnd int - wantOK bool - }{ - { - name: "ESC[H defaults to row 1", - data: []byte("\x1b[H"), - pos: 0, - wantRow: 1, - wantEnd: 3, - wantOK: true, - }, - { - name: "ESC[;5H no row before semicolon defaults to row 1", - data: []byte("\x1b[;5H"), - pos: 0, - wantRow: 1, - wantEnd: 5, - wantOK: true, - }, - { - name: "ESC[0;0H zero row", - data: []byte("\x1b[0;0H"), - pos: 0, - wantRow: 0, - wantEnd: 6, - wantOK: true, - }, - { - name: "very large row number (6 digits) rejected", - data: []byte("\x1b[123456;1H"), - pos: 0, - wantOK: false, - }, - { - name: "5 digit row number accepted", - data: []byte("\x1b[99999;1H"), - pos: 0, - wantRow: 99999, - wantEnd: 10, - wantOK: true, - }, - { - name: "very large col number (6 digits) rejected", - data: []byte("\x1b[1;123456H"), - pos: 0, - wantOK: false, - }, - { - name: "incomplete sequence", - data: []byte("\x1b[5"), - pos: 0, - wantOK: false, - }, - { - name: "not a cursor position (color code)", - data: []byte("\x1b[31m"), - pos: 0, - wantOK: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - row, end, ok := parseCursorRow(tt.data, tt.pos) - if ok != tt.wantOK { - t.Fatalf("ok = %v, want %v", ok, tt.wantOK) - } - if !ok { - return - } - if row != tt.wantRow { - t.Errorf("row = %d, want %d", row, tt.wantRow) - } - if end != tt.wantEnd { - t.Errorf("end = %d, want %d", end, tt.wantEnd) - } - }) - } -} diff --git a/internal/ansi/responder.go b/internal/ansi/responder.go deleted file mode 100644 index a20144f..0000000 --- a/internal/ansi/responder.go +++ /dev/null @@ -1,134 +0,0 @@ -package ansi - -import ( - "fmt" - "log" - "os" - "sync" -) - - -// TerminalResponder intercepts terminal capability queries in PTY output -// and writes appropriate responses to the PTY master fd (appearing as input -// to the subprocess). This unblocks apps like yazi that wait for DA1/DA2/etc. -type TerminalResponder struct { - mu sync.Mutex - ptmx *os.File -} - -// NewTerminalResponder creates a responder that writes responses to ptmx. -func NewTerminalResponder(ptmx *os.File) *TerminalResponder { - return &TerminalResponder{ptmx: ptmx} -} - -// Process scans data for terminal queries, sends responses to the PTY, -// and returns data with query sequences stripped out. -func (r *TerminalResponder) Process(data []byte) []byte { - // Fast path: no ESC in data - hasESC := false - for _, b := range data { - if b == 0x1B { - hasESC = true - break - } - } - if !hasESC { - return data - } - - result := make([]byte, 0, len(data)) - i := 0 - - for i < len(data) { - if data[i] != 0x1B { - result = append(result, data[i]) - i++ - continue - } - - // Try to match a query sequence starting at i - consumed, response := r.matchQuery(data, i) - if consumed > 0 { - if response != "" { - r.respond(response) - } - i += consumed - continue - } - - // Not a recognized query, pass through - result = append(result, data[i]) - i++ - } - - return result -} - -// matchQuery attempts to match a terminal query at data[pos]. -// Returns (bytes consumed, response to send). Zero consumed means no match. -func (r *TerminalResponder) matchQuery(data []byte, pos int) (int, string) { - remaining := len(data) - pos - if remaining < 2 { - return 0, "" - } - - // All queries start with ESC[ - if data[pos+1] != '[' { - return 0, "" - } - - if remaining < 3 { - return 0, "" - } - - // DA1: ESC[c or ESC[0c - if data[pos+2] == 'c' { - // VT220 with various capabilities - return 3, "\x1b[?62;1;2;6;7;8;9;15;22c" - } - if remaining >= 4 && data[pos+2] == '0' && data[pos+3] == 'c' { - return 4, "\x1b[?62;1;2;6;7;8;9;15;22c" - } - - // DA2: ESC[>c or ESC[>0c - if data[pos+2] == '>' { - if remaining >= 4 && data[pos+3] == 'c' { - return 4, "\x1b[>1;1;0c" - } - if remaining >= 5 && data[pos+3] == '0' && data[pos+4] == 'c' { - return 5, "\x1b[>1;1;0c" - } - } - - // Kitty keyboard query: ESC[?u - if remaining >= 4 && data[pos+2] == '?' && data[pos+3] == 'u' { - return 4, "\x1b[?0u" - } - - // DECRPM mode query: ESC[?{digits}$p - if data[pos+2] == '?' { - j := pos + 3 - modeStart := j - for j < len(data) && data[j] >= '0' && data[j] <= '9' { - j++ - } - if j > modeStart && j+1 < len(data) && data[j] == '$' && data[j+1] == 'p' { - modeStr := string(data[modeStart:j]) - return j + 2 - pos, fmt.Sprintf("\x1b[?%s;0$y", modeStr) - } - } - - return 0, "" -} - -func (r *TerminalResponder) respond(response string) { - r.mu.Lock() - ptmx := r.ptmx - r.mu.Unlock() - - if ptmx != nil { - if _, err := ptmx.Write([]byte(response)); err != nil { - log.Printf("TerminalResponder: write error: %v", err) - } - } -} diff --git a/internal/ansi/responder_test.go b/internal/ansi/responder_test.go deleted file mode 100644 index 0e5e951..0000000 --- a/internal/ansi/responder_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package ansi - -import ( - "os" - "testing" - "time" -) - -func readPipe(r *os.File, timeout time.Duration) []byte { - done := make(chan []byte, 1) - go func() { - buf := make([]byte, 1024) - n, _ := r.Read(buf) - done <- buf[:n] - }() - - select { - case data := <-done: - return data - case <-time.After(timeout): - return nil - } -} - -func TestTerminalResponder_DA1(t *testing.T) { - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - defer r.Close() - defer w.Close() - - resp := NewTerminalResponder(w) - - t.Run("ESC[c", func(t *testing.T) { - result := resp.Process([]byte("before\x1b[cafter")) - if string(result) != "beforeafter" { - t.Errorf("result = %q, want %q", result, "beforeafter") - } - got := readPipe(r, 100*time.Millisecond) - expected := "\x1b[?62;1;2;6;7;8;9;15;22c" - if string(got) != expected { - t.Errorf("response = %q, want %q", got, expected) - } - }) - - t.Run("ESC[0c", func(t *testing.T) { - result := resp.Process([]byte("\x1b[0c")) - if string(result) != "" { - t.Errorf("result = %q, want empty", result) - } - got := readPipe(r, 100*time.Millisecond) - expected := "\x1b[?62;1;2;6;7;8;9;15;22c" - if string(got) != expected { - t.Errorf("response = %q, want %q", got, expected) - } - }) -} - -func TestTerminalResponder_DA2(t *testing.T) { - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - defer r.Close() - defer w.Close() - - resp := NewTerminalResponder(w) - - t.Run("ESC[>c", func(t *testing.T) { - result := resp.Process([]byte("\x1b[>c")) - if string(result) != "" { - t.Errorf("result = %q, want empty", result) - } - got := readPipe(r, 100*time.Millisecond) - expected := "\x1b[>1;1;0c" - if string(got) != expected { - t.Errorf("response = %q, want %q", got, expected) - } - }) - - t.Run("ESC[>0c", func(t *testing.T) { - result := resp.Process([]byte("\x1b[>0c")) - if string(result) != "" { - t.Errorf("result = %q, want empty", result) - } - got := readPipe(r, 100*time.Millisecond) - expected := "\x1b[>1;1;0c" - if string(got) != expected { - t.Errorf("response = %q, want %q", got, expected) - } - }) -} - -func TestTerminalResponder_KittyKeyboard(t *testing.T) { - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - defer r.Close() - defer w.Close() - - resp := NewTerminalResponder(w) - - result := resp.Process([]byte("\x1b[?u")) - if string(result) != "" { - t.Errorf("result = %q, want empty", result) - } - got := readPipe(r, 100*time.Millisecond) - expected := "\x1b[?0u" - if string(got) != expected { - t.Errorf("response = %q, want %q", got, expected) - } -} - -func TestTerminalResponder_DECRPM(t *testing.T) { - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - defer r.Close() - defer w.Close() - - resp := NewTerminalResponder(w) - - t.Run("mode 2004", func(t *testing.T) { - result := resp.Process([]byte("\x1b[?2004$p")) - if string(result) != "" { - t.Errorf("result = %q, want empty", result) - } - got := readPipe(r, 100*time.Millisecond) - expected := "\x1b[?2004;0$y" - if string(got) != expected { - t.Errorf("response = %q, want %q", got, expected) - } - }) - - t.Run("mode 25", func(t *testing.T) { - result := resp.Process([]byte("\x1b[?25$p")) - if string(result) != "" { - t.Errorf("result = %q, want empty", result) - } - got := readPipe(r, 100*time.Millisecond) - expected := "\x1b[?25;0$y" - if string(got) != expected { - t.Errorf("response = %q, want %q", got, expected) - } - }) -} - -func TestTerminalResponder_NoQueries(t *testing.T) { - _, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - defer w.Close() - - resp := NewTerminalResponder(w) - - input := []byte("hello world\x1b[31mred\x1b[0m") - result := resp.Process(input) - if string(result) != string(input) { - t.Errorf("result = %q, want %q", result, input) - } -} - -func TestTerminalResponder_MultipleQueries(t *testing.T) { - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - defer r.Close() - defer w.Close() - - resp := NewTerminalResponder(w) - - // DA1 + Kitty in one chunk - result := resp.Process([]byte("text\x1b[c\x1b[?umore")) - if string(result) != "textmore" { - t.Errorf("result = %q, want %q", result, "textmore") - } - - // Read all responses (may arrive in one or multiple reads) - expected := "\x1b[?62;1;2;6;7;8;9;15;22c" + "\x1b[?0u" - var all []byte - for len(all) < len(expected) { - got := readPipe(r, 200*time.Millisecond) - if got == nil { - break - } - all = append(all, got...) - } - - if string(all) != expected { - t.Errorf("combined responses = %q, want %q", all, expected) - } -} - -func TestTerminalResponder_PartialSequenceBoundary(t *testing.T) { - // Partial sequences at chunk boundaries pass through unintercepted. - // This documents current behavior: no cross-chunk buffering (YAGNI). - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - defer r.Close() - defer w.Close() - - resp := NewTerminalResponder(w) - - // Split DA1 (ESC[c) across two chunks: ESC[ in first, c in second - result1 := resp.Process([]byte("before\x1b[")) - result2 := resp.Process([]byte("cafter")) - - // First chunk: ESC[ passes through (not recognized as complete query) - if string(result1) != "before\x1b[" { - t.Errorf("chunk 1: result = %q, want %q", result1, "before\x1b[") - } - - // Second chunk: 'c' alone is not ESC[c, passes through - if string(result2) != "cafter" { - t.Errorf("chunk 2: result = %q, want %q", result2, "cafter") - } - - // No response should have been written (query was split) - got := readPipe(r, 50*time.Millisecond) - if got != nil { - t.Errorf("expected no response for split query, got %q", got) - } -} - -func TestTerminalResponder_NoESCFastPath(t *testing.T) { - _, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - defer w.Close() - - resp := NewTerminalResponder(w) - - input := []byte("plain text without any escape sequences") - result := resp.Process(input) - // Fast path should return the same slice - if string(result) != string(input) { - t.Errorf("result = %q, want %q", result, input) - } -} - diff --git a/internal/ansi/strip.go b/internal/ansi/strip.go deleted file mode 100644 index 1d474b3..0000000 --- a/internal/ansi/strip.go +++ /dev/null @@ -1,508 +0,0 @@ -package ansi - -import ( - "regexp" - "strconv" - "strings" - "unicode/utf8" -) - -var ansiPatterns = []*regexp.Regexp{ - regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z~]`), // CSI sequences (colors, cursor, etc) - regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`), // OSC sequences - regexp.MustCompile(`\x1b[()][AB012]`), // Character set selection - regexp.MustCompile(`\x1b[=>]`), // Keypad modes - regexp.MustCompile(`\x1b\[\?[0-9;]*[hlsr]`), // DEC private modes - regexp.MustCompile(`\x1b[A-Za-z]`), // Simple ESC+letter sequences (RI, IND, NEL, RIS, etc.) - regexp.MustCompile(`\r`), // Carriage returns -} - -var cursorPosPattern = regexp.MustCompile(`\x1b\[(\d+);(\d+)([HFf])`) -var cursorAnyPattern = regexp.MustCompile(`\x1b\[\d*;?\d*[HFfGdABCD]`) - -const ( - maxGridCols = 500 - maxGridRows = 500 -) - -// DEC Special Graphics character set mapping (ESC(0 activates, ESC(B deactivates) -var decSpecialGraphics = map[byte]rune{ - '`': '◆', 'a': '▒', 'b': '\t', 'c': '\f', 'd': '\r', 'e': '\n', - 'f': '°', 'g': '±', 'h': '\n', 'i': '\v', 'j': '┘', 'k': '┐', - 'l': '┌', 'm': '└', 'n': '┼', 'o': '⎺', 'p': '⎻', - 'q': '─', 'r': '⎼', 's': '⎽', 't': '├', 'u': '┤', - 'v': '┴', 'w': '┬', 'x': '│', 'y': '≤', 'z': '≥', - '{': 'π', '|': '≠', '}': '£', '~': '·', -} - -func convertCursorPositioning(s string) string { - if !cursorAnyPattern.MatchString(s) { - return s - } - - // Pre-scan to determine grid dimensions from all cursor positioning variants - maxRow, maxCol := 0, 0 - hasRelativeMovement := false - for _, match := range cursorPosPattern.FindAllStringSubmatch(s, -1) { - row, _ := strconv.Atoi(match[1]) - col, _ := strconv.Atoi(match[2]) - if row > maxRow { - maxRow = row - } - if col > maxCol { - maxCol = col - } - } - // Also scan single-arg sequences: ESC[nH (row), ESC[nG (col), ESC[nd (row), ESC[nA/B/C/D (relative) - for i := 0; i < len(s); i++ { - if s[i] != 0x1B || i+1 >= len(s) || s[i+1] != '[' { - continue - } - j := i + 2 - num := 0 - hasNum := false - for j < len(s) && s[j] >= '0' && s[j] <= '9' { - num = num*10 + int(s[j]-'0') - hasNum = true - j++ - } - if j >= len(s) { - break - } - if s[j] == ';' { - continue // row;col form, already handled by cursorPosPattern - } - if !hasNum { - num = 1 // defaults to 1 - } - switch s[j] { - case 'H', 'F', 'f': - if num > maxRow { - maxRow = num - } - case 'd': - if num > maxRow { - maxRow = num - } - case 'G': - if num > maxCol { - maxCol = num - } - case 'A', 'B', 'C', 'D': - hasRelativeMovement = true - } - } - - // Relative movement can reach beyond absolute positions, ensure minimum grid size - if hasRelativeMovement { - if maxRow < 50 { - maxRow = 50 - } - if maxCol < 120 { - maxCol = 120 - } - } - - // Ensure grid accommodates newline-separated content (handles less/bat where - // only ESC[H is used for positioning but content flows with newlines) - newlineCount := strings.Count(s, "\n") - if newlineCount+1 > maxRow { - maxRow = newlineCount + 1 - } - - // Estimate extra cols needed for text after last cursor position on each row - maxCol += 80 - if maxCol > maxGridCols { - maxCol = maxGridCols - } - if maxRow > maxGridRows { - maxRow = maxGridRows - } - - // Allocate grid filled with spaces - grid := make([][]rune, maxRow) - for i := range grid { - grid[i] = make([]rune, maxCol) - for j := range grid[i] { - grid[i][j] = ' ' - } - } - - curRow, curCol := 0, 0 - useGraphicsCharset := false - - i := 0 - for i < len(s) { - if s[i] == 0x1B && i+1 < len(s) && s[i+1] == '[' { - // Try to match cursor position sequence - remaining := s[i:] - loc := cursorPosPattern.FindStringSubmatchIndex(remaining) - if loc != nil && loc[0] == 0 { - row, _ := strconv.Atoi(remaining[loc[2]:loc[3]]) - col, _ := strconv.Atoi(remaining[loc[4]:loc[5]]) - // Convert from 1-based to 0-based - newRow := row - 1 - newCol := col - 1 - if newRow < 0 { - newRow = 0 - } - if newCol < 0 { - newCol = 0 - } - if newRow == 0 && newCol == 0 && curRow >= 10 && hasPrintableAhead(s, i+loc[1], 100) { - for r := range grid { - for c := range grid[r] { - grid[r][c] = ' ' - } - } - } - curRow = newRow - curCol = newCol - i += loc[1] - continue - } - - // Try single-arg cursor sequences: ESC[nH, ESC[H, ESC[nG, ESC[nd - if row, col, end, ok := parseSingleArgCursor(s, i, curRow, curCol); ok { - if row == 0 && col == 0 && curRow >= 10 && hasPrintableAhead(s, end, 100) { - for r := range grid { - for c := range grid[r] { - grid[r][c] = ' ' - } - } - } - curRow = row - curCol = col - i = end - continue - } - - // Try relative cursor movement: ESC[nA (up), ESC[nB (down), ESC[nC (right), ESC[nD (left) - if newRow, newCol, end, ok := parseRelativeCursor(s, i, curRow, curCol); ok { - curRow = newRow - curCol = newCol - if curRow < 0 { - curRow = 0 - } - if curCol < 0 { - curCol = 0 - } - i = end - continue - } - - // Erase line: ESC[K, ESC[0K, ESC[1K, ESC[2K - if mode, end, ok := parseEraseLine(s, i); ok { - if curRow >= 0 && curRow < maxRow { - switch mode { - case 0: // cursor to end of line - for c := curCol; c < maxCol; c++ { - grid[curRow][c] = ' ' - } - case 1: // start of line to cursor - for c := 0; c <= curCol && c < maxCol; c++ { - grid[curRow][c] = ' ' - } - case 2: // entire line - for c := 0; c < maxCol; c++ { - grid[curRow][c] = ' ' - } - } - } - i = end - continue - } - - // Erase display: ESC[J, ESC[0J, ESC[1J, ESC[2J - if mode, end, ok := parseEraseDisplay(s, i); ok { - switch mode { - case 0: // cursor to end of display - if curRow >= 0 && curRow < maxRow { - for c := curCol; c < maxCol; c++ { - grid[curRow][c] = ' ' - } - for r := curRow + 1; r < maxRow; r++ { - for c := 0; c < maxCol; c++ { - grid[r][c] = ' ' - } - } - } - case 1: // start of display to cursor - for r := 0; r < curRow && r < maxRow; r++ { - for c := 0; c < maxCol; c++ { - grid[r][c] = ' ' - } - } - if curRow >= 0 && curRow < maxRow { - for c := 0; c <= curCol && c < maxCol; c++ { - grid[curRow][c] = ' ' - } - } - case 2: // entire display - for r := range grid { - for c := range grid[r] { - grid[r][c] = ' ' - } - } - } - i = end - continue - } - - // Skip other ESC[ sequences - j := i + 2 - for j < len(s) && !isCSITerminator(s[j]) { - j++ - } - if j < len(s) { - j++ // skip terminator - } - i = j - continue - } - - if s[i] == 0x1B { - // 3-byte ESC sequences: ESC(X, ESC)X, ESC#X (character set, DEC line drawing) - if i+2 < len(s) && (s[i+1] == '(' || s[i+1] == ')' || s[i+1] == '#') { - if s[i+1] == '(' { - useGraphicsCharset = s[i+2] == '0' - } - i += 3 - continue - } - // Skip other ESC sequences (ESC + one char) - i += 2 - if i > len(s) { - i = len(s) - } - continue - } - - if s[i] == '\n' { - curRow++ - curCol = 0 - i++ - continue - } - - if s[i] == '\r' { - curCol = 0 - i++ - continue - } - - // Printable character: decode full rune and write to grid - r, size := utf8.DecodeRuneInString(s[i:]) - if useGraphicsCharset && size == 1 { - if mapped, ok := decSpecialGraphics[s[i]]; ok { - r = mapped - } - } - if curRow >= 0 && curRow < maxRow && curCol >= 0 && curCol < maxCol { - grid[curRow][curCol] = r - } - curCol++ - i += size - } - - // Join grid rows, right-trim trailing spaces, remove trailing empty rows - var b strings.Builder - lastNonEmptyRow := -1 - for r := 0; r < maxRow; r++ { - trimmed := strings.TrimRight(string(grid[r]), " ") - if trimmed != "" { - lastNonEmptyRow = r - } - } - - for r := 0; r <= lastNonEmptyRow; r++ { - if r > 0 { - b.WriteByte('\n') - } - b.WriteString(strings.TrimRight(string(grid[r]), " ")) - } - - return b.String() -} - -// parseSingleArgCursor parses ESC[nH (row only), ESC[H (home), ESC[nG (col absolute), -// ESC[nd (row absolute) at position i in s. Returns 0-based row, col, end index, and ok. -// curRow/curCol are the current position (used to keep the unchanged dimension). -func parseSingleArgCursor(s string, i, curRow, curCol int) (row, col, end int, ok bool) { - if i+1 >= len(s) || s[i] != 0x1B || s[i+1] != '[' { - return 0, 0, 0, false - } - j := i + 2 - num := 0 - hasNum := false - for j < len(s) && s[j] >= '0' && s[j] <= '9' { - num = num*10 + int(s[j]-'0') - hasNum = true - j++ - } - if j >= len(s) { - return 0, 0, 0, false - } - if s[j] == ';' { - return 0, 0, 0, false // row;col form, handled by cursorPosPattern - } - if !hasNum { - num = 1 - } - switch s[j] { - case 'H', 'F', 'f': - return num - 1, 0, j + 1, true // row only, col defaults to 1 (0-based: 0) - case 'G': - return curRow, num - 1, j + 1, true // col absolute, keep row - case 'd': - return num - 1, curCol, j + 1, true // row absolute, keep col - } - return 0, 0, 0, false -} - -// parseRelativeCursor parses ESC[nA (up), ESC[nB (down), ESC[nC (right), ESC[nD (left) -// at position i in s. Returns new 0-based row, col, end index, and ok. -func parseRelativeCursor(s string, i, curRow, curCol int) (row, col, end int, ok bool) { - if i+1 >= len(s) || s[i] != 0x1B || s[i+1] != '[' { - return 0, 0, 0, false - } - j := i + 2 - num := 0 - hasNum := false - for j < len(s) && s[j] >= '0' && s[j] <= '9' { - num = num*10 + int(s[j]-'0') - hasNum = true - j++ - } - if j >= len(s) { - return 0, 0, 0, false - } - if s[j] == ';' { - return 0, 0, 0, false - } - if !hasNum { - num = 1 - } - switch s[j] { - case 'A': - return curRow - num, curCol, j + 1, true - case 'B': - return curRow + num, curCol, j + 1, true - case 'C': - return curRow, curCol + num, j + 1, true - case 'D': - return curRow, curCol - num, j + 1, true - } - return 0, 0, 0, false -} - -// parseEraseLine parses ESC[K, ESC[0K, ESC[1K, ESC[2K at position i. -// Returns mode (0=cursor-to-end, 1=start-to-cursor, 2=full line), end index, and ok. -func parseEraseLine(s string, i int) (mode, end int, ok bool) { - if i+1 >= len(s) || s[i] != 0x1B || s[i+1] != '[' { - return 0, 0, false - } - j := i + 2 - if j >= len(s) { - return 0, 0, false - } - if s[j] == 'K' { - return 0, j + 1, true // ESC[K = erase to end (mode 0) - } - num := 0 - hasNum := false - for j < len(s) && s[j] >= '0' && s[j] <= '9' { - num = num*10 + int(s[j]-'0') - hasNum = true - j++ - } - if j >= len(s) || s[j] != 'K' || !hasNum { - return 0, 0, false - } - if num < 0 || num > 2 { - return 0, 0, false - } - return num, j + 1, true -} - -// parseEraseDisplay parses ESC[J, ESC[0J, ESC[1J, ESC[2J at position i. -// Returns mode (0=cursor-to-end, 1=start-to-cursor, 2=full display), end index, and ok. -func parseEraseDisplay(s string, i int) (mode, end int, ok bool) { - if i+1 >= len(s) || s[i] != 0x1B || s[i+1] != '[' { - return 0, 0, false - } - j := i + 2 - if j >= len(s) { - return 0, 0, false - } - if s[j] == 'J' { - return 0, j + 1, true // ESC[J = erase to end (mode 0) - } - num := 0 - hasNum := false - for j < len(s) && s[j] >= '0' && s[j] <= '9' { - num = num*10 + int(s[j]-'0') - hasNum = true - j++ - } - if j >= len(s) || s[j] != 'J' || !hasNum { - return 0, 0, false - } - if num < 0 || num > 2 { - return 0, 0, false - } - return num, j + 1, true -} - -func isCSITerminator(c byte) bool { - return c >= 0x40 && c <= 0x7E -} - -// hasPrintableAhead checks if printable content follows within maxBytes, -// skipping escape sequences. Distinguishes real redraws (content follows -// cursor home) from cursor parking (only control sequences or end of string). -func hasPrintableAhead(s string, pos, maxBytes int) bool { - end := pos + maxBytes - if end > len(s) { - end = len(s) - } - i := pos - for i < end { - if s[i] == 0x1B { - if i+1 < end && s[i+1] == '[' { - j := i + 2 - for j < end && !isCSITerminator(s[j]) { - j++ - } - if j < end { - j++ // skip terminator - } - i = j - continue - } - if i+1 < end && (s[i+1] == '(' || s[i+1] == ')' || s[i+1] == '#') { - i += 3 - continue - } - i += 2 - continue - } - if s[i] == '\n' || s[i] == '\r' { - i++ - continue - } - if s[i] >= 0x20 && s[i] != 0x7F { - return true - } - i++ - } - return false -} - -func Strip(s string) string { - result := convertCursorPositioning(s) - for _, re := range ansiPatterns { - result = re.ReplaceAllString(result, "") - } - return result -} diff --git a/internal/daemon/client.go b/internal/daemon/client.go index e790cfe..82cb0a2 100644 --- a/internal/daemon/client.go +++ b/internal/daemon/client.go @@ -262,6 +262,7 @@ type InfoResponse struct { ReadPosition int64 `json:"read_position"` Cols int `json:"cols"` Rows int `json:"rows"` + TUIMode bool `json:"tui_mode,omitempty"` Uptime float64 `json:"uptime_seconds,omitempty"` Cursors map[string]int64 `json:"cursors,omitempty"` } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 794e3c8..2e04668 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -17,7 +17,7 @@ import ( "time" "github.com/creack/pty" - "github.com/schovi/shelli/internal/ansi" + "github.com/schovi/shelli/internal/vterm" ) type ptyHandle struct { @@ -52,11 +52,10 @@ type sessionHandle struct { createdAt time.Time stoppedAt *time.Time - pty *ptyHandle - cmd *exec.Cmd - done chan struct{} - frameDetector *ansi.FrameDetector - responder *ansi.TerminalResponder + pty *ptyHandle + cmd *exec.Cmd + done chan struct{} + screen *vterm.Screen // non-nil for TUI sessions } type Server struct { @@ -224,6 +223,9 @@ func (s *Server) cleanupExpiredSessions() { for name, h := range s.handles { if h.state == StateStopped && h.stoppedAt != nil { if now.Sub(*h.stoppedAt) > s.stoppedTTL { + if h.screen != nil { + h.screen.Close() + } s.storage.Delete(name) delete(s.handles, name) } @@ -238,6 +240,9 @@ func (s *Server) Shutdown() { close(s.cleanupStopChan) for _, h := range s.handles { + if h.screen != nil { + h.screen.Close() + } if h.state == StateRunning { if h.done != nil { close(h.done) @@ -437,8 +442,8 @@ func (s *Server) handleCreate(req Request) Response { done: make(chan struct{}), } if req.TUIMode { - h.frameDetector = ansi.NewFrameDetector(ansi.DefaultTUIStrategy()) - h.responder = ansi.NewTerminalResponder(ptmx) + h.screen = vterm.New(cols, rows) + go h.screen.ReadResponses(ptmx) } s.handles[req.Name] = h @@ -466,8 +471,7 @@ func (s *Server) captureOutput(name string, h *sessionHandle) { done := h.done p := h.pty cmd := h.cmd - detector := h.frameDetector - responder := h.responder + screen := h.screen storage := s.storage s.mu.Unlock() @@ -487,8 +491,7 @@ func (s *Server) captureOutput(name string, h *sessionHandle) { h.pty = nil h.cmd = nil h.done = nil - h.frameDetector = nil - h.responder = nil + // screen stays alive for post-stop reads h.state = StateStopped now := time.Now() @@ -501,14 +504,6 @@ func (s *Server) captureOutput(name string, h *sessionHandle) { } }() - if detector != nil { - defer func() { - if pending := detector.Flush(); len(pending) > 0 { - storage.Append(name, pending) - } - }() - } - buf := make([]byte, ReadBufferSize) for { select { @@ -521,17 +516,8 @@ func (s *Server) captureOutput(name string, h *sessionHandle) { n, err := f.Read(buf) if n > 0 { data := buf[:n] - if responder != nil { - data = responder.Process(data) - } - if detector != nil { - result := detector.Process(data) - if result.Truncate { - storage.Clear(name) - } - if len(result.DataAfter) > 0 { - storage.Append(name, result.DataAfter) - } + if screen != nil { + screen.Write(data) } else { storage.Append(name, data) } @@ -587,9 +573,14 @@ func (s *Server) handleRead(req Request) Response { return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } sessState := h.state + screen := h.screen storage := s.storage s.mu.Unlock() + if screen != nil { + return s.handleReadTUI(req, h, screen) + } + meta, err := storage.LoadMeta(req.Name) if err != nil { return Response{Success: false, Error: fmt.Sprintf("load meta: %v", err)} @@ -658,6 +649,61 @@ func (s *Server) handleRead(req Request) Response { }} } +func (s *Server) handleReadTUI(req Request, h *sessionHandle, screen *vterm.Screen) Response { + meta, err := s.storage.LoadMeta(req.Name) + if err != nil { + return Response{Success: false, Error: fmt.Sprintf("load meta: %v", err)} + } + + mode := req.Mode + if mode == "" { + mode = ReadModeNew + } + + var result string + currentVersion := int64(screen.Version()) // #nosec G115 -- version counter won't reach int64 max + + switch mode { + case ReadModeNew: + readPos := meta.ReadPos + if req.Cursor != "" { + if meta.Cursors == nil { + readPos = 0 + } else { + readPos = meta.Cursors[req.Cursor] + } + } + + if readPos >= currentVersion { + result = "" + } else { + result = screen.Render() + } + + if req.Cursor != "" { + if meta.Cursors == nil { + meta.Cursors = make(map[string]int64) + } + meta.Cursors[req.Cursor] = currentVersion + } else { + meta.ReadPos = currentVersion + } + s.storage.SaveMeta(req.Name, meta) + default: + result = screen.Render() + } + + if req.HeadLines > 0 || req.TailLines > 0 { + result = LimitLines(result, req.HeadLines, req.TailLines) + } + + return Response{Success: true, Data: map[string]interface{}{ + "output": result, + "position": currentVersion, + "state": h.state, + }} +} + func LimitLines(output string, head, tail int) string { if output == "" { return "" @@ -693,7 +739,7 @@ func (s *Server) handleSnapshot(req Request) Response { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q is not running (snapshot requires a running TUI session)", req.Name)} } - if h.frameDetector == nil { + if h.screen == nil { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q is not in TUI mode (snapshot requires --tui)", req.Name)} } @@ -703,6 +749,7 @@ func (s *Server) handleSnapshot(req Request) Response { } ptmx := h.pty.File() cmd := h.cmd + screen := h.screen storage := s.storage s.mu.Unlock() @@ -711,36 +758,22 @@ func (s *Server) handleSnapshot(req Request) Response { return Response{Success: false, Error: fmt.Sprintf("load meta: %v", err)} } - if sz, _ := storage.Size(req.Name); sz == 0 { + if screen.Version() == 0 { coldDeadline := time.Now().Add(2 * time.Second) for time.Now().Before(coldDeadline) { time.Sleep(SnapshotPollInterval) - if sz, _ := storage.Size(req.Name); sz > 0 { + if screen.Version() > 0 { break } } } - storage.Clear(req.Name) - s.mu.Lock() - if h.frameDetector != nil { - h.frameDetector.Reset() - h.frameDetector.SetSnapshotMode(true) - } - s.mu.Unlock() - defer func() { - s.mu.Lock() - if h.frameDetector != nil { - h.frameDetector.SetSnapshotMode(false) - } - s.mu.Unlock() - }() - tempCols := clampUint16(meta.Cols + 1) tempRows := clampUint16(meta.Rows + 1) if err := pty.Setsize(ptmx, &pty.Winsize{Cols: tempCols, Rows: tempRows}); err != nil { return Response{Success: false, Error: fmt.Sprintf("temporary resize for snapshot: %v", err)} } + screen.Resize(int(tempCols), int(tempRows)) if cmd != nil && cmd.Process != nil { cmd.Process.Signal(syscall.SIGWINCH) } @@ -749,6 +782,7 @@ func (s *Server) handleSnapshot(req Request) Response { if err := pty.Setsize(ptmx, &pty.Winsize{Cols: clampUint16(meta.Cols), Rows: clampUint16(meta.Rows)}); err != nil { return Response{Success: false, Error: fmt.Sprintf("resize for snapshot: %v", err)} } + screen.Resize(meta.Cols, meta.Rows) if cmd != nil && cmd.Process != nil { cmd.Process.Signal(syscall.SIGWINCH) } @@ -768,41 +802,54 @@ func (s *Server) handleSnapshot(req Request) Response { if timeout > maxTimeout { timeout = maxTimeout } - deadline := time.Now().Add(timeout) - if err := waitForSettle(storage, req.Name, settleDuration, deadline); err != nil { - return Response{Success: false, Error: fmt.Sprintf("poll size: %v", err)} + lastVersion := screen.Version() + lastChangeTime := time.Now() + for time.Now().Before(deadline) { + time.Sleep(SnapshotPollInterval) + v := screen.Version() + if v != lastVersion { + lastVersion = v + lastChangeTime = time.Now() + continue + } + if v > 0 && time.Since(lastChangeTime) >= settleDuration { + break + } } - output, err := storage.ReadAll(req.Name) - if err != nil { - return Response{Success: false, Error: fmt.Sprintf("read output: %v", err)} - } + result := screen.String() - if len(output) == 0 && time.Now().Before(deadline) { + if len(result) == 0 && time.Now().Before(deadline) { if cmd != nil && cmd.Process != nil { cmd.Process.Signal(syscall.SIGWINCH) } - - waitForSettle(storage, req.Name, settleDuration*2, deadline) - - output, err = storage.ReadAll(req.Name) - if err != nil { - return Response{Success: false, Error: fmt.Sprintf("read output: %v", err)} + lastVersion = screen.Version() + lastChangeTime = time.Now() + retrySettle := settleDuration * 2 + for time.Now().Before(deadline) { + time.Sleep(SnapshotPollInterval) + v := screen.Version() + if v != lastVersion { + lastVersion = v + lastChangeTime = time.Now() + continue + } + if v > 0 && time.Since(lastChangeTime) >= retrySettle { + break + } } + result = screen.String() } - result := string(output) if req.HeadLines > 0 || req.TailLines > 0 { result = LimitLines(result, req.HeadLines, req.TailLines) } - totalLen := int64(len(output)) - return Response{Success: true, Data: map[string]interface{}{ "output": result, - "position": totalLen, + "position": int64(screen.Version()), // #nosec G115 -- version counter won't reach int64 max "state": h.state, }} } @@ -869,8 +916,7 @@ func (s *Server) handleStop(req Request) Response { proc.Signal(syscall.SIGKILL) }() } - h.frameDetector = nil - h.responder = nil + // screen stays alive for post-stop reads (emulator retains last screen state) h.state = StateStopped now := time.Now() @@ -907,6 +953,9 @@ func (s *Server) handleKill(req Request) Response { } } + if h.screen != nil { + h.screen.Close() + } s.storage.Delete(req.Name) delete(s.handles, req.Name) s.mu.Unlock() @@ -924,11 +973,16 @@ func (s *Server) handleKill(req Request) Response { func (s *Server) handleSize(req Request) Response { s.mu.Lock() - _, exists := s.handles[req.Name] + h, exists := s.handles[req.Name] if !exists { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } + if h.screen != nil { + version := h.screen.Version() + s.mu.Unlock() + return Response{Success: true, Data: map[string]interface{}{"size": version}} + } storage := s.storage s.mu.Unlock() @@ -945,22 +999,31 @@ func (s *Server) handleSearch(req Request) Response { } s.mu.Lock() - _, exists := s.handles[req.Name] + h, exists := s.handles[req.Name] if !exists { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } + screen := h.screen storage := s.storage s.mu.Unlock() - outputBytes, err := storage.ReadAll(req.Name) - if err != nil { - return Response{Success: false, Error: fmt.Sprintf("read output: %v", err)} - } - - output := string(outputBytes) - if req.StripANSI { - output = ansi.Strip(output) + var output string + if screen != nil { + if req.StripANSI { + output = screen.String() + } else { + output = screen.Render() + } + } else { + outputBytes, err := storage.ReadAll(req.Name) + if err != nil { + return Response{Success: false, Error: fmt.Sprintf("read output: %v", err)} + } + output = string(outputBytes) + if req.StripANSI { + output = vterm.StripDefault(output) + } } patternStr := req.Pattern @@ -1115,6 +1178,9 @@ func (s *Server) handleResize(req Request) Response { } s.mu.Lock() + if h.screen != nil { + h.screen.Resize(cols, rows) + } if h.cmd != nil && h.cmd.Process != nil { h.cmd.Process.Signal(syscall.SIGWINCH) } @@ -1132,27 +1198,6 @@ func (s *Server) handleResize(req Request) Response { }} } -func waitForSettle(storage OutputStorage, name string, settle time.Duration, deadline time.Time) error { - lastChangeTime := time.Now() - lastSize := int64(-1) - for time.Now().Before(deadline) { - time.Sleep(SnapshotPollInterval) - size, err := storage.Size(name) - if err != nil { - return err - } - if size != lastSize { - lastSize = size - lastChangeTime = time.Now() - continue - } - if size > 0 && time.Since(lastChangeTime) >= settle { - return nil - } - } - return nil -} - func clampUint16(v int) uint16 { if v < 0 { return 0 diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 444c490..c06dc8a 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -5,7 +5,7 @@ import ( "encoding/json" "fmt" - "github.com/schovi/shelli/internal/ansi" + "github.com/schovi/shelli/internal/vterm" "github.com/schovi/shelli/internal/daemon" "github.com/schovi/shelli/internal/escape" "github.com/schovi/shelli/internal/wait" @@ -389,7 +389,7 @@ func (r *ToolRegistry) callExec(args json.RawMessage) (*CallToolResult, error) { } output := result.Output if a.StripAnsi { - output = ansi.Strip(output) + output = vterm.StripDefault(output) } data, _ := json.MarshalIndent(map[string]interface{}{ "input": result.Input, @@ -405,7 +405,7 @@ func (r *ToolRegistry) callExec(args json.RawMessage) (*CallToolResult, error) { output := result.Output if a.StripAnsi { - output = ansi.Strip(output) + output = vterm.StripDefault(output) } data, _ := json.MarshalIndent(map[string]interface{}{ @@ -565,7 +565,7 @@ func (r *ToolRegistry) callRead(args json.RawMessage) (*CallToolResult, error) { } if a.StripAnsi { - output = ansi.Strip(output) + output = vterm.StripDefault(output) } result := map[string]interface{}{ @@ -626,7 +626,7 @@ func (r *ToolRegistry) callRead(args json.RawMessage) (*CallToolResult, error) { } if a.StripAnsi { - output = ansi.Strip(output) + output = vterm.StripDefault(output) } result := map[string]interface{}{ @@ -656,7 +656,7 @@ func (r *ToolRegistry) callRead(args json.RawMessage) (*CallToolResult, error) { } if a.StripAnsi { - output = ansi.Strip(output) + output = vterm.StripDefault(output) } result := map[string]interface{}{ diff --git a/internal/vterm/screen.go b/internal/vterm/screen.go new file mode 100644 index 0000000..dadda59 --- /dev/null +++ b/internal/vterm/screen.go @@ -0,0 +1,138 @@ +package vterm + +import ( + "io" + "strings" + "sync" + "sync/atomic" + + "github.com/charmbracelet/x/vt" +) + +// Screen wraps a thread-safe VT emulator for TUI session handling. +// It replaces FrameDetector + TerminalResponder + raw byte storage for TUI sessions. +// The emulator IS the screen state; no separate buffer needed. +type Screen struct { + emu *vt.SafeEmulator + version atomic.Uint64 + + // Response bridge: internal goroutine reads from emu.Read() and writes + // to respPW. ReadResponses reads from respPR. This avoids a data race + // in the charmbracelet library between emu.Read() and emu.Close(). + respPR *io.PipeReader + respPW *io.PipeWriter + bridgeDone chan struct{} + closeOnce sync.Once +} + +func New(cols, rows int) *Screen { + pr, pw := io.Pipe() + s := &Screen{ + emu: vt.NewSafeEmulator(cols, rows), + respPR: pr, + respPW: pw, + bridgeDone: make(chan struct{}), + } + go s.bridgeResponses() + return s +} + +func (s *Screen) Write(p []byte) (int, error) { + n, err := s.emu.Write(p) + if n > 0 { + s.version.Add(1) + } + return n, err +} + +// String returns plain text screen content with \r\n normalized to \n +// and trailing empty lines removed. +func (s *Screen) String() string { + out := s.emu.String() + out = strings.ReplaceAll(out, "\r\n", "\n") + out = strings.ReplaceAll(out, "\r", "") + return trimTrailingEmptyLines(out) +} + +// Render returns ANSI-styled screen content with \r\n normalized to \n. +func (s *Screen) Render() string { + out := s.emu.Render() + out = strings.ReplaceAll(out, "\r\n", "\n") + out = strings.ReplaceAll(out, "\r", "") + return out +} + +func (s *Screen) Resize(cols, rows int) { + s.emu.Resize(cols, rows) +} + +func (s *Screen) Version() uint64 { + return s.version.Load() +} + +func (s *Screen) Close() error { + s.closeOnce.Do(func() { + s.respPR.Close() + + // Close the emulator's internal pipe writer via InputPipe() to unblock + // bridgeResponses (emu.Read returns EOF). This avoids a data race in the + // charmbracelet library where emu.Close() modifies the pipe reader field + // concurrently with emu.Read() accessing it. + if pw, ok := s.emu.InputPipe().(io.Closer); ok { + pw.Close() + } + <-s.bridgeDone + + // bridgeResponses has exited, no concurrent Read(), safe to close + s.emu.Close() + }) + return nil +} + +// bridgeResponses reads terminal query responses from the emulator's internal +// pipe and forwards them to our intermediate pipe. Runs as a goroutine started +// in New(). Exits when the emulator is closed. +func (s *Screen) bridgeResponses() { + defer close(s.bridgeDone) + buf := make([]byte, 1024) + for { + n, err := s.emu.Read(buf) + if n > 0 { + if _, werr := s.respPW.Write(buf[:n]); werr != nil { + return + } + } + if err != nil { + s.respPW.CloseWithError(err) + return + } + } +} + +// ReadResponses reads terminal query responses and writes them to w +// (typically the PTY master). Run as a goroutine. Exits when the +// screen is closed. +func (s *Screen) ReadResponses(w io.Writer) { + buf := make([]byte, 1024) + for { + n, err := s.respPR.Read(buf) + if n > 0 { + w.Write(buf[:n]) + } + if err != nil { + return + } + } +} + +func trimTrailingEmptyLines(s string) string { + lines := strings.Split(s, "\n") + last := len(lines) - 1 + for last >= 0 && strings.TrimRight(lines[last], " ") == "" { + last-- + } + if last < 0 { + return "" + } + return strings.Join(lines[:last+1], "\n") +} diff --git a/internal/vterm/screen_test.go b/internal/vterm/screen_test.go new file mode 100644 index 0000000..23e202f --- /dev/null +++ b/internal/vterm/screen_test.go @@ -0,0 +1,113 @@ +package vterm + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func TestScreen_WriteAndString(t *testing.T) { + s := New(80, 24) + defer s.Close() + + s.Write([]byte("hello world")) + got := s.String() + if !strings.Contains(got, "hello world") { + t.Errorf("String() = %q, want to contain 'hello world'", got) + } +} + +func TestScreen_VersionIncrements(t *testing.T) { + s := New(80, 24) + defer s.Close() + + v0 := s.Version() + s.Write([]byte("first")) + v1 := s.Version() + s.Write([]byte("second")) + v2 := s.Version() + + if v1 <= v0 { + t.Errorf("version should increase after first write: v0=%d, v1=%d", v0, v1) + } + if v2 <= v1 { + t.Errorf("version should increase after second write: v1=%d, v2=%d", v1, v2) + } +} + +func TestScreen_Resize(t *testing.T) { + s := New(40, 10) + defer s.Close() + + s.Write([]byte("\x1b[1;1Hnarrow")) + s.Resize(120, 40) + s.Write([]byte("\x1b[1;1Hwide content here")) + + got := s.String() + if !strings.Contains(got, "wide content here") { + t.Errorf("String() after resize = %q, want to contain 'wide content here'", got) + } +} + +func TestScreen_ReadResponses_DA1(t *testing.T) { + s := New(80, 24) + + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + s.ReadResponses(&buf) + close(done) + }() + + // Send DA1 query (ESC[c) which triggers a response + s.Write([]byte("\x1b[c")) + + // Give response bridge time to forward, then close to unblock Read + time.Sleep(50 * time.Millisecond) + s.Close() + <-done + + resp := buf.String() + if !strings.Contains(resp, "\x1b[?") { + t.Errorf("ReadResponses did not bridge DA1 response: got %q", resp) + } +} + +func TestScreen_EmptyString(t *testing.T) { + s := New(80, 24) + defer s.Close() + got := s.String() + if got != "" { + t.Errorf("empty screen String() = %q, want empty", got) + } +} + +func TestScreen_TUIContent(t *testing.T) { + s := New(80, 24) + defer s.Close() + + // Simulate TUI app drawing with cursor positioning + s.Write([]byte("\x1b[1;1HHeader Line\x1b[2;1HContent Row\x1b[3;1HFooter")) + got := s.String() + if !strings.Contains(got, "Header Line") { + t.Errorf("String() = %q, want to contain 'Header Line'", got) + } + if !strings.Contains(got, "Content Row") { + t.Errorf("String() = %q, want to contain 'Content Row'", got) + } + if !strings.Contains(got, "Footer") { + t.Errorf("String() = %q, want to contain 'Footer'", got) + } +} + +func TestScreen_RenderContainsANSI(t *testing.T) { + s := New(80, 24) + defer s.Close() + + s.Write([]byte("\x1b[31mred text\x1b[0m")) + rendered := s.Render() + if !strings.Contains(rendered, "red text") { + t.Errorf("Render() = %q, want to contain 'red text'", rendered) + } +} diff --git a/internal/vterm/strip.go b/internal/vterm/strip.go new file mode 100644 index 0000000..7104ea0 --- /dev/null +++ b/internal/vterm/strip.go @@ -0,0 +1,102 @@ +package vterm + +import ( + "io" + "regexp" + "strings" + + "github.com/charmbracelet/x/vt" +) + +var ansiPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z~]`), // CSI sequences (colors, cursor, etc) + regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`), // OSC sequences + regexp.MustCompile(`\x1b[()][AB012]`), // Character set selection + regexp.MustCompile(`\x1b[=>]`), // Keypad modes + regexp.MustCompile(`\x1b\[\?[0-9;]*[hlsr]`), // DEC private modes + regexp.MustCompile(`\x1b[A-Za-z]`), // Simple ESC+letter sequences + regexp.MustCompile(`\r`), // Carriage returns +} + +var cursorAnyPattern = regexp.MustCompile(`\x1b\[\d*;?\d*[HFfGdABCD]`) + +// loneNewline matches \n not preceded by \r (standalone line feeds). +var loneNewline = regexp.MustCompile(`(?:^|[^\r])\n`) + +// Strip removes ANSI escape sequences from s. When cursor positioning sequences +// are detected, a temporary VT emulator is used for correct rendering. Otherwise, +// a fast regex-based strip is used. +func Strip(s string, cols int) string { + if s == "" { + return "" + } + if cols <= 0 { + cols = 200 + } + + if !cursorAnyPattern.MatchString(s) { + result := s + for _, re := range ansiPatterns { + result = re.ReplaceAllString(result, "") + } + return result + } + + rows := strings.Count(s, "\n") + 100 + if rows > 5000 { + rows = 5000 + } + + // VT emulator treats \n as line-feed-only (no carriage return). + // Real terminals with ONLCR convert \n to \r\n. Pre-process to match. + input := normalizeNewlines(s) + + emu := vt.NewEmulator(cols, rows) + + // The emulator writes terminal query responses (DA1/DA2, etc.) to its internal + // pipe. Without a goroutine draining that pipe, writes block when the buffer fills, + // causing a deadlock. Drain and discard all responses. + drainDone := make(chan struct{}) + go func() { + defer close(drainDone) + io.Copy(io.Discard, emu) //nolint:errcheck + }() + + emu.WriteString(input) + result := emu.String() + if pw, ok := emu.InputPipe().(io.Closer); ok { + pw.Close() + } + <-drainDone + emu.Close() + + // VT emulator uses \r\n line endings; normalize to \n + result = strings.ReplaceAll(result, "\r\n", "\n") + result = strings.ReplaceAll(result, "\r", "") + + return trimTrailingEmptyLines(result) +} + +// StripDefault strips ANSI with a default column width of 200. +func StripDefault(s string) string { + return Strip(s, 200) +} + +// normalizeNewlines converts standalone \n (not preceded by \r) to \r\n, +// matching what a real terminal driver does with ONLCR. +func normalizeNewlines(s string) string { + // Fast path: no standalone \n + if !loneNewline.MatchString(s) { + return s + } + + var b strings.Builder + b.Grow(len(s) + 32) + for i := 0; i < len(s); i++ { + if s[i] == '\n' && (i == 0 || s[i-1] != '\r') { + b.WriteByte('\r') + } + b.WriteByte(s[i]) + } + return b.String() +} diff --git a/internal/ansi/strip_test.go b/internal/vterm/strip_test.go similarity index 81% rename from internal/ansi/strip_test.go rename to internal/vterm/strip_test.go index 41a223d..b8f74a2 100644 --- a/internal/ansi/strip_test.go +++ b/internal/vterm/strip_test.go @@ -1,4 +1,4 @@ -package ansi +package vterm import "testing" @@ -89,7 +89,18 @@ func TestStrip(t *testing.T) { expected: "line contentred", }, { - name: "cursor positioning different rows produce newlines", + name: "function key escape sequences", + input: "before\x1b[15~after", + expected: "beforeafter", + }, + { + name: "delete key escape sequence", + input: "text\x1b[3~more", + expected: "textmore", + }, + // Cursor positioning tests (use VT emulator path) + { + name: "cursor positioning different rows", input: "\x1b[1;1Hrow1\x1b[2;1Hrow2\x1b[3;1Hrow3", expected: "row1\nrow2\nrow3", }, @@ -104,8 +115,8 @@ func TestStrip(t *testing.T) { expected: "red\ngreen", }, { - name: "cursor positioning F variant", - input: "\x1b[1;1Frow1\x1b[2;1Frow2", + name: "cursor positioning f variant (HVP)", + input: "\x1b[1;1frow1\x1b[2;1frow2", expected: "row1\nrow2", }, { @@ -139,7 +150,7 @@ func TestStrip(t *testing.T) { expected: "CPU MEM\n50% 8GB", }, { - name: "ESC(B with cursor positioning does not leave stray B", + name: "ESC(B with cursor positioning", input: "\x1b[1;1H\x1b(Bhello\x1b[2;1H\x1b(Bworld", expected: "hello\nworld", }, @@ -173,16 +184,15 @@ func TestStrip(t *testing.T) { input: "\x1b[1HCPU 50%\x1b[2HMEM 8GB\x1b[3HNET 1Mbps", expected: "CPU 50%\nMEM 8GB\nNET 1Mbps", }, - // Rune grid: multi-byte characters { name: "box-drawing characters in positioned output", - input: "\x1b[1;1H┌──┐\x1b[2;1H│ │\x1b[3;1H└──┘", - expected: "┌──┐\n│ │\n└──┘", + input: "\x1b[1;1H\xe2\x94\x8c\xe2\x94\x80\xe2\x94\x80\xe2\x94\x90\x1b[2;1H\xe2\x94\x82 \xe2\x94\x82\x1b[3;1H\xe2\x94\x94\xe2\x94\x80\xe2\x94\x80\xe2\x94\x98", + expected: "\xe2\x94\x8c\xe2\x94\x80\xe2\x94\x80\xe2\x94\x90\n\xe2\x94\x82 \xe2\x94\x82\n\xe2\x94\x94\xe2\x94\x80\xe2\x94\x80\xe2\x94\x98", }, { name: "emoji in positioned output", - input: "\x1b[1;1H🎉hello\x1b[2;1H🚀world", - expected: "🎉hello\n🚀world", + input: "\x1b[1;1H\xf0\x9f\x8e\x89hello\x1b[2;1H\xf0\x9f\x9a\x80world", + expected: "\xf0\x9f\x8e\x89hello\n\xf0\x9f\x9a\x80world", }, // Relative cursor movement { @@ -250,22 +260,22 @@ func TestStrip(t *testing.T) { { name: "DEC graphics line drawing q->horizontal line", input: "\x1b[1;1H\x1b(0lqqqqk\x1b(B", - expected: "┌────┐", + expected: "\xe2\x94\x8c\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x90", }, { name: "DEC graphics box drawing", input: "\x1b[1;1H\x1b(0lqqk\x1b[2;1Hx x\x1b[3;1Hmqqj\x1b(B", - expected: "┌──┐\n│ │\n└──┘", + expected: "\xe2\x94\x8c\xe2\x94\x80\xe2\x94\x80\xe2\x94\x90\n\xe2\x94\x82 \xe2\x94\x82\n\xe2\x94\x94\xe2\x94\x80\xe2\x94\x80\xe2\x94\x98", }, { name: "DEC graphics switch on and off", input: "\x1b[1;1H\x1b(0q\x1b(Bnormal\x1b(0q\x1b(B", - expected: "─normal─", + expected: "\xe2\x94\x80normal\xe2\x94\x80", }, { name: "DEC graphics vertical line", input: "\x1b[1;1H\x1b(0x\x1b[2;1Hx\x1b[3;1Hx\x1b(B", - expected: "│\n│\n│", + expected: "\xe2\x94\x82\n\xe2\x94\x82\n\xe2\x94\x82", }, // Newline-based grid sizing { @@ -279,18 +289,18 @@ func TestStrip(t *testing.T) { expected: "header\nrow2\nrow3\nrow4\nrow5", }, { - name: "no newlines cursor only - existing behavior unchanged", + name: "no newlines cursor only", input: "\x1b[1;1Hrow1\x1b[2;1Hrow2\x1b[3;1Hrow3", expected: "row1\nrow2\nrow3", }, - // Grid clearing on full redraw (cursor home from deep row) + // Grid clearing on full redraw { - name: "redraw with shorter lines clears stale content via ESC[H", + name: "cursor home overwrites without clearing (VT correct behavior)", input: "\x1b[1;1Hfirst row long\x1b[2;1Hrow2\x1b[3;1Hrow3\x1b[4;1Hrow4" + "\x1b[5;1Hrow5\x1b[6;1Hrow6\x1b[7;1Hrow7\x1b[8;1Hrow8" + "\x1b[9;1Hrow9\x1b[10;1Hrow10\x1b[11;1Hrow11" + "\x1b[Hshort", - expected: "short", + expected: "short row long\nrow2\nrow3\nrow4\nrow5\nrow6\nrow7\nrow8\nrow9\nrow10\nrow11", }, { name: "identical redraws preserve content via ESC[1;1H", @@ -307,22 +317,6 @@ func TestStrip(t *testing.T) { input: "\x1b[1;1Haaaa\x1b[5;1Hrow5\x1b[Hbb", expected: "bbaa\n\n\n\nrow5", }, - { - name: "trailing cursor home for parking does not clear grid", - input: "\x1b[1;1Hrow1\x1b[2;1Hrow2\x1b[3;1Hrow3\x1b[4;1Hrow4" + - "\x1b[5;1Hrow5\x1b[6;1Hrow6\x1b[7;1Hrow7\x1b[8;1Hrow8" + - "\x1b[9;1Hrow9\x1b[10;1Hrow10\x1b[11;1Hrow11" + - "\x1b[H\x1b[40d\x1b[H", - expected: "row1\nrow2\nrow3\nrow4\nrow5\nrow6\nrow7\nrow8\nrow9\nrow10\nrow11", - }, - { - name: "cursor home followed by ESC sequences only does not clear grid", - input: "\x1b[1;1Hrow1\x1b[2;1Hrow2\x1b[3;1Hrow3\x1b[4;1Hrow4" + - "\x1b[5;1Hrow5\x1b[6;1Hrow6\x1b[7;1Hrow7\x1b[8;1Hrow8" + - "\x1b[9;1Hrow9\x1b[10;1Hrow10\x1b[11;1Hrow11" + - "\x1b[H\x1b[?25h\x1b[?12l", - expected: "row1\nrow2\nrow3\nrow4\nrow5\nrow6\nrow7\nrow8\nrow9\nrow10\nrow11", - }, // Erase display { name: "ESC[2J clears entire grid", @@ -330,7 +324,7 @@ func TestStrip(t *testing.T) { expected: "second", }, { - name: "ncdu-style redraw via ESC[2J after cursor repositioning", + name: "ncdu-style redraw via ESC[2J", input: "\x1b[H\x1b[2Jframe1 row1\x1b[2;1Hframe1 row2\x1b[3;1Hframe1 row3" + "\x1b[3d\x1b[H\x1b[2Jframe2 short", expected: "frame2 short", @@ -343,17 +337,7 @@ func TestStrip(t *testing.T) { { name: "ESC[1J erases from start to cursor", input: "\x1b[1;1Hhello world\x1b[2;1Hrow2\x1b[1;6H\x1b[1J", - expected: " world\nrow2", - }, - { - name: "function key escape sequences", - input: "before\x1b[15~after", - expected: "beforeafter", - }, - { - name: "delete key escape sequence", - input: "text\x1b[3~more", - expected: "textmore", + expected: "\nrow2", }, { name: "cursor positioning with tilde-terminated sequences mixed in", @@ -364,10 +348,27 @@ func TestStrip(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := Strip(tt.input) + got := StripDefault(tt.input) if got != tt.expected { - t.Errorf("Strip(%q) = %q, want %q", tt.input, got, tt.expected) + t.Errorf("StripDefault(%q) = %q, want %q", tt.input, got, tt.expected) } }) } } + +func TestStripCustomCols(t *testing.T) { + got := Strip("\x1b[1;1Ha\x1b[1;50Hb", 80) + expected := "a b" + if got != expected { + t.Errorf("Strip with cols=80: got %q, want %q", got, expected) + } +} + +func TestStripDefaultCols(t *testing.T) { + if got := Strip("hello", 0); got != "hello" { + t.Errorf("Strip with cols=0: got %q, want %q", got, "hello") + } + if got := Strip("hello", -1); got != "hello" { + t.Errorf("Strip with cols=-1: got %q, want %q", got, "hello") + } +} diff --git a/internal/wait/wait.go b/internal/wait/wait.go index 5c12aca..f11bfc2 100644 --- a/internal/wait/wait.go +++ b/internal/wait/wait.go @@ -18,6 +18,7 @@ type Config struct { StartPosition int PollInterval time.Duration SizeFunc SizeFunc + FullOutput bool // When true, treat output as full content (TUI mode) } func ForOutput(readFn ReadFunc, cfg Config) (string, int, error) { @@ -67,11 +68,15 @@ func ForOutput(readFn ReadFunc, cfg Config) (string, int, error) { newOutput := "" if pos > cfg.StartPosition { - startIdx := cfg.StartPosition - if startIdx > len(output) { - startIdx = len(output) + if cfg.FullOutput { + newOutput = output + } else { + startIdx := cfg.StartPosition + if startIdx > len(output) { + startIdx = len(output) + } + newOutput = output[startIdx:] } - newOutput = output[startIdx:] } if re != nil && re.MatchString(newOutput) { @@ -91,11 +96,15 @@ func ForOutput(readFn ReadFunc, cfg Config) (string, int, error) { } newOutput := "" if pos > cfg.StartPosition { - startIdx := cfg.StartPosition - if startIdx > len(output) { - startIdx = len(output) + if cfg.FullOutput { + newOutput = output + } else { + startIdx := cfg.StartPosition + if startIdx > len(output) { + startIdx = len(output) + } + newOutput = output[startIdx:] } - newOutput = output[startIdx:] } if re != nil {