Truecolor terminal backend (x/vt) + mouse support, rebased onto config/keymap + resizable panes - #17
Truecolor terminal backend (x/vt) + mouse support, rebased onto config/keymap + resizable panes#17citizen-123 wants to merge 19 commits into
Conversation
…e modes Two independent first-run/security defects found while smoke-testing on macOS. cmd/cli-capture/main.go opened <confDir>/cli-capture.log before ca.LoadOrCreate created confDir, and discarded the error. On a machine without ~/.cli-capture the os.Create failed silently, log.SetOutput never ran, and every log.Printf landed on the bubbletea alt-screen — defeating the intent stated in the comment directly above it. Move the open below ca.LoadOrCreate and fail loudly instead of discarding the error. The capture exports (capture.har, flagged.txt, flow-*.txt, flow-*.curl) and session.json hold Authorization headers and request bodies verbatim, but were written 0644. ca.key was already 0600, so this reads as an oversight. Narrow all five to 0600. os.MkdirAll does not chmod a directory that already exists, so a config dir the user created by hand stays 0755 and the mode on ca.key becomes the only thing guarding the captures. Chmod the dir to 0700 either way. Verified on darwin/arm64, Go 1.26.5: go build, go vet and go test ./... all exit 0. With ~/.cli-capture absent, a run now writes cli-capture.log and sprays nothing onto the TUI; with the dir pre-created 0755 it comes back 0700 and the exports land -rw-------.
vt10x packs a 24-bit color as r<<16|g<<8|b in the same uint32 space it uses for palette indices, so colorSGR's "16-255 is the xterm palette" branch swallowed every RGB color. An orange cell (255,100,0) came out as "38;5;16737280" — a palette index 16 million out of range. Terminals discard the parameter and fall back to the default foreground, which is why targets that use truecolor rendered as plain white text. Add the 256 <= c < 1<<24 branch. The existing colour test only asserted that some SGR escape survived, and a bogus index is still an escape, so it passed throughout; the new tests assert the actual parameters. One case stays wrong and is marked debt: an RGB triple that packs below 256 (near-black blues) is indistinguishable from a palette index in vt10x's encoding, and still renders as palette.
The tmux-style prefix was hardcoded to Ctrl+A, which collides with readline's beginning-of-line and with tmux's own default prefix. Since the leader is the one chord cli-capture takes away from the target app, it is exactly the key that should be movable. Add -leader, accepting ctrl+a … ctrl+z and ctrl+space. Across the ctrl+<letter> range a bubbletea KeyType is numerically the C0 byte that chord transmits, so one value drives both the match and the literal-passthrough write; a test pins that relationship in case bubbletea ever renumbers. Ctrl+Space and Ctrl+@ are the same NUL byte and resolve to one binding under the requested name. Three things had the old key baked in and would have made a rebind useless: the literal-passthrough write sent a hardcoded 0x01, the status line advertised Ctrl+A, and the help overlay's shortcut table did too. All three now follow the configured leader. A bad spec fails on stderr before the TUI takes the screen, naming the accepted forms. Verified end to end: with -leader ctrl+space, NUL+q quits at 7.1s, while Ctrl+A+q runs to the timeout untouched — the key is moved, not duplicated.
…erface The TUI is about to gain mouse forwarding into the left pane's child PTY, but blindly writing mouse escapes into a child that never asked for them would just inject garbage keystrokes. vt10x already tracks the DEC private mouse modes (1000/1002/1003) and the SGR (1006) coordinate encoding as part of its terminal state, so expose both through two new Emulator methods rather than reaching into vt10x from the tui package. Screen, the line-oriented fallback emulator, has no mode state and always answers false.
Tool-call arguments and MCP payloads carry JSON-in-a-JSON-string, and system prompts/model output carry text with escaped newlines — both are common in the LLM/agent traffic this tool targets, and json.Indent correctly leaves string contents untouched, so both render as one unreadable escaped line today. colorizeJSON now recognizes a string VALUE (never a key) that decodes to embedded JSON or to text containing "\n", and expands it inline, indented under its key, recursively (capped at maxEmbedDepth — captured traffic is adversarial by construction, so the cap bounds render time/stack depth against a malicious or malformed payload rather than following it arbitrarily deep). Every line of expanded content is prefixed with a distinctly-styled marker (jsonExpandStyle, not a reuse of the existing semantic JSON styles) so it can never be mistaken for what was literally on the wire. This is render-time only: prettyJSON/colorizeJSON take a new `expand bool` and build a new string from the already-captured bytes — the stored Flow/Message bytes are never touched, and internal/export/ (HAR, flow-*.txt, curl) is untouched and stays byte-exact. wrapDetail's signature is unchanged; it now calls renderFlowDetail(f, width, true), so a future UI toggle can call it with expand=false without any exported-path impact.
…lit panes Enable cell-motion mouse reporting and route tea.MouseMsg through a new paneRects()/contentGrid() pair that both View() and the mouse handlers call, so hit-testing can never drift from what's actually drawn. Clicking a pane focuses it; clicking a flow row selects it (mirroring renderTraffic's own reserved-row accounting via the shared trafficRowLayout helper, extracted so the render path and the click path can't disagree); the wheel scrolls the open detail viewport or walks the flow-list selection with the same clamping j/k already use. Left-pane events only forward into the child PTY when the child emulator reports both mouse tracking and SGR (mode 1006) encoding are on — writing mouse escapes into an app that never asked for them would just inject garbage keystrokes, and legacy X10 encoding is out of scope.
Capturing the mouse takes over the terminal, which kills the operator's native text selection and copy-paste — there was no way back short of restarting cli-capture. Wire a leader "m" command that flips a mouseCapture flag and returns tea.EnableMouseCellMotion/tea.DisableMouse to re-issue the DECSET/DECRST sequences live, with a status line naming whatever leader is configured (never a hardcoded chord) so the operator can find their way back. Quitting was already safe: bubbletea's own shutdown path disables mouse reporting unconditionally before it restores the terminal.
… decoder encodeSGRMouse was derived by inverting bubbletea's own parse path, which can faithfully reproduce that decoder's quirks instead of the wire format. These expectations come from xterm's ctlseqs button-code definition instead, so the two derivations have to agree independently: button bits, the 4/8/16 modifier bits, the 32 motion bit, the 64 wheel range, the 128 extended-button range, M-versus-m finals, and 1-based coordinates. Includes the case that would be silently wrong rather than obviously broken: a wheel event must never pick up the motion bit, since 96 decodes as a drag and the child would act on the wrong event entirely.
…into the emulator hinshun/vt10x has been unmaintained since 2022 and packed 24-bit truecolor into the same numeric space as 256-color palette indices, so truecolor targets silently lost color. x/vt's Style carries a real color.Color per cell (color.RGBA vs ansi.IndexedColor), so Render now walks CellAt and uses Style.Diff to emit only the SGR delta between adjacent cells instead of the old hand-rolled sgr()/colorSGR(). x/vt's Read is pull-based (an io.Pipe) rather than vt10x's push-based WithWriter, so NewVT now starts a pump goroutine draining replies (and forwarded mouse bytes) into resp; Close stops it cleanly instead of leaking it. The pump always runs, even with resp==nil, since a query/mouse write into an undrained pipe blocks the emulator forever. x/vt's SendMouse already gates itself on the child having enabled a DECSET mouse mode, so the TUI's own MouseEnabled/MouseSGR checks and hand-rolled encodeSGRMouse are no longer needed: internal/tui now translates tea.MouseMsg into a neutral terminal.MouseEvent and lets Emulator.ForwardMouse do the rest, keeping bubbletea out of the terminal package and x/vt out of internal/tui.
Screen predates VT and had zero non-test callers — main.go has wired NewVT since full-screen TUI support landed. It only still existed to satisfy the old Emulator interface. Drop it, its helpers (escape, isFinalCSI, clip, newline, backspace), and its tests, and rewrite the package doc: it described Screen as the design and warned that full-screen alt-buffer TUIs wouldn't render faithfully, which VT has made false since it became the only implementation.
…on click Two reports from using the merged build: clicking a pane didn't seem to take keyboard focus, and traffic rows couldn't be opened by clicking. The second was a plain gap; the first turned out to be a symptom of a pre-existing layout bug that adding mouse support made visible. paneRects sized each pane as m.width/2-1 of content and then added 2 border columns, but lipgloss puts a border on BOTH sides of BOTH panes — four columns, not two. Measured, the rendered row came out m.width+2 at every even width (80 -> 82, 120 -> 122, 200 -> 202). The terminal wrapped every pane line, which slid the layout down the screen, so mouse Y no longer addressed the row the user could see and clicks landed on the wrong row or on nothing at all. Size the two boxes to sum to exactly m.width instead, giving the spare column of an odd width to the right pane, and let View size each pane from its own box. The old geometry test asserted the panes must be exactly equal, which is the assumption that made the overflow unavoidable — an odd width cannot be split evenly without rendering a column too many. It now allows a one-column difference and asserts the sum instead. Clicking a flow row only ever set m.selected. Clicking the row that is already selected now opens it, the same path enter takes. bubbletea reports no click count, so click-again avoids hand-rolling double-click timing while keeping "move the selection without opening" available.
…otches Scrolling the terminal pane crawled a line or so per notch, which reads like tapping the arrow keys and takes forever to get anywhere in a long transcript. The cause is not a translation bug: x/vt does no alternate-scroll rewriting at all — with mouse tracking off it emits nothing for a wheel event, even with mode 1007 set — so the notch was reaching the child verbatim. Claude Code turns on ?1000h ?1002h ?1003h ?1006h, so it was receiving real SGR wheel reports and choosing to scroll line-wise. Faithful, and not what you want from a host. Send PgUp/PgDn for a vertical wheel over the terminal pane instead, so one notch moves a screenful. Only the vertical wheel is remapped: clicks, drags and horizontal wheel still forward untouched, so a target's clickable UI keeps working. Acting only on the press avoids paging twice when bubbletea reports a companion release or motion event for one physical notch. Also guards a nil Pty on a Target that was constructed but never started — the shape several existing tests build, and previously a panic on this path.
…& resizable panes Integrates the emulator swap and mouse support onto current main, which had independently landed a config-driven keymap, resizable panes, and CI. Kept from the feature branch: - terminal: swap hinshun/vt10x for charmbracelet/x/vt (real per-cell truecolor; vt10x collapsed 24-bit color into palette indices). Reply-pump goroutine with clean Close, mouse forwarding gated in the emulator. - mouse support in the split-pane TUI, re-wired onto main's architecture. - detail view expands embedded JSON / escaped-newline strings. - security: 0600 capture/export files, 0700 config dir, log opened after the config dir exists. Reconciled with main: - Dropped the branch's -leader flag and leader.go: main already makes the leader configurable via its keymap config (cfg.Keys.Leader). Mouse-capture toggle is now a first-class keymap action (ActMouseCapture, leader "m") so it is rebindable and shows up in the generated help. - Mouse hit-testing now derives pane geometry from main's splitRatio via a shared paneBoxWidths, so clicks track the resizable divider. - Carried the pane-overflow fix onto main's geometry: lipgloss borders cost four columns across two panes, not two, so the pane boxes now sum to exactly the terminal width (main summed to width-2, rendering width+2 and wrapping every row). resize_test updated to the corrected invariant. - renderTraffic row accounting (shared by mouse hit-testing) now also reserves main's ':' command line. Known issue: go test -race fails in internal/terminal on an unsynchronized `closed` field inside charmbracelet/x/vt (Close vs Read). It is upstream and not reachable to fix from here; every other package is race-clean. See the PR body. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7CdY6QQ9VDRjsoDWRSxHm
… the wheel
Update dispatched tea.MouseMsg straight to the panes without consulting a
single modal flag, so a click landed on whatever the hit-test said was there
even when View() was drawing something else entirely. Two review findings on
the predecessor PR, one gate:
- A click behind the help overlay moved the flow selection or reached the
child, under a screen showing neither pane. The same held for the repeater
modal, and for the filter and ':' command line, which own the input stream
the way a focused text input does for keystrokes.
- The wheel over the right pane guarded only on m.viewing, while the click
path guarded on m.editing || m.viewing. A notch over the open raw editor
therefore moved the flow selection hidden underneath it.
The gate mirrors the precedence Update's tea.KeyMsg branch already enforces,
and splits the states on what View() actually draws. showHelp and repeating
paint over the whole screen, so they are handled in onMouse before any
hit-testing; editing and viewing replace only the right pane's content, so they
stay pane-local in onRightPaneMouse and the terminal pane keeps taking clicks.
Routing rather than blanket swallowing: the help overlay scrolls on the wheel
with the same clamp onHelpKey applies to j/k, the repeater forwards a notch to
whichever section has focus, and the editor scrolls.
The editor takes the wheel as up/down keypresses rather than the tea.MouseMsg
itself. bubbles' textarea has no mouse handling at all — unlike viewport, which
is why the detail view can forward the message verbatim — so forwarding it
would compile, run, and scroll nothing. This is the move wheelPageKey already
makes on the terminal pane, where a notch becomes PgUp/PgDn.
Session-Name: cedar-fugue
Session-Id: a516020b-4fa8-4479-ab35-fc781fb6025a
Model: claude-opus-5
Harness: claude-code 2.1.220
Machine: cf6e768835c7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Close waited on v.done unconditionally, and term.Close can only reach one of
the two places pumpReplies parks. Unblocking a pump sitting in Read is exactly
what it is for; a pump sitting in resp.Write — where it goes whenever the child
has stopped draining its own pty — is not reachable from Close at all. So a
wedged child made quitting wedge too, taking away the operator's last way out
of a target that had stopped responding.
The wait now has a ceiling. Past it the pump is abandoned: it is blocked
writing to a descriptor the process is about to drop, and shutting down beats
reclaiming the goroutine. The emulator's error is returned either way.
This treats the symptom. The cause-level fix would be a write deadline on the
pty, since a deadline makes the blocked write return on its own — but darwin
rejects SetWriteDeadline on a pty master outright ("file type does not support
deadline"), confirmed against a writer verifiably parked on a full pty, so
there is nothing to set.
Two tests, one per branch of the select: one parks the pump in resp.Write and
asserts Close still returns, one asserts an ordinary Close returns on done
rather than paying the grace period.
Session-Name: cedar-fugue
Session-Id: a516020b-4fa8-4479-ab35-fc781fb6025a
Model: claude-opus-5
Harness: claude-code 2.1.220
Machine: cf6e768835c7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…est modes build-test fails on exactly one thing, and it is not in this repo. charmbracelet/x/vt's Emulator.closed is a plain bool that Close writes while a parked Read reads it, unsynchronized. Read blocks on an empty pipe and Close is the only thing that unblocks it, so the two necessarily run concurrently and no shutdown can be written that the detector accepts. SafeEmulator does not cover it: its Read takes no lock and it does not override Close. The five tests that call VT.Close move to a file tagged //go:build !race, since -race sets the `race` build tag. They are quarantined rather than deleted, and the file says at its head what has to happen to bring them back. CI gains a plain `go test ./...` step alongside the existing -race one. Without it the build tag would not relocate those tests, it would remove them from CI altogether — this workflow only ever ran the race job. Two steps means the quarantined tests still run on every PR, just not under the detector. Filed upstream with a one-line patch making the field an atomic.Bool. Against a patched vt this repo's whole suite passes -race with no exclusions at all, which is the evidence that the exclusion here is covering someone else's bug and not one of ours. This commit is the one to revert once that lands. Session-Name: cedar-fugue Session-Id: a516020b-4fa8-4479-ab35-fc781fb6025a Model: claude-opus-5 Harness: claude-code 2.1.220 Machine: cf6e768835c7 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…clamp Two pre-PR review findings against the mouse-gate commit. wheelEditor drives the textarea three lines per notch, and every Update that moves the cursor returns a fresh cursor-blink timer. Batching all three left two stray timers racing to toggle the cursor. Only the last command is kept now. The wheel path clamped helpScroll with its own copy of the arithmetic onHelpKey already had. Two clamps that must agree is the precise failure the mouse gate exists to prevent, so both paths now end in one clampHelpScroll. Session-Name: cedar-fugue Session-Id: a516020b-4fa8-4479-ab35-fc781fb6025a Model: claude-opus-5 Harness: claude-code 2.1.220 Machine: cf6e768835c7 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
modalModel stored its *fakeEmulator argument unconditionally, so passing nil left m.screen as a non-nil interface wrapping a nil pointer. onLeftPaneMouse checks m.screen == nil and would have waved that through, then panicked in ForwardMouse on the nil receiver. No current test reaches that path; the next one to try would have paid for it. The field is now left alone when nil. TestHelpOverlayWheelClampsAtBottom seeded helpScroll from maxHelpScroll and asserted it had not moved, which passes for free on any terminal tall enough to show the whole help body — max would be 0 and there would be no clamp to observe. It now fails loudly in that case instead. Session-Name: cedar-fugue Session-Id: a516020b-4fa8-4479-ab35-fc781fb6025a Model: claude-opus-5 Harness: claude-code 2.1.220 Machine: cf6e768835c7 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The header said the race had been filed upstream with a patch. It had already been reported as charmbracelet/x#879 since May, and fixed in the still-open charmbracelet/x#881, both of which a bad search missed. Naming those two is what makes the removal condition checkable: when #881 merges, this file goes. Session-Name: cedar-fugue Session-Id: a516020b-4fa8-4479-ab35-fc781fb6025a Model: claude-opus-5 Harness: claude-code 2.1.220 Machine: cf6e768835c7 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for carrying the twelve commits forward rather than sending #3 back for another rebase — and for the write access. I took you up on the "have a look" by fixing my own carried-forward bugs directly on this branch rather than stacking a PR in front of your merge. Six commits; here's each one, and the two judgment calls worth disagreeing with.
Two things there you may want to push back on. First, One implementation note, since it looks like a mistake otherwise: the editor takes the wheel as
(Finding 3 —
Three follow-ups from a pre-PR pass over my own diff, all small: the editor wheel batched three cursor-blink timers where one will do; the help-scroll clamp was duplicated between the key and wheel paths, which is the exact drift this change exists to prevent, so both now share one
|
cameronsjo
left a comment
There was a problem hiding this comment.
Approving the reconciliation. I checked the merge (9ac5ad4, first parent 2941f9d = the #3 tip, second fa37bfa = main) headline by headline against what #3 carried, and nothing was dropped:
- the
hinshun/vt10x→charmbracelet/x/vtswap, with truecolor SGR emission surviving into the pane (vt_color_test.gostill covers the near-black case the old packed-uint32 path could not represent) - mouse click, focus and wheel handling, and the SGR encoder tests
- JSON detail expansion
- the
0600session file and the0700CA-directoryChmodininternal/proxy/ca/ca.go— both intact, including theMkdirAll-does-not-narrow-an-existing-dir case
The two places you reworked it are improvements rather than losses. paneRects now derives from paneBoxWidths instead of carrying its own m.width / 2 split, so hit-testing follows the divider wherever it is dragged — the resizable-pane work would have broken the old version outright. And threading cmdline through trafficRowLayout / flowRowIndex keeps a click mapping to the row actually drawn now that the : line takes a row; missing that would have put every click one row off whenever the command line was open.
Approving on the reconciliation and on the four commits I pushed to this branch. The merge itself stays your call.
This carries the work from #3 (by @cameronsjo) forward onto the current
main, which has since landed the config-driven keymap, resizable panes, repeater, and CI. I re-integrated the changes on top of that architecture rather than asking for another round of rebasing, and resolved the conflicts and the geometry interaction myself. Opening it as a separate PR so #3's history and authorship stay intact.What's kept from #3
hinshun/vt10x→charmbracelet/x/vt. This is the headline.vt10xis unmaintained and packs 24-bit truecolor into the same integer space as 256-color palette indices, so truecolor targets silently lost color.x/vtkeeps a realcolor.Colorper cell, soRenderwalksCellAtand usesStyle.Diffto emit only the SGR delta between adjacent cells.x/vt'sReadis pull-based, soNewVTruns a reply-pump goroutine thatClosestops cleanly.terminal.MouseEventsointernal/tuinever importsx/vtandinternal/terminalnever imports bubbletea.0600, the config dir is forced to0700, and the log file is opened after the config dir exists.What I changed to fit
main-leaderflag andleader.go.mainalready makes the leader configurable through its keymap config (cfg.Keys.Leader), so a parallel CLI mechanism would just be a competing duplicate. The runtime mouse-capture toggle is now a first-class keymap action (ActMouseCapture, default leaderm), so it's rebindable and shows up in the generated help overlay automatically.main'ssplitRatiovia a sharedpaneBoxWidths, so clicks track the resizable divider instead of a fixed 50/50 split.main'sleftPaneWidth + rightPaneWidth == width - 2actually rendered a rowwidth + 2wide (verified: at width 80 the joined row is 82 cells), wrapping every pane line and pushing mouse rows off. The pane boxes now sum to exactly the terminal width;resize_testis updated to the corrected invariant, and there's a test that measures the rendered row width against the terminal width.renderTraffic's row accounting (shared with mouse hit-testing viatrafficRowLayout) now also reservesmain's:command-line row, so a click still lands on the row drawn when the vim command line is open.go build,go vet, andgo test ./...all pass.Known issue:
go test -raceis red (upstream, inx/vt)The CI
-racejob will fail, ininternal/terminalonly — every other package is race-clean. The race is entirely upstream:x/vt'sEmulator.Close()writes an unsynchronizedclosedfield thatEmulator.Read()reads. It is reachable by design, not misuse —Close()is the only way to interrupt a blockedRead(), so any correct shutdown of the reply pump touches it.I traced the
x/vtsource to confirm it can't be worked around from here: replies only drain through the racyRead,Closeis the only way to unblock it, and theclosedfield is internal, so no wrapper-side lock can cover it. The newestx/vtpseudo-version (…20260727…) still has the bug. The fix is a one-lineatomic.Boolincharmbracelet/x/vt.Three ways to green CI, for whoever merges to decide:
-racewith a build tag and a comment linking the upstream issue (keeps normalgo testcoverage; loses-racecoverage of the Close path).replaceto a forkedx/vtwith the fix (heavier; adds a fork to the dependency tree).I can apply (2) on request. I'll file the upstream issue and link it here.
Generated by Claude Code