feat(tui): New session dialog with context-aware defaults - #78
Conversation
The picker's new-session dialog needs to know which agents this machine can actually start. Built-ins are gated on PATH (the catalogue is far wider than any one machine's install), custom ccmux.json agents are listed unconditionally, and Claude honors the `command` preference so a wrapper install doesn't drop it from the list. Also extracts the launcher-binary resolution POST /spawn was doing inline, so both routes agree on what binary an agent runs.
The dialog's fields are driven off NEW_SESSION_FIELDS plus matching draft keys rather than hardcoded per-field handling, so the worktree destination field (#69) slots in without reworking focus movement. lastSpawnAgent persists to state.json: the one-shot picker exits the moment it spawns, so an in-process memory of the last agent would never be read back.
Renders the agent list, placement choices, prompt input, and the derived directory, with a windowed agent list so nine installed agents still fit a short viewport and abbreviated placement labels for a narrow one. Spacing is structural (box widths and margins) rather than padded strings: a <text> measures on its trimmed content, so trailing spaces collapse once flex applies any pressure.
Entry points are the n key (context taken from the selected row), 'New session here' on a session row, and the same on a group header. cwd is derived from that context and shown, never typed. Placement targets the pane the TUI was launched over, resolved once at mount: by the time the dialog opens, tmux's idea of the current pane can be the picker's popup or the sidebar rail. It is sent as callerPane rather than target, because the daemon reads an explicit target as 'insert a window here' and renumbers every later window in the session. The picker jumps to the new pane and exits; the sidebar spawns detached and toasts.
- GET /agents read the config file while POST /spawn used the daemon's boot-time list, so an agent added to ccmux.json since the daemon started appeared in the dialog and answered 'Unknown agent' on Enter. The route now resolves every name through the daemon's own lookup. - resolveLaunchPane ran a bare 'tmux list-panes', which resolves the SESSION's current window rather than our own. A surface in a non-current window enumerated someone else's panes and split there. - lastSpawnAgent never reached disk: the one-shot picker calls process.exit(0) the instant its spawn lands, and the 300ms persist debounce never survived that. It is now written straight through and awaited before the exit. Also stubs setUIState in the App tests, which were rewriting the developer's real ~/.config/ccmux/state.json.
At 34 columns the placement row and the prompt placeholder both ran past the dialog's own border, and clipping would have hidden two of the three placements outright. The options now stack vertically below the width the row needs, and the placeholder is truncated to the content box (the input draws its own placeholder in full, past its box).
Covers the key table and dialog keys in the README, the endpoint's list-only-what-spawn-accepts rule and the callerPane-vs-target choice in architecture.md, and the field-list contract in AGENTS.md for whoever adds the worktree destination field.
Three parallel `draft.field` switches (options, current value, setter) become one `focusedOptionField()` returning all three, so a future field is a single case rather than an edit in three places. The placement -> `POST /spawn` split becomes a keyed record instead of a nested ternary, and the `paneCwd ?? cwd` fallback repeated at five call sites becomes `sessionCwd()`, which carries the reason once.
The bracket, the label colour, and the closing bracket each re-derived "is this the drafted placement". One accessor per option covers all three. The prompt placeholder's nested ternary becomes an if/else on the one condition that matters (can this agent take a prompt), leaving a single width choice in each branch.
Two conflicts, both from #68 and this branch adding to the same places: - App.tsx: each side added a <Show> overlay block after the confirmation dialog. Kept both. - HelpOverlay.test.tsx: each side added a help entry (n / W) and each independently raised the sidebar test's viewport, because the stacked scrollbox pushed a section past the fold. Took the larger height (70) and merged the two near-identical explanatory comments into one. Measured the real requirement with both entries present: 54 rows, so 70 keeps genuine headroom rather than sitting at the exact fit that caused this collision twice already.
The dialog drew its own key-hint row while the Footer simultaneously switched to a near-identical newSessionMode line, so the picker showed the same hints in two places at once. The footer keeps them there, since that is where the picker's hints always live; the dialog carries the row only where there is no footer to carry it, which is the sidebar. The row and its blank spacer leave the height calculation with it, so the dialog shrinks by two rows rather than leaving a gap.
VerificationRan the full diff-driven verification against an isolated stack (scratch Tier 0 (static): PASS. Tier 2 (TUI render): PASS. The dialog was captured at three widths and each responsive branch behaves as written:
The picker's footer carries the key hints and the dialog suppresses its own, so the hints appear exactly once. The sidebar, which has no footer, draws its own row. Tier 4 (daemon HTTP): PASS. Tier 3 (spawn wiring): PASS, verified live with real agents rather than mocks.
One finding, non-blockingInside a real 30-column sidebar rail, the placement labels truncate to the point of being ambiguous: Options 2 and 3 are indistinguishable. The stacked branch deliberately prefers the full label over This is cosmetic and the number keys still select correctly. Worth noting that Not coveredEvery built-in on this machine has a |
E2E recordingDriven end to end with real keystrokes and three real agent spawns, against an isolated daemon. 92s at 180x46. Arc: picker → The three headline behaviors are proven against tmux state rather than pixels:
ccmux-pr78-1785333763.mp4 |
The row click handlers each carried their own copy of the modal-guard list, and neither had been extended when a new overlay landed — so both this dialog and the prune dialog were modal for keys and transparent to clicks. Both are centered, leaving rows visible above and below: a click on one ran activateItem, and in the one-shot picker that switched panes and exited, discarding a half-filled dialog and its typed prompt. A right-click stacked a context menu under a dialog that owns every key, so nothing could dismiss it. Replaces the duplicated list with one predicate over the same overlays the keyboard handler already returns early for, so the next overlay cannot repeat this by omission. Also names a stale daemon instead of showing its status code: `/agents` 404s against a daemon predating it, and "old daemon, new client" is the likeliest failure right after an upgrade, where a bare "HTTP 404" in the Agent field just reads as broken.
Adding `n new` pushed the default hint line to 121 characters, past the 118 a 120-column terminal leaves after padding, and the hint it clipped was `quit`. #68 had already measured this ceiling and withheld its own `W prune` hint rather than collide with it, so the line was one feature away from breaking regardless of which one landed first. The line now drops whole hints by rank until it fits, rather than being a fixed string that either fits or doesn't. View toggles go first, then actions on the selected session, then navigation; `? help` and `q quit` outlive everything, because they are how someone stuck gets out. That also gives the next picker feature somewhere to put its hint.
CHROME_ROWS was a hand-summed constant, so adding a field to NEW_SESSION_FIELDS type-checked cleanly and silently clipped the bottom row at runtime — the Directory line in the picker, the hint row in the sidebar. It is now a `Record<NewSessionField, () => number>`, which fails to compile until the new field declares its height. Also corrects the extensibility claim it came from: adding a field is additive, but it is not the one-liner the docs implied, and the omission that mattered was the one TypeScript used not to catch.
parseLaunchPane excluded the surface's own pane unconditionally. That is right for the sidebar, which persists — splitting its rail would halve the strip. It is exactly wrong for the inline picker, which VACATES its pane on spawn: "Split right" from a picker in a two-pane window posted the neighbour as callerPane, so the pane that got halved was whatever the user had open beside it, while the pane they ran ccmux from was left alone. Alone in a window it resolved to null, dropping placement entirely and letting the new window land on tmux's current-session guess. Self-exclusion is now the sidebar's rule only; the ccmux-title check still covers a persistent picker, which does not vacate either. The pane is also resolved per spawn rather than cached at mount. A sidebar recorded its neighbour at startup, and once that neighbour was closed every later spawn failed with "Unknown target pane" until the sidebar restarted. Nothing changes between launch and spawn that made the cache worth its staleness, and one tmux round-trip on an explicit user action is not a cost worth carrying it for. A sidebar alone in its window now degrades a split to a new window and says so, rather than letting the daemon halve the rail.
This PR adds a third concurrent writer to state.json, alongside every
other picker/sidebar and the daemon's AttentionTracker. The write was a
read-modify-write straight onto the real path, so a crash mid-write left
a truncated file — and getUIState swallows the parse error and returns
{}, silently discarding collapsedGroups, pinnedGroups, previewWidth,
promptDisplay and groupBy. Written to a pid-suffixed temp file and
renamed, the way the session markers already are, so a reader sees the
old file or the new one and never a half-written one.
Atomicity bounds the damage to "one writer's keys lose to another's"
rather than "the file is gone"; the read-modify-write is still unlocked.
setLastSpawnAgent also flushes the debounced queue instead of bypassing
it: pressing `f` and spawning inside 300ms left hideIdle on a timer that
process.exit killed.
…esolve `command` is documented as possibly being a bare alias (`c`), a `~` path, or `$HOME/.local/bin/claude` — spawn-command.ts explicitly supports the last of those. Bun.which resolves none of them, so PATH-gating on it made Claude simply absent from the dialog, with no explanation, for a user whose spawn would have worked fine: the command is typed into an interactive shell, where it does resolve. A declared `command` makes Claude hand-declared in exactly the way a custom agent's `executable` is, and this module already argues that case must be listed unconditionally. Same argument, same answer. Claude stays PATH-gated when no command is declared.
Two things the 30-column rail exposed that 34 columns did not. The stacked placement layout preferred the full label on the grounds that each option has its own row, but the rail leaves 8 columns for it: `New window`/`Split right`/`Split down` rendered as `New`/`Split`/`Split` — two of three indistinguishable, with number keys that still selected correctly. The short labels exist for this; they are now the fallback whenever the full one does not fit. The hint row overran the border there too, so Esc drops its gloss at that width. Field focus was signalled by colour alone while the selections inside each field use colour-safe markers. Since the number keys are scoped to the focused field, that gap let someone press `2` believing they were on Agent, get "Split right", and spawn with no confirmation step. A one-character marker in the label gutter closes it. Also lets <For> reuse the agent rows: the windowing minted fresh wrapper objects per recompute, so every visible row was torn down and rebuilt on each j/k rather than just moving the marker.
handleActivePaneNotification reaches AttentionTracker.save(), which writes state.json, so running this file alone rewrote the developer's own ~/.config/ccmux/state.json. A full `bun test` escaped it only because another test file happens to set CCMUX_HOME process-wide first — accidental and order-dependent, while AGENTS.md documents single-file runs as normal. Redirects CCMUX_HOME and mocks STATE_FILE the same way index.no-hooks.test.ts does; the env var alone is too late, since imports are hoisted and lib/config freezes STATE_FILE at load.
Closes #65
Summary
Adds a new-session dialog to the picker, opened with n or from the existing context menus. Every field is optional with context-aware defaults, so the fast path is two keystrokes:
n, Enter.j/kor number keys. Defaults to the selected row's agent, else the last agent spawned from the dialog.promptCommandhandling.GET /agentsfor spawnable-agent discovery.Design notes
Placement travels as
callerPane, nottarget. #64 gave the two different meanings: an explicittargetfor a new window resolves to{kind:"window"}and runsnew-window -a -t @win, renumbering every later window;callerPaneresolves to{kind:"session"}and appends. For a split,callerPanestill splits exactly that pane. One field, correct for all three placements. Verified live: spawning a new window with later windows present left their indices untouched.Structured for #69.
NEW_SESSION_FIELDSinstore.tsis the single list driving focus order, option-key routing, and rendered rows, and a simplify pass folded the three per-field switches into onefocusedOptionField()lookup. Adding the worktree destination field is one array entry, one draft key, one case, and one render branch.Three defects the live run flushed out
Unit tests would not have caught any of these:
/agentsand/spawndisagreed. The route read the config file fresh whilePOST /spawnused the daemon's boot-time agent list, so an agent added since the daemon started appeared in the menu and answered "Unknown agent" on Enter. The route now resolves every name through the daemon's own lookup, so the menu can only offer what/spawnaccepts.resolveLaunchPaneenumerated the wrong window. A baretmux list-panesresolves the session's current window, not the caller's, so a picker in a non-current window split an unrelated pane. Now passes-t $TMUX_PANE; a popup, which has no pane, correctly keeps the client's current window.lastSpawnAgentnever reached disk. The one-shot picker callsprocess.exit(0)the moment its spawn lands, and the store's 300ms persist debounce never survived it — so persistence failed in exactly the case it exists for. Now written straight through and awaited.Also fixed: the App tests were writing to the developer's real
~/.config/ccmux/state.json.setUIStateis now stubbed, which also closes a pre-existing leak from thef/p/bkey tests.Verification
bun run typecheckclean;bun test3471 pass / 0 fail across 154 files.OK.