Skip to content

feat(tui): New session dialog with context-aware defaults - #78

Merged
epilande merged 20 commits into
mainfrom
feat/new-session-dialog
Jul 29, 2026
Merged

feat(tui): New session dialog with context-aware defaults#78
epilande merged 20 commits into
mainfrom
feat/new-session-dialog

Conversation

@epilande

Copy link
Copy Markdown
Owner

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.

  • Agent: spawnable agents (built-ins found on PATH plus custom config agents), j/k or number keys. Defaults to the selected row's agent, else the last agent spawned from the dialog.
  • Placement: new window / split right / split down. Defaults to new window.
  • Prompt: free text, empty by default, delivered through [Feature]: Spawn API: split direction, target pane, per-agent prompt handling #64's per-agent promptCommand handling.
  • Directory is derived rather than asked: session row → that session's cwd, group header → the group's cwd, no selection → the picker's own cwd. Shown in the dialog so it is never a surprise.
  • New GET /agents for spawnable-agent discovery.
  • Entry points: n, "New session here" on session rows, "New session in repo" on group headers. Background (paneless) rows work too, using their cwd.

Design notes

Placement travels as callerPane, not target. #64 gave the two different meanings: an explicit target for a new window resolves to {kind:"window"} and runs new-window -a -t @win, renumbering every later window; callerPane resolves to {kind:"session"} and appends. For a split, callerPane still 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_FIELDS in store.ts is the single list driving focus order, option-key routing, and rendered rows, and a simplify pass folded the three per-field switches into one focusedOptionField() 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:

  1. /agents and /spawn disagreed. The route read the config file fresh while POST /spawn used 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 /spawn accepts.
  2. resolveLaunchPane enumerated the wrong window. A bare tmux list-panes resolves 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.
  3. lastSpawnAgent never reached disk. The one-shot picker calls process.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. setUIState is now stubbed, which also closes a pre-existing leak from the f/p/b key tests.

Verification

  • bun run typecheck clean; bun test 3471 pass / 0 fail across 154 files.
  • Rendered at 100x30, 50x30 and 34x40 headless, plus live in tmux at 200x50 and in a 34-column sidebar rail. At 34 columns the placement options stack so nothing runs past the border.
  • Live, against an isolated daemon: split targeted the launch pane rather than the picker's; new window appended without moving later windows; the prompt reached the pane single-quote escaped through the per-agent template; context defaults correct for all three cases; the remembered agent survived process exit and was the default in the next picker; in sidebar mode the split hit the main-area pane, the rail was untouched, and focus did not move. One real Claude spawn answered OK.

epilande added 12 commits July 29, 2026 05:45
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.
@epilande

Copy link
Copy Markdown
Owner Author

Verification

Ran the full diff-driven verification against an isolated stack (scratch CCMUX_HOME, own daemon on port 2323, own tmux server) so none of this touched the live fleet.

Tier 0 (static): PASS. typecheck, knip, build, and bun test all green (3471 pass / 0 fail). The six test files this PR touches account for 507 of those.

Tier 2 (TUI render): PASS. The dialog was captured at three widths and each responsive branch behaves as written:

  • 200 cols renders the full placement row (1 [New window] 2 Split right 3 Split down)
  • 62 cols drops to the compact labels (Window / Right / Down)
  • 36 cols stacks the options vertically so all three stay visible

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. GET /agents returns the nine PATH-gated built-ins with displayName, shortCode, and supportsPrompt. The same route 404s against a daemon running main, which confirms the picker degrades to a clear failure rather than a silent one on a stale daemon, exactly as the handler comment describes.

Tier 3 (spawn wiring): PASS, verified live with real agents rather than mocks.

  • Split lands beside the launch pane. Spawning with Split right from a picker in window 1 put the new pane in window 1 next to the launcher. Window list stayed 0,1,2.
  • New window appends without renumbering. This is the behavior from [Feature]: Spawn API: split direction, target pane, per-agent prompt handling #64 that matters most here. Windows were 0:agent 1:launch 2:notes and the picker was launched from window 1, so an insert-at-caller would have taken index 2 and pushed notes to 3. Instead the new window appended at 3 and notes kept 2. That is callerPane resolving to {kind:"session"} rather than {kind:"window"}.
  • Sidebar spawn does not steal focus and does not split the rail. With a 30-column sidebar rail on the left and a work pane on the right, spawning from the sidebar split the work pane, left the rail untouched at 30 columns, and left the active pane exactly where it was. This exercises parseLaunchPane's fallback branch, since the active pane at sidebar launch was the rail itself.

One finding, non-blocking

Inside a real 30-column sidebar rail, the placement labels truncate to the point of being ambiguous:

Placement 1 [New     ]
          2  Split
          3  Split

Options 2 and 3 are indistinguishable. The stacked branch deliberately prefers the full label over compactLabel, and the comment explains why (a stacked row has the width to itself), but at the rail's actual width the box is only 26 columns, which leaves 8 for the label. New window, Split right, and Split down all wrap and render only their first word.

This is cosmetic and the number keys still select correctly. Worth noting that compactLabel already exists and would fit; it is just gated on compact() && !stacked(). At 36 columns and up the stacked layout renders the full labels correctly, so this only bites the narrowest surface.

Not covered

Every built-in on this machine has a promptCommand, so supportsPrompt came back true for all nine and the "can't start with a prompt" placeholder and the submit-time guard were never reachable live. Both have unit coverage in NewSessionDialog.test.tsx and App.test.tsx.

@epilande

Copy link
Copy Markdown
Owner Author

E2E recording

Driven end to end with real keystrokes and three real agent spawns, against an isolated daemon. 92s at 180x46.

Arc: picker → n → context defaults (agent, placement, prompt, directory) → j/k navigation → number keys → tab across fields → spawn as Split right → relaunch → spawn as New window → sidebar rail → spawn from the sidebar.

The three headline behaviors are proven against tmux state rather than pixels:

  1. Split landed beside the launch pane — window 1 gained a second pane, window list unchanged at 0,1,2.
  2. New window appended without renumbering. Launched deliberately from window 1 with notes sitting at window 2, so an insert-at-caller would have taken index 2 and pushed notes to 3. The status bar in frame reads 0:agent 1:launch- 2:notes 3:2.1.220*.
  3. Sidebar spawn did not steal focus and did not land in the rail — 30-column rail untouched, the new pane split the work pane, active pane and current window both unchanged. This exercises parseLaunchPanes fallback branch, since the active pane at sidebar launch was the rail itself.
ccmux-pr78-1785333763.mp4

epilande added 8 commits July 29, 2026 07:27
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.
@epilande
epilande merged commit 33c1efc into main Jul 29, 2026
1 check passed
@epilande
epilande deleted the feat/new-session-dialog branch July 29, 2026 14:58
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.

[Feature]: Picker: new session dialog with context-aware defaults

1 participant