diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ff840b..f370013d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **StatusFinishing Sidebar State** - Added a `finishing` status for pipeline instances between sentinel file detection and verification completion, providing accurate sidebar feedback instead of showing "working" during the verification phase - **Spec-Driven Planning (`--spec`)** - New `--spec` flag for ultraplan that converts an existing product spec (Notion page, GitHub issue, markdown file, etc.) into an ultraplan instead of open-ended codebase exploration. The planning agent fetches the spec, preserves its task structure faithfully, and enriches it with codebase-specific file paths. - **Remove All Instances Command** - Added `:D!` / `:remove!` command to remove all instances from the session at once, complementing the existing `:D` single-instance removal - **Automatic Tmux Session Recovery** - When a tmux server dies during a live session (macOS `/tmp` cleanup, crash, or kill), the capture loop now automatically detects the death and resumes the Claude session in a fresh tmux session using `--resume`. Recovery attempts are limited (default 3) and only triggered when a backend session ID exists. Includes `OnRecovery` callback for orchestrator state synchronization. diff --git a/internal/bridge/AGENTS.md b/internal/bridge/AGENTS.md index 4ce30fcd..9175b9cc 100644 --- a/internal/bridge/AGENTS.md +++ b/internal/bridge/AGENTS.md @@ -15,7 +15,9 @@ Gate.ClaimNext() → FileLockRegistry.ClaimMultiple() → ContextPropagation → InstanceFactory.CreateInstance() → StartInstance() → Gate.MarkRunning() → (auto-approve if gated) → monitor loop (poll CompletionChecker) - → Gate.Complete/Fail() + SessionRecorder + → SessionRecorder.RecordSentinelDetected() (on sentinel detection, before verify) + → CompletionChecker.VerifyWork() + → Gate.Complete/Fail() + SessionRecorder.RecordCompletion/RecordFailure → FileLockRegistry.ReleaseAll() + ContextPropagation.ShareDiscovery() ``` @@ -48,6 +50,7 @@ These interfaces are implemented by adapters in `internal/orchestrator/bridgewir - **TaskQueue retry interacts with bridge claim loop** — `TaskQueue.Fail()` has retry logic (`defaultMaxRetries=2`). When the bridge monitor calls `gate.Fail()`, the task may return to `TaskPending` (not permanently failed), and the claim loop re-claims it. Tests that assert on `Running()` after failure must either disable retries via `SetMaxRetries(taskID, 0)` or account for the re-claim cycle. - **Always log gate.Fail errors** — `gate.Fail()` can fail if the task has already transitioned. Always check and log the return error rather than discarding with `_ =`. - **File lock conflicts use Release, not Fail** — When `ClaimMultiple` returns `ErrAlreadyClaimed`, use `gate.Release` to return the task to pending without burning retries. Using `gate.Fail` would consume retry attempts, and with scaling enabled (semaphore > 1), multiple tasks competing for the same file lock would exhaust retries and permanently fail. After releasing, call `waitForWake` to avoid a hot retry loop. +- **RecordSentinelDetected before VerifyWork** — When the sentinel file is detected (`done == true`), `recorder.RecordSentinelDetected` is called *before* `checker.VerifyWork`. This lets the production wiring set `inst.Status = StatusFinishing` so the TUI shows an accurate state while verification runs. The ordering is: sentinel detected → RecordSentinelDetected → VerifyWork → RecordCompletion/RecordFailure. - **Record completion/failure before file lock release** — `recorder.RecordCompletion`/`RecordFailure` must be called immediately after `gate.Complete`/`gate.Fail`, before `reg.ReleaseAll` and `shareCompletion`. The gate transition triggers a synchronous event cascade that can complete the pipeline before the monitor goroutine reaches subsequent lines. If the recorder call comes after file lock I/O, tests (and observers) see the pipeline complete before the recorder fires. - **Scaling monitor increases semaphore concurrency** — The hub's `ScalingMonitor` reacts to `QueueDepthChangedEvent` and may increase the bridge's semaphore limit via the `OnDecision` callback. Code that assumes semaphore=1 (sequential task execution) is incorrect when scaling is active. File lock claims are the safety net for concurrent access to the same files. diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go index fea3e412..802a4930 100644 --- a/internal/bridge/bridge.go +++ b/internal/bridge/bridge.go @@ -371,7 +371,11 @@ func (b *Bridge) monitorInstance(taskID string, inst Instance) { continue } - // Instance wrote its sentinel file. Verify the work. + // Instance wrote its sentinel file — notify recorder so the UI + // can transition to "finishing" while verification runs. + b.recorder.RecordSentinelDetected(taskID, inst.ID()) + + // Verify the work. success, commitCount, verifyErr := b.checker.VerifyWork( taskID, inst.ID(), inst.WorktreePath(), inst.Branch(), ) diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go index e2719c12..ab3d7e68 100644 --- a/internal/bridge/bridge_test.go +++ b/internal/bridge/bridge_test.go @@ -130,6 +130,8 @@ func (r *mockRecorder) AssignTask(taskID, instanceID string) { r.assigned[taskID] = instanceID } +func (r *mockRecorder) RecordSentinelDetected(_, _ string) {} + func (r *mockRecorder) RecordCompletion(taskID string, commitCount int) { r.mu.Lock() defer r.mu.Unlock() @@ -843,6 +845,10 @@ func (r *signalingRecorder) AssignTask(taskID, instanceID string) { r.inner.AssignTask(taskID, instanceID) } +func (r *signalingRecorder) RecordSentinelDetected(taskID, instanceID string) { + r.inner.RecordSentinelDetected(taskID, instanceID) +} + func (r *signalingRecorder) RecordCompletion(taskID string, commitCount int) { r.inner.RecordCompletion(taskID, commitCount) } @@ -855,6 +861,126 @@ func (r *signalingRecorder) RecordFailure(taskID, reason string) { } } +// orderRecorder tracks the order of RecordSentinelDetected and RecordCompletion calls +// to verify that sentinel detection is reported before completion. +type orderRecorder struct { + inner *mockRecorder + callOrder []string + mu sync.Mutex + completeCh chan string +} + +func newOrderRecorder() *orderRecorder { + return &orderRecorder{ + inner: newMockRecorder(), + completeCh: make(chan string, 1), + } +} + +func (r *orderRecorder) AssignTask(taskID, instanceID string) { + r.inner.AssignTask(taskID, instanceID) +} + +func (r *orderRecorder) RecordSentinelDetected(taskID, instanceID string) { + r.mu.Lock() + r.callOrder = append(r.callOrder, "sentinel:"+taskID) + r.mu.Unlock() +} + +func (r *orderRecorder) RecordCompletion(taskID string, commitCount int) { + r.mu.Lock() + r.callOrder = append(r.callOrder, "completion:"+taskID) + r.mu.Unlock() + r.inner.RecordCompletion(taskID, commitCount) + select { + case r.completeCh <- taskID: + default: + } +} + +func (r *orderRecorder) RecordFailure(taskID, reason string) { + r.inner.RecordFailure(taskID, reason) +} + +func (r *orderRecorder) CallOrder() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.callOrder)) + copy(out, r.callOrder) + return out +} + +func TestBridge_SentinelDetectedBeforeCompletion(t *testing.T) { + bus := event.NewBus() + tasks := []ultraplan.PlannedTask{ + {ID: "t1", Title: "Task 1", Description: "Do thing 1"}, + } + tt := newTestTeam(t, bus, tasks) + + factory := newMockFactory() + checker := newMockChecker() + recorder := newOrderRecorder() + + b := bridge.New(tt, factory, checker, recorder, bus, + bridge.WithPollInterval(10*time.Millisecond), + ) + + // Subscribe before Start to avoid missing the event. + startedCh := make(chan event.Event, 1) + subID := bus.Subscribe("bridge.task_started", func(e event.Event) { + select { + case startedCh <- e: + default: + } + }) + defer bus.Unsubscribe(subID) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := b.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + defer b.Stop() + + // Wait for the bridge to claim the task. + select { + case <-startedCh: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for bridge.task_started event") + } + + // Signal completion. + factory.mu.Lock() + var worktreePath string + for _, inst := range factory.instances { + worktreePath = inst.worktreePath + break + } + factory.mu.Unlock() + + checker.MarkComplete(worktreePath) + + // Wait for completion to be recorded. + select { + case <-recorder.completeCh: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for RecordCompletion") + } + + // Verify that sentinel was recorded before completion. + order := recorder.CallOrder() + if len(order) < 2 { + t.Fatalf("expected at least 2 calls, got %d: %v", len(order), order) + } + if order[0] != "sentinel:t1" { + t.Errorf("first call = %q, want %q", order[0], "sentinel:t1") + } + if order[1] != "completion:t1" { + t.Errorf("second call = %q, want %q", order[1], "completion:t1") + } +} + func TestBuildTaskPrompt(t *testing.T) { prompt := bridge.BuildTaskPrompt( "task-1", diff --git a/internal/bridge/types.go b/internal/bridge/types.go index 4dcac5e5..9c1a82aa 100644 --- a/internal/bridge/types.go +++ b/internal/bridge/types.go @@ -36,6 +36,10 @@ type SessionRecorder interface { // AssignTask records that a task has been assigned to an instance. AssignTask(taskID, instanceID string) + // RecordSentinelDetected signals that the instance wrote its sentinel + // file and is entering the finishing phase (verification in progress). + RecordSentinelDetected(taskID, instanceID string) + // RecordCompletion records successful task completion. RecordCompletion(taskID string, commitCount int) diff --git a/internal/cmd/session/pipeline_wire.go b/internal/cmd/session/pipeline_wire.go index 534acd5b..dc2cc381 100644 --- a/internal/cmd/session/pipeline_wire.go +++ b/internal/cmd/session/pipeline_wire.go @@ -14,6 +14,9 @@ func registerPipelineFactory(coordinator *orchestrator.Coordinator, orch *orches coordinator.SetPipelineFactory(func(deps orchestrator.PipelineRunnerDeps) (orchestrator.ExecutionRunner, error) { recorder := bridgewire.NewSessionRecorder(bridgewire.SessionRecorderDeps{ OnAssign: coordinator.AssignTaskInstance, + OnSentinelDetected: func(_, instanceID string) { + orch.SetInstanceStatus(instanceID, orchestrator.StatusFinishing) + }, }) return bridgewire.NewPipelineRunner(bridgewire.PipelineRunnerConfig{ Orch: deps.Orch, diff --git a/internal/orchestrator/bridgewire/adapters.go b/internal/orchestrator/bridgewire/adapters.go index 6486c820..7b49e998 100644 --- a/internal/orchestrator/bridgewire/adapters.go +++ b/internal/orchestrator/bridgewire/adapters.go @@ -96,6 +96,10 @@ type SessionRecorderDeps struct { // OnAssign is called when a task is assigned to an instance. OnAssign func(taskID, instanceID string) + // OnSentinelDetected is called when the instance writes its sentinel file + // (entering the finishing phase before verification completes). + OnSentinelDetected func(taskID, instanceID string) + // OnComplete is called when a task completes successfully. OnComplete func(taskID string, commitCount int) @@ -119,6 +123,12 @@ func (r *sessionRecorder) AssignTask(taskID, instanceID string) { } } +func (r *sessionRecorder) RecordSentinelDetected(taskID, instanceID string) { + if r.deps.OnSentinelDetected != nil { + r.deps.OnSentinelDetected(taskID, instanceID) + } +} + func (r *sessionRecorder) RecordCompletion(taskID string, commitCount int) { if r.deps.OnComplete != nil { r.deps.OnComplete(taskID, commitCount) diff --git a/internal/orchestrator/bridgewire/adapters_test.go b/internal/orchestrator/bridgewire/adapters_test.go index 2413b73f..a2c451cf 100644 --- a/internal/orchestrator/bridgewire/adapters_test.go +++ b/internal/orchestrator/bridgewire/adapters_test.go @@ -85,6 +85,7 @@ func TestNewCompletionChecker_VerifyWorkFailure(t *testing.T) { func TestNewSessionRecorder(t *testing.T) { var assignedTask, assignedInst string + var sentinelTask, sentinelInst string var completedTask string var completedCommits int var failedTask, failedReason string @@ -94,6 +95,10 @@ func TestNewSessionRecorder(t *testing.T) { assignedTask = taskID assignedInst = instanceID }, + OnSentinelDetected: func(taskID, instanceID string) { + sentinelTask = taskID + sentinelInst = instanceID + }, OnComplete: func(taskID string, commitCount int) { completedTask = taskID completedCommits = commitCount @@ -109,6 +114,11 @@ func TestNewSessionRecorder(t *testing.T) { t.Errorf("AssignTask: got (%q, %q), want (%q, %q)", assignedTask, assignedInst, "t1", "inst-1") } + recorder.RecordSentinelDetected("t1", "inst-1") + if sentinelTask != "t1" || sentinelInst != "inst-1" { + t.Errorf("RecordSentinelDetected: got (%q, %q), want (%q, %q)", sentinelTask, sentinelInst, "t1", "inst-1") + } + recorder.RecordCompletion("t2", 5) if completedTask != "t2" || completedCommits != 5 { t.Errorf("RecordCompletion: got (%q, %d), want (%q, %d)", completedTask, completedCommits, "t2", 5) @@ -125,6 +135,7 @@ func TestNewSessionRecorder_NilCallbacks(t *testing.T) { // Should not panic with nil callbacks. recorder.AssignTask("t1", "inst-1") + recorder.RecordSentinelDetected("t1", "inst-1") recorder.RecordCompletion("t1", 1) recorder.RecordFailure("t1", "reason") } diff --git a/internal/orchestrator/bridgewire/executor_test.go b/internal/orchestrator/bridgewire/executor_test.go index 71490ded..4d73b85f 100644 --- a/internal/orchestrator/bridgewire/executor_test.go +++ b/internal/orchestrator/bridgewire/executor_test.go @@ -167,6 +167,8 @@ func (r *trackingRecorder) AssignTask(taskID, instanceID string) { } } +func (r *trackingRecorder) RecordSentinelDetected(_, _ string) {} + func (r *trackingRecorder) RecordCompletion(taskID string, commitCount int) { r.mu.Lock() r.completed[taskID] = commitCount diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index f336f7e4..5b9f875e 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -2375,6 +2375,25 @@ func (o *Orchestrator) GetInstance(id string) *Instance { return nil } +// SetInstanceStatus atomically sets the status of an instance by ID. +// Returns false if the instance was not found. +func (o *Orchestrator) SetInstanceStatus(id string, status InstanceStatus) bool { + o.mu.Lock() + defer o.mu.Unlock() + + if o.session == nil { + return false + } + + for _, inst := range o.session.Instances { + if inst.ID == id { + inst.Status = status + return true + } + } + return false +} + // GetInstanceDiff returns the git diff for an instance against main func (o *Orchestrator) GetInstanceDiff(worktreePath string) (string, error) { return o.wt.GetDiffAgainstMain(worktreePath) diff --git a/internal/orchestrator/session.go b/internal/orchestrator/session.go index 96fcb9bd..fbc7a9ea 100644 --- a/internal/orchestrator/session.go +++ b/internal/orchestrator/session.go @@ -19,6 +19,7 @@ const ( StatusPending InstanceStatus = "pending" StatusPreparing InstanceStatus = "preparing" // Worktree creation in progress (async setup) StatusWorking InstanceStatus = "working" + StatusFinishing InstanceStatus = "finishing" // Sentinel file detected, verification in progress StatusWaitingInput InstanceStatus = "waiting_input" StatusPaused InstanceStatus = "paused" StatusCompleted InstanceStatus = "completed" @@ -255,7 +256,7 @@ func (s *Session) NeedsRecovery() bool { // Check if any instances were in a working state for _, inst := range s.Instances { - if inst.Status == StatusWorking || inst.Status == StatusWaitingInput { + if inst.Status == StatusWorking || inst.Status == StatusFinishing || inst.Status == StatusWaitingInput { return true } } @@ -267,7 +268,7 @@ func (s *Session) NeedsRecovery() bool { func (s *Session) GetInterruptedInstances() []*Instance { var interrupted []*Instance for _, inst := range s.Instances { - if inst.Status == StatusWorking || inst.Status == StatusWaitingInput { + if inst.Status == StatusWorking || inst.Status == StatusFinishing || inst.Status == StatusWaitingInput { // Mark as interrupted if we're detecting a recovery scenario interrupted = append(interrupted, inst) } @@ -280,7 +281,7 @@ func (s *Session) GetResumableInstances() []*Instance { var resumable []*Instance for _, inst := range s.Instances { // Instances with backend session ID can be resumed if they were running or interrupted - if inst.ClaudeSessionID != "" && (inst.Status == StatusWorking || inst.Status == StatusWaitingInput || inst.Status == StatusPaused || inst.Status == StatusInterrupted) { + if inst.ClaudeSessionID != "" && (inst.Status == StatusWorking || inst.Status == StatusFinishing || inst.Status == StatusWaitingInput || inst.Status == StatusPaused || inst.Status == StatusInterrupted) { resumable = append(resumable, inst) } } @@ -292,7 +293,7 @@ func (s *Session) GetResumableInstances() []*Instance { func (s *Session) MarkInstancesInterrupted() { now := time.Now() for _, inst := range s.Instances { - if inst.Status == StatusWorking || inst.Status == StatusWaitingInput { + if inst.Status == StatusWorking || inst.Status == StatusFinishing || inst.Status == StatusWaitingInput { inst.Status = StatusInterrupted inst.InterruptedAt = &now } diff --git a/internal/orchestrator/workflows/tripleshot/teamwire/adapters.go b/internal/orchestrator/workflows/tripleshot/teamwire/adapters.go index a5701f65..66be0358 100644 --- a/internal/orchestrator/workflows/tripleshot/teamwire/adapters.go +++ b/internal/orchestrator/workflows/tripleshot/teamwire/adapters.go @@ -102,9 +102,10 @@ func (c *judgeCompletionChecker) VerifyWork(_, _, worktreePath, _ string) (bool, // sessionRecorderDeps defines the callbacks for the session recorder. type sessionRecorderDeps struct { - OnAssign func(taskID, instanceID string) - OnComplete func(taskID string, commitCount int) - OnFailure func(taskID, reason string) + OnAssign func(taskID, instanceID string) + OnSentinelDetected func(taskID, instanceID string) + OnComplete func(taskID string, commitCount int) + OnFailure func(taskID, reason string) } // sessionRecorder delegates to caller-provided callbacks. @@ -122,6 +123,12 @@ func (r *sessionRecorder) AssignTask(taskID, instanceID string) { } } +func (r *sessionRecorder) RecordSentinelDetected(taskID, instanceID string) { + if r.deps.OnSentinelDetected != nil { + r.deps.OnSentinelDetected(taskID, instanceID) + } +} + func (r *sessionRecorder) RecordCompletion(taskID string, commitCount int) { if r.deps.OnComplete != nil { r.deps.OnComplete(taskID, commitCount) diff --git a/internal/orchestrator/workflows/tripleshot/teamwire/adapters_test.go b/internal/orchestrator/workflows/tripleshot/teamwire/adapters_test.go index b0fb78d6..4b3100d3 100644 --- a/internal/orchestrator/workflows/tripleshot/teamwire/adapters_test.go +++ b/internal/orchestrator/workflows/tripleshot/teamwire/adapters_test.go @@ -303,12 +303,13 @@ func TestJudgeCompletionChecker_VerifyWork_NoFile(t *testing.T) { // --- sessionRecorder tests --- func TestSessionRecorder_AllCallbacks(t *testing.T) { - var assigned, completed, failed bool + var assigned, sentinel, completed, failed bool recorder := newSessionRecorder(sessionRecorderDeps{ - OnAssign: func(_, _ string) { assigned = true }, - OnComplete: func(_ string, _ int) { completed = true }, - OnFailure: func(_, _ string) { failed = true }, + OnAssign: func(_, _ string) { assigned = true }, + OnSentinelDetected: func(_, _ string) { sentinel = true }, + OnComplete: func(_ string, _ int) { completed = true }, + OnFailure: func(_, _ string) { failed = true }, }) recorder.AssignTask("t1", "i1") @@ -316,6 +317,11 @@ func TestSessionRecorder_AllCallbacks(t *testing.T) { t.Error("OnAssign not called") } + recorder.RecordSentinelDetected("t1", "i1") + if !sentinel { + t.Error("OnSentinelDetected not called") + } + recorder.RecordCompletion("t1", 1) if !completed { t.Error("OnComplete not called") @@ -332,6 +338,7 @@ func TestSessionRecorder_NilCallbacks(t *testing.T) { // Should not panic with nil callbacks. recorder.AssignTask("t1", "i1") + recorder.RecordSentinelDetected("t1", "i1") recorder.RecordCompletion("t1", 1) recorder.RecordFailure("t1", "reason") } diff --git a/internal/tui/pipeline_wire.go b/internal/tui/pipeline_wire.go index dc3974e2..1716465d 100644 --- a/internal/tui/pipeline_wire.go +++ b/internal/tui/pipeline_wire.go @@ -15,6 +15,9 @@ func registerPipelineFactory(coordinator *orchestrator.Coordinator, orch *orches coordinator.SetPipelineFactory(func(deps orchestrator.PipelineRunnerDeps) (orchestrator.ExecutionRunner, error) { recorder := bridgewire.NewSessionRecorder(bridgewire.SessionRecorderDeps{ OnAssign: coordinator.AssignTaskInstance, + OnSentinelDetected: func(_, instanceID string) { + orch.SetInstanceStatus(instanceID, orchestrator.StatusFinishing) + }, }) return bridgewire.NewPipelineRunner(bridgewire.PipelineRunnerConfig{ Orch: deps.Orch, diff --git a/internal/tui/styles/loader.go b/internal/tui/styles/loader.go index 37f4a4eb..5f8a560a 100644 --- a/internal/tui/styles/loader.go +++ b/internal/tui/styles/loader.go @@ -63,6 +63,7 @@ type ThemeStatusColors struct { Complete string `yaml:"complete,omitempty"` Error string `yaml:"error,omitempty"` CreatingPR string `yaml:"creating_pr,omitempty"` + Finishing string `yaml:"finishing,omitempty"` Stuck string `yaml:"stuck,omitempty"` Timeout string `yaml:"timeout,omitempty"` Interrupted string `yaml:"interrupted,omitempty"` @@ -160,6 +161,7 @@ func (t *ThemeFile) Validate() error { "status.complete": t.Colors.Status.Complete, "status.error": t.Colors.Status.Error, "status.creating_pr": t.Colors.Status.CreatingPR, + "status.finishing": t.Colors.Status.Finishing, "status.stuck": t.Colors.Status.Stuck, "status.timeout": t.Colors.Status.Timeout, "status.interrupted": t.Colors.Status.Interrupted, @@ -216,6 +218,7 @@ func (t *ThemeFile) ToPalette() *ColorPalette { p.StatusComplete = colorOrDefault(t.Colors.Status.Complete, t.Colors.Primary) p.StatusError = colorOrDefault(t.Colors.Status.Error, t.Colors.Error) p.StatusCreatingPR = colorOrDefault(t.Colors.Status.CreatingPR, t.Colors.Primary) + p.StatusFinishing = colorOrDefault(t.Colors.Status.Finishing, t.Colors.Secondary) p.StatusStuck = colorOrDefault(t.Colors.Status.Stuck, t.Colors.Warning) p.StatusTimeout = colorOrDefault(t.Colors.Status.Timeout, t.Colors.Error) p.StatusInterrupted = colorOrDefault(t.Colors.Status.Interrupted, t.Colors.Warning) @@ -414,6 +417,7 @@ func paletteToThemeFile(name string, p *ColorPalette) *ThemeFile { Complete: string(p.StatusComplete), Error: string(p.StatusError), CreatingPR: string(p.StatusCreatingPR), + Finishing: string(p.StatusFinishing), Stuck: string(p.StatusStuck), Timeout: string(p.StatusTimeout), Interrupted: string(p.StatusInterrupted), diff --git a/internal/tui/styles/loader_test.go b/internal/tui/styles/loader_test.go index 67f54d04..cd9900aa 100644 --- a/internal/tui/styles/loader_test.go +++ b/internal/tui/styles/loader_test.go @@ -88,6 +88,7 @@ func TestThemeFileValidate(t *testing.T) { Complete: "#A78BFA", Error: "#F87171", CreatingPR: "#F472B6", + Finishing: "#2DD4BF", Stuck: "#FB923C", Timeout: "#F87171", Interrupted: "#FBBF24", diff --git a/internal/tui/styles/palette.go b/internal/tui/styles/palette.go index e5e23ed0..85fa83cc 100644 --- a/internal/tui/styles/palette.go +++ b/internal/tui/styles/palette.go @@ -93,6 +93,7 @@ type ColorPalette struct { StatusComplete lipgloss.Color StatusError lipgloss.Color StatusCreatingPR lipgloss.Color + StatusFinishing lipgloss.Color StatusStuck lipgloss.Color StatusTimeout lipgloss.Color StatusInterrupted lipgloss.Color @@ -139,6 +140,7 @@ func DefaultPalette() *ColorPalette { StatusComplete: lipgloss.Color("#A78BFA"), // Purple StatusError: lipgloss.Color("#F87171"), // Red StatusCreatingPR: lipgloss.Color("#F472B6"), // Pink + StatusFinishing: lipgloss.Color("#2DD4BF"), // Teal StatusStuck: lipgloss.Color("#FB923C"), // Orange StatusTimeout: lipgloss.Color("#F87171"), // Red StatusInterrupted: lipgloss.Color("#FBBF24"), // Yellow @@ -183,6 +185,7 @@ func MonokaiPalette() *ColorPalette { StatusComplete: lipgloss.Color("#AE81FF"), // Purple StatusError: lipgloss.Color("#F92672"), // Pink StatusCreatingPR: lipgloss.Color("#FD971F"), // Orange + StatusFinishing: lipgloss.Color("#66D9EF"), // Cyan StatusStuck: lipgloss.Color("#FD971F"), // Orange StatusTimeout: lipgloss.Color("#F92672"), // Pink StatusInterrupted: lipgloss.Color("#E6DB74"), // Yellow @@ -227,6 +230,7 @@ func DraculaPalette() *ColorPalette { StatusComplete: lipgloss.Color("#BD93F9"), // Purple StatusError: lipgloss.Color("#FF5555"), // Red StatusCreatingPR: lipgloss.Color("#FF79C6"), // Pink + StatusFinishing: lipgloss.Color("#8BE9FD"), // Cyan StatusStuck: lipgloss.Color("#FFB86C"), // Orange StatusTimeout: lipgloss.Color("#FF5555"), // Red StatusInterrupted: lipgloss.Color("#F1FA8C"), // Yellow @@ -271,6 +275,7 @@ func NordPalette() *ColorPalette { StatusComplete: lipgloss.Color("#B48EAD"), // Aurora purple StatusError: lipgloss.Color("#BF616A"), // Aurora red StatusCreatingPR: lipgloss.Color("#B48EAD"), // Purple + StatusFinishing: lipgloss.Color("#88C0D0"), // Frost teal StatusStuck: lipgloss.Color("#D08770"), // Aurora orange StatusTimeout: lipgloss.Color("#BF616A"), // Red StatusInterrupted: lipgloss.Color("#EBCB8B"), // Yellow @@ -315,6 +320,7 @@ func ClaudeCodePalette() *ColorPalette { StatusComplete: lipgloss.Color("#DA7756"), // Orange StatusError: lipgloss.Color("#EC7063"), // Red StatusCreatingPR: lipgloss.Color("#BB8FCE"), // Purple + StatusFinishing: lipgloss.Color("#48C9B0"), // Teal StatusStuck: lipgloss.Color("#E59866"), // Light orange StatusTimeout: lipgloss.Color("#EC7063"), // Red StatusInterrupted: lipgloss.Color("#F5B041"), // Amber @@ -359,6 +365,7 @@ func SolarizedDarkPalette() *ColorPalette { StatusComplete: lipgloss.Color("#6C71C4"), // Violet StatusError: lipgloss.Color("#DC322F"), // Red StatusCreatingPR: lipgloss.Color("#D33682"), // Magenta + StatusFinishing: lipgloss.Color("#2AA198"), // Cyan StatusStuck: lipgloss.Color("#CB4B16"), // Orange StatusTimeout: lipgloss.Color("#DC322F"), // Red StatusInterrupted: lipgloss.Color("#B58900"), // Yellow @@ -403,6 +410,7 @@ func SolarizedLightPalette() *ColorPalette { StatusComplete: lipgloss.Color("#6C71C4"), // Violet StatusError: lipgloss.Color("#DC322F"), // Red StatusCreatingPR: lipgloss.Color("#D33682"), // Magenta + StatusFinishing: lipgloss.Color("#2AA198"), // Cyan StatusStuck: lipgloss.Color("#CB4B16"), // Orange StatusTimeout: lipgloss.Color("#DC322F"), // Red StatusInterrupted: lipgloss.Color("#B58900"), // Yellow @@ -447,6 +455,7 @@ func OneDarkPalette() *ColorPalette { StatusComplete: lipgloss.Color("#C678DD"), // Purple StatusError: lipgloss.Color("#E06C75"), // Red StatusCreatingPR: lipgloss.Color("#C678DD"), // Purple + StatusFinishing: lipgloss.Color("#56B6C2"), // Cyan StatusStuck: lipgloss.Color("#D19A66"), // Orange StatusTimeout: lipgloss.Color("#E06C75"), // Red StatusInterrupted: lipgloss.Color("#E5C07B"), // Yellow @@ -491,6 +500,7 @@ func GitHubDarkPalette() *ColorPalette { StatusComplete: lipgloss.Color("#A371F7"), // Purple StatusError: lipgloss.Color("#F85149"), // Red StatusCreatingPR: lipgloss.Color("#DB61A2"), // Pink + StatusFinishing: lipgloss.Color("#39D2C0"), // Teal StatusStuck: lipgloss.Color("#F0883E"), // Orange StatusTimeout: lipgloss.Color("#F85149"), // Red StatusInterrupted: lipgloss.Color("#D29922"), // Yellow @@ -535,6 +545,7 @@ func GruvboxPalette() *ColorPalette { StatusComplete: lipgloss.Color("#D3869B"), // Purple StatusError: lipgloss.Color("#FB4934"), // Red StatusCreatingPR: lipgloss.Color("#D3869B"), // Purple + StatusFinishing: lipgloss.Color("#8EC07C"), // Aqua StatusStuck: lipgloss.Color("#FE8019"), // Orange StatusTimeout: lipgloss.Color("#FB4934"), // Red StatusInterrupted: lipgloss.Color("#FABD2F"), // Yellow @@ -579,6 +590,7 @@ func TokyoNightPalette() *ColorPalette { StatusComplete: lipgloss.Color("#BB9AF7"), // Purple StatusError: lipgloss.Color("#F7768E"), // Red StatusCreatingPR: lipgloss.Color("#FF9E64"), // Orange + StatusFinishing: lipgloss.Color("#73DACA"), // Teal StatusStuck: lipgloss.Color("#FF9E64"), // Orange StatusTimeout: lipgloss.Color("#F7768E"), // Red StatusInterrupted: lipgloss.Color("#E0AF68"), // Yellow @@ -623,6 +635,7 @@ func CatppuccinPalette() *ColorPalette { StatusComplete: lipgloss.Color("#CBA6F7"), // Mauve StatusError: lipgloss.Color("#F38BA8"), // Red StatusCreatingPR: lipgloss.Color("#F5C2E7"), // Pink + StatusFinishing: lipgloss.Color("#94E2D5"), // Teal StatusStuck: lipgloss.Color("#FAB387"), // Peach StatusTimeout: lipgloss.Color("#F38BA8"), // Red StatusInterrupted: lipgloss.Color("#F9E2AF"), // Yellow @@ -667,6 +680,7 @@ func SynthwavePalette() *ColorPalette { StatusComplete: lipgloss.Color("#FF7EDB"), // Pink StatusError: lipgloss.Color("#FE4450"), // Red StatusCreatingPR: lipgloss.Color("#B893CE"), // Purple + StatusFinishing: lipgloss.Color("#72F1B8"), // Teal StatusStuck: lipgloss.Color("#F97E72"), // Orange StatusTimeout: lipgloss.Color("#FE4450"), // Red StatusInterrupted: lipgloss.Color("#FEDE5D"), // Yellow @@ -711,6 +725,7 @@ func AyuPalette() *ColorPalette { StatusComplete: lipgloss.Color("#D2A6FF"), // Purple StatusError: lipgloss.Color("#D95757"), // Red StatusCreatingPR: lipgloss.Color("#F28779"), // Orange (Ayu uses this as accent) + StatusFinishing: lipgloss.Color("#95E6CB"), // Teal StatusStuck: lipgloss.Color("#FF8F40"), // Orange StatusTimeout: lipgloss.Color("#D95757"), // Red StatusInterrupted: lipgloss.Color("#E6B450"), // Yellow diff --git a/internal/tui/styles/palette_test.go b/internal/tui/styles/palette_test.go index 5286858b..588dccba 100644 --- a/internal/tui/styles/palette_test.go +++ b/internal/tui/styles/palette_test.go @@ -205,6 +205,7 @@ func TestPaletteColorConsistency(t *testing.T) { "StatusComplete": string(p.StatusComplete), "StatusError": string(p.StatusError), "StatusCreatingPR": string(p.StatusCreatingPR), + "StatusFinishing": string(p.StatusFinishing), "StatusStuck": string(p.StatusStuck), "StatusTimeout": string(p.StatusTimeout), "StatusInterrupted": string(p.StatusInterrupted), diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go index f86c1398..b63dc873 100644 --- a/internal/tui/styles/styles.go +++ b/internal/tui/styles/styles.go @@ -62,6 +62,7 @@ var ( StatusCreatingPR = lipgloss.Color("#F472B6") // Pink (brighter for readability) StatusStuck = lipgloss.Color("#FB923C") // Orange - for stuck/no activity StatusTimeout = lipgloss.Color("#F87171") // Red (red-400, was #DC2626 - improved contrast) + StatusFinishing = lipgloss.Color("#2DD4BF") // Teal - sentinel detected, verifying work StatusInterrupted = lipgloss.Color("#FBBF24") // Yellow/Amber - for interrupted sessions // Base styles @@ -345,6 +346,8 @@ func StatusColor(status string) lipgloss.Color { return StatusPending case "preparing": return StatusPreparing + case "finishing": + return StatusFinishing case "waiting_input": return StatusInput case "paused": @@ -375,6 +378,8 @@ func StatusIcon(status string) string { return "○" case "preparing": return "◐" // Half-filled circle for async setup in progress + case "finishing": + return "◉" case "waiting_input": return "?" case "paused": diff --git a/internal/tui/styles/styles_test.go b/internal/tui/styles/styles_test.go index 38b84906..036b8201 100644 --- a/internal/tui/styles/styles_test.go +++ b/internal/tui/styles/styles_test.go @@ -9,6 +9,7 @@ func TestStatusColor(t *testing.T) { }{ {"working", "#10B981"}, {"pending", "#9CA3AF"}, + {"finishing", "#2DD4BF"}, {"waiting_input", "#F59E0B"}, {"paused", "#60A5FA"}, {"completed", "#A78BFA"}, @@ -37,6 +38,7 @@ func TestStatusIcon(t *testing.T) { }{ {"working", "●"}, {"pending", "○"}, + {"finishing", "◉"}, {"waiting_input", "?"}, {"paused", "⏸"}, {"completed", "✓"}, diff --git a/internal/tui/styles/themed.go b/internal/tui/styles/themed.go index 667f3538..9da1b407 100644 --- a/internal/tui/styles/themed.go +++ b/internal/tui/styles/themed.go @@ -31,6 +31,7 @@ type ThemedStyles struct { StatusComplete lipgloss.Color StatusError lipgloss.Color StatusCreatingPR lipgloss.Color + StatusFinishing lipgloss.Color StatusStuck lipgloss.Color StatusTimeout lipgloss.Color StatusInterrupted lipgloss.Color @@ -167,6 +168,7 @@ func NewThemedStyles(p *ColorPalette) *ThemedStyles { StatusComplete: p.StatusComplete, StatusError: p.StatusError, StatusCreatingPR: p.StatusCreatingPR, + StatusFinishing: p.StatusFinishing, StatusStuck: p.StatusStuck, StatusTimeout: p.StatusTimeout, StatusInterrupted: p.StatusInterrupted, @@ -450,6 +452,8 @@ func (s *ThemedStyles) StatusColor(status string) lipgloss.Color { return s.StatusPending case "preparing": return s.StatusPreparing + case "finishing": + return s.StatusFinishing case "waiting_input": return s.StatusInput case "paused": @@ -540,6 +544,7 @@ func syncGlobalStyles() { StatusComplete = activeTheme.StatusComplete StatusError = activeTheme.StatusError StatusCreatingPR = activeTheme.StatusCreatingPR + StatusFinishing = activeTheme.StatusFinishing StatusStuck = activeTheme.StatusStuck StatusTimeout = activeTheme.StatusTimeout StatusInterrupted = activeTheme.StatusInterrupted diff --git a/internal/tui/styles/themed_test.go b/internal/tui/styles/themed_test.go index bc1ab023..8e6e8797 100644 --- a/internal/tui/styles/themed_test.go +++ b/internal/tui/styles/themed_test.go @@ -30,6 +30,7 @@ func TestThemedStyles_StatusColor(t *testing.T) { }{ {"working", "#10B981"}, {"pending", "#9CA3AF"}, + {"finishing", "#2DD4BF"}, {"waiting_input", "#F59E0B"}, {"paused", "#60A5FA"}, {"completed", "#A78BFA"}, diff --git a/internal/tui/view/group.go b/internal/tui/view/group.go index 1eac29ce..464ecdd9 100644 --- a/internal/tui/view/group.go +++ b/internal/tui/view/group.go @@ -831,6 +831,8 @@ func instanceStatusAbbrev(status orchestrator.InstanceStatus) string { return "PREP" case orchestrator.StatusWorking: return "WORK" + case orchestrator.StatusFinishing: + return "FIN." case orchestrator.StatusWaitingInput: return "WAIT" case orchestrator.StatusPaused: