From 1d1de57d8ff742b0b0331a70a8aa6082eada6e3c Mon Sep 17 00:00:00 2001 From: jitokim Date: Wed, 29 Jul 2026 04:17:05 +0900 Subject: [PATCH] feat(tui): opt-in cmux tab auto-rename reflecting loop state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The captain runs many cmux tabs and renames each by hand to the task. fleetops already knows each loop's state, goal, and (since v0.9.0) what it's delegating to a subagent — so it can write that to the cmux tab strip, killing the manual step. This is "fleetops goes to where the captain already looks (the tab strip)" rather than "make the captain open fleetops" (see .notes/pivot.md). First slice — opt-in, cmux-only, fail-closed: - `--rename-tabs` flag, default OFF. Observation stays pure unless opted in. - New optional `control.TabTitler` interface (mirrors TerminalOpener/TTYLocator), implemented by cmuxController only: `cmux rename-tab --surface -- ` (same-workspace; title positional after `--`). The exec goes through an injectable `cmuxTabRunner` seam defaulting to runWithTimeout (5s bound), so it is unit-tested without a live cmux and a wedged cmux can never hang a tick. - Title composer (pure): glyph + state token (StateString, includes stall slug) + short DisplayLabel truncated to ~18 cols; delegation wins ("▸deleg code-reviewer"). - Presentation-sync driver on the scan tick: debounced (per-session last-title ledger, advanced only on a CONFIRMED write — ambiguous/failed writes retry next tick), and only for unambiguously-mapped cmux loops (reuses the actuation ambiguity refusal — ambiguous ⇒ skip, never rename a guessed tab). - Honesty: delegation is detected only against claude.formatDelegating's authoritative boundaries, so a raw tail literally starting with "delegating…" can't fabricate a delegation on the tab. ResolveActuationTarget was refactored (extracted tierOneAAcross/tierOneBAcross) so tab-title resolution reuses the exact tier-1a/1b locate + ambiguity rule instead of reimplementing it — behavior-preserving (all pre-existing ResolveActuationTarget tests unchanged and green). Not routed through internal/control/actuate: a tab rename writes terminal chrome, not a keystroke into the session. Tests: go test ./... -race green (20 pkgs); new tests cover argv/error, resolver ambiguity/tty/non-cmux, composer table, driver gate/debounce/mixed-batch. NOT YET VERIFIED against a live cmux binary (none on this box): that `rename-tab` exists with `--surface <ref>` + positional title, that omitting `--window` scopes to the caller's workspace, and that a bad surface ref exits non-zero. This is the merge gate — confirm with `cmux rename-tab --help` before merging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: jitokim <pigberger70@gmail.com> --- cmd/fleetops/main.go | 57 +++- cmd/fleetops/main_test.go | 45 ++- internal/control/actuation.go | 90 +++++- internal/control/cmux.go | 44 +++ internal/control/cmux_tabtitle_test.go | 87 +++++ internal/control/control.go | 24 ++ internal/control/tabtitler_resolve_test.go | 122 +++++++ internal/tui/model.go | 56 +++- internal/tui/tabtitle.go | 234 ++++++++++++++ internal/tui/tabtitle_test.go | 354 +++++++++++++++++++++ 10 files changed, 1093 insertions(+), 20 deletions(-) create mode 100644 internal/control/cmux_tabtitle_test.go create mode 100644 internal/control/tabtitler_resolve_test.go create mode 100644 internal/tui/tabtitle.go create mode 100644 internal/tui/tabtitle_test.go diff --git a/cmd/fleetops/main.go b/cmd/fleetops/main.go index de2a7a4..8e993ef 100644 --- a/cmd/fleetops/main.go +++ b/cmd/fleetops/main.go @@ -4,6 +4,9 @@ // `--demo`: launch the same TUI seeded with a fixed synthetic fleet instead // of scanning ~/.claude/projects — no real data read, nothing written to // ~/.fleetops (see internal/tui.NewDemo). +// `--rename-tabs`: opt-in (default off) — auto-rename the cmux tab of each +// unambiguously-mapped loop to reflect its state/delegation (see +// internal/tui tab-title sync; cmux-only, a terminal write side-effect). // Subcommands: `hook notify|session-start|session-end` (Claude Code hook // entry points, see hook.go), `hooks install|uninstall` (register/remove // those hooks in ~/.claude/settings.json, see hooks.go), and @@ -35,18 +38,49 @@ func main() { case "report": runReportCmd(os.Args[2:]) return - case "--demo": - runTUI(true) - return case "help", "--help", "-h": fmt.Print(helpText()) return default: - fmt.Fprintf(os.Stderr, "fleetops: unknown command %q\n", os.Args[1]) - os.Exit(1) + // Not a subcommand — parse the remaining args as TUI flags + // (--demo / --rename-tabs). An unknown token still errors (naming the + // OFFENDING token, not just os.Args[1]), so `fleetops bogus` keeps its + // exit-1 behavior. + f, bad, ok := parseTUIFlags(os.Args[1:]) + if !ok { + fmt.Fprintf(os.Stderr, "fleetops: unknown command %q\n", bad) + os.Exit(1) + } + runTUI(f.demo, f.renameTabs) + return + } + } + runTUI(false, false) +} + +// tuiFlags are the flags that modify the TUI launch (no subcommand). +type tuiFlags struct { + demo bool + renameTabs bool +} + +// parseTUIFlags parses the TUI-launch flags out of args (everything after the +// program name). Order-independent; on the first unrecognized token it returns +// ok=false and that token (unknown), so the caller reports the OFFENDING token +// rather than a misattributed os.Args[1]. Pure, so the routing is unit-testable +// without starting a real Bubble Tea program. +func parseTUIFlags(args []string) (f tuiFlags, unknown string, ok bool) { + for _, a := range args { + switch a { + case "--demo": + f.demo = true + case "--rename-tabs": + f.renameTabs = true + default: + return tuiFlags{}, a, false } } - runTUI(false) + return f, "", true } // helpText is `fleetops --help`/`-h`/`help`'s full output: a one-line @@ -63,6 +97,7 @@ Code sessions and lets you approve/resume/inject/kill them from one TUI. Usage: fleetops launch the fleet cockpit (TUI) fleetops --demo launch the TUI with a synthetic fleet — no real data, no disk writes + fleetops --rename-tabs (with the TUI) mirror each loop's state + goal onto its cmux tab title (opt-in, cmux only) fleetops report [--since D] plain-text summary of the event history (default 24h) fleetops hooks install register fleetops's Claude Code hooks (gate/idle detection) fleetops hooks uninstall remove them @@ -101,14 +136,16 @@ TUI keymap: // directly testable without starting a real Bubble Tea program (Run() // takes over the terminal and blocks on input, unsafe to invoke in a // test). -func newModel(demo bool) tea.Model { +func newModel(demo, renameTabs bool) tea.Model { if demo { + // --demo ignores renameTabs: demo never scans real loops, so there is + // nothing to mirror onto a tab (and demo must touch nothing real). return tui.NewDemo() } - return tui.New() + return tui.New().WithRenameTabs(renameTabs) } -func runTUI(demo bool) { +func runTUI(demo, renameTabs bool) { if demo { // --demo ignores ~/.fleetops/settings.json entirely and always spawns // with the built-in ["claude"]. Demo mode's contract is "nothing real", @@ -121,7 +158,7 @@ func runTUI(demo bool) { // keymap. control.UseDefaultSpawnCommand() } - p := tea.NewProgram(newModel(demo), tea.WithAltScreen()) + p := tea.NewProgram(newModel(demo, renameTabs), tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, "fleetops:", err) os.Exit(1) diff --git a/cmd/fleetops/main_test.go b/cmd/fleetops/main_test.go index b640f67..bb0c995 100644 --- a/cmd/fleetops/main_test.go +++ b/cmd/fleetops/main_test.go @@ -99,6 +99,47 @@ func TestHelpText_MentionsDemoFlag(t *testing.T) { } } +func TestParseTUIFlags(t *testing.T) { + cases := []struct { + name string + args []string + wantDemo bool + wantRenameTabs bool + wantUnknown string + wantOK bool + }{ + {"no flags", nil, false, false, "", true}, + {"demo only", []string{"--demo"}, true, false, "", true}, + {"rename-tabs only", []string{"--rename-tabs"}, false, true, "", true}, + {"both, any order", []string{"--rename-tabs", "--demo"}, true, true, "", true}, + {"unknown token refuses", []string{"--bogus"}, false, false, "--bogus", false}, + {"names the offending token, not the first arg", []string{"--demo", "--nope"}, false, false, "--nope", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, unknown, ok := parseTUIFlags(tc.args) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if !ok { + if unknown != tc.wantUnknown { + t.Errorf("unknown token = %q, want %q", unknown, tc.wantUnknown) + } + return // on refusal the flags are the zero value; nothing else to check + } + if f.demo != tc.wantDemo || f.renameTabs != tc.wantRenameTabs { + t.Errorf("parseTUIFlags(%v) = %+v, want demo=%v renameTabs=%v", tc.args, f, tc.wantDemo, tc.wantRenameTabs) + } + }) + } +} + +func TestHelpText_MentionsRenameTabsFlag(t *testing.T) { + if got := helpText(); !strings.Contains(got, "--rename-tabs") { + t.Errorf("expected help text to mention --rename-tabs, got:\n%s", got) + } +} + // TestNewModel_DemoFlag_RoutesToDemoConstructor is the "--demo flag routes // to demo mode" proof at this package's level: newModel(true) must be // tui.NewDemo()'s result, not tui.New()'s — verified through the ONLY @@ -108,7 +149,7 @@ func TestHelpText_MentionsDemoFlag(t *testing.T) { // would take over the terminal and block on input), so this is safe to // run in-process. func TestNewModel_DemoFlag_RoutesToDemoConstructor(t *testing.T) { - demoView := newModel(true).View() + demoView := newModel(true, false).View() if !strings.Contains(demoView, "demo mode") { t.Errorf("newModel(true).View() did not contain the demo status line:\n%s", demoView) } @@ -116,7 +157,7 @@ func TestNewModel_DemoFlag_RoutesToDemoConstructor(t *testing.T) { t.Errorf("newModel(true).View() did not contain the demo hostname \"dev-box\":\n%s", demoView) } - normalView := newModel(false).View() + normalView := newModel(false, false).View() if strings.Contains(normalView, "demo mode") { t.Errorf("newModel(false).View() unexpectedly contained the demo status line:\n%s", normalView) } diff --git a/internal/control/actuation.go b/internal/control/actuation.go index 4ea61e4..760d500 100644 --- a/internal/control/actuation.go +++ b/internal/control/actuation.go @@ -127,10 +127,8 @@ func ResolveActuationTarget(sessionsDir, sessionID, projectDir string) (act Actu if entry, err := sessions.ReadSession(sessionsDir, sessionID); err == nil && entry.TTY != "" && pidTTYFn(entry.PID) == normalizeTTY(entry.TTY) { // Tier 1a — session-unique tty. Probe every available TTYLocator // backend; first hit wins (no ambiguity guard needed). - for _, c := range avail { - if t, ok := tierOneA(c, entry.TTY); ok { - return boundController{ctrl: c, target: t}, true, true - } + if c, t, ok := tierOneAAcross(avail, entry.TTY); ok { + return boundController{ctrl: c, target: t}, true, true } // Tier 1h — the host terminal writes to the session in place, keyed by // the registry's host_app + window_id. @@ -183,6 +181,36 @@ func ResolveActuationTarget(sessionsDir, sessionID, projectDir string) (act Actu // Tier 1b — cwd is many-to-one, so probe ALL available backends and count // matches; >=2 distinct backends matching is cross-backend ambiguity and // must refuse, never silently pick one. + if c, t, ok := tierOneBAcross(avail, projectDir); ok { + return boundController{ctrl: c, target: t}, true, true + } + return nil, true, false +} + +// tierOneAAcross runs Tier 1a (the session-unique tty locate) across the +// available backends: the first TTYLocator hit wins, with NO ambiguity guard +// because a tty is session-unique by construction (see ResolveActuationTarget's +// Tier 1a doc). Extracted so the tab-title resolver reuses the EXACT same +// locate discipline as typed actuation rather than re-spelling it (DRY: if the +// tty rule changes, both move together). +func tierOneAAcross(avail []Controller, tty string) (Controller, Target, bool) { + for _, c := range avail { + if t, ok := tierOneA(c, tty); ok { + return c, t, true + } + } + return nil, Target{}, false +} + +// tierOneBAcross runs Tier 1b (the cwd LocateClaude probe) across the available +// backends, REFUSING on cross-backend ambiguity: because cwd is many-to-one it +// counts matches and returns not-found when >=2 DISTINCT backends each locate a +// claude surface for the same projectDir — the cross-backend analogue of +// LocateClaude's own ">1 match" refusal. Exactly one match → use it; zero or +// >=2 → not found. Extracted alongside tierOneAAcross for the same DRY reason +// (see its doc) — this is the single home of the fail-closed ambiguity rule +// both actuation and tab-titling depend on. +func tierOneBAcross(avail []Controller, projectDir string) (Controller, Target, bool) { var matchedCtrl Controller var matchedTarget Target matches := 0 @@ -193,9 +221,59 @@ func ResolveActuationTarget(sessionsDir, sessionID, projectDir string) (act Actu } } if matches == 1 { - return boundController{ctrl: matchedCtrl, target: matchedTarget}, true, true + return matchedCtrl, matchedTarget, true } - return nil, true, false + return nil, Target{}, false +} + +// ResolveTabTitler resolves the terminal surface a loop's tab-title write +// should land on, reusing ResolveActuationTarget's SAME fail-closed locate +// discipline — Tier 1a (session-unique tty) then Tier 1b (cwd LocateClaude +// across every backend, REFUSING on cross-backend ambiguity) — but returning a +// TabTitler + its Target instead of an Actuator. A tab rename writes terminal +// CHROME, not the session, so it must not travel the send/keystroke Actuator +// seam (pivot §5.1: borrow the discipline, not the types). +// +// Tier 1h (host-send) is deliberately OMITTED: a bare host window has no cmux +// tab to rename, and cmux is the only TabTitler today — so there is nothing for +// a host-send tier to resolve to here. +// +// ok=false — and the caller MUST skip, never rename a guessed tab — when: no +// backend is available, the cwd mapping is ambiguous (>=2 backends match), or +// the resolved backend does not implement TabTitler (tmux/orca ⇒ no-op). Same +// fail-closed posture as ResolveActuationTarget. +func ResolveTabTitler(sessionsDir, sessionID, projectDir string) (TabTitler, Target, bool) { + ctrl, target, ok := resolveClaudeSurface(sessionsDir, sessionID, projectDir) + if !ok { + return nil, Target{}, false + } + titler, ok := ctrl.(TabTitler) + if !ok { + return nil, Target{}, false // resolved backend can't title tabs (tmux/orca) — no-op + } + return titler, target, true +} + +// resolveClaudeSurface is the (Controller, Target) resolution core behind +// tab-titling: Tier 1a (tty) then Tier 1b (cwd, ambiguity-refusing) from +// ResolveActuationTarget, WITHOUT Tier 1h (host-send has no Controller/Target) +// and WITHOUT wrapping the result in an Actuator. It shares the exact tier +// helpers ResolveActuationTarget uses (tierOneAAcross/tierOneBAcross), so the +// fail-closed locate rules cannot drift between the two paths; it stays a +// SEPARATE function rather than being folded into ResolveActuationTarget +// because that function's Actuator return and interleaved Tier 1h branch are +// load-bearing for typed actuation and must not change shape. +func resolveClaudeSurface(sessionsDir, sessionID, projectDir string) (Controller, Target, bool) { + avail := availableBackends() + if entry, err := sessions.ReadSession(sessionsDir, sessionID); err == nil && entry.TTY != "" && pidTTYFn(entry.PID) == normalizeTTY(entry.TTY) { + if c, t, ok := tierOneAAcross(avail, entry.TTY); ok { + return c, t, true + } + } + if len(avail) == 0 { + return nil, Target{}, false + } + return tierOneBAcross(avail, projectDir) } // availableBackends returns the backends usable right now, in the shared diff --git a/internal/control/cmux.go b/internal/control/cmux.go index bb38a28..be024b7 100644 --- a/internal/control/cmux.go +++ b/internal/control/cmux.go @@ -165,6 +165,50 @@ func cmuxInterruptCmd(surfaceRef, windowRef string) []string { return append(argv, "escape") } +// SetTabTitle implements control.TabTitler: it renames the cmux TAB (window +// chrome) hosting a surface to title, via `cmux rename-tab --surface <ref> -- +// <title>` (the name is positional, after the `--` terminator). Routed through +// the SAME bounded-exec discipline every other cmux actuation uses +// (runWithTimeout, via cmuxTabRunner) so a wedged cmux can never hang a scan +// tick — a failure/timeout returns an error and the caller degrades to "didn't +// rename", never a hang. +// +// SAME-WORKSPACE ONLY in v1: unlike Resume/Approve/Focus this passes NO +// `--window` ref (see appendCmuxWindow), so cmux scopes the `--surface` ref to +// the caller's own workspace and a tab in another workspace fails (→ caller +// skips). Cross-workspace tab rename is a documented follow-up (pivot §5.1), +// not fabricated here. +// +// cmux is the ONLY backend implementing TabTitler today; tmux/iTerm2/orca are +// interface-sufficient follow-ups. +// +// NOT live-verified: no `cmux` binary is installed on the machine this was +// written on, and the `rename-tab` subcommand's exact contract must be +// confirmed against a real cmux (`cmux rename-tab --help`) before this is +// relied on — cmux's public docs contradict each other on the surface-rename +// verbs (pivot §5.1). The injectable cmuxTabRunner seam is what lets the argv +// shape and error propagation be unit-tested WITHOUT that binary in the +// meantime. +func (cmuxController) SetTabTitle(t Target, title string) error { + return cmuxTabRunner(cmuxRenameTabCmd(t.ID, title)) +} + +// cmuxTabRunner is the injectable exec seam SetTabTitle's cmux call goes +// through — runWithTimeout in production (bounded by actuationTimeout), swapped +// by a fake in tests so the exact argv and error propagation are verifiable +// WITHOUT a cmux binary installed. A func var defaulted to the real impl, the +// same injectable-seam idiom cmux.go already uses for ttyResolver. +var cmuxTabRunner = runWithTimeout + +// cmuxRenameTabCmd builds the argv that renames the tab hosting a surface: +// cmux rename-tab --surface <ref> -- <title>. The title is positional AFTER the +// `--` terminator so a title beginning with a dash (e.g. a glyph that reads as +// a flag) is never mis-parsed as an option. No `--window`: v1 is same-workspace +// only (see SetTabTitle). +func cmuxRenameTabCmd(surfaceRef, title string) []string { + return []string{"cmux", "rename-tab", "--surface", surfaceRef, "--", title} +} + // cmuxTreeJSON runs `cmux tree --json`, bounded by availabilityTimeout so a // wedged cmux never hangs a keypress. func cmuxTreeJSON() ([]byte, error) { diff --git a/internal/control/cmux_tabtitle_test.go b/internal/control/cmux_tabtitle_test.go new file mode 100644 index 0000000..95440d4 --- /dev/null +++ b/internal/control/cmux_tabtitle_test.go @@ -0,0 +1,87 @@ +package control + +import ( + "errors" + "reflect" + "testing" +) + +// --- cmux tab-rename argv shape (feat/cmux-tab-rename) --- +// +// The title is positional AFTER the `--` terminator (a title that begins with a +// dash must never be read as a flag), and v1 passes NO --window (same-workspace +// only), unlike the other cmux actuation builders. + +func TestCmuxRenameTabCmd_TitleIsPositionalAfterTerminator(t *testing.T) { + got := cmuxRenameTabCmd("surface:2", "◆GATE auth-mw") + want := []string{"cmux", "rename-tab", "--surface", "surface:2", "--", "◆GATE auth-mw"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestCmuxRenameTabCmd_DashLeadingTitle_NotParsedAsFlag(t *testing.T) { + // A title starting with "-" must sit after "--" so cmux treats it as the + // name, not an option. + got := cmuxRenameTabCmd("surface:9", "-idle") + want := []string{"cmux", "rename-tab", "--surface", "surface:9", "--", "-idle"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + // The "--" terminator must precede the title. + if got[len(got)-2] != "--" { + t.Errorf("expected %q before the title, got argv %v", "--", got) + } +} + +// --- SetTabTitle routes through the injectable cmuxTabRunner seam --- + +// withCmuxTabRunner swaps the exec seam for one test, restoring it after. +func withCmuxTabRunner(t *testing.T, fn func([]string) error) { + t.Helper() + orig := cmuxTabRunner + t.Cleanup(func() { cmuxTabRunner = orig }) + cmuxTabRunner = fn +} + +func TestCmuxSetTabTitle_PassesExactArgvThroughSeam(t *testing.T) { + var gotArgv []string + withCmuxTabRunner(t, func(argv []string) error { + gotArgv = argv + return nil + }) + + err := cmuxController{}.SetTabTitle(Target{Backend: "cmux", ID: "surface:3"}, "⏸STALL rate-limit fx") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []string{"cmux", "rename-tab", "--surface", "surface:3", "--", "⏸STALL rate-limit fx"} + if !reflect.DeepEqual(gotArgv, want) { + t.Errorf("runner received %v, want %v", gotArgv, want) + } +} + +func TestCmuxSetTabTitle_PropagatesRunnerError(t *testing.T) { + // A bounded-exec failure/timeout (the runner returning an error) must + // propagate so the caller degrades to "didn't rename" rather than assuming + // success. + sentinel := errors.New("cmux wedged / timed out") + withCmuxTabRunner(t, func([]string) error { return sentinel }) + + err := cmuxController{}.SetTabTitle(Target{Backend: "cmux", ID: "surface:3"}, "◆GATE x") + if !errors.Is(err, sentinel) { + t.Errorf("SetTabTitle error = %v, want it to wrap/return %v", err, sentinel) + } +} + +// TestCmuxSetTabTitle_DefaultSeamIsBoundedExec pins that the PRODUCTION seam is +// the shared bounded-exec path (runWithTimeout), the same never-hang discipline +// every other cmux actuation uses — so a wedged cmux cannot hang a scan tick. +// The func identity is what guarantees the bound; runWithTimeout's own kill +// behavior is covered by TestRunBounded_KillsAHangingCommand. +func TestCmuxSetTabTitle_DefaultSeamIsBoundedExec(t *testing.T) { + if reflect.ValueOf(cmuxTabRunner).Pointer() != reflect.ValueOf(runWithTimeout).Pointer() { + t.Error("cmuxTabRunner must default to runWithTimeout (bounded exec) so a wedged cmux never hangs a tick") + } +} diff --git a/internal/control/control.go b/internal/control/control.go index 37452a4..4920897 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -210,6 +210,30 @@ type TTYLocator interface { LocateByTTY(tty string) (Target, bool) } +// TabTitler is another OPTIONAL capability (same narrow-interface idiom as +// WorktreeSpawner/TerminalOpener/TTYLocator — don't widen Controller with a +// method every backend must stub): write a loop's presentation state onto the +// TAB STRIP of the multiplexer hosting it, so a human watching the tab bar +// (not the fleetops TUI) still sees state + goal. The tab itself becomes an +// output surface for what fleetops already knows. +// +// This is terminal CHROME — a write SIDE-EFFECT on a surface fleetops did NOT +// create — so it is the SAME wrong-pane hazard class as typed actuation, and +// callers MUST treat it that way: gate it behind an opt-in flag and resolve the +// target through the SAME fail-closed locate/ambiguity refusal (see +// ResolveTabTitler), never a guessed tab. It deliberately does NOT route through +// the Actuator seam (send/keystroke verbs): a rename touches the window chrome, +// not the session, so it borrows actuation's discipline but not its types. +// +// cmux implements it (`cmux rename-tab`); tmux/iTerm2/orca are interface- +// sufficient follow-ups and deliberately do NOT implement it today, so +// ResolveTabTitler's type-assert simply yields a no-op for them (absent ⇒ skip). +// Callers type-assert (ctrl.(TabTitler)), exactly like the other optional +// capabilities. +type TabTitler interface { + SetTabTitle(t Target, title string) error +} + // backends is the ordered, install-preference backend list every resolver in // this package shares — orca preferred (the user's own environment), cmux then // tmux as fallbacks. Extracted to a single package var (rather than a literal diff --git a/internal/control/tabtitler_resolve_test.go b/internal/control/tabtitler_resolve_test.go new file mode 100644 index 0000000..d8c3445 --- /dev/null +++ b/internal/control/tabtitler_resolve_test.go @@ -0,0 +1,122 @@ +package control + +import ( + "testing" + + "github.com/jitokim/fleetops/internal/sessions" +) + +// fakeTitlerCtl is a fakeResolveCtl that ALSO implements TabTitler — the cmux +// shape (a backend that can rename its tabs). Records the last SetTabTitle call +// so a resolver test can confirm the returned titler is really this backend. +type fakeTitlerCtl struct { + *fakeResolveCtl + gotTarget Target + gotTitle string +} + +func (f *fakeTitlerCtl) SetTabTitle(t Target, title string) error { + f.gotTarget = t + f.gotTitle = title + return nil +} + +// TestResolveTabTitler_SingleCmuxMatch_ReturnsTitler: exactly one available +// backend locates the loop AND implements TabTitler → resolve it. +func TestResolveTabTitler_SingleCmuxMatch_ReturnsTitler(t *testing.T) { + titlerBackend := &fakeTitlerCtl{fakeResolveCtl: &fakeResolveCtl{t: t, name: "cmux", available: true, locateClaudeOK: true}} + withBackends(t, titlerBackend) + + titler, target, ok := ResolveTabTitler(t.TempDir(), "sess-1", "-x-proj") + + if !ok { + t.Fatal("expected ok=true — one cmux backend locates the loop and can title tabs") + } + if target.Backend != "cmux" { + t.Errorf("target.Backend = %q, want cmux", target.Backend) + } + // The returned titler must be the resolved backend — drive it and confirm. + if err := titler.SetTabTitle(target, "◆GATE x"); err != nil { + t.Fatalf("SetTabTitle: %v", err) + } + if titlerBackend.gotTitle != "◆GATE x" || titlerBackend.gotTarget != target { + t.Errorf("titler did not forward to the resolved backend: title=%q target=%+v", titlerBackend.gotTitle, titlerBackend.gotTarget) + } +} + +// TestResolveTabTitler_AmbiguousCwd_Refuses: two distinct backends both locate a +// claude surface for the same projectDir → fail closed (never rename a guessed +// tab), exactly like typed actuation's cross-backend ambiguity refusal. +func TestResolveTabTitler_AmbiguousCwd_Refuses(t *testing.T) { + a := &fakeTitlerCtl{fakeResolveCtl: &fakeResolveCtl{t: t, name: "cmux", available: true, locateClaudeOK: true}} + b := &fakeTitlerCtl{fakeResolveCtl: &fakeResolveCtl{t: t, name: "cmux2", available: true, locateClaudeOK: true}} + withBackends(t, a, b) + + _, _, ok := ResolveTabTitler(t.TempDir(), "sess-1", "-x-proj") + if ok { + t.Error("expected ok=false — two backends match the same cwd (ambiguous, must refuse)") + } +} + +// TestResolveTabTitler_ResolvedBackendNotATitler_NoOp: the loop resolves to a +// backend that does NOT implement TabTitler (tmux/orca shape) → no-op (ok=false), +// so the caller skips rather than crashing on the failed type assertion. +func TestResolveTabTitler_ResolvedBackendNotATitler_NoOp(t *testing.T) { + plain := &fakeResolveCtl{t: t, name: "tmux", available: true, locateClaudeOK: true} + withBackends(t, plain) + + _, _, ok := ResolveTabTitler(t.TempDir(), "sess-1", "-x-proj") + if ok { + t.Error("expected ok=false — resolved backend does not implement TabTitler") + } +} + +// fakeTitlerTTYCtl implements TTYLocator (Tier 1a) AND TabTitler — the cmux +// shape (a per-terminal tty is reachable and its tab can be renamed). +type fakeTitlerTTYCtl struct { + *fakeResolveTTYCtl +} + +func (fakeTitlerTTYCtl) SetTabTitle(Target, string) error { return nil } + +// TestResolveTabTitler_TierOneA_TTYMatch_ReturnsTitler pins the tty (Tier 1a) +// path specifically: a validated registry binding (entry tty == live pid's tty) +// resolves via LocateByTTY, then narrows to TabTitler — the high-confidence +// mapping the pivot calls out (tty-exact ⇒ no cwd ambiguity at all). +func TestResolveTabTitler_TierOneA_TTYMatch_ReturnsTitler(t *testing.T) { + dir := t.TempDir() + if err := sessions.WriteSession(dir, "sess-1", sessions.SessionEntry{PID: 42, TTY: "ttys012"}); err != nil { + t.Fatalf("WriteSession: %v", err) + } + backend := fakeTitlerTTYCtl{&fakeResolveTTYCtl{ + fakeResolveCtl: &fakeResolveCtl{t: t, name: "cmux", available: true}, + locateByTTYOK: true, + }} + withBackends(t, backend) + + origPidTTY := pidTTYFn + t.Cleanup(func() { pidTTYFn = origPidTTY }) + pidTTYFn = func(int) string { return "ttys012" } // binding confirmed + + titler, target, ok := ResolveTabTitler(dir, "sess-1", "-x-proj") + if !ok { + t.Fatal("expected ok=true via Tier 1a tty match") + } + if target.Backend != "cmux" { + t.Errorf("target.Backend = %q, want cmux", target.Backend) + } + if titler == nil { + t.Error("expected a non-nil titler from the tty-resolved backend") + } +} + +// TestResolveTabTitler_NoMatch_Refuses: no backend locates the loop → not found. +func TestResolveTabTitler_NoMatch_Refuses(t *testing.T) { + miss := &fakeTitlerCtl{fakeResolveCtl: &fakeResolveCtl{t: t, name: "cmux", available: true, locateClaudeOK: false}} + withBackends(t, miss) + + _, _, ok := ResolveTabTitler(t.TempDir(), "sess-1", "-x-proj") + if ok { + t.Error("expected ok=false — no backend can locate the loop's surface") + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 1977ff6..1097115 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -67,8 +67,16 @@ var ( resolveActuationTargetFn = control.ResolveActuationTarget redriveFn = control.Redrive sessionsDirFn = sessions.SessionsDir - historyDirFn = events.HistoryDir - notifySendFn = notify.Send + // resolveTabTitlerFn is control.ResolveTabTitler by default — the + // feat/cmux-tab-rename target resolver, which reuses actuation's SAME + // fail-closed locate/ambiguity discipline but returns a TabTitler+Target + // (terminal chrome, not the send/keystroke Actuator seam — pivot §5.1). + // A seam so the presentation-sync driver's debounce, opt-in gate, and + // ambiguity-skip are unit-testable without a real cmux binary (there is + // none on the dev box). + resolveTabTitlerFn = control.ResolveTabTitler + historyDirFn = events.HistoryDir + notifySendFn = notify.Send // hiddenFileFn is hidden.HiddenFile by default — the persisted hide-set // seam ("d"/"x"). Overridable so the hide/delete keys and startup load can // be verified without touching the real ~/.fleetops/hidden.json. @@ -715,6 +723,31 @@ type Model struct { // re-surfaces a still-broken install, since "dismissed once" must never // mean "silently half-working forever". hookDismissed bool + + // renameTabs is feat/cmux-tab-rename's OPT-IN gate (the `--rename-tabs` + // flag, default OFF — see WithRenameTabs). When false the fleet is observed + // purely: no tab is ever renamed. When true, each scan tick mirrors every + // unambiguously-mapped cmux loop's composed title onto its tab (see + // renameTabsCmd). Off by default because a rename is a write side-effect on + // a terminal fleetops did not create (pivot §5.1). + renameTabs bool + // lastTabTitle is the per-session debounce ledger for tab rename: sessionID + // → the title LAST SUCCESSFULLY WRITTEN. A rename fires only when the freshly + // composed title differs from this (steady state ⇒ zero cmux writes), and an + // entry is recorded ONLY after the write confirms (tabTitlesSyncedMsg), so a + // skipped/failed write is retried next tick rather than silently suppressed. + // In-memory only, like notifiedAt: a restart re-writes each tab once, which + // is harmless (the rename is idempotent). + lastTabTitle map[string]string +} + +// WithRenameTabs sets the opt-in tab-rename gate (the `--rename-tabs` flag) and +// returns the updated Model — a value-receiver setter so the constructor stays +// arg-free (New()) and the flag is threaded in one explicit place at launch +// (cmd/fleetops). Default OFF: a Model built by New() alone never renames a tab. +func (m Model) WithRenameTabs(on bool) Model { + m.renameTabs = on + return m } func New() Model { @@ -1074,6 +1107,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // promoted State/Last for anything that converged, so triggerDrives // only ever sees the POST-scan picture, never a stale pre-scan one. cmds := append([]tea.Cmd{m.triggerJudgments(), m.triggerDrives(), emitTransitionsCmd(transitions), gitCmd, detailCmd, fleetOracleCountsCmd(newLoops)}, autoRedriveCmds...) + // feat/cmux-tab-rename: mirror each unambiguously-mapped cmux loop's + // composed state/goal onto its tab title, opt-in and debounced (see + // renameTabsCmd). nil (no-op) when the flag is off or nothing changed. + cmds = append(cmds, m.renameTabsCmd(newLoops)) return m, tea.Batch(cmds...) case gitStatsMsg: if m.gitStats == nil { @@ -1097,6 +1134,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.detailCache[msg.sessionID] = msg.entry return m, nil + case tabTitlesSyncedMsg: + // Record ONLY the tabs whose rename actually confirmed — a skipped + // (ambiguous / non-cmux) or failed (bounded-exec) write leaves its + // sessionID out of the ledger so it is retried next tick, never + // silently marked "already at this title" (see lastTabTitle). + if len(msg.written) == 0 { + return m, nil + } + if m.lastTabTitle == nil { + m.lastTabTitle = make(map[string]string, len(msg.written)) + } + for sessionID, title := range msg.written { + m.lastTabTitle[sessionID] = title + } + return m, nil case autoRedriveScheduledMsg: // Re-check the CURRENT (latest scan) state before firing — a loop // that recovered (or aged out of the fleet) during the 5-minute diff --git a/internal/tui/tabtitle.go b/internal/tui/tabtitle.go new file mode 100644 index 0000000..2318da4 --- /dev/null +++ b/internal/tui/tabtitle.go @@ -0,0 +1,234 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/jitokim/fleetops/internal/domain" +) + +// tabLabelMax bounds the goal-label (or subagent-type) portion of a cmux tab +// title to a compact width — the tab strip is narrow, so a long goal must be +// clipped rather than pushing the state token off-screen. ~16-20 columns per +// pivot §5.1; 18 sits in the middle. Measured in terminal COLUMNS via trunc +// (the SAME go-runewidth measure the TUI's own columns use — see trunc), not +// bytes or runes, so a CJK goal clips at the width it actually draws. +const tabLabelMax = 18 + +// Delegation takes precedence in a tab title: a delegating loop's parent tail +// is quiet, so its state alone ("running"/"idle") is uninformative — the tab +// should instead name what the subagent is doing, mirroring v0.9.0's DETAIL +// delegation row onto the tab strip. tabDelegateGlyph/tabDelegateToken compose +// with the label (the subagent type) into the same "<glyph><token> <label>" +// shape every other state uses, e.g. "▸deleg code-reviewer" (pivot §5.1). +const ( + tabDelegateGlyph = "▸" + tabDelegateToken = "deleg" +) + +// composeTabTitle builds the compact, width-bounded cmux tab title for a loop: +// a state glyph + token (from domain.StateString, so it carries the stall slug) +// plus a short goal label. A DELEGATING loop wins — its title names the +// subagent instead of the parent's quiet state (see delegationSubagent). +// +// Pure and total: every loop maps to a title, so the driver never has to decide +// whether a loop is titleable — that decision belongs to the target-resolution +// step (ambiguity refusal), not the composer. Examples (pivot §5.1): +// "◆GATE auth-mw", "▸deleg code-reviewer", "⏸STALL rate-limit fx", +// "✗DRIFT auth-mw". +func composeTabTitle(l domain.Loop) string { + if subType, ok := delegationSubagent(l); ok { + return tabTitleJoin(tabDelegateGlyph, tabDelegateToken, subType) + } + return tabTitleJoin(tabStateGlyph(l.State), tabStateToken(l.State, l.Stall), l.DisplayLabel()) +} + +// tabTitleJoin assembles "<glyph><token> <label>", width-bounding the label to +// tabLabelMax columns and omitting the label section entirely when it is empty +// (e.g. a delegation carrying no subagent type ⇒ just "▸deleg", honest rather +// than a dangling separator). +func tabTitleJoin(glyph, token, label string) string { + head := glyph + token + label = trunc(strings.TrimSpace(label), tabLabelMax) + if label == "" { + return head + } + return head + " " + label +} + +// tabStateToken renders a loop's state as the tab strip's compact token, +// derived from domain.StateString so it carries the stall slug the event +// history already encodes (e.g. "stalled:rate-limit" → "STALL rate-limit") — +// kept consistent with that persisted form rather than re-deriving the slug +// here. +func tabStateToken(state domain.LoopState, stall domain.StallKind) string { + if base, slug, ok := strings.Cut(domain.StateString(state, stall), ":"); ok { + return tabStateWord(domain.LoopState(base)) + " " + slug + } + return tabStateWord(state) +} + +// tabStateWord is the short, upper-case word for a state in a tab title. The +// exact words are a presentation choice consistent with the pivot §5.1 examples +// (GATE / STALL / DRIFT) and with the fleet list's own state labels +// (stateLabel); the fallback upper-cases any state not enumerated so a new +// LoopState still renders honestly rather than blank. +func tabStateWord(state domain.LoopState) string { + switch state { + case domain.StateRunning: + return "RUN" + case domain.StateGate: + return "GATE" + case domain.StateStalled: + return "STALL" + case domain.StateIdle: + return "IDLE" + case domain.StateDrift: + return "DRIFT" + case domain.StateDone: + return "DONE" + case domain.StateFailed: + return "FAIL" + case domain.StatePaused: + return "PAUSE" + case domain.StateKilled: + return "KILL" + default: + return strings.ToUpper(string(state)) + } +} + +// tabStateGlyph is the single glyph a state leads with in a tab title — a +// presentation choice matching the pivot §5.1 examples (◆ gate, ⏸ stalled, +// ✗ drift) and the glyph vocabulary the fleet list already draws (stateLabel). +func tabStateGlyph(state domain.LoopState) string { + switch state { + case domain.StateGate: + return "◆" + case domain.StateStalled: + return "⏸" + case domain.StateRunning: + return "●" + case domain.StateIdle: + return "·" + case domain.StateDrift, domain.StateFailed: + return "✗" + case domain.StateDone: + return "✓" + case domain.StateKilled: + return "☠" + case domain.StatePaused: + return "‖" + default: + return "·" + } +} + +// tabSyncEntry pairs a loop whose composed tab title CHANGED with the title to +// write — the unit renameTabsCmd's off-loop write pass consumes. +type tabSyncEntry struct { + loop domain.Loop + title string +} + +// tabTitlesSyncedMsg reports back which sessions' tabs were ACTUALLY renamed +// (sessionID → title written) so the handler can advance the debounce ledger — +// only confirmed writes, so a skipped/failed one is retried next tick. +type tabTitlesSyncedMsg struct { + written map[string]string +} + +// pendingTabTitleChanges is the driver's pure diff step: the loops whose freshly +// composed title differs from the last one written (debounce), or NONE when the +// opt-in gate is off. Keeping the gate + debounce here (off the event loop, no +// I/O) makes both directly unit-testable and means a steady-state tick produces +// zero work — the composer is pure and cheap, so this runs inline in the handler. +func (m Model) pendingTabTitleChanges(loops []domain.Loop) []tabSyncEntry { + if !m.renameTabs { + return nil // opt-in gate: observation stays pure unless --rename-tabs + } + var changed []tabSyncEntry + for i := range loops { + title := composeTabTitle(loops[i]) + if m.lastTabTitle[loops[i].SessionID] == title { + continue // debounce: unchanged since the last successful write + } + changed = append(changed, tabSyncEntry{loop: loops[i], title: title}) + } + return changed +} + +// renameTabsCmd mirrors each changed loop's composed title onto its cmux tab, +// off the event loop. It resolves every target through resolveTabTitlerFn — the +// SAME fail-closed locate/ambiguity discipline typed actuation uses — and skips +// (never renames) any loop that is ambiguous, non-cmux, or whose bounded-exec +// write fails: a cmux failure degrades to "didn't rename", never a hang or a +// crash. Returns nil (no command) when the gate is off or nothing changed, so a +// steady-state tick costs a pure diff and no goroutine. +func (m Model) renameTabsCmd(loops []domain.Loop) tea.Cmd { + changed := m.pendingTabTitleChanges(loops) + if len(changed) == 0 { + return nil + } + sessionsDir := sessionsDirFn() + return func() tea.Msg { + written := make(map[string]string, len(changed)) + for _, e := range changed { + titler, target, ok := resolveTabTitlerFn(sessionsDir, e.loop.SessionID, e.loop.ProjectDir) + if !ok { + continue // ambiguous / non-cmux / no backend — fail-closed skip + } + if err := titler.SetTabTitle(target, e.title); err != nil { + continue // bounded-exec failure/timeout — degrade to "didn't rename" + } + written[e.loop.SessionID] = e.title + } + return tabTitlesSyncedMsg{written: written} + } +} + +// delegationSubagent reports whether a loop is DELEGATING and, if so, the +// subagent type it handed off to — reusing the SAME signal the scanner writes: +// a delegating loop's LastText is the "delegating: <type> — <child line>" +// summary claude.applySubagentDelegation synthesizes (see claude.formatDelegating). +// This intentionally keys off that string form rather than a new field so tab +// titling and the DETAIL row read one signal; the coupling to formatDelegating's +// literal output is the trade (a structured domain.Loop field carried from the +// scanner would be the cleaner follow-up, but it lives in a different package). +// +// HONESTY (why this is not a bare HasPrefix): composeTabTitle runs for EVERY +// loop, and a non-delegating loop's LastText is the RAW assistant tail +// (domain.Loop.LastText). So the match must reject prose that merely begins with +// the word "delegating" (e.g. "delegating the migration to a script") — else the +// tab would fabricate a delegation. It accepts ONLY the exact boundaries +// formatDelegating ever emits after the head: end-of-string (bare), ":" (typed), +// " —" (child line, no type), or " (" (live count, no type). Anything else is +// prose ⇒ not a delegation. +// +// The type is "" when the delegation carried none (the bare/child/live-count +// forms), which composeTabTitle renders as a bare "▸deleg" — honest, not invented. +func delegationSubagent(l domain.Loop) (string, bool) { + const prefix = "delegating" + if !strings.HasPrefix(l.LastText, prefix) { + return "", false + } + rest := l.LastText[len(prefix):] + // Only the scanner's authoritative boundaries count (see the doc above); a + // raw tail like " the migration…" is prose, not a delegation. + if rest != "" && !strings.HasPrefix(rest, ":") && !strings.HasPrefix(rest, " —") && !strings.HasPrefix(rest, " (") { + return "", false + } + if !strings.HasPrefix(rest, ":") { + return "", true // the bare / child-line / live-count forms carry no type + } + // Typed form: strip the ": " and drop the child-line ("… — <line>") and + // live-count ("… (N live)") suffixes formatDelegating may append. + rest = strings.TrimPrefix(strings.TrimPrefix(rest, ":"), " ") + if i := strings.Index(rest, " — "); i >= 0 { + rest = rest[:i] + } + if i := strings.Index(rest, " ("); i >= 0 { + rest = rest[:i] + } + return strings.TrimSpace(rest), true +} diff --git a/internal/tui/tabtitle_test.go b/internal/tui/tabtitle_test.go new file mode 100644 index 0000000..3e8bd25 --- /dev/null +++ b/internal/tui/tabtitle_test.go @@ -0,0 +1,354 @@ +package tui + +import ( + "errors" + "testing" + + "github.com/jitokim/fleetops/internal/control" + "github.com/jitokim/fleetops/internal/domain" +) + +// --- composeTabTitle: one row per state + delegation precedence + width --- + +func TestComposeTabTitle_Table(t *testing.T) { + cases := []struct { + name string + loop domain.Loop + want string + }{ + { + name: "gate", + loop: domain.Loop{State: domain.StateGate, Goal: domain.Goal{Text: "auth-mw"}}, + want: "◆GATE auth-mw", + }, + { + name: "stalled rate-limit carries the slug", + loop: domain.Loop{State: domain.StateStalled, Stall: domain.StallRateLimit, Goal: domain.Goal{Text: "fx"}}, + want: "⏸STALL rate-limit fx", + }, + { + name: "stalled no-output", + loop: domain.Loop{State: domain.StateStalled, Stall: domain.StallNoOutput, Goal: domain.Goal{Text: "fx"}}, + want: "⏸STALL no-output fx", + }, + { + name: "stalled gone", + loop: domain.Loop{State: domain.StateStalled, Stall: domain.StallGone, Goal: domain.Goal{Text: "fx"}}, + want: "⏸STALL gone fx", + }, + { + name: "running", + loop: domain.Loop{State: domain.StateRunning, Goal: domain.Goal{Text: "fx"}}, + want: "●RUN fx", + }, + { + name: "idle", + loop: domain.Loop{State: domain.StateIdle, Goal: domain.Goal{Text: "fx"}}, + want: "·IDLE fx", + }, + { + name: "drift", + loop: domain.Loop{State: domain.StateDrift, Goal: domain.Goal{Text: "auth-mw"}}, + want: "✗DRIFT auth-mw", + }, + { + name: "done", + loop: domain.Loop{State: domain.StateDone, Goal: domain.Goal{Text: "fx"}}, + want: "✓DONE fx", + }, + { + name: "explicit name wins over goal for the label", + loop: domain.Loop{State: domain.StateGate, Name: "nice-name", Goal: domain.Goal{Text: "raw goal text"}}, + want: "◆GATE nice-name", + }, + { + // Delegation precedence: even when the state is Stalled, a delegating + // LastText makes the tab name the subagent, not the parent's state. + name: "delegating wins over state", + loop: domain.Loop{State: domain.StateStalled, Stall: domain.StallNoOutput, Goal: domain.Goal{Text: "fx"}, LastText: "delegating: code-reviewer — reviewing the diff"}, + want: "▸deleg code-reviewer", + }, + { + name: "delegating drops the (N live) suffix", + loop: domain.Loop{State: domain.StateRunning, LastText: "delegating: code-reviewer (2 live) — foo"}, + want: "▸deleg code-reviewer", + }, + { + name: "delegating with no subagent type omits the label", + loop: domain.Loop{State: domain.StateRunning, LastText: "delegating"}, + want: "▸deleg", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := composeTabTitle(tc.loop); got != tc.want { + t.Errorf("composeTabTitle = %q, want %q", got, tc.want) + } + }) + } +} + +func TestComposeTabTitle_LabelIsWidthBounded(t *testing.T) { + long := "this-is-a-very-long-goal-name-that-overflows-the-tab-strip" + title := composeTabTitle(domain.Loop{State: domain.StateRunning, Goal: domain.Goal{Text: long}}) + + const head = "●RUN " + if len(title) <= len(head) || title[:len(head)] != head { + t.Fatalf("title %q did not start with %q", title, head) + } + label := title[len(head):] + if w := narrowAmbiguous.StringWidth(label); w > tabLabelMax { + t.Errorf("label %q width = %d, want <= %d", label, w, tabLabelMax) + } + // A truncated label must carry the ellipsis, not silently drop characters. + if narrowAmbiguous.StringWidth(long) > tabLabelMax && label[len(label)-len("…"):] != "…" { + t.Errorf("expected an ellipsis on the truncated label, got %q", label) + } +} + +func TestComposeTabTitle_UnknownState_UppercasesRatherThanBlank(t *testing.T) { + // A LoopState not enumerated must still render an honest token, never blank. + got := composeTabTitle(domain.Loop{State: domain.LoopState("weird"), Goal: domain.Goal{Text: "x"}}) + if got != "·WEIRD x" { + t.Errorf("composeTabTitle for unknown state = %q, want %q", got, "·WEIRD x") + } +} + +// --- delegationSubagent signal extraction --- + +func TestDelegationSubagent(t *testing.T) { + cases := []struct { + name string + lastText string + wantType string + wantOK bool + }{ + {"not delegating", "some ordinary tail line", "", false}, + {"empty", "", "", false}, + {"type only", "delegating: code-reviewer", "code-reviewer", true}, + {"type + child line", "delegating: developer — writing tests", "developer", true}, + {"type + live count + child", "delegating: developer (3 live) — writing tests", "developer", true}, + {"bare delegating, no type", "delegating", "", true}, + {"no type but child line", "delegating — writing tests", "", true}, + {"no type but live count + child", "delegating (2 live) — writing tests", "", true}, + // HONESTY guard: raw assistant prose that merely starts with the word + // "delegating" must NOT be read as a delegation (no fabricated tab). + {"prose false-positive rejected", "delegating the migration to a script", "", false}, + {"prose word-boundary rejected", "delegatingx: nope", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotType, gotOK := delegationSubagent(domain.Loop{LastText: tc.lastText}) + if gotOK != tc.wantOK || gotType != tc.wantType { + t.Errorf("delegationSubagent(%q) = (%q, %v), want (%q, %v)", tc.lastText, gotType, gotOK, tc.wantType, tc.wantOK) + } + }) + } +} + +// --- presentation-sync driver: gate, debounce, refusal --- + +// fakeTabTitler records every SetTabTitle it receives and returns err. +type fakeTabTitler struct { + titles map[string]string // ID → title + err error +} + +func (f *fakeTabTitler) SetTabTitle(target control.Target, title string) error { + if f.titles == nil { + f.titles = map[string]string{} + } + f.titles[target.ID] = title + return f.err +} + +// withResolveTabTitler swaps the resolver seam for one test. +func withResolveTabTitler(t *testing.T, fn func(sessionsDir, sessionID, projectDir string) (control.TabTitler, control.Target, bool)) { + t.Helper() + orig := resolveTabTitlerFn + t.Cleanup(func() { resolveTabTitlerFn = orig }) + resolveTabTitlerFn = fn +} + +func TestPendingTabTitleChanges_OptInGateOff_NoWork(t *testing.T) { + m := New() // renameTabs defaults to false + l := domain.Loop{SessionID: "s1", State: domain.StateGate, Goal: domain.Goal{Text: "x"}} + if changed := m.pendingTabTitleChanges([]domain.Loop{l}); changed != nil { + t.Errorf("gate OFF must yield no changes, got %v", changed) + } + if cmd := m.renameTabsCmd([]domain.Loop{l}); cmd != nil { + t.Error("gate OFF must yield a nil cmd (no rename attempted)") + } +} + +func TestRenameTabsCmd_GateOff_NeverResolves(t *testing.T) { + resolved := false + withResolveTabTitler(t, func(_, _, _ string) (control.TabTitler, control.Target, bool) { + resolved = true + return nil, control.Target{}, false + }) + m := New() // gate off + if cmd := m.renameTabsCmd([]domain.Loop{{SessionID: "s1", State: domain.StateGate}}); cmd != nil { + cmd() + } + if resolved { + t.Error("gate OFF must never reach the target resolver") + } +} + +func TestPendingTabTitleChanges_Debounce_UnchangedTitleSkipped(t *testing.T) { + l := domain.Loop{SessionID: "s1", State: domain.StateGate, Goal: domain.Goal{Text: "x"}} + m := New().WithRenameTabs(true) + m.lastTabTitle = map[string]string{"s1": composeTabTitle(l)} + if changed := m.pendingTabTitleChanges([]domain.Loop{l}); len(changed) != 0 { + t.Errorf("unchanged title must be debounced (no work), got %v", changed) + } + // A state change flips the title, which must NOT be debounced. + l.State = domain.StateDrift + if changed := m.pendingTabTitleChanges([]domain.Loop{l}); len(changed) != 1 { + t.Errorf("changed title must produce one entry, got %v", changed) + } +} + +func TestRenameTabsCmd_WritesChangedTitle_ThenDebouncesEndToEnd(t *testing.T) { + titler := &fakeTabTitler{} + withResolveTabTitler(t, func(_, sessionID, _ string) (control.TabTitler, control.Target, bool) { + return titler, control.Target{Backend: "cmux", ID: "surface:" + sessionID}, true + }) + + l := domain.Loop{SessionID: "s1", ProjectDir: "-x-proj", State: domain.StateGate, Goal: domain.Goal{Text: "auth-mw"}} + m := New().WithRenameTabs(true) + + cmd := m.renameTabsCmd([]domain.Loop{l}) + if cmd == nil { + t.Fatal("expected a rename cmd for the changed title") + } + msg := cmd() + synced, ok := msg.(tabTitlesSyncedMsg) + if !ok { + t.Fatalf("expected tabTitlesSyncedMsg, got %T", msg) + } + if titler.titles["surface:s1"] != "◆GATE auth-mw" { + t.Errorf("SetTabTitle got %q, want %q", titler.titles["surface:s1"], "◆GATE auth-mw") + } + if synced.written["s1"] != "◆GATE auth-mw" { + t.Errorf("written ledger = %v, want s1→◆GATE auth-mw", synced.written) + } + + // Feed the result back: the handler advances the debounce ledger, and the + // NEXT tick with the SAME loop must produce zero work (steady state). + updated, _ := m.Update(msg) + mm := updated.(Model) + if changed := mm.pendingTabTitleChanges([]domain.Loop{l}); len(changed) != 0 { + t.Errorf("after a confirmed write the same loop must debounce, got %v", changed) + } +} + +func TestRenameTabsCmd_AmbiguousLoop_NoRenameNoLedger(t *testing.T) { + titler := &fakeTabTitler{} + // Resolver refuses (ok=false) — the ambiguity/fail-closed case. + withResolveTabTitler(t, func(_, _, _ string) (control.TabTitler, control.Target, bool) { + return nil, control.Target{}, false + }) + + l := domain.Loop{SessionID: "s1", State: domain.StateGate, Goal: domain.Goal{Text: "x"}} + m := New().WithRenameTabs(true) + + msg := m.renameTabsCmd([]domain.Loop{l})() + synced := msg.(tabTitlesSyncedMsg) + if len(synced.written) != 0 { + t.Errorf("ambiguous loop must not be renamed, written = %v", synced.written) + } + if len(titler.titles) != 0 { + t.Error("ambiguous loop must never reach SetTabTitle") + } + // Ledger must stay empty so the loop is retried once it disambiguates. + updated, _ := m.Update(msg) + if got := updated.(Model).lastTabTitle["s1"]; got != "" { + t.Errorf("ambiguous loop must not enter the debounce ledger, got %q", got) + } +} + +func TestRenameTabsCmd_NonCmuxBackend_NoOp(t *testing.T) { + // A non-cmux backend resolves to no TabTitler — control.ResolveTabTitler + // returns ok=false, so the driver skips exactly as for ambiguity. + withResolveTabTitler(t, func(_, _, _ string) (control.TabTitler, control.Target, bool) { + return nil, control.Target{}, false + }) + l := domain.Loop{SessionID: "s1", State: domain.StateGate, Goal: domain.Goal{Text: "x"}} + m := New().WithRenameTabs(true) + synced := m.renameTabsCmd([]domain.Loop{l})().(tabTitlesSyncedMsg) + if len(synced.written) != 0 { + t.Errorf("non-cmux backend must be a no-op, written = %v", synced.written) + } +} + +func TestRenameTabsCmd_WriteError_NotLedgered(t *testing.T) { + // A bounded-exec failure (SetTabTitle error) must NOT advance the ledger, so + // the title is retried next tick. + titler := &fakeTabTitler{err: errWriteFailed} + withResolveTabTitler(t, func(_, sessionID, _ string) (control.TabTitler, control.Target, bool) { + return titler, control.Target{Backend: "cmux", ID: sessionID}, true + }) + l := domain.Loop{SessionID: "s1", State: domain.StateGate, Goal: domain.Goal{Text: "x"}} + m := New().WithRenameTabs(true) + synced := m.renameTabsCmd([]domain.Loop{l})().(tabTitlesSyncedMsg) + if len(synced.written) != 0 { + t.Errorf("a failed write must not be ledgered, written = %v", synced.written) + } +} + +// TestRenameTabsCmd_MixedBatch_OnlyConfirmedLedgered drives ONE tick carrying +// three loops at once — an OK write, a failed write, and an ambiguous (refused) +// loop — and asserts only the confirmed one enters the debounce ledger. This is +// the crux invariant (skipped/failed writes must be retried, not suppressed), +// exercised for a multi-loop batch rather than one loop at a time. +func TestRenameTabsCmd_MixedBatch_OnlyConfirmedLedgered(t *testing.T) { + okTitler := &fakeTabTitler{} + errTitler := &fakeTabTitler{err: errWriteFailed} + withResolveTabTitler(t, func(_, sessionID, _ string) (control.TabTitler, control.Target, bool) { + switch sessionID { + case "ok": + return okTitler, control.Target{Backend: "cmux", ID: "surface:ok"}, true + case "writeerr": + return errTitler, control.Target{Backend: "cmux", ID: "surface:writeerr"}, true + default: // "ambiguous" + return nil, control.Target{}, false + } + }) + + loops := []domain.Loop{ + {SessionID: "ok", State: domain.StateGate, Goal: domain.Goal{Text: "a"}}, + {SessionID: "writeerr", State: domain.StateGate, Goal: domain.Goal{Text: "b"}}, + {SessionID: "ambiguous", State: domain.StateGate, Goal: domain.Goal{Text: "c"}}, + } + m := New().WithRenameTabs(true) + synced := m.renameTabsCmd(loops)().(tabTitlesSyncedMsg) + + if len(synced.written) != 1 || synced.written["ok"] != "◆GATE a" { + t.Fatalf("written = %v, want only {ok: ◆GATE a}", synced.written) + } + updated, _ := m.Update(synced) + mm := updated.(Model) + if mm.lastTabTitle["ok"] == "" { + t.Error("confirmed write must be ledgered") + } + if mm.lastTabTitle["writeerr"] != "" || mm.lastTabTitle["ambiguous"] != "" { + t.Errorf("failed/ambiguous loops must not be ledgered: %v", mm.lastTabTitle) + } +} + +// TestUpdate_TabTitlesSyncedMsg_AdvancesLedger confirms the handler records only +// the confirmed writes into the debounce ledger. +func TestUpdate_TabTitlesSyncedMsg_AdvancesLedger(t *testing.T) { + m := New().WithRenameTabs(true) + updated, cmd := m.Update(tabTitlesSyncedMsg{written: map[string]string{"s1": "◆GATE x"}}) + if cmd != nil { + t.Error("tabTitlesSyncedMsg must not schedule a follow-up cmd") + } + if got := updated.(Model).lastTabTitle["s1"]; got != "◆GATE x" { + t.Errorf("ledger[s1] = %q, want %q", got, "◆GATE x") + } +} + +var errWriteFailed = errors.New("cmux rename-tab failed")