Skip to content

Truecolor terminal backend (x/vt) + mouse support, rebased onto config/keymap + resizable panes - #17

Open
citizen-123 wants to merge 19 commits into
mainfrom
claude/vt-mouse-integration
Open

Truecolor terminal backend (x/vt) + mouse support, rebased onto config/keymap + resizable panes#17
citizen-123 wants to merge 19 commits into
mainfrom
claude/vt-mouse-integration

Conversation

@citizen-123

Copy link
Copy Markdown
Owner

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

  • Terminal backend swap — hinshun/vt10xcharmbracelet/x/vt. This is the headline. vt10x is unmaintained and packs 24-bit truecolor into the same integer space as 256-color palette indices, so truecolor targets silently lost color. x/vt keeps a real color.Color per cell, so Render walks CellAt and uses Style.Diff to emit only the SGR delta between adjacent cells. x/vt's Read is pull-based, so NewVT runs a reply-pump goroutine that Close stops cleanly.
  • Mouse support in the split panes: clicks move focus and select flows (clicking the already-selected row opens it), the wheel scrolls the traffic list or pages the hosted app, and events reach the child through a neutral terminal.MouseEvent so internal/tui never imports x/vt and internal/terminal never imports bubbletea.
  • Detail view expands embedded JSON / escaped-newline strings, capped in depth, and clearly marked so decoded content is never confused with what was on the wire.
  • Security hardening: capture/export files are 0600, the config dir is forced to 0700, and the log file is opened after the config dir exists.

What I changed to fit main

  • Dropped the branch's -leader flag and leader.go. main already 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 leader m), so it's rebindable and shows up in the generated help overlay automatically.
  • Mouse hit-testing now derives pane geometry from main's splitRatio via a shared paneBoxWidths, so clicks track the resizable divider instead of a fixed 50/50 split.
  • Carried Swap vt10x for charmbracelet/x/vt, add mouse support to the split-pane TUI #3's pane-overflow fix onto the resizable geometry. lipgloss draws a border column on both sides of both panes — four columns, not two — so main's leftPaneWidth + rightPaneWidth == width - 2 actually rendered a row width + 2 wide (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_test is 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 via trafficRowLayout) now also reserves main's : command-line row, so a click still lands on the row drawn when the vim command line is open.

go build, go vet, and go test ./... all pass.

Known issue: go test -race is red (upstream, in x/vt)

The CI -race job will fail, in internal/terminal only — every other package is race-clean. The race is entirely upstream: x/vt's Emulator.Close() writes an unsynchronized closed field that Emulator.Read() reads. It is reachable by design, not misuse — Close() is the only way to interrupt a blocked Read(), so any correct shutdown of the reply pump touches it.

I traced the x/vt source to confirm it can't be worked around from here: replies only drain through the racy Read, Close is the only way to unblock it, and the closed field is internal, so no wrapper-side lock can cover it. The newest x/vt pseudo-version (…20260727…) still has the bug. The fix is a one-line atomic.Bool in charmbracelet/x/vt.

Three ways to green CI, for whoever merges to decide:

  1. Land the upstream fix and pin past it (correct, my recommendation).
  2. Skip the three shutdown tests under -race with a build tag and a comment linking the upstream issue (keeps normal go test coverage; loses -race coverage of the Close path).
  3. replace to a forked x/vt with 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

cameronsjo and others added 13 commits July 26, 2026 13:06
…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
cameronsjo and others added 6 commits July 30, 2026 15:28
… 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>
@cameronsjo

Copy link
Copy Markdown
Collaborator

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.

tui: gate mouse input on the modal states, and scroll the editor with the wheel — closes findings 1 and 2 from your review of #3, which were both still live here. Update dispatched tea.MouseMsg straight to the panes without consulting a single modal flag, so a click behind an overlay moved the flow selection or reached the child under a screen showing neither pane. Rather than three scattered guards, there is now one gate at the top of onMouse that mirrors the precedence your tea.KeyMsg branch already establishes. It splits the states on what View() actually draws: showHelp and repeating paint over the whole screen, so they are handled 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. Finding 2 was the same bug one level down — the click path guarded m.editing || m.viewing while the wheel path guarded only m.viewing. It routes rather than swallows: the help overlay scrolls on the wheel with the same clamp onHelpKey applies to j/k, and the editor scrolls too.

Two things there you may want to push back on. First, filtering and cmdline swallow clicks on both panes, not just the traffic list. That mirrors the keyboard exactly — no keystroke reaches the terminal pane while a text input is focused, since onFilterKey and onCmdKey sit above onKey — but it does mean you cannot click into the terminal with the : line open. Consistent, and arguably over-broad; easy to narrow if you would rather the left pane stayed live. Second, repeating is not one of your review findings — I added it because View() returns repeaterView() over the whole screen, so clicks were landing on panes that weren't drawn. Same bug, but it is scope I added rather than scope you asked for.

One implementation note, since it looks like a mistake otherwise: 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.

terminal: bound VT.Close so a stalled child can't wedge the quit path — finding 4. Close waited on v.done unconditionally, and term.Close only reaches one of the two places pumpReplies parks. Unblocking a pump sitting in Read is what it is for; a pump sitting in resp.Write, where it goes once a stalled child stops draining its pty, is not reachable from Close at all — so a wedged child made quitting wedge too, taking away the last way out of an unresponsive target. The wait now has a ceiling and the pump is abandoned past it, since it is blocked writing to a descriptor the process is about to drop. I tried the cause-level fix first: darwin rejects SetWriteDeadline on a pty master outright ("file type does not support deadline"), verified against a writer demonstrably parked on a full pty, so there is nothing to set. Two tests cover the two branches of the select.

(Finding 3 — -leader=ctrl+i / ctrl+m breaking Tab and Enter — was already fixed on main; leader.go is gone and NewKeyMap rejects both.)

ci: quarantine the emulator-shutdown tests from -race, and run both test modes — this is why build-test was red, and the cause 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 no shutdown can be written that the detector accepts, and SafeEmulator does not help — its Read takes no lock and it does not override Close. The five tests that call VT.Close move behind a //go:build !race tag. Note this also adds a plain go test ./... step to CI, because the workflow only ran the race job — without it the build tag would not have relocated those tests, it would have deleted them from CI entirely. Tracked upstream as charmbracelet/x#879 with a fix already proposed in charmbracelet/x#881; I verified that applying that patch locally turns this repo's whole suite green under -race with no exclusions at all, which is the evidence the exclusion is covering someone else's bug and not one of ours. That commit is the one to revert when #881 merges, and the file header says so.

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 clampHelpScroll; and two test traps — a nil *fakeEmulator stored into an interface field made m.screen == nil false, which would have panicked the first test to reach the left pane, and a clamp test that passed for free on any terminal tall enough to fit the help body.

go build, go vet, go test and go test -race all clean locally. Merge is yours to call, as you asked — flagging the two judgment calls above in case either is not what you want.

@cameronsjo cameronsjo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/vt10xcharmbracelet/x/vt swap, with truecolor SGR emission surviving into the pane (vt_color_test.go still 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 0600 session file and the 0700 CA-directory Chmod in internal/proxy/ca/ca.go — both intact, including the MkdirAll-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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants