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 -- +// ` (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")