Skip to content

Commit 29eacc5

Browse files
authored
refactor: replace internal/ansi with charmbracelet/x/vt emulator (#9)
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)
1 parent 0ac25a7 commit 29eacc5

File tree

20 files changed

+729
-3263
lines changed

20 files changed

+729
-3263
lines changed

CLAUDE.md

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,24 +44,30 @@ shelli provides persistent interactive shell sessions via PTY-backed processes m
4444
- Commands: create, exec, send, read, list, stop, kill, search, cursor, version, daemon
4545

4646
**Utilities** (`internal/`)
47-
- `wait/`: Output polling with settle-time and pattern-matching modes
48-
- `ansi/`: ANSI escape code stripping, TUI frame detection, terminal query responses (see `docs/TUI.md` for details)
49-
- `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
50-
- `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.
51-
- `responder.go`: `TerminalResponder` intercepts DA1/DA2/Kitty queries and writes responses to PTY
47+
- `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.
48+
- `vterm/`: VT terminal emulator wrapper using `charmbracelet/x/vt` (see `docs/TUI.md` for details)
49+
- `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).
50+
- `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.
5251
- `escape/`: Escape sequence interpretation for raw mode
5352

5453
### Data Flow
5554

5655
```
5756
CLI/MCP → daemon.Client → Unix socket → daemon.Server → PTY → subprocess
5857
59-
OutputStorage
60-
├─ MemoryStorage (default)
61-
└─ FileStorage (persistent)
58+
┌─── TUI sessions ───┐
59+
│ vterm.Screen │
60+
│ (VT emulator IS │
61+
│ the screen state) │
62+
└─────────────────────┘
63+
┌─── Non-TUI sessions ┐
64+
│ OutputStorage │
65+
│ ├─ MemoryStorage │
66+
│ └─ FileStorage │
67+
└──────────────────────┘
6268
```
6369

64-
PTY sessions accessible via both MCP and CLI, with optional size-based poll optimization. Additional endpoints: `size` (lightweight buffer size check for poll optimization)
70+
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)
6571

6672
### Key Design Decisions
6773

@@ -73,11 +79,11 @@ PTY sessions accessible via both MCP and CLI, with optional size-based poll opti
7379
- **Stop vs Kill**: `stop` terminates process but keeps output accessible; `kill` deletes everything
7480
- **Session states**: Sessions can be "running" or "stopped" with timestamp tracking
7581
- **TTL cleanup**: Optional auto-deletion of stopped sessions via `--stopped-ttl`
76-
- **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
77-
- **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.
78-
- **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.
79-
- **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).
80-
- **Size endpoint**: Lightweight `size` action returns output buffer size without transferring content. Used by wait polling to skip expensive full reads when nothing changed.
82+
- **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).
83+
- **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.
84+
- **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.
85+
- **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).
86+
- **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.
8187

8288
## Claude Plugin
8389

@@ -93,7 +99,7 @@ Skills in `.claude/skills/`:
9399
- **Linting**: `.golangci.yml` - golangci-lint config with gosec, gocritic, revive
94100
- **CI/CD**: `.github/workflows/ci.yml` - lint, test, build, security on push/PR
95101
- **Releases**: `.goreleaser.yml` - multi-platform binaries, Homebrew tap update on tags
96-
- **Tests**: `internal/ansi/strip_test.go`, `internal/ansi/clear_test.go`, `internal/wait/wait_test.go`, `internal/daemon/limitlines_test.go`
102+
- **Tests**: `internal/vterm/strip_test.go`, `internal/vterm/screen_test.go`, `internal/wait/wait_test.go`, `internal/daemon/limitlines_test.go`
97103
- **Version**: `shelli version` - build info injected by goreleaser
98104

99105
## Documentation Sync Rules

cmd/exec.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"os"
77
"strings"
88

9-
"github.com/schovi/shelli/internal/ansi"
9+
"github.com/schovi/shelli/internal/vterm"
1010
"github.com/schovi/shelli/internal/daemon"
1111
"github.com/spf13/cobra"
1212
)
@@ -83,7 +83,7 @@ func runExec(cmd *cobra.Command, args []string) error {
8383

8484
output := result.Output
8585
if execStripAnsiFlag {
86-
output = ansi.Strip(output)
86+
output = vterm.StripDefault(output)
8787
}
8888

8989
if execJsonFlag {

cmd/read.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"syscall"
1010
"time"
1111

12-
"github.com/schovi/shelli/internal/ansi"
12+
"github.com/schovi/shelli/internal/vterm"
1313
"github.com/schovi/shelli/internal/daemon"
1414
"github.com/schovi/shelli/internal/wait"
1515
"github.com/spf13/cobra"
@@ -166,7 +166,7 @@ func runRead(cmd *cobra.Command, args []string) error {
166166
}
167167

168168
if readStripAnsiFlag {
169-
output = ansi.Strip(output)
169+
output = vterm.StripDefault(output)
170170
}
171171

172172
if readJsonFlag {
@@ -199,7 +199,7 @@ func runReadSnapshot(name string) error {
199199
}
200200

201201
if readStripAnsiFlag {
202-
output = ansi.Strip(output)
202+
output = vterm.StripDefault(output)
203203
}
204204

205205
if readJsonFlag {
@@ -253,7 +253,7 @@ func runReadFollow(name string) error {
253253
}
254254
if output != "" {
255255
if readStripAnsiFlag {
256-
output = ansi.Strip(output)
256+
output = vterm.StripDefault(output)
257257
}
258258
fmt.Print(output)
259259
}

cmd/search.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66

7-
"github.com/schovi/shelli/internal/ansi"
7+
"github.com/schovi/shelli/internal/vterm"
88
"github.com/schovi/shelli/internal/daemon"
99
"github.com/spf13/cobra"
1010
)
@@ -97,21 +97,21 @@ func runSearch(cmd *cobra.Command, args []string) error {
9797
for j, line := range match.Before {
9898
display := line
9999
if searchStripAnsiFlag {
100-
display = ansi.Strip(line)
100+
display = vterm.StripDefault(line)
101101
}
102102
fmt.Printf("%4d: %s\n", startLine+j, display)
103103
}
104104

105105
display := match.Line
106106
if searchStripAnsiFlag {
107-
display = ansi.Strip(match.Line)
107+
display = vterm.StripDefault(match.Line)
108108
}
109109
fmt.Printf(">%3d: %s\n", match.LineNumber, display)
110110

111111
for j, line := range match.After {
112112
display := line
113113
if searchStripAnsiFlag {
114-
display = ansi.Strip(line)
114+
display = vterm.StripDefault(line)
115115
}
116116
fmt.Printf("%4d: %s\n", match.LineNumber+1+j, display)
117117
}

0 commit comments

Comments
 (0)