Skip to content

CPLAT-12795: adopt tuikit for keys, toasts, render, prefs, charts, kitty and tmux - #177

Merged
gavin-jeong merged 8 commits into
masterfrom
CPLAT-12795-adopt-tuikit
Oct 15, 2026
Merged

gavin-jeong merged 8 commits into
masterfrom
CPLAT-12795-adopt-tuikit

Conversation

@gavin-jeong

Copy link
Copy Markdown
Collaborator

JIRA: https://sendbird.atlassian.net/browse/CPLAT-12795

Summary

ccx now uses the shared TUI kit keyolk/tuikit, pinned at 74b15dc, for keys, toasts, rendering, UI-state persistence, sparklines, Kitty graphics and tmux. The PR has one commit per step. ccx-specific logic stays in ccx: the keymap yaml and its legacy aliases, finding Claude sessions on top of the tmux panes and process table, and the inline image layer.

Changes

keys (bindings.go, keymap*.go)

  • Footers are laid out by keys.Footer. It drops low-priority hints first, keeps help/quit pinned, uses a CJK-safe ellipsis and never exceeds the width. Hints now read key label with two spaces between hints, which is the fleet footer format.
  • Keymap.Conflicts builds a keys.Keymap from the binding table (current keys plus live legacy aliases) and runs keys.Conflicts per scope. This adds a check that every action can be typed under a Korean input source.
  • The Hangul-to-Latin table and the set of uppercase keys reachable under Korean now come from the kit. The user langmap override stays.

toast (status_toast.go)

  • The status line was a copiedMsg string that any key cleared. It is now a toast.Queue with one source:
    • Info messages expire after 4s.
    • Failures stay until a key acknowledges them.
    • doing X… messages stay until the next status replaces them.
    • Each line has a level prefix, so an error still reads as an error under NO_COLOR.
  • All 370 call sites were rewritten mechanically with go/ast.

render

  • Truncate/pad helpers now call render.TruncateMarker, Truncate and PadRight, and overlayCenter is render.Center. Widths are measured the way lipgloss draws.
  • Fix: truncName sliced bytes. It returned invalid UTF-8 for Hangul names and panicked when the budget was below 1.

prefs (ui_state.go)

  • UI state (grouping, preview and view mode, split ratio, folds, hidden badges, filter) moves from config.yaml to $XDG_STATE_HOME/ccx/ui.json. It is saved while it changes (debounced), written off the UI loop and flushed on quit. ccx exits once that write reports back.
  • config.yaml keeps only the settings a person edits (worktree_dir, initial_focus).
  • Migration: on the first run, the values in config.yaml are the defaults that ui.json is read over. On that quit, ccx drops those fields from config.yaml.

chart (stats_timeline.go)

  • sparkline uses chart.Sparkline.
  • Fix: the old ceil-sized buckets drew 9 points into 5 of 8 cells and averaged a partial last bucket as a whole one.
  • Fix: width 0 divided by zero.
  • Under a CJK locale the ramp is now ASCII.

kitty (internal/kitty, kitty_watch.go, picker)

  • Detection runs once in Init, before the program starts. It used to run tmux inside the first View, and it also changed allow-passthrough synchronously. That change is now EnsurePassthroughCmd, off the UI loop, and its error is shown.
  • Escape sequences come from Caps.
  • The pane offset no longer comes from a cache queried in View. Resize, focus and blur ask tmux off the loop.
  • Both the TUI and the picker use kitty.WatchPane. The picker's 250ms tick that ran tmux in Update is gone.

tmux (internal/tmux)

  • internal/tmux keeps its API, but every call goes through a tmux.Client, and process walks use one shared Procs snapshot.
  • Calls now target pane and window IDs.
  • Fix: ListPanes now reads each field by its length, so a cwd containing |, a tab or Hangul no longer shifts the columns after it.
  • Fix: a Hangul syllable typed into the live preview was dropped. Key forwarding now passes the tea.KeyMsg to SendKeyMsg.
  • Errors that were dropped are now reported: the popup in RunPopup, the input popups, and failed sends.

Tests

  • No test can reach the real tmux server or ps any more, even when the suite runs inside tmux. TestMain in tmux, tui and cli installs an unscripted tmux.Fake and an empty ps table. Kitty detection never runs in tests. Before this change, a test run inside tmux could change the real server's allow-passthrough. internal/tui now runs in about 7s instead of 40s.
  • New tests:
    • Korean reachability in Conflicts
    • toast levels, expiry and stickiness
    • ui.json migration, precedence and save-on-change, and that pick mode never saves
    • sparkline width and edge cases
    • Kitty placement in and out of tmux
    • the pane query targets our own pane ID
    • ListPanes with an awkward cwd
    • Hangul sent with send-keys -l
    • ID-only targets
    • window attribution with faked claude processes
  • TestDayCursorMoveCost also fails under -race on master: the race detector turns 3.7ms per move into 18.6ms. Both frame-budget tests now skip under the race build tag.
  • go vet ./..., gofmt -l ., go test -race -count=1 ./... and env -u LC_ALL -u LC_CTYPE LANG=ko_KR.UTF-8 go test -count=1 ./... are all clean.

Not moved (and why)

  • Dispatch is still a switch over Keymap fields, and the ? overlay keeps its own rows. Moving roughly 94 dispatch sites to keys.Resolve is a separate change.
  • overlayLine clips at a column the caller gives (the split divider), and render.Overlay does not take one. truncateExact counts ambiguous-width runes as two cells on purpose, for the split border.
  • histogramBar and the other whole-cell bars guarantee one cell for any non-zero count, and chart.Bar does not.
  • The image layer still writes cursor-placed sequences into each frame. It does not use kitty.Image placeholders.
  • The async tmux paths (tmux_async.go, pane_actions.go, worktree_async.go) keep their generation-guarded closures and their test seams. Only the tmux calls inside them changed. SpawnHiddenWindow and KillWindow for the remote live pane still run inside Update, as before.

…from tuikit/keys

Pin github.com/keyolk/tuikit at 74b15dc.

- renderFooter lays hints out with keys.Footer (priority drop, pinned
  help/quit, CJK-safe ellipsis, hard width cap). Hints now read 'key label'
  separated by two spaces, the fleet footer format.
- Keymap.Conflicts builds a keys.Keymap from the binding table (current keys
  plus live legacy aliases) and runs keys.Conflicts per scope, which adds the
  Korean-reachability check to the duplicate check.
- The Hangul-to-Latin table and the reachable-uppercase set come from
  keys.Latin / keys.ReachableUnderCJK; the user langmap override layer stays.
- displayKey is keys.Names with ccx's short spellings (sp, S-tab, ^G).
copiedMsg was a string every key cleared. It is now a toast.Queue with one
source ('status'), so a newer message still replaces the shown one, but:

- info messages expire on their own tick (4s) instead of on the next key;
- failures ('... failed', 'Error ...') stay until a key after they were seen;
- 'doing X…' progress stays until the next status replaces it;
- the footer line carries a level prefix (i / ✗ / ◌), so an error reads as
  one under NO_COLOR.

Call sites were rewritten mechanically (go/ast): X.copiedMsg = v -> X.flash(v),
+= -> flashAppend, reads -> flashText(). Update schedules the expiry tick
with toast.Ticks(before, after), so handlers keep not returning commands.
- truncate, truncateContextText, truncCells, truncateDisplayWidth, padToWidth,
  padSelectedRow and the picker's padRightWidth/truncateWidth delegate to
  render.TruncateMarker/Truncate/PadRight: ANSI-safe, and measured the way
  lipgloss draws (runewidth counted East Asian ambiguous runes as two cells
  under a CJK locale, so those columns disagreed with the frame).
- truncName sliced bytes: Hangul names came back as invalid UTF-8 and a
  budget under 1 panicked. Now render.Truncate (test added).
- overlayCenter is render.Center: the frame stays exactly w x h even with a
  wide character on the modal's edge (test added).
- The unused byte-based padRight in the picker is gone.

Kept: overlayLine (clips at a caller-given column such as the split divider,
which render.Overlay does not take) and truncateExact (deliberately counts
ambiguous runes as two cells for the split-pane border).
Where the user left the list (grouping, preview mode, view, split ratio,
folded groups, hidden badges, filter, editor-input choice) was re-marshalled
into ~/.config/ccx/config.yaml synchronously on quit: a crash lost it, and
every quit rewrote the user's config file.

It now lives in $XDG_STATE_HOME/ccx/ui.json (tuikit/prefs): saved debounced
while it changes, written off the UI loop with temp-file-plus-rename, and
flushed on quit, which exits once that write reports back. config.yaml keeps
the settings a person edits (worktree_dir, initial_focus).

Migration: the config.yaml values are the defaults ui.json is read over, so
the first run keeps what an older build saved; that quit drops the state
fields from config.yaml. Pick mode still persists nothing.

Tests: TestMain points XDG_STATE_HOME at the temp home and turns persistence
off (a shared state file would carry one test's grouping into the next);
persistence tests turn it on over private dirs.
sparkline() now draws with chart.Sparkline on a zero baseline:

- a series longer than the width fills the width with equal-share buckets;
  the old ceil-sized buckets drew 9 points into 5 of 8 cells and averaged a
  partial last bucket as a whole one (test added);
- maxWidth 0 no longer divides by zero;
- under a CJK locale (or CCX_ASCII) the ramp is ASCII, since block elements
  are East Asian ambiguous and draw two cells there.

A shorter series still takes one cell per point and an idle one sits on the
floor, as before. histogramBar and the timestamp bucketing stay: whole-cell
bars with a one-cell minimum for any non-zero count, which chart.Bar's
eighth-cell fill does not promise.
internal/kitty is now a thin layer over tuikit/kitty. ccx still draws an
image by appending cursor-placed, file-transmitted sequences to the frame;
what moved to the kit:

- Detection (kitty.Detect, CCX_KITTY still forces it) runs once in Init
  from main / the pickers, before the program. It used to run lazily inside
  the first View and, inside tmux, run tmux there - and also flip tmux's
  allow-passthrough synchronously. That promotion is now
  EnsurePassthroughCmd from Init, off the loop, and a failure is shown.
- Escapes are Caps.TransmitFile + Caps.Place (cursor saved/restored, pane
  offset applied inside the passthrough) and Caps.DeleteAll.
- The pane offset is no longer a sync.Once cache queried from View: resize,
  focus and blur ask tmux off the loop (PaneCmd) and the answer is recorded
  with SetPane. BlurMsg no longer runs tmux synchronously in Update.
- The visibility watcher is kitty.WatchPane in both the TUI and the picker;
  the picker's 250ms tick that ran tmux in Update is gone.

Tests no longer detect anything: without Init the terminal is unsupported,
so no test runs tmux (before, a test run inside tmux could set the real
server's allow-passthrough). Tests that need graphics use kitty.SetCaps.
internal/tmux keeps its API (and Claude-session discovery: FindPane,
CurrentWindowClaudes, CurrentWindowClaudeSessionIDs, CurrentWindowKey,
MarkLiveSessions), but every tmux call now goes through a tuikit/tmux
Client and every process walk through a Procs snapshot:

- ListPanes is Client.Panes: free-text fields are read by length, so a cwd
  or window name holding the old '|' separator, a tab or Hangul no longer
  shifts the columns after it (test added).
- Calls target pane/window IDs, never session:window.pane; SwitchToPane is
  Jump; Pane gains WindowID; SpawnHiddenWindow returns the new pane's ID.
- Key forwarding takes the tea.KeyMsg (SendKeyMsg): a Hangul syllable typed
  into the live preview used to be dropped, since only one-byte keys were
  sent as text (test added). SendKeys is SendLine, Ctrl-C is SendInterrupt,
  popups are DisplayPopup without a timeout (an editor waits on a person),
  and RunPopup / the input popups now report their error.
- HasClaude, HasClaudeSession and the PPID walk share one memoized Procs
  snapshot (HasClaudeSession used a pgrep per pane and only saw direct
  children); walkToPane is Procs.WalkTo.
- kitty.Init takes the same Client as its TmuxQuerier.

Tests: TestMain in tmux, tui and cli installs an unscripted tmux.Fake and an
empty ps table, so no test reaches the real tmux or process table even
inside tmux (tui tests: ~40s -> ~7s). The window/attribution tests now fake
claude processes instead of asserting only the empty case. The opt-in
CCX_PROBE_WINDOW diagnostic is the one test that talks to the real tmux.
TestDayCursorMoveCost fails under go test -race on master too: the race
detector makes a 3.7ms cursor move take 18.6ms, over the 16ms budget. The
budget measures the code, not the instrumentation, so both budget tests
skip under the race build tag; a plain run still enforces them.
@upwind-code-us

upwind-code-us Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Upwind Upwind Code Scan - ✅ Passed

0 newly introduced vulnerabilities · 0 resolved · 2 total in this PR vs the default branch

Total breakdown: 🔶 2 High

View full analysis in Upwind Console

Scan completed in 8s

Scan history (2 scans)
Commit Scanned at New Resolved Net
a7ce9be 2026-10-15 00:08 UTC — — —
a7ce9be < 2026-10-15 00:08 UTC 0 0 0

Last scanned: a7ce9be · 2026-10-15 00:08 UTC

@jinsekim jinsekim left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM!

@jinsekim jinsekim added the auto-review/approved Auto-approved by the Slack auto-reviewer bot label Oct 15, 2026
@gavin-jeong
gavin-jeong merged commit b33699d into master Oct 15, 2026
5 checks passed
@gavin-jeong
gavin-jeong deleted the CPLAT-12795-adopt-tuikit branch October 15, 2026 00:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-review/approved Auto-approved by the Slack auto-reviewer bot

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants