From 5995ff6c9e7ae892723ebf95f62c262889993c36 Mon Sep 17 00:00:00 2001 From: crossben Date: Fri, 25 Sep 2026 14:12:34 +0000 Subject: [PATCH 1/6] docs: dashboard UI overhaul design spec Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-25-dashboard-ui-design.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-25-dashboard-ui-design.md diff --git a/docs/superpowers/specs/2026-09-25-dashboard-ui-design.md b/docs/superpowers/specs/2026-09-25-dashboard-ui-design.md new file mode 100644 index 0000000..d999a14 --- /dev/null +++ b/docs/superpowers/specs/2026-09-25-dashboard-ui-design.md @@ -0,0 +1,132 @@ +# Dashboard UI overhaul — design + +Date: 2026-09-25 · Branch: `feat/dashboard-ui` · Scope: `orchestra dashboard` (`internal/tui`), plus +small, optional hooks in `internal/engine`, `internal/agent`, `internal/runner`, `internal/validate`, +`internal/memory`, `internal/gitutil`. + +## Intent + +Make the dashboard the place people actually work in: you can **watch a run as it happens**, +**review changes like a small code-review tool**, it **looks polished**, and the known **rough edges are +gone**. Success = a dashboard session where a user sends a task, sees routing → agent output → each +validation stage live, then reviews a multi-file diff file-by-file and accepts/rejects — and can reopen +that diff later from History, even after restarting. + +Non-goals: changing the plain shell (`internal/shell`) or `run`/`do` output; mouse support; new +third-party dependencies (Bubble Tea / Lipgloss / Glamour / Chroma already cover everything). + +## Constraints + +- The engine stays the single pipeline. New hooks are **optional** (nil = today's behaviour), so `run`, + `do`, shell and benchmark are untouched. +- No new agent interface: streaming is an optional `io.Writer` on `agent.Task`. +- CGO-free, stdlib + existing deps only. Tests headless and offline with fake agents. + +## Phases (each ships and passes CI on its own) + +### Phase 1 — Foundation + rough edges + +- **Split `tui.go`** (943 lines) into focused files: `tui.go` (model, messages, Update routing), + `theme.go` (palette + styles), `chrome.go` (header, status bar), `chat.go`, `views.go` + (agents/history/bench/logs), `changes.go`, `table.go` (width-aware columns). +- **Tab order**: Chat · Changes · History · Agents · Benchmarks · Logs. Dashboard opens on **Chat**. +- **Width-aware tables**: pad cells by rendered width (`lipgloss.Width`), so coloured cells no longer + skew columns. +- **Record the routed agent** on changes (today it always records the default agent). +- **Dashboard runs are recorded to history** (accepted / rejected / no-change / failed); today the + dashboard writes nothing to memory, so its runs never appear in History. +- **Persist diffs**: `memory.Run` gains `Diff`; the `runs` table gains `diff TEXT NOT NULL DEFAULT ''` + via an idempotent `ALTER TABLE` migration. The engine's `recordMemory` stores the diff too. +- **Changes tab is persistent**: built from history runs that have a diff (falls back to the in-session + list when no memory store is available). +- **History rows are selectable**; `enter` opens that run's diff in the review screen. +- Esc in Chat no longer jumps to Agents; `q` quits outside Chat, `ctrl+c` quits everywhere. + +### Phase 2 — Review screen + +One `reviewer` component used by Chat review, Changes and History: + +``` + REVIEW opencode · attempt 2/3 · build ✓ lint ✓ test ✓ 3 files +42 −7 +┌ files ───────────────┐┌ internal/api/health.go ─────────────────────────── +30 −0 ┐ +│▸ internal/api/hea… +30││ @@ -0,0 +1,30 @@ │ +│ internal/api/rout… +4 ││ +package api │ +│ README.md +8 −7 ││ … │ +└──────────────────────┘└───────────────────────────────────────────────────────────┘ + y accept · n reject · [ ] file · ↑↓ scroll · a all files +``` + +- `parseDiff(unified) []fileDiff{Path, Added, Removed, Body, Binary, Status(new/deleted/modified)}` — + counting `+`/`-` lines directly (drops the `go-diff` usage in `diffStats`). +- File list (left, ~28 cols, hidden under 90 cols wide) + scrollable per-file diff (right, `viewport`, + chroma-highlighted). `[`/`]` (and `J`/`K`) move between files, `a` toggles all-files view, ↑↓ / pgup / + pgdn / j / k / g / G scroll. +- Summary bar: agent, attempts, per-stage validation badges, file count, total `+/−`. +- Validation failure: the failing stage's output tail is shown above the diff so you can judge it. +- Read-only mode (Changes/History) hides accept/reject; `esc` goes back to the list. + +### Phase 3 — Live runs + +Engine (optional hooks, nil-safe): + +```go +// engine +type EventKind int // EventAttempt, EventAgentDone, EventStageStart, EventStageDone, EventDiff +type Event struct { + Kind EventKind + Attempt, Max int + Stage string + Passed bool + ExitCode int + Duration time.Duration +} +type Options struct { …; OnEvent func(Event); Output io.Writer } +``` + +- `validate.RunPipelineObserved(ctx, dir, stages, func(name string, done bool, res StageResult))`; + `RunPipeline` delegates with a nil observer. +- `agent.Task.Output io.Writer` — when set, `CLIAgent.RunQuiet` tees combined stdout/stderr into it + (via new `runner.Spec.Output`); `APIAgent` writes one line when it sends the request and one with the + outcome (it has no token stream). +- `engine.Produce` emits events and passes `Output` through. `Execute`/`ExecuteHeadless` unchanged. + +TUI: + +- `produceCmd` runs in a goroutine and forwards events / output lines over a channel; a + `waitForRun(ch)` command re-arms itself until the final `turnMsg` (standard Bubble Tea pattern). +- **Run panel** — a stage timeline: `✓ routed → opencode (implementation)`, `● attempt 1/3 · 0:42`, + `○ build ○ lint ○ test`, updating live; plus the tail of agent output (ring buffer, 400 lines). + Beside the transcript when the terminal is ≥ 110 cols wide, stacked above the input otherwise. +- **Cancel**: `esc` while running cancels the run's context; any partial changes are reverted + (`engine.Revert` with the turn's baseline) and the transcript says so. +- Elapsed timer in the status bar while running. + +### Phase 4 — Visual design + +- **Theme** (`theme.go`): one palette of `lipgloss.AdaptiveColor`s (violet accent, cyan secondary, + green/red/amber semantic, muted gray) so light terminals are readable; every style derives from it. +- **Header**: `⬡ ORCHESTRA` wordmark, pill tabs with number hints, right-aligned context: + repo folder name + git branch (or `plain folder`). +- **Status bar**: coloured mode pill (`READY` / `RUNNING` / `REVIEW`) · active agent / routing · + key hints · transient status / elapsed time, right-aligned. +- **Chat transcript**: message blocks with a coloured left border per role (you / agent / system), + agent replies rendered as markdown. +- **Agents tab**: adds per-agent stats from history (runs, accept rate, last used). +- **Empty states**: centred, bordered hint boxes that say what to do next. + +## Error handling + +- Stream writer never blocks the agent: lines are sent non-blockingly to a buffered channel; if the UI + falls behind, lines are dropped (the final full output still arrives with the turn). +- Migration failure is non-fatal the same way memory already is (dashboard runs without history). +- Any render helper (chroma, glamour) keeps its raw-text fallback. + +## Testing + +- `runner`: `Spec.Output` receives combined output. +- `validate`: observer sees start/done per stage, stops at first failure. +- `engine`: `Produce` with a fake agent emits attempt → agent done → stage events → retry, and streams output. +- `memory`: new `diff` column round-trips; migration is idempotent on an old-schema DB. +- `tui`: `parseDiff` counts/paths/status; reviewer file navigation and scrolling; tab order and + default tab; width-aware padding with ANSI; run panel renders events; cancel returns to idle; history + enter opens review; existing tests updated to the new tab order. From 840f9ea1dfe32ccd5451f905107ceb9cc507bbfd Mon Sep 17 00:00:00 2001 From: crossben Date: Fri, 25 Sep 2026 14:13:40 +0000 Subject: [PATCH 2/6] memory: persist each run's diff (idempotent migration) and per-agent stats The runs table gains a diff column, added in place on existing databases. The engine records the reviewed diff so history entries can be reopened. Co-Authored-By: Claude Opus 5.5 --- internal/engine/engine.go | 4 ++ internal/memory/memory.go | 82 +++++++++++++++++++++++++++++++--- internal/memory/memory_test.go | 68 ++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 internal/memory/memory_test.go diff --git a/internal/engine/engine.go b/internal/engine/engine.go index a869872..523f8c2 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -67,6 +67,7 @@ type Outcome struct { Report validate.Report HadChanges bool Accepted bool + Diff string // the reviewed diff ("" when nothing changed) } // Execute runs the full supervised pipeline once (including retries). The reader @@ -104,6 +105,7 @@ func Execute(ctx context.Context, in *bufio.Reader, opts Options) (out Outcome, return out, nil } out.HadChanges = true + out.Diff = diff out.Accepted = review.Prompt(in, diff, out.Report) if out.Accepted { @@ -366,6 +368,7 @@ func ExecuteHeadless(ctx context.Context, opts Options) (out Outcome, err error) return out, nil } out.HadChanges = true + out.Diff = diff if err := gitutil.Commit(opts.Dir, commitMessage(opts.Prompt)); err != nil { return out, fmt.Errorf("commit changes: %w", err) } @@ -389,6 +392,7 @@ func recordMemory(opts Options, out Outcome, outcome string) { Outcome: outcome, Attempts: out.Attempts, Passed: out.Report.Passed(), + Diff: out.Diff, }, time.Now()); rerr != nil { opts.logf("(warning: could not record to memory: %v)", rerr) } diff --git a/internal/memory/memory.go b/internal/memory/memory.go index 8da9245..a4cab87 100644 --- a/internal/memory/memory.go +++ b/internal/memory/memory.go @@ -32,7 +32,8 @@ type Run struct { Prompt string Outcome string // "accepted" | "rejected" | "no-change" | "failed" Attempts int - Passed bool // validation passed + Passed bool // validation passed + Diff string // unified diff the run produced ("" when none or not recorded) } // DefaultPath returns ~/.orchestra/orchestra.db. @@ -93,9 +94,38 @@ CREATE TABLE IF NOT EXISTS benchmarks ( won INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_bench_dir ON benchmarks(dir);`) + if err != nil { + return err + } + // Added after the first release: databases created earlier lack the diff + // column. SQLite has no ADD COLUMN IF NOT EXISTS, so check first. + has, err := s.hasColumn("runs", "diff") + if err != nil || has { + return err + } + _, err = s.db.Exec(`ALTER TABLE runs ADD COLUMN diff TEXT NOT NULL DEFAULT ''`) return err } +// hasColumn reports whether table has a column named col. +func (s *Store) hasColumn(table, col string) (bool, error) { + rows, err := s.db.Query(`SELECT name FROM pragma_table_info(?)`, table) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return false, err + } + if name == col { + return true, nil + } + } + return false, rows.Err() +} + // BenchRun is one agent's result in a benchmark. type BenchRun struct { Dir string @@ -138,8 +168,8 @@ func (s *Store) Record(r Run, now time.Time) error { passed = 1 } _, err := s.db.Exec( - `INSERT INTO runs (ts, dir, agent, prompt, outcome, attempts, passed) VALUES (?, ?, ?, ?, ?, ?, ?)`, - now.UTC().Format(time.RFC3339), r.Dir, r.Agent, r.Prompt, r.Outcome, r.Attempts, passed, + `INSERT INTO runs (ts, dir, agent, prompt, outcome, attempts, passed, diff) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + now.UTC().Format(time.RFC3339), r.Dir, r.Agent, r.Prompt, r.Outcome, r.Attempts, passed, r.Diff, ) return err } @@ -151,9 +181,9 @@ func (s *Store) Recent(dir string, limit int) ([]Run, error) { err error ) if dir == "" { - rows, err = s.db.Query(`SELECT id, ts, dir, agent, prompt, outcome, attempts, passed FROM runs ORDER BY id DESC LIMIT ?`, limit) + rows, err = s.db.Query(`SELECT id, ts, dir, agent, prompt, outcome, attempts, passed, diff FROM runs ORDER BY id DESC LIMIT ?`, limit) } else { - rows, err = s.db.Query(`SELECT id, ts, dir, agent, prompt, outcome, attempts, passed FROM runs WHERE dir = ? ORDER BY id DESC LIMIT ?`, dir, limit) + rows, err = s.db.Query(`SELECT id, ts, dir, agent, prompt, outcome, attempts, passed, diff FROM runs WHERE dir = ? ORDER BY id DESC LIMIT ?`, dir, limit) } if err != nil { return nil, err @@ -165,7 +195,7 @@ func (s *Store) Recent(dir string, limit int) ([]Run, error) { var r Run var ts string var passed int - if err := rows.Scan(&r.ID, &ts, &r.Dir, &r.Agent, &r.Prompt, &r.Outcome, &r.Attempts, &passed); err != nil { + if err := rows.Scan(&r.ID, &ts, &r.Dir, &r.Agent, &r.Prompt, &r.Outcome, &r.Attempts, &passed, &r.Diff); err != nil { return nil, err } r.Time, _ = time.Parse(time.RFC3339, ts) @@ -194,6 +224,46 @@ func (s *Store) PreferredAgent(dir string) (string, int, error) { } } +// AgentStats summarises one agent's history for a directory. +type AgentStats struct { + Runs int + Accepted int + LastUsed time.Time +} + +// StatsByAgent returns per-agent run counts, accepted counts and last-used time +// for dir (all dirs if ""). +func (s *Store) StatsByAgent(dir string) (map[string]AgentStats, error) { + const q = `SELECT agent, COUNT(*), SUM(outcome = 'accepted'), MAX(ts) FROM runs` + var ( + rows *sql.Rows + err error + ) + if dir == "" { + rows, err = s.db.Query(q + ` GROUP BY agent`) + } else { + rows, err = s.db.Query(q+` WHERE dir = ? GROUP BY agent`, dir) + } + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]AgentStats{} + for rows.Next() { + var ( + name string + st AgentStats + ts string + ) + if err := rows.Scan(&name, &st.Runs, &st.Accepted, &ts); err != nil { + return nil, err + } + st.LastUsed, _ = time.Parse(time.RFC3339, ts) + out[name] = st + } + return out, rows.Err() +} + // BenchRow is a stored benchmark result read back for display. type BenchRow struct { Time time.Time diff --git a/internal/memory/memory_test.go b/internal/memory/memory_test.go new file mode 100644 index 0000000..567250e --- /dev/null +++ b/internal/memory/memory_test.go @@ -0,0 +1,68 @@ +package memory + +import ( + "database/sql" + "path/filepath" + "testing" + "time" +) + +func TestRecordRoundTripsDiff(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "o.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + now := time.Now() + if err := s.Record(Run{Dir: "/p", Agent: "a", Prompt: "x", Outcome: "accepted", Attempts: 1, Passed: true, Diff: "diff --git a/f b/f\n+hi\n"}, now); err != nil { + t.Fatal(err) + } + if err := s.Record(Run{Dir: "/p", Agent: "b", Prompt: "y", Outcome: "rejected", Attempts: 2}, now); err != nil { + t.Fatal(err) + } + runs, err := s.Recent("/p", 10) + if err != nil { + t.Fatal(err) + } + if len(runs) != 2 || runs[1].Diff != "diff --git a/f b/f\n+hi\n" || runs[0].Diff != "" { + t.Fatalf("diff not round-tripped: %+v", runs) + } + + stats, err := s.StatsByAgent("/p") + if err != nil { + t.Fatal(err) + } + if stats["a"].Runs != 1 || stats["a"].Accepted != 1 || stats["b"].Accepted != 0 { + t.Fatalf("unexpected stats: %+v", stats) + } +} + +// A database created before the diff column existed must be upgraded in place, +// and opening it again must not fail (the migration is idempotent). +func TestMigrateAddsDiffToOldSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "old.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`CREATE TABLE runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, dir TEXT NOT NULL, agent TEXT NOT NULL, + prompt TEXT NOT NULL, outcome TEXT NOT NULL, attempts INTEGER NOT NULL, passed INTEGER NOT NULL); +INSERT INTO runs (ts, dir, agent, prompt, outcome, attempts, passed) VALUES ('2026-01-01T00:00:00Z', '/p', 'a', 'old', 'accepted', 1, 1);`); err != nil { + t.Fatal(err) + } + db.Close() + + for i := 0; i < 2; i++ { + s, err := Open(path) + if err != nil { + t.Fatalf("open #%d: %v", i+1, err) + } + runs, err := s.Recent("/p", 10) + s.Close() + if err != nil || len(runs) != 1 || runs[0].Prompt != "old" || runs[0].Diff != "" { + t.Fatalf("open #%d: runs=%+v err=%v", i+1, runs, err) + } + } +} From 0a86768d8fd5e5d2cf77075b1757e1d6152986db Mon Sep 17 00:00:00 2001 From: crossben Date: Fri, 25 Sep 2026 14:15:30 +0000 Subject: [PATCH 3/6] engine: optional progress events and live agent output for the dashboard - engine.Options gains OnEvent (attempt, agent exit, validation stage start/done) and Output; Produce emits/streams them. nil = unchanged. - agent.Task.Output: CLIAgent.RunQuiet tees output via runner.Spec.Output; APIAgent writes a request/outcome line (no token stream). - validate.RunPipelineObserved reports each stage as it runs. - gitutil.Branch for the dashboard header. Co-Authored-By: Claude Opus 5.5 --- internal/agent/agent.go | 6 +++ internal/agent/apiagent.go | 13 ++++++ internal/engine/engine.go | 45 +++++++++++++++++- internal/engine/engine_test.go | 75 ++++++++++++++++++++++++++++++ internal/gitutil/gitutil.go | 10 ++++ internal/runner/runner.go | 13 +++++- internal/runner/runner_test.go | 29 ++++++++++++ internal/validate/validate.go | 14 ++++++ internal/validate/validate_test.go | 40 ++++++++++++++++ 9 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 internal/runner/runner_test.go create mode 100644 internal/validate/validate_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c2767e8..7169aa9 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -9,6 +9,7 @@ package agent import ( "context" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -34,6 +35,10 @@ type Task struct { Prompt string Dir string Timeout time.Duration + + // Output, if set, receives the agent's output live during RunQuiet (the + // dashboard streams it into its run panel). Nil = capture only. + Output io.Writer } // Result reports how an agent run finished. @@ -222,5 +227,6 @@ func (a *CLIAgent) spec(task Task) runner.Spec { Args: args, Dir: dir, Timeout: task.Timeout, + Output: task.Output, } } diff --git a/internal/agent/apiagent.go b/internal/agent/apiagent.go index 8161be8..b361bd2 100644 --- a/internal/agent/apiagent.go +++ b/internal/agent/apiagent.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "strings" "time" @@ -113,6 +114,8 @@ func (a *APIAgent) Run(ctx context.Context, task Task) (Result, error) { if err != nil { return Result{}, fmt.Errorf("build repo snapshot: %w", err) } + // There is no token stream, so progress is one line out and one line back. + progress(task.Output, "→ asking %s (%s) · %d KiB of repo context", a.model, a.prov.Name(), len(snap)>>10) text, err := a.complete(ctx, outputContract, snap+"\n\n=== TASK ===\n"+task.Prompt, task.Timeout) if err != nil { return Result{}, err @@ -122,12 +125,22 @@ func (a *APIAgent) Run(ctx context.Context, task Task) (Result, error) { if err := patch.Apply(ctx, task.Dir, p); err != nil { return Result{Duration: time.Since(start)}, fmt.Errorf("apply model changes: %w", err) } + progress(task.Output, "← applied %d diff(s) and %d file write(s) in %s", len(p.Diffs), len(p.Files), time.Since(start).Round(time.Second/10)) + } else { + progress(task.Output, "← reply with no changes in %s", time.Since(start).Round(time.Second/10)) } // Output carries the model's raw text so the engine's no-change/question // detection (package engine) sees exactly what the model said. return Result{ExitCode: 0, Duration: time.Since(start), Output: text}, nil } +// progress writes one line to w when it is set. +func progress(w io.Writer, format string, a ...any) { + if w != nil { + fmt.Fprintf(w, format+"\n", a...) + } +} + // RunQuiet implements QuietRunner. API calls never stream, so this is Run. func (a *APIAgent) RunQuiet(ctx context.Context, task Task) (Result, error) { return a.Run(ctx, task) diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 523f8c2..21d4476 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -13,6 +13,7 @@ import ( "bufio" "context" "fmt" + "io" "path/filepath" "strings" "time" @@ -50,6 +51,39 @@ type Options struct { // Principles is an optional lean-code preamble prepended to the task (see // config.PrinciplesText). Empty = off. Principles string + + // OnEvent, if set, is told about progress during Produce (attempts, agent + // exit, each validation stage). Output, if set, receives the agent's output + // live. Both are for the dashboard; nil keeps the quiet behaviour. + OnEvent func(Event) + Output io.Writer +} + +// EventKind identifies a progress event emitted by Produce. +type EventKind int + +const ( + EventAttempt EventKind = iota // an attempt is starting (Attempt, Max) + EventAgentDone // the agent exited (ExitCode, Duration) + EventStageStart // a validation stage started (Stage) + EventStageDone // a validation stage finished (Stage, Passed) +) + +// Event is one progress notification from Produce. +type Event struct { + Kind EventKind + Attempt int + Max int + Stage string + Passed bool + ExitCode int + Duration time.Duration +} + +func (o Options) emit(e Event) { + if o.OnEvent != nil { + o.OnEvent(e) + } } func (o Options) logf(format string, a ...any) { @@ -265,10 +299,12 @@ func Produce(ctx context.Context, opts Options) Turn { maxAttempts := opts.MaxRetries + 1 for attempt := 1; attempt <= maxAttempts; attempt++ { t.Attempts = attempt + opts.emit(Event{Kind: EventAttempt, Attempt: attempt, Max: maxAttempts}) res, err := quietRun(ctx, opts.Agent, agent.Task{ Prompt: taskGuardrail + opts.Principles + prompt, Dir: opts.Dir, Timeout: opts.Timeout, + Output: opts.Output, }) if err != nil { t.Err = err @@ -276,7 +312,14 @@ func Produce(ctx context.Context, opts Options) Turn { } t.ExitCode = res.ExitCode t.AgentText = res.Output - t.Report = validate.RunPipeline(ctx, opts.Dir, opts.Stages) + opts.emit(Event{Kind: EventAgentDone, Attempt: attempt, Max: maxAttempts, ExitCode: res.ExitCode, Duration: res.Duration}) + t.Report = validate.RunPipelineObserved(ctx, opts.Dir, opts.Stages, func(done bool, r validate.StageResult) { + if done { + opts.emit(Event{Kind: EventStageDone, Attempt: attempt, Max: maxAttempts, Stage: r.Name, Passed: r.Passed}) + } else { + opts.emit(Event{Kind: EventStageStart, Attempt: attempt, Max: maxAttempts, Stage: r.Name}) + } + }) if t.Report.Skipped || t.Report.Passed() || attempt == maxAttempts { break } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 213c09c..b07dcab 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -7,9 +7,11 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "testing" "github.com/crossben/orchestra-code/internal/agent" + "github.com/crossben/orchestra-code/internal/validate" ) // writeAgent is an in-process fake agent that "edits" the task directory by @@ -171,3 +173,76 @@ func TestExecuteGitRepoAcceptStillCommits(t *testing.T) { t.Errorf("tree should be clean after commit: %q", status) } } + +// syncBuffer is a goroutine-safe bytes.Buffer for capturing streamed output. +type syncBuffer struct { + mu sync.Mutex + b strings.Builder +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} + +func (s *syncBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.String() +} + +// Produce must report each attempt, the agent's exit and every validation +// stage, and stream the agent's output — the dashboard's live run panel. +func TestProduceEmitsEventsAndStreamsOutput(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(t.TempDir(), "flaky.sh") + // First attempt writes "broken", later ones "fixed"; the counter lives + // next to the script so it never shows up in the diff. + mustWrite(t, script, `#!/bin/sh +c="$(dirname "$0")/count"; n=$(cat "$c" 2>/dev/null || echo 0); n=$((n+1)); echo $n > "$c" +if [ $n -ge 2 ]; then echo fixed > out.txt; else echo broken > out.txt; fi +echo "attempt $n" +`) + if err := os.Chmod(script, 0o755); err != nil { + t.Fatal(err) + } + + var events []Event + var out syncBuffer + turn := Produce(context.Background(), Options{ + Agent: agent.New("flaky", script, nil, "", []agent.Capability{agent.CapImplement}), + Prompt: "fix it", + Dir: dir, + Stages: []validate.Stage{{Name: "test", Command: "grep -q fixed out.txt"}}, + MaxRetries: 2, + OnEvent: func(e Event) { events = append(events, e) }, + Output: &out, + }) + if turn.Err != nil { + t.Fatal(turn.Err) + } + if turn.Attempts != 2 || !turn.Report.Passed() || !turn.HadChanges { + t.Fatalf("unexpected turn: attempts=%d passed=%v changed=%v", turn.Attempts, turn.Report.Passed(), turn.HadChanges) + } + + var kinds []string + names := map[EventKind]string{EventAttempt: "attempt", EventAgentDone: "agent", EventStageStart: "start", EventStageDone: "done"} + for _, e := range events { + k := names[e.Kind] + if e.Kind == EventStageDone && !e.Passed { + k += "✗" + } + kinds = append(kinds, k) + } + want := "attempt agent start done✗ attempt agent start done" + if got := strings.Join(kinds, " "); got != want { + t.Fatalf("events = %q, want %q", got, want) + } + if events[4].Attempt != 2 || events[4].Max != 3 { + t.Fatalf("second attempt event = %+v", events[4]) + } + if s := out.String(); !strings.Contains(s, "attempt 1") || !strings.Contains(s, "attempt 2") { + t.Fatalf("output not streamed: %q", s) + } +} diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go index 69f9c88..94d8ce1 100644 --- a/internal/gitutil/gitutil.go +++ b/internal/gitutil/gitutil.go @@ -98,3 +98,13 @@ func run(dir string, args ...string) (string, error) { } return stdout.String(), nil } + +// Branch returns the current branch name, or "" when detached or not a repo. +// symbolic-ref (unlike rev-parse) also works before the first commit. +func Branch(dir string) string { + out, err := run(dir, "symbolic-ref", "--short", "-q", "HEAD") + if err != nil { + return "" + } + return strings.TrimSpace(out) +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index bb727ab..856fc7b 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -20,6 +20,10 @@ type Spec struct { Dir string // working directory Timeout time.Duration // hard cap on runtime (0 = none) Env []string // extra environment (appended to os.Environ) + + // Output, if set, additionally receives the combined stdout+stderr as it is + // produced. Only RunProbe honours it (the quiet path the dashboard streams). + Output io.Writer } // Result reports how the process finished. @@ -164,8 +168,13 @@ func RunProbe(ctx context.Context, spec Spec) (string, Result, error) { var buf bytes.Buffer cmd := exec.CommandContext(ctx, spec.Bin, spec.Args...) cmd.Dir = spec.Dir - cmd.Stdout = &buf - cmd.Stderr = &buf + // One writer value for both streams: exec then serialises the writes. + var w io.Writer = &buf + if spec.Output != nil { + w = io.MultiWriter(&buf, spec.Output) + } + cmd.Stdout = w + cmd.Stderr = w cmd.Env = append(os.Environ(), spec.Env...) start := time.Now() diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 0000000..e1d3df7 --- /dev/null +++ b/internal/runner/runner_test.go @@ -0,0 +1,29 @@ +package runner + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestRunProbeTeesToOutput(t *testing.T) { + var live bytes.Buffer + out, res, err := RunProbe(context.Background(), Spec{ + Bin: "sh", + Args: []string{"-c", "echo to-stdout; echo to-stderr 1>&2; exit 3"}, + Dir: t.TempDir(), + Output: &live, + }) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != 3 { + t.Fatalf("exit code = %d, want 3", res.ExitCode) + } + for _, s := range []string{out, live.String()} { + if !strings.Contains(s, "to-stdout") || !strings.Contains(s, "to-stderr") { + t.Fatalf("combined output missing a stream: %q", s) + } + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index cdda730..19787b1 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -70,6 +70,18 @@ func (r Report) FeedbackText() string { // RunPipeline runs stages in order in dir, stopping at the first failure. func RunPipeline(ctx context.Context, dir string, stages []Stage) Report { + return RunPipelineObserved(ctx, dir, stages, nil) +} + +// Observer is told when each stage starts (done=false, res.Name only) and +// finishes (done=true, full result). Used to show validation progress live. +type Observer func(done bool, res StageResult) + +// RunPipelineObserved is RunPipeline with progress callbacks; obs may be nil. +func RunPipelineObserved(ctx context.Context, dir string, stages []Stage, obs Observer) Report { + if obs == nil { + obs = func(bool, StageResult) {} + } if len(stages) == 0 { return Report{Skipped: true} } @@ -78,10 +90,12 @@ func RunPipeline(ctx context.Context, dir string, stages []Stage) Report { if strings.TrimSpace(st.Command) == "" { continue } + obs(false, StageResult{Name: st.Name}) cmd := exec.CommandContext(ctx, "sh", "-c", st.Command) cmd.Dir = dir out, err := cmd.CombinedOutput() res := StageResult{Name: st.Name, Passed: err == nil, Output: string(out)} + obs(true, res) rep.Stages = append(rep.Stages, res) if !res.Passed { break // stop-on-first-failure diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go new file mode 100644 index 0000000..8c33c3e --- /dev/null +++ b/internal/validate/validate_test.go @@ -0,0 +1,40 @@ +package validate + +import ( + "context" + "strings" + "testing" +) + +func TestRunPipelineObservedStopsAtFirstFailure(t *testing.T) { + var seen []string + rep := RunPipelineObserved(context.Background(), t.TempDir(), []Stage{ + {Name: "build", Command: "true"}, + {Name: "lint", Command: "echo nope; exit 1"}, + {Name: "test", Command: "true"}, + }, func(done bool, r StageResult) { + switch { + case !done: + seen = append(seen, "start:"+r.Name) + case r.Passed: + seen = append(seen, "pass:"+r.Name) + default: + seen = append(seen, "fail:"+r.Name) + } + }) + if got, want := strings.Join(seen, " "), "start:build pass:build start:lint fail:lint"; got != want { + t.Fatalf("observer saw %q, want %q", got, want) + } + if rep.Passed() || len(rep.Stages) != 2 { + t.Fatalf("report should fail after 2 stages: %+v", rep) + } + if !strings.Contains(rep.FeedbackText(), "nope") { + t.Fatalf("feedback missing stage output: %q", rep.FeedbackText()) + } +} + +func TestRunPipelineEmptyIsSkipped(t *testing.T) { + if rep := RunPipeline(context.Background(), t.TempDir(), nil); !rep.Skipped || rep.Passed() { + t.Fatalf("empty pipeline should be skipped, not passed: %+v", rep) + } +} From 71efb37c9b541d9fd7cbf67418c1969c8879ab11 Mon Sep 17 00:00:00 2001 From: crossben Date: Fri, 25 Sep 2026 14:34:33 +0000 Subject: [PATCH 4/6] =?UTF-8?q?tui:=20dashboard=20overhaul=20=E2=80=94=20l?= =?UTF-8?q?ive=20runs,=20file-by-file=20review,=20persistent=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split the 943-line tui.go into focused files (chat, live, review, views, chrome, theme, table). - Opens on Chat; tab order Chat · Changes · History · Agents · Benchmarks · Logs. - Live run panel: routing, each attempt, agent output as it streams, and every validation stage; beside the chat on wide terminals, stacked on narrow ones. esc cancels a run and reverts its changes. - Review screen shared by Chat, Changes and History: file list with +/− counts, per-file or all-files diff, validation badges and the failing check's output. - Dashboard runs are now recorded to history with their diff; Changes and History reopen any past diff, also after a restart. - Fixes: routed agent recorded (not the default), styled table cells no longer skew columns, Changes diff scrolls, frames never overflow. - Adaptive light/dark theme, header with repo + branch, status bar with mode and key hints, empty states, agent stats. - runner: cancelling the quiet path kills the agent's process group and stops waiting on inherited pipes (children no longer outlive a cancel). - Diff colouring is now a small built-in renderer; drops the direct chroma and go-diff usage. Co-Authored-By: Claude Opus 5.5 --- go.mod | 7 +- go.sum | 14 +- internal/runner/procgroup_other.go | 9 + internal/runner/procgroup_unix.go | 18 + internal/runner/runner.go | 4 + internal/runner/runner_test.go | 16 + internal/tui/chat.go | 365 +++++++++++ internal/tui/chrome.go | 145 +++++ internal/tui/highlight.go | 120 ++-- internal/tui/live.go | 401 ++++++++++++ internal/tui/markdown.go | 11 +- internal/tui/review.go | 386 ++++++++++++ internal/tui/table.go | 116 ++++ internal/tui/theme.go | 79 +++ internal/tui/tui.go | 955 +++++++---------------------- internal/tui/tui_test.go | 502 +++++++++++---- internal/tui/views.go | 187 ++++++ 17 files changed, 2409 insertions(+), 926 deletions(-) create mode 100644 internal/runner/procgroup_other.go create mode 100644 internal/runner/procgroup_unix.go create mode 100644 internal/tui/chat.go create mode 100644 internal/tui/chrome.go create mode 100644 internal/tui/live.go create mode 100644 internal/tui/review.go create mode 100644 internal/tui/table.go create mode 100644 internal/tui/theme.go create mode 100644 internal/tui/views.go diff --git a/go.mod b/go.mod index 05ff14e..bef94be 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module github.com/crossben/orchestra-code go 1.22 require ( - github.com/alecthomas/chroma/v2 v2.14.0 github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.1.0 github.com/charmbracelet/glamour v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.8.0 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 @@ -15,11 +15,11 @@ require ( ) require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect @@ -28,6 +28,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -39,7 +40,6 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sergi/go-diff v1.4.0 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.4 // indirect @@ -49,6 +49,7 @@ require ( golang.org/x/sys v0.30.0 // indirect golang.org/x/term v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect diff --git a/go.sum b/go.sum index 61b8f5f..517ef48 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= @@ -31,8 +33,6 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -49,8 +49,10 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= @@ -73,7 +75,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -81,14 +82,10 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= @@ -115,12 +112,9 @@ golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= diff --git a/internal/runner/procgroup_other.go b/internal/runner/procgroup_other.go new file mode 100644 index 0000000..966b016 --- /dev/null +++ b/internal/runner/procgroup_other.go @@ -0,0 +1,9 @@ +//go:build !unix + +package runner + +import "os/exec" + +// killTree is a no-op where process groups are unavailable; WaitDelay still +// stops a cancelled run from hanging on inherited pipes. +func killTree(*exec.Cmd) {} diff --git a/internal/runner/procgroup_unix.go b/internal/runner/procgroup_unix.go new file mode 100644 index 0000000..af68cef --- /dev/null +++ b/internal/runner/procgroup_unix.go @@ -0,0 +1,18 @@ +//go:build unix + +package runner + +import ( + "os/exec" + "syscall" +) + +// killTree makes cancelling cmd kill its whole process group, not just the +// direct child: agent CLIs are often wrappers (sh, node) whose children would +// otherwise keep running — and keep editing files — after a cancel. +func killTree(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 856fc7b..8148491 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -176,6 +176,10 @@ func RunProbe(ctx context.Context, spec Spec) (string, Result, error) { cmd.Stdout = w cmd.Stderr = w cmd.Env = append(os.Environ(), spec.Env...) + // The dashboard cancels runs: take the agent's children down with it, and + // don't wait long on pipes a stray grandchild may still hold open. + killTree(cmd) + cmd.WaitDelay = 2 * time.Second start := time.Now() err := cmd.Run() diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index e1d3df7..1bc1761 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -5,6 +5,7 @@ import ( "context" "strings" "testing" + "time" ) func TestRunProbeTeesToOutput(t *testing.T) { @@ -27,3 +28,18 @@ func TestRunProbeTeesToOutput(t *testing.T) { } } } + +// Cancelling must stop the agent's children too, and return promptly even +// though a grandchild inherited the output pipe. +func TestRunProbeCancelKillsChildren(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { time.Sleep(200 * time.Millisecond); cancel() }() + start := time.Now() + _, _, err := RunProbe(ctx, Spec{Bin: "sh", Args: []string{"-c", "sleep 30; echo done"}, Dir: t.TempDir()}) + if err != context.Canceled { + t.Fatalf("err = %v, want context.Canceled", err) + } + if d := time.Since(start); d > 5*time.Second { + t.Fatalf("cancel took %s", d) + } +} diff --git a/internal/tui/chat.go b/internal/tui/chat.go new file mode 100644 index 0000000..3c0dc02 --- /dev/null +++ b/internal/tui/chat.go @@ -0,0 +1,365 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/crossben/orchestra-code/internal/engine" + "github.com/crossben/orchestra-code/internal/memory" + "github.com/crossben/orchestra-code/internal/ui" +) + +const ( + sideMinWidth = 110 // below this the run panel stacks instead of sitting beside the chat + inputHeight = 5 // textarea (3) + border +) + +func (m Model) wide() bool { return m.width >= sideMinWidth } + +func (m Model) sideWidth() int { return min(max(m.width*36/100, 40), 64) } + +func (m Model) mainWidth() int { + if m.wide() { + return m.width - m.sideWidth() - 1 + } + return m.width +} + +func (m Model) stackedPanelHeight() int { return min(14, m.bodyHeight()/2) } + +// layout sizes the transcript, input and any open reviewer to the window. +func (m *Model) layout() { + bodyH := m.bodyHeight() + mw := m.mainWidth() + vpH := bodyH - inputHeight + if m.cstate == chatRunning && !m.wide() { + vpH = bodyH - m.stackedPanelHeight() + } + m.vp.Width = mw + m.vp.Height = max(vpH, 3) + m.ta.SetWidth(max(mw-4, 10)) + if m.cstate == chatReviewing { + m.rv.setSize(m.width, bodyH) + } + if m.browsing { + m.browse.setSize(m.width, bodyH) + } +} + +// setChatContent refreshes the transcript viewport, keeping it pinned to the +// newest message. +func (m *Model) setChatContent() { + m.vp.SetContent(m.renderTranscript()) + m.vp.GotoBottom() +} + +func (m Model) updateChat(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.cstate { + case chatReviewing: + switch msg.String() { + case "y", "Y": + return m.accept() + case "n", "N": + return m.reject() + } + var cmd tea.Cmd + m.rv, cmd, _ = m.rv.update(msg) + return m, cmd + case chatRunning: + switch msg.String() { + case "esc": + if m.run != nil && !m.run.cancelled { + m.run.cancelled = true + m.run.cancel() + m.setStatus("cancelling…") + } + return m, nil + case "pgup", "pgdown", "up", "down": + var cmd tea.Cmd + m.vp, cmd = m.vp.Update(msg) + return m, cmd + } + return m, nil + } + + // idle + switch msg.String() { + case "enter": + return m.submitChat() + case "pgup", "pgdown": + var cmd tea.Cmd + m.vp, cmd = m.vp.Update(msg) + return m, cmd + case "esc": + return m, nil + } + var cmd tea.Cmd + m.ta, cmd = m.ta.Update(msg) + return m, cmd +} + +func (m Model) submitChat() (tea.Model, tea.Cmd) { + text := strings.TrimSpace(m.ta.Value()) + if text == "" { + return m, nil + } + m.ta.Reset() + m.ta.Blur() + m.messages = append(m.messages, chatLine{role: "you", text: text}) + m.cstate = chatRunning + m.frame = 0 + m.status = "" + cmd := m.startRun(text) + m.logEvent("info", "task: "+firstLine(text)) + m.layout() + m.setChatContent() + return m, cmd +} + +// onTurn handles the worker's final message. +func (m Model) onTurn(msg turnMsg) (tea.Model, tea.Cmd) { + run, t := m.run, msg.turn + agentName := msg.agent + if agentName == "" { + agentName = run.agent + } + m.cstate = chatIdle + + switch { + case run.cancelled: + run.result = "cancelled" + text := "↺ cancelled" + if msg.started { + if err := engine.Revert(m.d.Dir, t.Baseline); err != nil { + text = "↺ cancelled — restoring the working tree failed: " + err.Error() + } else { + text = "↺ cancelled — any changes were reverted" + } + m.record(memory.Run{Agent: agentName, Prompt: run.task, Outcome: "cancelled", Attempts: t.Attempts}) + } + m.messages = append(m.messages, chatLine{role: "sys", text: text}) + m.logEvent("turn", "run cancelled") + case t.Err != nil: + run.result = "failed" + m.messages = append(m.messages, chatLine{role: "sys", text: "✗ " + t.Err.Error()}) + m.logEvent("error", t.Err.Error()) + if msg.started { + m.record(memory.Run{Agent: agentName, Prompt: run.task, Outcome: "failed", Attempts: t.Attempts}) + } + case msg.answered: + run.result = "answered" + run.agent = "" + m.messages = append(m.messages, chatLine{role: "agent", text: strings.TrimSpace(t.AgentText), agent: "orchestra"}) + m.logEvent("turn", "question answered") + ui.Notify("Orchestra", "Answered — "+firstLine(t.AgentText)) + case !t.HadChanges: + run.result = "no changes" + resp := strings.TrimSpace(t.AgentText) + if resp == "" { + resp = "(the agent made no file changes)" + } + m.messages = append(m.messages, chatLine{role: "agent", text: resp, agent: agentName}) + m.record(memory.Run{Agent: agentName, Prompt: run.task, Outcome: "no-change", Attempts: t.Attempts, Passed: t.Report.Passed()}) + m.logEvent("turn", fmt.Sprintf("%s replied without changes", agentName)) + ui.Notify("Orchestra", "Agent finished — "+firstLine(resp)) + default: + run.result = "review" + m.cstate = chatReviewing + m.pending, m.pendAg, m.pendTask = t, agentName, run.task + rep := t.Report + m.rv = newReviewer(t.Diff, reviewMeta{ + Agent: agentName, Task: run.task, Attempts: t.Attempts, + Max: m.d.MaxRetries + 1, Report: &rep, + }, m.width, m.bodyHeight()) + m.messages = append(m.messages, chatLine{role: "sys", text: fmt.Sprintf("● %s changed %d file%s — review required", + agentName, len(m.rv.files), plural(len(m.rv.files)))}) + m.logEvent("turn", agentName+" produced changes — review required") + ui.Notify("Orchestra", "Agent produced changes — review required") + } + if m.cstate == chatIdle && m.active == tabChat { + m.ta.Focus() + } + m.layout() + m.setChatContent() + return m, nil +} + +func (m Model) accept() (tea.Model, tea.Cmd) { + committed, err := engine.CommitAccepted(m.d.Dir, "orchestra: "+firstLine(m.pendTask)) + switch { + case err != nil: + m.messages = append(m.messages, chatLine{role: "sys", text: "✗ commit failed: " + err.Error()}) + case committed: + m.messages = append(m.messages, chatLine{role: "sys", text: "✓ accepted & committed"}) + default: + m.messages = append(m.messages, chatLine{role: "sys", text: "✓ accepted — kept as plain files (not a git repo)"}) + } + return m.finishReview("accepted"), nil +} + +func (m Model) reject() (tea.Model, tea.Cmd) { + if err := engine.Revert(m.d.Dir, m.pending.Baseline); err != nil { + m.messages = append(m.messages, chatLine{role: "sys", text: "✗ restore failed: " + err.Error()}) + } else { + m.messages = append(m.messages, chatLine{role: "sys", text: "↺ rejected & reverted"}) + } + return m.finishReview("rejected"), nil +} + +func (m Model) finishReview(outcome string) Model { + t := m.pending + m.record(memory.Run{ + Agent: m.pendAg, Prompt: m.pendTask, Outcome: outcome, + Attempts: t.Attempts, Passed: t.Report.Passed(), Diff: t.Diff, + }) + m.logEvent("turn", outcome) + m.cstate = chatIdle + m.pending = engine.Turn{} + if m.active == tabChat { + m.ta.Focus() + } + m.layout() + m.setChatContent() + return m +} + +// --- rendering --- + +func (m Model) chatView() string { + if m.cstate == chatReviewing { + return m.rv.view() + } + bodyH := m.bodyHeight() + mw := m.mainWidth() + + var bottom string + switch { + case m.cstate == chatRunning && !m.wide(): + bottom = m.runPanel(mw, m.stackedPanelHeight()) + case m.cstate == chatRunning: + bottom = panelBox("", warnSty.Render(spin(m.frame))+dimSty.Render(" the agent is working — follow along on the right · esc cancels"), + mw, inputHeight, faint) + default: + bottom = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent). + Padding(0, 1).Width(max(mw-2, 12)).Render(m.ta.View()) + } + main := m.vp.View() + "\n" + bottom + if !m.wide() { + return main + } + var side string + if m.run != nil { + side = m.runPanel(m.sideWidth(), bodyH) + } else { + side = m.sessionCard(m.sideWidth(), bodyH) + } + return lipgloss.JoinHorizontal(lipgloss.Top, padLines(main, bodyH, mw), " ", side) +} + +func (m Model) renderTranscript() string { + w := max(m.vp.Width, 20) + if len(m.messages) == 0 { + return lipgloss.Place(w, max(m.vp.Height, 3), lipgloss.Center, lipgloss.Center, m.welcome()) + } + var blocks []string + for _, msg := range m.messages { + switch msg.role { + case "you": + blocks = append(blocks, turnBlock(youSty.Render("you"), lipgloss.NewStyle().Width(w-3).Render(msg.text), accent2, w)) + case "agent": + label := msg.agent + if label == "" { + label = "agent" + } + blocks = append(blocks, turnBlock(titleSty.Render(label), renderMarkdown(msg.text, w-3), accent, w)) + case "sys": + blocks = append(blocks, sysLine(msg.text, w)) + } + } + return strings.Join(blocks, "\n\n") +} + +// turnBlock renders a message with a coloured bar down its left edge. +func turnBlock(label, body string, bar lipgloss.TerminalColor, w int) string { + return lipgloss.NewStyle().Border(lipgloss.ThickBorder(), false, false, false, true). + BorderForeground(bar).PaddingLeft(1).Width(w - 1).Render(label + "\n" + body) +} + +// sysLine colours a system note by its leading glyph. +func sysLine(text string, w int) string { + st := dimSty + switch { + case strings.HasPrefix(text, "✓"): + st = okSty + case strings.HasPrefix(text, "✗"): + st = badSty + case strings.HasPrefix(text, "↺"), strings.HasPrefix(text, "●"): + st = warnSty + } + return lipgloss.NewStyle().Width(w).Render(" " + st.Render(text)) +} + +func (m Model) welcome() string { + lines := []string{ + titleSty.Render("What should we build?"), + "", + dimSty.Render("Describe a change and an agent makes it. You watch it work,"), + dimSty.Render("the checks run, and nothing is kept until you accept the diff."), + "", + keySty.Render("try ") + "add a /health endpoint with a test", + keySty.Render(" ") + "why is the build slow?" + dimSty.Render(" (questions get answers)"), + } + return strings.Join(lines, "\n") +} + +// sessionCard fills the side panel before the first run. +func (m Model) sessionCard(w, h int) string { + row := func(k, v string) string { return fit(dimSty.Render(k), 10) + v } + folder := m.repoName + switch { + case m.branch != "": + folder += " " + youSty.Render("⎇ "+m.branch) + case !m.inRepo: + folder += dimSty.Render(" plain folder") + } + routing := "fixed: " + youSty.Render(m.d.DefaultAgent) + if m.d.RoutingOn && m.d.Router != nil { + routing = okSty.Render("AI") + " picks the agent" + } + ready, total := 0, 0 + if m.d.Reg != nil { + for _, a := range m.d.Reg.All() { + total++ + if a.Health() == nil { + ready++ + } + } + } + checks := warnSty.Render("none — changes are unverified") + var names []string + for _, st := range m.d.Stages { + if strings.TrimSpace(st.Command) != "" { + names = append(names, st.Name) + } + } + if len(names) > 0 { + checks = strings.Join(names, dimSty.Render(" → ")) + } + lines := []string{ + row("folder", folder), + row("routing", routing), + row("agents", fmt.Sprintf("%d of %d installed", ready, total)), + row("checks", checks), + row("retries", fmt.Sprintf("%d", m.d.MaxRetries)+dimSty.Render(fmt.Sprintf(" · timeout %s", m.d.Timeout))), + "", + headSty.Render("How it works"), + dimSty.Render("1 ") + "you describe a change", + dimSty.Render("2 ") + "an agent edits the files", + dimSty.Render("3 ") + "checks run; failures retry", + dimSty.Render("4 ") + "you review the diff: " + okSty.Render("y") + dimSty.Render(" / ") + badSty.Render("n"), + } + return panelBox("Session", strings.Join(lines, "\n"), w, h, faint) +} diff --git a/internal/tui/chrome.go b/internal/tui/chrome.go new file mode 100644 index 0000000..385ef05 --- /dev/null +++ b/internal/tui/chrome.go @@ -0,0 +1,145 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// header is the wordmark + tab pills, the project context on the right, and a +// rule underneath. +func (m Model) header() string { + title := titleSty.Render("⬡ ORCHESTRA") + var tabs []string + for i, name := range tabNames { + label := fmt.Sprintf("%d %s", i+1, name) + if tab(i) == tabChat { + switch m.cstate { + case chatRunning: + label += " " + spinnerFrames[m.frame%len(spinnerFrames)] + case chatReviewing: + label += " ●" + } + } + if tab(i) == tabChanges { + if n := len(m.changeIdx()); n > 0 { + label += fmt.Sprintf(" %d", n) + } + } + if tab(i) == m.active { + tabs = append(tabs, tabOn.Render(label)) + } else { + tabs = append(tabs, tabOff.Render(label)) + } + } + left := title + " " + strings.Join(tabs, "") + + ctx := dimSty.Render(m.repoName) + switch { + case m.branch != "": + ctx += dimSty.Render(" on ") + youSty.Render("⎇ "+m.branch) + case !m.inRepo: + ctx += dimSty.Render(" · plain folder") + } + line := left + if gap := m.width - lipgloss.Width(left) - lipgloss.Width(ctx) - 1; gap >= 2 { + line = left + strings.Repeat(" ", gap) + ctx + } + return ansiTruncate(line, m.width) + "\n" + faintSty.Render(strings.Repeat("─", max(m.width, 1))) +} + +// statusBar: mode pill, context, key hints, and a transient status or the +// run timer on the right. +func (m Model) statusBar() string { + var mode string + switch { + case m.cstate == chatRunning: + mode = pill("RUNNING", amber) + case m.cstate == chatReviewing: + mode = pill("REVIEW", pillBg) + default: + mode = pill("READY", accent2) + } + + hints := renderHints(m.hints()) + + right := "" + switch { + case m.status != "" && time.Since(m.statusAt) < 6*time.Second: + right = dimSty.Render(m.status) + case m.cstate == chatRunning && m.run != nil: + right = warnSty.Render(fmtElapsed(time.Since(m.run.start))) + } + + left := mode + " " + hints + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) - 1 + if gap < 1 { + return ansiTruncate(left, m.width) + } + return left + strings.Repeat(" ", gap) + right +} + +// hints are the keys that do something right now, as "key label" pairs. +func (m Model) hints() []string { + if m.browsing { + return append(m.browse.hints(), "tab switch") + } + switch m.active { + case tabChat: + switch m.cstate { + case chatRunning: + return []string{"esc cancel", "pgup/pgdn scroll", "tab switch"} + case chatReviewing: + return append(m.rv.hints(), "tab switch") + } + return []string{"enter send", "ctrl+j newline", "pgup/pgdn scroll", "tab switch", "ctrl+c quit"} + case tabChanges, tabHistory: + return []string{"↑↓ select", "enter open", "r refresh", "tab switch", "q quit"} + case tabAgents: + return []string{"p probe", "r refresh", "tab switch", "q quit"} + } + return []string{"r refresh", "tab switch", "q quit"} +} + +func renderHints(hs []string) string { + out := make([]string, len(hs)) + for i, h := range hs { + k, label, _ := strings.Cut(h, " ") + out[i] = keySty.Render(k) + " " + dimSty.Render(label) + } + return strings.Join(out, dimSty.Render(" ")) +} + +func fmtElapsed(d time.Duration) string { + d = d.Round(time.Second) + if d >= time.Hour { + return fmt.Sprintf("%d:%02d:%02d", int(d.Hours()), int(d.Minutes())%60, int(d.Seconds())%60) + } + return fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60) +} + +// padLines forces s to exactly n lines of at most w cells, so a view can never +// wrap or leave stale rows from the previous frame. +func padLines(s string, n, w int) string { + lines := strings.Split(s, "\n") + if len(lines) > n { + lines = lines[:n] + } + for i, l := range lines { + if lipgloss.Width(l) > w { + lines[i] = ansiTruncate(l, w) + } + } + for len(lines) < n { + lines = append(lines, "") + } + return strings.Join(lines, "\n") +} + +// emptyState is a centred hint box for tabs with nothing to show yet. +func emptyState(title, hint string, w, h int) string { + b := box("", headSty.Render(title)+"\n"+dimSty.Render(hint), min(max(lipgloss.Width(hint)+6, 40), w), faint) + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, b) +} diff --git a/internal/tui/highlight.go b/internal/tui/highlight.go index 62e0152..bfeba1d 100644 --- a/internal/tui/highlight.go +++ b/internal/tui/highlight.go @@ -1,100 +1,50 @@ package tui import ( - "bytes" - "fmt" "strings" - "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/formatters" - "github.com/alecthomas/chroma/v2/lexers" - "github.com/alecthomas/chroma/v2/styles" - "github.com/sergi/go-diff/diffmatchpatch" + "github.com/charmbracelet/lipgloss" ) -// diffStyle is chosen once (fallback-safe) for a consistent dark look. -var diffStyle = func() *chroma.Style { - if s := styles.Get("catppuccin-mocha"); s != nil { - return s - } - if s := styles.Get("monokai"); s != nil { - return s - } - return styles.Fallback -}() +// Diff line styles: added/removed lines get a tinted full-width band (like a +// code-review UI), hunk headers are cyan, file headers muted. +var ( + diffAddBg = lipgloss.AdaptiveColor{Light: "#E6F4EA", Dark: "#16291F"} + diffDelBg = lipgloss.AdaptiveColor{Light: "#FBE9EB", Dark: "#2E1719"} -// highlightDiff renders a unified diff with chroma's diff lexer (ANSI 256-color). -// Falls back to the raw diff if anything goes wrong, so it can never break the view. -func highlightDiff(src string) string { - lexer := lexers.Get("diff") - if lexer == nil { - return src - } - formatter := formatters.Get("terminal256") - if formatter == nil { - return src - } - it, err := lexer.Tokenise(nil, src) - if err != nil { - return src - } - var buf bytes.Buffer - if err := formatter.Format(&buf, diffStyle, it); err != nil { - return src - } - return buf.String() -} + diffAddSty = lipgloss.NewStyle().Foreground(green).Background(diffAddBg) + diffDelSty = lipgloss.NewStyle().Foreground(red).Background(diffDelBg) + diffHunkSty = lipgloss.NewStyle().Foreground(accent2) + diffMetaSty = lipgloss.NewStyle().Foreground(gray).Bold(true) +) -// parseDiffFiles extracts file names from a unified diff string. -func parseDiffFiles(diff string) []string { - var files []string - seen := map[string]bool{} - for _, line := range strings.Split(diff, "\n") { - if strings.HasPrefix(line, "diff --git ") { - parts := strings.Split(line, " b/") - if len(parts) == 2 { - name := parts[1] - if !seen[name] { - seen[name] = true - files = append(files, name) - } +// highlightDiff colours a unified diff for display, padding changed lines to +// width so their background forms a band (width <= 0 disables padding). +func highlightDiff(src string, width int) string { + lines := strings.Split(strings.TrimRight(src, "\n"), "\n") + for i, l := range lines { + l = strings.ReplaceAll(l, "\t", " ") + band := func(st lipgloss.Style) string { + if pad := width - lipgloss.Width(l); pad > 0 { + l += strings.Repeat(" ", pad) } + return st.Render(l) } - } - return files -} - -// diffStats returns a summary line like "+12 -5" from a unified diff. -func diffStats(diff string) string { - dmp := diffmatchpatch.New() - lines := strings.Split(diff, "\n") - var a, b []string - for _, line := range lines { switch { - case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"): - b = append(b, line[1:]) - case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"): - a = append(a, line[1:]) - } - } - diffs := dmp.DiffMain(strings.Join(a, "\n"), strings.Join(b, "\n"), false) - added, removed := 0, 0 - for _, d := range diffs { - switch d.Type { - case diffmatchpatch.DiffInsert: - added += strings.Count(d.Text, "\n") - if !strings.HasSuffix(d.Text, "\n") { - added++ - } - case diffmatchpatch.DiffDelete: - removed += strings.Count(d.Text, "\n") - if !strings.HasSuffix(d.Text, "\n") { - removed++ - } + case strings.HasPrefix(l, "+++ "), strings.HasPrefix(l, "--- "), + strings.HasPrefix(l, "diff --git "), strings.HasPrefix(l, "index "), + strings.HasPrefix(l, "new file mode"), strings.HasPrefix(l, "deleted file mode"), + strings.HasPrefix(l, "rename "), strings.HasPrefix(l, "similarity "): + lines[i] = diffMetaSty.Render(l) + case strings.HasPrefix(l, "@@"): + lines[i] = diffHunkSty.Render(l) + case strings.HasPrefix(l, "+"): + lines[i] = band(diffAddSty) + case strings.HasPrefix(l, "-"): + lines[i] = band(diffDelSty) + default: + lines[i] = l } } - if added == 0 && removed == 0 { - return "no changes" - } - return okSty.Render(fmt.Sprintf("+%d", added)) + " " + badSty.Render(fmt.Sprintf("-%d", removed)) + return strings.Join(lines, "\n") } diff --git a/internal/tui/live.go b/internal/tui/live.go new file mode 100644 index 0000000..7f83e30 --- /dev/null +++ b/internal/tui/live.go @@ -0,0 +1,401 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + + "github.com/crossben/orchestra-code/internal/engine" + "github.com/crossben/orchestra-code/internal/gitutil" +) + +// Stage states in the live timeline. +const ( + stPending = iota + stRunning + stPassed + stFailed +) + +type stageState struct { + name string + state int +} + +// liveRun is one chat turn in flight (and, once finished, the last run shown +// in the side panel). Only Update mutates it; the worker goroutine talks to +// the model exclusively through ch. +type liveRun struct { + ch chan tea.Msg + ctx context.Context + cancel context.CancelFunc + task string + start time.Time + end time.Time + + routing bool // waiting for the router + agent string + reason string + attempt int + max int + agentDone bool + exitCode int + agentDur time.Duration + stages []stageState + past []string // one summary line per finished attempt + lines []string // agent output tail + cancelled bool + result string // set when finished: "review", "answered", "no changes", "failed", "cancelled" +} + +const maxRunLines = 400 + +func (r *liveRun) addLines(ls ...string) { + r.lines = append(r.lines, ls...) + if len(r.lines) > maxRunLines { + r.lines = r.lines[len(r.lines)-maxRunLines:] + } +} + +// --- worker → model messages --- + +type routedMsg struct{ agent, reason string } +type eventMsg struct{ e engine.Event } +type outputMsg struct{ lines []string } +type turnMsg struct { + turn engine.Turn + agent string + answered bool // the router answered a question; nothing ran + started bool // Produce ran, so the tree may have been touched +} + +// waitRun delivers the next message from the worker (nil once it is done). +func waitRun(ch chan tea.Msg) tea.Cmd { + return func() tea.Msg { + msg, ok := <-ch + if !ok { + return nil + } + return msg + } +} + +// startRun launches the worker for text and returns the commands that drive it. +func (m *Model) startRun(text string) tea.Cmd { + ctx, cancel := context.WithCancel(m.d.Ctx) + run := &liveRun{ + ch: make(chan tea.Msg, 256), + ctx: ctx, + cancel: cancel, + task: text, + start: time.Now(), + routing: m.d.RoutingOn && m.d.Router != nil, + agent: m.d.DefaultAgent, + } + m.run = run + d := m.d + start := func() tea.Msg { + go runTurn(ctx, d, text, run.ch) + return nil + } + return tea.Batch(tickCmd(), start, waitRun(run.ch)) +} + +// runTurn is the worker: route, then Produce with live events and output. +// The final turnMsg is always delivered, then the channel is closed. +func runTurn(ctx context.Context, d Deps, text string, ch chan tea.Msg) { + defer close(ch) + send := func(msg tea.Msg) { + select { + case ch <- msg: + case <-ctx.Done(): + } + } + + // Inside a git repo the tree must start clean (reject uses git restore). + // Plain directories are always allowed: the engine snapshots them. + if gitutil.IsRepo(d.Dir) { + if clean, _ := gitutil.IsClean(d.Dir); !clean { + ch <- turnMsg{turn: engine.Turn{Err: errDirty}} + return + } + } + + agentName := d.DefaultAgent + if d.RoutingOn && d.Router != nil { + dec := d.Router.Route(ctx, text, d.Dir) // quiet: safe in the TUI + if dec.IsQuestion() { + ans, err := d.Router.Answer(ctx, text, d.Dir, d.Timeout) + ch <- turnMsg{turn: engine.Turn{AgentText: ans, Err: err}, answered: true} + return + } + agentName = dec.Agent + send(routedMsg{agent: dec.Agent, reason: dec.Reason}) + } + + ag, ok := d.Reg.Get(agentName) + if !ok { + ch <- turnMsg{turn: engine.Turn{Err: fmt.Errorf("agent %q not available", agentName)}, agent: agentName} + return + } + lw := &lineWriter{ch: ch} + t := engine.Produce(ctx, engine.Options{ + Agent: ag, Prompt: text, Dir: d.Dir, + Stages: d.Stages, MaxRetries: d.MaxRetries, + Timeout: d.Timeout, Principles: d.Principles, + OnEvent: func(e engine.Event) { send(eventMsg{e}) }, + Output: lw, + }) + lw.flush() + ch <- turnMsg{turn: t, agent: agentName, started: true} +} + +// lineWriter turns streamed agent output into cleaned lines for the panel. +// It never blocks the agent: if the UI is behind, lines are dropped (the full +// output still arrives with the final turn). +type lineWriter struct { + mu sync.Mutex + ch chan tea.Msg + partial string +} + +func (w *lineWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + w.partial += string(p) + i := strings.LastIndexByte(w.partial, '\n') + if i < 0 { + return len(p), nil + } + complete := w.partial[:i] + w.partial = w.partial[i+1:] + w.emit(strings.Split(complete, "\n")) + return len(p), nil +} + +func (w *lineWriter) flush() { + w.mu.Lock() + defer w.mu.Unlock() + if w.partial != "" { + w.emit([]string{w.partial}) + w.partial = "" + } +} + +func (w *lineWriter) emit(raw []string) { + lines := make([]string, 0, len(raw)) + for _, l := range raw { + lines = append(lines, cleanLine(l)) + } + select { + case w.ch <- outputMsg{lines: lines}: + default: + } +} + +// cleanLine strips terminal escapes and keeps only what a carriage-return +// progress line finally shows. +func cleanLine(s string) string { + s = ansi.Strip(s) + if i := strings.LastIndexByte(strings.TrimRight(s, "\r"), '\r'); i >= 0 { + s = s[i+1:] + } + s = strings.TrimRight(s, "\r") + return strings.ReplaceAll(s, "\t", " ") +} + +// onRunMsg applies one worker message to the model. +func (m Model) onRunMsg(msg tea.Msg) (tea.Model, tea.Cmd) { + run := m.run + if run == nil { + return m, nil + } + rearm := waitRun(run.ch) + switch msg := msg.(type) { + case routedMsg: + run.routing = false + run.agent, run.reason = msg.agent, msg.reason + note := "routed to " + msg.agent + if msg.reason != "" { + note += " — " + msg.reason + } + m.logEvent("route", note) + m.messages = append(m.messages, chatLine{role: "sys", text: "↳ " + note}) + m.setChatContent() + case eventMsg: + m.applyEvent(msg.e) + case outputMsg: + run.addLines(msg.lines...) + case turnMsg: + run.routing = false + run.end = time.Now() + return m.onTurn(msg) + } + return m, rearm +} + +func (m *Model) applyEvent(e engine.Event) { + run := m.run + run.routing = false + switch e.Kind { + case engine.EventAttempt: + if run.attempt > 0 { + run.past = append(run.past, run.attemptSummary()) + } + run.attempt, run.max = e.Attempt, e.Max + run.agentDone = false + run.stages = run.stages[:0] + for _, st := range m.d.Stages { + if strings.TrimSpace(st.Command) != "" { + run.stages = append(run.stages, stageState{name: st.Name}) + } + } + if e.Attempt > 1 { + run.addLines(fmt.Sprintf("── retry %d/%d: self-correcting ──", e.Attempt-1, e.Max-1)) + m.logEvent("turn", fmt.Sprintf("retry %d/%d", e.Attempt-1, e.Max-1)) + } + case engine.EventAgentDone: + run.agentDone = true + run.exitCode, run.agentDur = e.ExitCode, e.Duration + case engine.EventStageStart, engine.EventStageDone: + state := stRunning + if e.Kind == engine.EventStageDone { + state = stFailed + if e.Passed { + state = stPassed + } + } + for i := range run.stages { + if run.stages[i].name == e.Stage { + run.stages[i].state = state + return + } + } + run.stages = append(run.stages, stageState{name: e.Stage, state: state}) + } +} + +// attemptSummary condenses the attempt that just ended into one line. +func (r *liveRun) attemptSummary() string { + label := fmt.Sprintf("attempt %d/%d", r.attempt, r.max) + for _, st := range r.stages { + if st.state == stFailed { + return badSty.Render("✗") + " " + label + dimSty.Render(" · "+st.name+" failed") + } + } + return warnSty.Render("!") + " " + label +} + +// runPanel renders the live timeline + output tail in a w×h box. +func (m Model) runPanel(w, h int) string { + run := m.run + inner := max(w-4, 10) + var head []string + + // Title line: agent · elapsed, or the final result. + elapsed := time.Since(run.start) + if !run.end.IsZero() { + elapsed = run.end.Sub(run.start) + } + status := "" + switch run.result { + case "": + status = warnSty.Render(spinnerFrames[m.frame%len(spinnerFrames)] + " running") + case "review": + status = okSty.Render("✓ ready for review") + case "answered": + status = okSty.Render("✓ answered") + case "no changes": + status = dimSty.Render("○ no file changes") + case "cancelled": + status = warnSty.Render("↺ cancelled") + default: + status = badSty.Render("✗ " + run.result) + } + head = append(head, spread(status, dimSty.Render(fmtElapsed(elapsed)), inner)) + + // Timeline. + switch { + case run.routing: + head = append(head, warnSty.Render(spin(m.frame))+" routing…") + case run.reason != "": + head = append(head, okSty.Render("✓")+" routed → "+youSty.Render(run.agent)+dimSty.Render(" · "+run.reason)) + case run.agent != "" && run.result != "answered": + head = append(head, dimSty.Render("•")+" agent "+youSty.Render(run.agent)) + } + head = append(head, run.past...) + if run.attempt > 0 { + label := "attempt " + fmt.Sprint(run.attempt) + if run.max > 1 { + label = fmt.Sprintf("attempt %d/%d", run.attempt, run.max) + } + switch { + case !run.agentDone && run.result == "": + head = append(head, warnSty.Render(spin(m.frame))+" "+label+dimSty.Render(" · agent working")) + case !run.agentDone: + head = append(head, dimSty.Render("•")+" "+label) + default: + mark := okSty.Render("✓") + if run.exitCode != 0 { + mark = warnSty.Render("!") + } + head = append(head, mark+" "+label+dimSty.Render(fmt.Sprintf(" · exit %d · %s", run.exitCode, run.agentDur.Round(time.Second/10)))) + } + if len(run.stages) > 0 { + var parts []string + for _, st := range run.stages { + var g string + switch st.state { + case stPending: + g = dimSty.Render("○") + case stRunning: + g = warnSty.Render(spin(m.frame)) + case stPassed: + g = okSty.Render("✓") + default: + g = badSty.Render("✗") + } + parts = append(parts, g+" "+st.name) + } + head = append(head, " "+strings.Join(parts, " ")) + } else if run.agentDone { + head = append(head, " "+warnSty.Render("no checks configured — unverified")) + } + } + + // Output tail fills the rest. + outH := h - 2 - len(head) - 2 // border, head, gap + label + var body []string + body = append(body, head...) + if outH >= 1 { + body = append(body, "", faintSty.Render("output "+strings.Repeat("─", max(inner-7, 0)))) + tail := run.lines + if len(tail) > outH { + tail = tail[len(tail)-outH:] + } + if len(tail) == 0 { + tail = []string{dimSty.Render("waiting for output…")} + } + for _, l := range tail { + body = append(body, dimSty.Render(ansiTruncate(l, inner))) + } + } + return panelBox("Run", strings.Join(body, "\n"), w, h, accent) +} + +func spin(frame int) string { return spinnerFrames[frame%len(spinnerFrames)] } + +// spread puts left and right on one line of width w. +func spread(left, right string, w int) string { + gap := w - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + return left + } + return left + strings.Repeat(" ", gap) + right +} diff --git a/internal/tui/markdown.go b/internal/tui/markdown.go index f14e60a..931e094 100644 --- a/internal/tui/markdown.go +++ b/internal/tui/markdown.go @@ -5,6 +5,7 @@ import ( "sync" "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" ) // markdown renderers are cached per wrap-width (creating one is not free). @@ -20,7 +21,7 @@ func mdRenderer(width int) *glamour.TermRenderer { return r } r, err := glamour.NewTermRenderer( - glamour.WithStandardStyle("dark"), + glamour.WithStandardStyle(mdStyle()), glamour.WithWordWrap(width), ) if err != nil { @@ -30,6 +31,14 @@ func mdRenderer(width int) *glamour.TermRenderer { return r } +// mdStyle picks glamour's light or dark theme to match the terminal. +func mdStyle() string { + if lipgloss.HasDarkBackground() { + return "dark" + } + return "light" +} + // renderMarkdown renders agent replies as terminal markdown (dark theme, wrapped // to width). Falls back to the raw text if rendering isn't possible. func renderMarkdown(md string, width int) string { diff --git a/internal/tui/review.go b/internal/tui/review.go new file mode 100644 index 0000000..441a329 --- /dev/null +++ b/internal/tui/review.go @@ -0,0 +1,386 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/crossben/orchestra-code/internal/validate" +) + +// fileDiff is one file's section of a unified diff. +type fileDiff struct { + Path string + Status byte // 'A' added, 'M' modified, 'D' deleted, 'R' renamed + Binary bool + Added int + Removed int + Body string // the section, from its "diff --git" line +} + +// parseDiff splits a git-style unified diff (git or fsdiff output) into files. +func parseDiff(diff string) []fileDiff { + var ( + files []fileDiff + cur *fileDiff + body strings.Builder + inHunk bool + ) + flush := func() { + if cur != nil { + cur.Body = strings.TrimRight(body.String(), "\n") + files = append(files, *cur) + } + body.Reset() + } + for _, line := range strings.Split(diff, "\n") { + if strings.HasPrefix(line, "diff --git ") { + flush() + cur = &fileDiff{Status: 'M', Path: pathFromHeader(line)} + inHunk = false + } + if cur == nil { + continue + } + body.WriteString(line) + body.WriteByte('\n') + switch { + case strings.HasPrefix(line, "@@"): + inHunk = true + case inHunk && strings.HasPrefix(line, "+"): + cur.Added++ + case inHunk && strings.HasPrefix(line, "-"): + cur.Removed++ + case inHunk: + case strings.HasPrefix(line, "new file mode"): + cur.Status = 'A' + case strings.HasPrefix(line, "deleted file mode"): + cur.Status = 'D' + case strings.HasPrefix(line, "rename to "): + cur.Status = 'R' + cur.Path = strings.TrimPrefix(line, "rename to ") + case strings.HasPrefix(line, "Binary files"), strings.HasPrefix(line, "GIT binary patch"): + cur.Binary = true + case strings.HasPrefix(line, "+++ ") && !strings.HasSuffix(line, "/dev/null"): + cur.Path = strings.TrimPrefix(strings.TrimPrefix(line, "+++ "), "b/") + } + } + flush() + return files +} + +// hunks drops a file section's git header lines (diff --git, index, ---/+++): +// the pane title already names the file and its status. +func hunks(body string) string { + if i := strings.Index(body, "\n@@"); i >= 0 { + return body[i+1:] + } + return body +} + +// pathFromHeader takes the b/ path from "diff --git a/x b/x". +func pathFromHeader(line string) string { + if i := strings.LastIndex(line, " b/"); i >= 0 { + return line[i+3:] + } + return strings.TrimPrefix(line, "diff --git ") +} + +func diffTotals(files []fileDiff) (added, removed int) { + for _, f := range files { + added += f.Added + removed += f.Removed + } + return added, removed +} + +// counts renders "+12 −3" in green/red, omitting zero sides. +func counts(added, removed int) string { + var parts []string + if added > 0 || removed == 0 { + parts = append(parts, addSty.Render(fmt.Sprintf("+%d", added))) + } + if removed > 0 { + parts = append(parts, delSty.Render(fmt.Sprintf("−%d", removed))) + } + return strings.Join(parts, " ") +} + +func statusGlyph(s byte) string { + switch s { + case 'A': + return addSty.Render("A") + case 'D': + return delSty.Render("D") + case 'R': + return warnSty.Render("R") + } + return youSty.Render("M") +} + +// reviewMeta describes where a diff came from, for the summary bar. +type reviewMeta struct { + Agent string + Task string + Attempts int + Max int // 0 = unknown + Report *validate.Report // nil = not available (history) + Outcome string // history outcome; "" while pending review + When time.Time + ReadOnly bool // no accept/reject (Changes, History) +} + +// reviewer is the file-by-file diff review screen shared by Chat, Changes and +// History: a file list on the left, the selected file's highlighted diff on +// the right, and a summary bar with validation results on top. +type reviewer struct { + meta reviewMeta + files []fileDiff + full string + sel int + all bool // show every file in one scroll instead of one at a time + vp viewport.Model + w, h int +} + +const fileListWidth = 34 + +func newReviewer(diff string, meta reviewMeta, w, h int) reviewer { + r := reviewer{meta: meta, full: diff, files: parseDiff(diff), vp: viewport.New(w, h)} + r.setSize(w, h) + return r +} + +func (r reviewer) showList() bool { return !r.all && len(r.files) > 1 && r.w >= 90 } + +func (r *reviewer) setSize(w, h int) { + r.w, r.h = w, h + paneW := w + if r.showList() { + paneW = w - fileListWidth - 1 + } + r.vp.Width = max(paneW-4, 10) // border + padding + r.vp.Height = max(h-r.chromeHeight(), 3) // summary, failure tail, pane borders + title + r.refresh() +} + +// chromeHeight is the number of lines around the diff viewport. +func (r reviewer) chromeHeight() int { + return 2 + len(r.failureTail()) + 3 +} + +func (r *reviewer) refresh() { + switch { + case len(r.files) == 0: + r.vp.SetContent(dimSty.Render(r.full)) + case r.all: + r.vp.SetContent(highlightDiff(r.full, r.vp.Width)) + default: + f := r.files[r.sel] + if f.Binary { + r.vp.SetContent(dimSty.Render("binary file — no text diff")) + } else { + r.vp.SetContent(highlightDiff(hunks(f.Body), r.vp.Width)) + } + } + r.vp.GotoTop() +} + +// update handles review navigation keys; handled=false lets the caller act. +func (r reviewer) update(msg tea.KeyMsg) (reviewer, tea.Cmd, bool) { + switch msg.String() { + case "]", "J": + if r.sel < len(r.files)-1 { + r.sel++ + r.all = false + r.setSize(r.w, r.h) + } + return r, nil, true + case "[", "K": + if r.sel > 0 { + r.sel-- + r.all = false + r.setSize(r.w, r.h) + } + return r, nil, true + case "a": + r.all = !r.all + r.setSize(r.w, r.h) + return r, nil, true + case "g", "home": + r.vp.GotoTop() + return r, nil, true + case "G", "end": + r.vp.GotoBottom() + return r, nil, true + case "up", "down", "j", "k", "pgup", "pgdown", "ctrl+u", "ctrl+d", "f", "b", " ": + var cmd tea.Cmd + r.vp, cmd = r.vp.Update(msg) + return r, cmd, true + } + return r, nil, false +} + +func (r reviewer) hints() []string { + h := []string{} + if !r.meta.ReadOnly { + h = append(h, "y accept", "n reject") + } else { + h = append(h, "esc back") + } + if len(r.files) > 1 { + h = append(h, "[ ] file", "a all files") + } + return append(h, "↑↓ scroll") +} + +// failureTail is the last lines of the failing validation stage, shown above +// the diff so a failed run can be judged without leaving the review. +func (r reviewer) failureTail() []string { + rep := r.meta.Report + if rep == nil || rep.Skipped || rep.Passed() { + return nil + } + f, ok := rep.Failure() + if !ok { + return nil + } + var lines []string + for _, l := range strings.Split(strings.TrimRight(f.Output, "\n"), "\n") { + if strings.TrimSpace(l) != "" { + lines = append(lines, l) + } + } + if len(lines) > 5 { + lines = lines[len(lines)-5:] + } + out := []string{badSty.Render("✗ "+f.Name+" failed") + dimSty.Render(" — last output:")} + for _, l := range lines { + out = append(out, faintSty.Render("│ ")+dimSty.Render(ansiTruncate(l, r.w-4))) + } + return out +} + +func (r reviewer) summary() string { + var left []string + switch r.meta.Outcome { + case "": + left = append(left, pill("REVIEW", pillBg)) + case "accepted": + left = append(left, pill("ACCEPTED", green)) + case "rejected": + left = append(left, pill("REJECTED", red)) + default: + left = append(left, pill(strings.ToUpper(r.meta.Outcome), gray)) + } + if r.meta.Agent != "" { + left = append(left, youSty.Render(r.meta.Agent)) + } + if r.meta.Max > 1 { + left = append(left, dimSty.Render(fmt.Sprintf("attempt %d/%d", r.meta.Attempts, r.meta.Max))) + } else if r.meta.Attempts > 1 { + left = append(left, dimSty.Render(fmt.Sprintf("%d attempts", r.meta.Attempts))) + } + if b := validationBadges(r.meta.Report); b != "" { + left = append(left, b) + } + if !r.meta.When.IsZero() { + left = append(left, dimSty.Render(r.meta.When.Local().Format("Jan 2 15:04"))) + } + a, d := diffTotals(r.files) + right := dimSty.Render(fmt.Sprintf("%d file%s ", len(r.files), plural(len(r.files)))) + counts(a, d) + line := left[0] + " " + strings.Join(left[1:], dimSty.Render(" · ")) + gap := r.w - lipgloss.Width(line) - lipgloss.Width(right) + if gap < 1 { + return line + } + return line + strings.Repeat(" ", gap) + right +} + +// validationBadges renders "build ✓ lint ✓ test ✗" (or "unverified"). +func validationBadges(rep *validate.Report) string { + if rep == nil { + return "" + } + if rep.Skipped { + return warnSty.Render("unverified") + } + var parts []string + for _, s := range rep.Stages { + if s.Passed { + parts = append(parts, dimSty.Render(s.Name)+" "+okSty.Render("✓")) + } else { + parts = append(parts, dimSty.Render(s.Name)+" "+badSty.Render("✗")) + } + } + return strings.Join(parts, " ") +} + +func (r reviewer) fileList(h int) string { + var b strings.Builder + start, end := listWindow(len(r.files), r.sel, h) + inner := fileListWidth - 4 + for i := start; i < end; i++ { + f := r.files[i] + c := counts(f.Added, f.Removed) + nameW := inner - 5 - lipgloss.Width(c) + name := truncLeft(f.Path, nameW) + marker := " " + if i == r.sel { + marker = selSty.Render("▸ ") + name = selSty.Render(name) + } + line := marker + statusGlyph(f.Status) + " " + fit(name, nameW) + " " + c + b.WriteString(line) + if i < end-1 { + b.WriteByte('\n') + } + } + return b.String() +} + +func (r reviewer) view() string { + var b strings.Builder + b.WriteString(r.summary()) + b.WriteString("\n") + if r.meta.Task != "" { + b.WriteString(dimSty.Render("task: ") + ansiTruncate(firstLine(r.meta.Task), r.w-6)) + } + b.WriteString("\n") + for _, l := range r.failureTail() { + b.WriteString(l + "\n") + } + + title := "all files" + if !r.all && len(r.files) > 0 { + f := r.files[r.sel] + title = f.Path + " " + counts(f.Added, f.Removed) + if len(r.files) > 1 { + title = fmt.Sprintf("%d/%d ", r.sel+1, len(r.files)) + title + } + } + paneH := r.vp.Height + 3 + pane := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent). + Padding(0, 1).Width(r.vp.Width + 2).Height(paneH - 2). + Render(headSty.Render(ansiTruncate(title, r.vp.Width)) + "\n" + r.vp.View()) + if !r.showList() { + b.WriteString(pane) + return b.String() + } + list := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(faint). + Padding(0, 1).Width(fileListWidth - 2).Height(paneH - 2). + Render(headSty.Render("files") + "\n" + r.fileList(paneH-3)) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, list, " ", pane)) + return b.String() +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/internal/tui/table.go b/internal/tui/table.go new file mode 100644 index 0000000..1d33293 --- /dev/null +++ b/internal/tui/table.go @@ -0,0 +1,116 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +// col is one table column: a fixed width (0 = take the remaining space). +type col struct { + title string + width int +} + +// fit pads or truncates s to exactly w terminal cells. Widths are measured on +// the rendered text, so styled (ANSI-coloured) cells line up — fmt's %-10s +// counts escape bytes and skews every column after a coloured one. +func fit(s string, w int) string { + if w <= 0 { + return "" + } + if lipgloss.Width(s) > w { + s = ansi.Truncate(s, w, "…") + } + return s + strings.Repeat(" ", w-lipgloss.Width(s)) +} + +// table lays out rows of pre-styled cells under a header, sized to width. +// The one zero-width column absorbs whatever space the others leave. +type table struct { + cols []col + width int +} + +func (t table) widths() []int { + ws := make([]int, len(t.cols)) + used, flex := 0, -1 + for i, c := range t.cols { + ws[i] = c.width + if c.width == 0 { + flex = i + } + used += c.width + 1 + } + if flex >= 0 { + ws[flex] = max(t.width-used, 8) + } + return ws +} + +func (t table) header() string { + ws := t.widths() + cells := make([]string, len(t.cols)) + for i, c := range t.cols { + cells[i] = fit(c.title, ws[i]) + } + return headSty.Render(strings.Join(cells, " ")) +} + +func (t table) row(cells ...string) string { + ws := t.widths() + out := make([]string, len(cells)) + for i, c := range cells { + if i < len(ws) { + out[i] = fit(c, ws[i]) + } + } + return strings.Join(out, " ") +} + +// listWindow returns the [start, end) slice of n rows to show in h lines so +// that sel stays visible. +func listWindow(n, sel, h int) (int, int) { + if h < 1 { + h = 1 + } + if n <= h { + return 0, n + } + start := sel - h/2 + if start < 0 { + start = 0 + } + if start+h > n { + start = n - h + } + return start, start + h +} + +// ansiTruncate shortens a (possibly styled) string to w cells. +func ansiTruncate(s string, w int) string { + if w <= 0 { + return "" + } + return ansi.Truncate(s, w, "…") +} + +// truncLeft keeps the end of a plain string (the useful part of a path). +func truncLeft(s string, w int) string { + r := []rune(s) + if len(r) <= w { + return s + } + if w < 2 { + return string(r[len(r)-w:]) + } + return "…" + string(r[len(r)-w+1:]) +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..4e8f327 --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,79 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Palette. Adaptive colours keep every view readable on light terminals as +// well as dark ones; all styles below derive from these. +var ( + accent = lipgloss.AdaptiveColor{Light: "#6D28D9", Dark: "#A78BFA"} // violet + accent2 = lipgloss.AdaptiveColor{Light: "#0E7490", Dark: "#22D3EE"} // cyan + green = lipgloss.AdaptiveColor{Light: "#15803D", Dark: "#4ADE80"} + red = lipgloss.AdaptiveColor{Light: "#B91C1C", Dark: "#F87171"} + amber = lipgloss.AdaptiveColor{Light: "#B45309", Dark: "#FBBF24"} + gray = lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#9CA3AF"} + faint = lipgloss.AdaptiveColor{Light: "#D1D5DB", Dark: "#374151"} // rules, borders + onPill = lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#111827"} // text on filled pills + pillBg = lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"} +) + +var ( + titleSty = lipgloss.NewStyle().Foreground(accent).Bold(true) + headSty = lipgloss.NewStyle().Foreground(accent2).Bold(true) + dimSty = lipgloss.NewStyle().Foreground(gray) + faintSty = lipgloss.NewStyle().Foreground(faint) + okSty = lipgloss.NewStyle().Foreground(green).Bold(true) + badSty = lipgloss.NewStyle().Foreground(red).Bold(true) + warnSty = lipgloss.NewStyle().Foreground(amber).Bold(true) + promptSty = lipgloss.NewStyle().Foreground(accent).Bold(true) + youSty = lipgloss.NewStyle().Foreground(accent2).Bold(true) + selSty = lipgloss.NewStyle().Foreground(accent).Bold(true) + addSty = lipgloss.NewStyle().Foreground(green) + delSty = lipgloss.NewStyle().Foreground(red) + + tabOn = lipgloss.NewStyle().Foreground(onPill).Background(pillBg).Bold(true).Padding(0, 1) + tabOff = lipgloss.NewStyle().Foreground(gray).Padding(0, 1) + keySty = lipgloss.NewStyle().Foreground(accent2) +) + +// pill renders a small filled label (mode indicators, badges). +func pill(text string, bg lipgloss.TerminalColor) string { + return lipgloss.NewStyle().Foreground(onPill).Background(bg).Bold(true).Padding(0, 1).Render(text) +} + +// box renders content in a rounded border sized to w (outer width). +func box(title, content string, w int, border lipgloss.TerminalColor) string { + st := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(border).Padding(0, 1) + if w > 4 { + st = st.Width(w - 2) + } + if title != "" { + content = headSty.Render(title) + "\n" + content + } + return st.Render(content) +} + +// panelBox renders content in a titled rounded box of exactly w×h cells. +func panelBox(title, content string, w, h int, border lipgloss.TerminalColor) string { + innerW, innerH := max(w-4, 1), max(h-2, 1) + lines := strings.Split(content, "\n") + if title != "" { + lines = append([]string{headSty.Render(title)}, lines...) + } + if len(lines) > innerH { + lines = lines[:innerH] + } + for i, l := range lines { + lines[i] = fit(l, innerW) + } + for len(lines) < innerH { + lines = append(lines, strings.Repeat(" ", innerW)) + } + return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(border). + Padding(0, 1).Render(strings.Join(lines, "\n")) +} + +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index fe7e7d0..d385232 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,18 +1,24 @@ -// Package tui is Orchestra's dashboard — a Bubble Tea full-screen view over your -// agents (with live health probing), run history, benchmark results, and an -// in-pane Chat tab. Chat runs the agent quietly in the background (spinner while -// it works), then shows the diff in a scrollable pane for accept/reject right in -// the dashboard. The transcript uses bubbles' viewport and the input uses -// bubbles' textarea (multi-line) so redraw and scrolling are handled correctly. +// Package tui is Orchestra's dashboard — a Bubble Tea full-screen app over the +// supervised engine. Chat sends tasks (AI-routed or to the default agent) and +// shows each run live: routing, every attempt, the agent's output, and each +// validation stage as it happens. Changes are then reviewed file by file and +// accepted or rejected in place. Changes and History reopen any past run's +// diff (diffs are persisted in the memory store); Agents, Benchmarks and Logs +// round it out. +// +// Layout: tui.go (model, messages, key routing), chat.go (chat + transcript), +// live.go (running a turn and the live run panel), review.go (diff review), +// views.go (list tabs), chrome.go (header + status bar), theme.go, table.go. package tui import ( "context" "fmt" - "strings" + "path/filepath" "sync" "time" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -25,22 +31,22 @@ import ( "github.com/crossben/orchestra-code/internal/memory" "github.com/crossben/orchestra-code/internal/router" "github.com/crossben/orchestra-code/internal/scheduler" - "github.com/crossben/orchestra-code/internal/ui" "github.com/crossben/orchestra-code/internal/validate" ) type tab int const ( - tabLogs tab = iota - tabAgents + tabChat tab = iota + tabChanges tabHistory + tabAgents tabBench - tabChanges - tabChat + tabLogs + numTabs ) -var tabNames = []string{"Logs", "Agents", "History", "Benchmarks", "Changes", "Chat"} +var tabNames = []string{"Chat", "Changes", "History", "Agents", "Benchmarks", "Logs"} type chatState int @@ -50,28 +56,6 @@ const ( chatReviewing ) -// palette -var ( - accent = lipgloss.Color("#7C3AED") - accent2 = lipgloss.Color("#06B6D4") - green = lipgloss.Color("#22C55E") - red = lipgloss.Color("#EF4444") - gray = lipgloss.Color("#6B7280") - - titleSty = lipgloss.NewStyle().Foreground(accent2).Bold(true) - tabOn = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(accent).Bold(true).Padding(0, 2) - tabOff = lipgloss.NewStyle().Foreground(gray).Padding(0, 2) - headSty = lipgloss.NewStyle().Foreground(accent2).Bold(true) - dimSty = lipgloss.NewStyle().Foreground(gray) - okSty = lipgloss.NewStyle().Foreground(green).Bold(true) - badSty = lipgloss.NewStyle().Foreground(red).Bold(true) - footerSty = lipgloss.NewStyle().Foreground(gray) - promptSty = lipgloss.NewStyle().Foreground(accent).Bold(true) - youSty = lipgloss.NewStyle().Foreground(accent2).Bold(true) -) - -var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - // Deps bundles everything the dashboard needs. type Deps struct { Ctx context.Context @@ -89,8 +73,9 @@ type Deps struct { } type chatLine struct { - role string // "you" | "agent" | "sys" - text string + role string // "you" | "agent" | "sys" + text string + agent string // who answered (agent lines) } type logEntry struct { @@ -99,13 +84,8 @@ type logEntry struct { text string } -type changeEntry struct { - time time.Time - agent string - task string - files []string // extracted from diff - diff string // full unified diff -} +// runStat caches a history run's diff size for list views. +type runStat struct{ files, added, removed int } // Model is the dashboard state. type Model struct { @@ -114,87 +94,135 @@ type Model struct { width, height int ready bool - runs []memory.Run - benches []memory.BenchRow + repoName string + branch string + inRepo bool + + runs []memory.Run // newest first (from memory, or this session without one) + runStats []runStat // parallel to runs + benches []memory.BenchRow + stats map[string]memory.AgentStats probing bool probed map[string]agent.ProbeResult // chat ta textarea.Model - vp viewport.Model + vp viewport.Model // transcript cstate chatState messages []chatLine - pending engine.Turn + run *liveRun // current or last run (nil before the first) frame int + rv reviewer // pending review (cstate == chatReviewing) + pending engine.Turn // the turn under review + pendAg string // agent that produced it + pendTask string - // logs - logs []logEntry + // changes / history + changeSel, histSel int + browsing bool + browse reviewer - // changes - changes []changeEntry - selectedChange int // index into changes for browsing - showingDiff bool // true = showing diff for selected change + logs []logEntry - status string + status string + statusAt time.Time } // New builds the dashboard model. func New(d Deps) Model { ta := textarea.New() - ta.Placeholder = "ask the agent to do something…" - ta.Prompt = promptSty.Render("┃ ") + ta.Placeholder = "Describe a change, or ask a question…" + ta.Prompt = "" ta.ShowLineNumbers = false ta.CharLimit = 0 - ta.SetHeight(4) + ta.SetHeight(3) ta.MaxHeight = 8 ta.FocusedStyle.CursorLine = lipgloss.NewStyle() - ta.KeyMap.InsertNewline.SetEnabled(false) // enter → newline disabled; ctrl+enter for submit handled in updateChat + ta.FocusedStyle.Placeholder = dimSty + ta.BlurredStyle.Placeholder = dimSty + // enter sends (handled in updateChat); ctrl+j inserts a newline. + ta.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("ctrl+j")) ta.Focus() m := Model{ - d: d, - width: 80, - height: 24, - probed: map[string]agent.ProbeResult{}, - ta: ta, - vp: viewport.New(80, 12), + d: d, + width: 80, + height: 24, + probed: map[string]agent.ProbeResult{}, + ta: ta, + vp: viewport.New(80, 12), + repoName: filepath.Base(d.Dir), + inRepo: gitutil.IsRepo(d.Dir), + } + if m.inRepo { + m.branch = gitutil.Branch(d.Dir) } m.reload() m.setChatContent() return m } +// reload refreshes history, benchmarks and agent stats from the memory store. func (m *Model) reload() { - if m.d.Mem == nil { - return + if m.d.Mem != nil { + if r, err := m.d.Mem.Recent(m.d.Dir, 200); err == nil { + m.runs = r + } + if b, err := m.d.Mem.RecentBenchmarks(m.d.Dir, 100); err == nil { + m.benches = b + } + if s, err := m.d.Mem.StatsByAgent(m.d.Dir); err == nil { + m.stats = s + } } - if r, err := m.d.Mem.Recent(m.d.Dir, 100); err == nil { - m.runs = r + m.runStats = make([]runStat, len(m.runs)) + for i, r := range m.runs { + if r.Diff != "" { + files := parseDiff(r.Diff) + a, d := diffTotals(files) + m.runStats[i] = runStat{files: len(files), added: a, removed: d} + } } - if b, err := m.d.Mem.RecentBenchmarks(m.d.Dir, 100); err == nil { - m.benches = b + m.clampSelections() +} + +// record stores a finished run in history (memory store, or this session). +func (m *Model) record(r memory.Run) { + r.Dir = m.d.Dir + if m.d.Mem != nil { + if err := m.d.Mem.Record(r, time.Now()); err != nil { + m.logEvent("error", "could not record run: "+err.Error()) + } + } else { + r.Time = time.Now() + m.runs = append([]memory.Run{r}, m.runs...) + if m.stats == nil { + m.stats = map[string]memory.AgentStats{} + } + st := m.stats[r.Agent] + st.Runs++ + if r.Outcome == "accepted" { + st.Accepted++ + } + st.LastUsed = r.Time + m.stats[r.Agent] = st } + m.reload() } -func (m Model) Init() tea.Cmd { return nil } +func (m Model) Init() tea.Cmd { return textarea.Blink } // --- messages --- type probeDoneMsg struct{ results map[string]agent.ProbeResult } -type turnMsg struct { - turn engine.Turn - note string // e.g. "routed to opencode — implementation" -} type tickMsg struct{} func tickCmd() tea.Cmd { return tea.Tick(90*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} }) } -var ( - errDirty = fmt.Errorf("working tree has uncommitted changes — commit/stash first") -) +var errDirty = fmt.Errorf("working tree has uncommitted changes — commit or stash first") func (m Model) probeCmd() tea.Cmd { agents := m.d.Reg.All() @@ -223,59 +251,24 @@ func (m Model) probeCmd() tea.Cmd { } } -func (m Model) produceCmd(text string) tea.Cmd { - d := m.d - return func() tea.Msg { - // Inside a git repo the tree must start clean (reject uses git - // restore). Plain directories are always allowed: the engine - // snapshots them and restores from that snapshot on reject. - if gitutil.IsRepo(d.Dir) { - if clean, _ := gitutil.IsClean(d.Dir); !clean { - return turnMsg{turn: engine.Turn{Err: errDirty}} - } - } - - agentName := d.DefaultAgent - note := "" - if d.RoutingOn && d.Router != nil { - dec := d.Router.Route(d.Ctx, text, d.Dir) // quiet: safe in the TUI - if dec.IsQuestion() { - ans, err := d.Router.Answer(d.Ctx, text, d.Dir, d.Timeout) - if err != nil { - return turnMsg{turn: engine.Turn{Err: err}} - } - return turnMsg{turn: engine.Turn{AgentText: ans, HadChanges: false}} - } - agentName = dec.Agent - note = fmt.Sprintf("↳ routed to %s — %s", dec.Agent, dec.Reason) - } - - ag, ok := d.Reg.Get(agentName) - if !ok { - return turnMsg{turn: engine.Turn{Err: fmt.Errorf("agent %q not available", agentName)}} - } - t := engine.Produce(d.Ctx, engine.Options{ - Agent: ag, Prompt: text, Dir: d.Dir, - Stages: d.Stages, MaxRetries: d.MaxRetries, - Timeout: d.Timeout, Principles: d.Principles, - }) - return turnMsg{turn: t, note: note} - } +func (m *Model) setStatus(s string) { + m.status = s + m.statusAt = time.Now() } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height - m.layout() m.ready = true + m.layout() m.setChatContent() return m, nil case probeDoneMsg: m.probed = msg.results m.probing = false m.logEvent("probe", fmt.Sprintf("probed %d agents", len(msg.results))) - m.status = "probe complete" + m.setStatus("probe complete") return m, nil case tickMsg: if m.cstate == chatRunning { @@ -283,80 +276,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tickCmd() } return m, nil - case turnMsg: - return m.onTurn(msg.turn, msg.note) + case routedMsg, eventMsg, outputMsg, turnMsg: + return m.onRunMsg(msg) case tea.KeyMsg: - if msg.String() == "ctrl+c" { - return m, tea.Quit - } - if m.active == tabChat { - return m.updateChat(msg) - } - prev := m.active - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "esc": - if m.active == tabChanges && m.showingDiff { - m.showingDiff = false - return m, nil - } - return m, tea.Quit - case "tab", "right", "l": - m.active = (m.active + 1) % 6 - case "shift+tab", "left", "h": - m.active = (m.active + 5) % 6 - case "1": - m.active = tabLogs - case "2": - m.active = tabAgents - case "3": - m.active = tabHistory - case "4": - m.active = tabBench - case "5": - m.active = tabChanges - case "6": - m.active = tabChat - case "r": - m.reload() - m.status = "refreshed" - case "j": - if m.active == tabChanges && !m.showingDiff && m.selectedChange < len(m.changes)-1 { - m.selectedChange++ - } - case "k": - if m.active == tabChanges && !m.showingDiff && m.selectedChange > 0 { - m.selectedChange-- - } - case "enter": - if m.active == tabChanges && !m.showingDiff && len(m.changes) > 0 { - m.showingDiff = true - } - case "p": - if m.active == tabAgents && !m.probing { - m.probing = true - m.status = "probing agents…" - return m, m.probeCmd() - } - } - if m.active != prev { - if prev == tabChat { - m.ta.Blur() - m.vp.SetContent("") // clear viewport to prevent stale chat leaking - } - if m.active == tabChat { - m.ta.Focus() - } - m.layout() - if m.active == tabChat { - m.setChatContent() - } - } - return m, nil + return m.onKey(msg) } // Non-key messages (e.g. cursor blink) go to the input. - if m.active == tabChat { + if m.active == tabChat && m.cstate == chatIdle { var cmd tea.Cmd m.ta, cmd = m.ta.Update(msg) return m, cmd @@ -364,580 +290,173 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// updateChat: tab/shift+tab always navigate; otherwise route to -// the input (idle), the review keys (reviewing), or scroll (running). -func (m Model) updateChat(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { + case "ctrl+c": + if m.run != nil && m.run.cancel != nil { + m.run.cancel() + } + return m, tea.Quit case "tab": - m.ta.Blur() - m.vp.SetContent("") // clear viewport to prevent stale content leaking - m.active = (m.active + 1) % 6 - m.layout() - return m, nil + return m.switchTab((m.active + 1) % numTabs), nil case "shift+tab": - m.ta.Blur() - m.vp.SetContent("") // clear viewport to prevent stale content leaking - m.active = (m.active + 5) % 6 - m.layout() - return m, nil + return m.switchTab((m.active + numTabs - 1) % numTabs), nil } - - switch m.cstate { - case chatReviewing: - switch msg.String() { - case "y", "Y": - return m.accept() - case "n", "N", "esc": - return m.reject() + if m.active == tabChat { + return m.updateChat(msg) + } + if m.browsing { + if msg.String() == "esc" || msg.String() == "q" { + m.browsing = false + return m, nil } var cmd tea.Cmd - m.vp, cmd = m.vp.Update(msg) // scroll the diff - return m, cmd - case chatRunning: - var cmd tea.Cmd - m.vp, cmd = m.vp.Update(msg) + m.browse, cmd, _ = m.browse.update(msg) return m, cmd } - // idle switch msg.String() { - case "esc": - m.active = tabAgents - return m, nil + case "q": + return m, tea.Quit + case "right", "l": + return m.switchTab((m.active + 1) % numTabs), nil + case "left", "h": + return m.switchTab((m.active + numTabs - 1) % numTabs), nil + case "1", "2", "3", "4", "5", "6": + return m.switchTab(tab(msg.String()[0] - '1')), nil + case "r": + m.reload() + m.setStatus("refreshed") + case "p": + if m.active == tabAgents && !m.probing { + m.probing = true + m.setStatus("probing agents…") + return m, m.probeCmd() + } + case "down", "j": + m.moveSel(1) + case "up", "k": + m.moveSel(-1) + case "pgdown": + m.moveSel(10) + case "pgup": + m.moveSel(-10) case "enter": - return m.submitChat() - case "pgup", "pgdown": - var cmd tea.Cmd - m.vp, cmd = m.vp.Update(msg) - return m, cmd - } - var cmd tea.Cmd - m.ta, cmd = m.ta.Update(msg) - return m, cmd -} - -func (m Model) submitChat() (tea.Model, tea.Cmd) { - text := strings.TrimSpace(m.ta.Value()) - if text == "" { - return m, nil + m.openSelected() } - m.ta.Reset() - m.messages = append(m.messages, chatLine{"you", text}) - m.cstate = chatRunning - m.frame = 0 - m.status = "" - m.setChatContent() - return m, tea.Batch(tickCmd(), m.produceCmd(text)) -} - -func (m Model) onTurn(t engine.Turn, note string) (tea.Model, tea.Cmd) { - if note != "" { - m.messages = append(m.messages, chatLine{"sys", note}) - m.logEvent("route", note) - } - switch { - case t.Err != nil: - m.messages = append(m.messages, chatLine{"sys", "error: " + t.Err.Error()}) - m.logEvent("error", t.Err.Error()) - m.cstate = chatIdle - case !t.HadChanges: - resp := strings.TrimSpace(t.AgentText) - if resp == "" { - resp = "(the agent made no file changes)" - } - m.messages = append(m.messages, chatLine{"agent", resp}) - m.logEvent("turn", "agent replied ("+fmt.Sprintf("%d", len(resp))+" chars)") - ui.Notify("Orchestra", "Agent finished — "+firstLine(resp)) - m.cstate = chatIdle - default: - m.pending = t - m.recordChange(t) - m.logEvent("turn", "agent produced changes — review required") - ui.Notify("Orchestra", "Agent produced changes — review required") - m.cstate = chatReviewing - } - m.setChatContent() - return m, nil -} - -func (m Model) accept() (tea.Model, tea.Cmd) { - last := lastUserMsg(m.messages) - committed, err := engine.CommitAccepted(m.d.Dir, "orchestra: "+firstLine(last)) - switch { - case err != nil: - m.messages = append(m.messages, chatLine{"sys", "commit failed: " + err.Error()}) - case committed: - m.messages = append(m.messages, chatLine{"sys", "✓ accepted & committed"}) - default: - m.messages = append(m.messages, chatLine{"sys", "✓ accepted — kept as plain files (not a git repo)"}) - } - m.cstate = chatIdle - m.pending = engine.Turn{} - m.reload() - m.setChatContent() return m, nil } -func (m Model) reject() (tea.Model, tea.Cmd) { - if err := engine.Revert(m.d.Dir, m.pending.Baseline); err != nil { - m.messages = append(m.messages, chatLine{"sys", "restore failed: " + err.Error()}) - } else { - m.messages = append(m.messages, chatLine{"sys", "↺ rejected & reverted"}) - } - m.cstate = chatIdle - m.pending = engine.Turn{} - m.setChatContent() - return m, nil -} - -func (m *Model) logEvent(kind, text string) { - m.logs = append(m.logs, logEntry{time: time.Now(), kind: kind, text: text}) - if len(m.logs) > 200 { - m.logs = m.logs[len(m.logs)-200:] - } -} - -func (m *Model) recordChange(t engine.Turn) { - task := "" - for i := len(m.messages) - 1; i >= 0; i-- { - if m.messages[i].role == "you" { - task = m.messages[i].text - break - } +func (m Model) switchTab(t tab) Model { + if t == m.active { + return m } - agent := m.d.DefaultAgent - m.changes = append(m.changes, changeEntry{ - time: time.Now(), - agent: agent, - task: task, - files: parseDiffFiles(t.Diff), - diff: t.Diff, - }) - if len(m.changes) > 100 { - m.changes = m.changes[len(m.changes)-100:] - } - m.selectedChange = len(m.changes) - 1 -} - -// layout sizes the viewport and input to the window. -func (m *Model) layout() { - w := m.width - vpH := m.height - 5 // header(2) + footer(2) + padding(1) if m.active == tabChat { - vpH -= 6 // textarea(4) + gap(2) + m.ta.Blur() } - if vpH < 3 { - vpH = 3 + m.active = t + m.browsing = false + if t == tabChat && m.cstate == chatIdle { + m.ta.Focus() } - m.vp.Width = w - m.vp.Height = vpH - m.ta.SetWidth(w) + m.layout() + m.setChatContent() + return m } -// setChatContent refreshes the viewport with the transcript or the diff. -func (m *Model) setChatContent() { - if m.cstate == chatReviewing { - m.vp.SetContent(m.renderDiff()) - m.vp.GotoTop() - return +// changeIdx lists the indexes of runs that produced a diff. +func (m Model) changeIdx() []int { + var out []int + for i, r := range m.runs { + if r.Diff != "" { + out = append(out, i) + } } - m.vp.SetContent(m.renderTranscript()) - m.vp.GotoBottom() + return out } -func (m Model) renderTranscript() string { - if len(m.messages) == 0 { - return dimSty.Render("Ask the agent to do something. It runs in the background; the diff\n" + - "appears here for you to accept or reject — all without leaving this screen.") - } - wrap := lipgloss.NewStyle().Width(max(m.vp.Width-1, 20)) - var b strings.Builder - for i, msg := range m.messages { - if i > 0 { - b.WriteString("\n") +func (m *Model) clampSelections() { + clamp := func(v, n int) int { + if v >= n { + v = n - 1 } - switch msg.role { - case "you": - b.WriteString(youSty.Render("you")) - b.WriteString("\n") - b.WriteString(wrap.Render(msg.text)) - b.WriteString("\n") - case "agent": - b.WriteString(headSty.Render("agent")) - b.WriteString("\n") - b.WriteString(renderMarkdown(msg.text, max(m.vp.Width-1, 20))) - b.WriteString("\n") - case "sys": - b.WriteString(dimSty.Render("· " + msg.text)) - b.WriteString("\n") + if v < 0 { + v = 0 } + return v } - return b.String() -} - -func (m Model) renderDiff() string { - var b strings.Builder - switch { - case m.pending.Report.Skipped: - b.WriteString(dimSty.Render("validation: skipped")) - b.WriteString("\n\n") - case m.pending.Report.Passed(): - b.WriteString(okSty.Render("validation: ✓ passed")) - b.WriteString("\n\n") - default: - b.WriteString(badSty.Render("validation: ✗ failed")) - b.WriteString("\n\n") - } - b.WriteString(highlightDiff(m.pending.Diff)) // chroma diff highlighting - return b.String() + m.changeSel = clamp(m.changeSel, len(m.changeIdx())) + m.histSel = clamp(m.histSel, len(m.runs)) } -func (m Model) View() string { - if !m.ready { - return "loading…" - } - var b strings.Builder - b.WriteString(m.header()) - b.WriteString("\n\n") +func (m *Model) moveSel(delta int) { switch m.active { - case tabAgents: - b.WriteString(m.agentsView()) - case tabHistory: - b.WriteString(m.historyView()) - case tabBench: - b.WriteString(m.benchView()) case tabChanges: - b.WriteString(m.changesView()) - case tabChat: - b.WriteString(m.chatView()) - case tabLogs: - b.WriteString(m.logsView()) - } - b.WriteString("\n") - b.WriteString(m.footer()) - return padHeight(b.String(), m.height) -} - -// padHeight pads a string to exactly n lines, preventing stale content from leaking. -func padHeight(s string, n int) string { - lines := strings.Split(s, "\n") - if len(lines) >= n { - return strings.Join(lines[:n], "\n") - } - for len(lines) < n { - lines = append(lines, "") - } - return strings.Join(lines, "\n") -} - -func (m Model) header() string { - title := titleSty.Render("⬡ ORCHESTRA") - var tabs []string - for i, name := range tabNames { - if tab(i) == m.active { - tabs = append(tabs, tabOn.Render(fmt.Sprintf("%d %s", i+1, name))) - } else { - tabs = append(tabs, tabOff.Render(fmt.Sprintf("%d %s", i+1, name))) - } + m.changeSel += delta + case tabHistory: + m.histSel += delta } - return title + " " + strings.Join(tabs, " ") + "\n" + dimSty.Render(strings.Repeat("─", m.width)) + m.clampSelections() } -func (m Model) footer() string { - var keys string +// openSelected opens the selected run's diff in the read-only reviewer. +func (m *Model) openSelected() { + idx := -1 switch m.active { - case tabAgents: - keys = "tab: switch • p: probe • r: refresh • q: quit" - case tabChat: - switch m.cstate { - case chatReviewing: - keys = "y: accept & commit • n: reject & revert • ↑/↓: scroll • tab: switch" - case chatRunning: - keys = "working… • ↑/↓: scroll • tab: switch • ctrl+c: quit" - default: - keys = "enter: send • ↑/↓ pgup/pgdn: scroll • tab: switch • esc: leave" - } case tabChanges: - if m.showingDiff { - keys = "esc: back to list • tab: switch • q: quit" - } else { - keys = "j/k: navigate • enter: view diff • tab: switch • q: quit" + if ci := m.changeIdx(); m.changeSel < len(ci) { + idx = ci[m.changeSel] } - default: - keys = "tab: switch • r: refresh • q: quit" - } - status := "" - if m.status != "" { - status = " " + m.status - } - return footerSty.Render(strings.Repeat("─", m.width) + "\n" + keys + status) -} - -func (m Model) chatView() string { - mode := "agent: " + m.d.DefaultAgent - if m.d.RoutingOn && m.d.Router != nil { - mode = "AI routing on" - } - head := headSty.Render("Chat") + dimSty.Render(" ("+mode+")") - var bottom string - switch m.cstate { - case chatRunning: - bottom = titleSty.Render(spinnerFrames[m.frame%len(spinnerFrames)]) + dimSty.Render(" agent is working…") - case chatReviewing: - bottom = promptSty.Render("accept these changes?") + " [y]es / [n]o" - default: - bottom = m.ta.View() - } - return head + "\n\n" + m.vp.View() + "\n\n" + bottom -} - -func (m Model) logsView() string { - if len(m.logs) == 0 { - return dimSty.Render("no events yet — actions in Chat and Agents tabs are logged here") - } - var b strings.Builder - b.WriteString(headSty.Render(fmt.Sprintf("%-20s %-8s %s", "TIME", "KIND", "MESSAGE"))) - b.WriteString("\n") - // Show newest first, fit to screen - start := 0 - avail := m.height - 5 // header + footer + padding - if avail < 1 { - avail = 1 - } - if len(m.logs) > avail { - start = len(m.logs) - avail - } - for i := start; i < len(m.logs); i++ { - e := m.logs[i] - kind := dimSty.Render(e.kind) - switch e.kind { - case "error": - kind = badSty.Render(e.kind) - case "turn": - kind = okSty.Render(e.kind) - case "route": - kind = youSty.Render(e.kind) - } - b.WriteString(fmt.Sprintf("%-20s %-8s %s\n", - e.time.Local().Format("01-02 15:04:05"), kind, truncate(e.text, m.width-32))) - } - return b.String() -} - -func (m Model) changesView() string { - if len(m.changes) == 0 { - return dimSty.Render("no file changes yet — send a task in Chat to see changes here") - } - // Diff view - if m.showingDiff && m.selectedChange >= 0 && m.selectedChange < len(m.changes) { - c := m.changes[m.selectedChange] - var b strings.Builder - b.WriteString(headSty.Render(fmt.Sprintf("Change %d/%d", m.selectedChange+1, len(m.changes)))) - b.WriteString(dimSty.Render(fmt.Sprintf(" %s %s %s", - c.time.Local().Format("01-02 15:04:05"), - youSty.Render(c.agent), - truncate(c.task, m.width-50)))) - b.WriteString("\n") - if len(c.files) > 0 { - b.WriteString(dimSty.Render("files: " + strings.Join(c.files, ", "))) - b.WriteString("\n") - } - b.WriteString("\n") - vpH := m.height - 8 - if vpH < 3 { - vpH = 3 - } - w := m.vp.Width - if w < 20 { - w = 20 - } - diff := highlightDiff(c.diff) - lines := strings.Split(diff, "\n") - start := 0 - if len(lines) > vpH { - start = len(lines) - vpH - } - for _, line := range lines[start:] { - b.WriteString(line) - b.WriteString("\n") - } - b.WriteString("\n") - b.WriteString(dimSty.Render("j/k: navigate • esc: back to list")) - return b.String() - } - // List view - var b strings.Builder - b.WriteString(headSty.Render(fmt.Sprintf("%-20s %-10s %-30s %s", "TIME", "AGENT", "TASK", "FILES"))) - b.WriteString("\n") - start := 0 - avail := m.height - 5 - if avail < 1 { - avail = 1 - } - if len(m.changes) > avail { - start = len(m.changes) - avail - } - for i := start; i < len(m.changes); i++ { - c := m.changes[i] - marker := " " - if i == m.selectedChange { - marker = okSty.Render("▸ ") - } - files := strings.Join(c.files, ", ") - if len(c.files) > 3 { - files = strings.Join(c.files[:3], ", ") + fmt.Sprintf(" +%d", len(c.files)-3) - } - b.WriteString(fmt.Sprintf("%s%-20s %-10s %-30s %s\n", - marker, - c.time.Local().Format("01-02 15:04:05"), - dimSty.Render(c.agent), - truncate(c.task, 30), - dimSty.Render(truncate(files, m.width-64)))) - } - b.WriteString("\n") - b.WriteString(dimSty.Render("j/k: navigate • enter: view diff • r: refresh")) - return b.String() -} - -func (m Model) agentsView() string { - var b strings.Builder - b.WriteString(headSty.Render(fmt.Sprintf("%-12s %-14s %-22s %s", "AGENT", "INSTALLED", "PROBE", "CAPABILITIES"))) - b.WriteString("\n") - for _, a := range m.d.Reg.All() { - installed := okSty.Render("✓") - if a.Health() != nil { - installed = dimSty.Render("✗") - } - probe := dimSty.Render("— (press p)") - if m.probing { - probe = dimSty.Render("…") - } - if res, ok := m.probed[a.Name()]; ok { - if res.OK { - probe = okSty.Render("✓ works") - } else { - probe = badSty.Render("✗ " + truncate(res.Detail, 30)) - } - } - name := a.Name() - if name == m.d.DefaultAgent { - name += "*" - } - b.WriteString(fmt.Sprintf("%-12s %-14s %-22s %s\n", name, installed, probe, dimSty.Render(caps(a)))) - } - b.WriteString("\n") - b.WriteString(dimSty.Render("* default agent · press p to live-probe whether each agent can actually run")) - return b.String() -} - -func (m Model) historyView() string { - if len(m.runs) == 0 { - return dimSty.Render("no run history yet — try the Chat tab, `orchestra run`, or `orchestra do`") - } - var b strings.Builder - b.WriteString(headSty.Render(fmt.Sprintf("%-17s %-10s %-10s %-4s %s", "WHEN", "AGENT", "OUTCOME", "ATT", "TASK"))) - b.WriteString("\n") - for _, r := range m.runs { - if m.linesShown(&b) { - break - } - outcome := dimSty.Render(r.Outcome) - switch r.Outcome { - case "accepted": - outcome = okSty.Render("accepted") - case "rejected": - outcome = badSty.Render("rejected") - } - b.WriteString(fmt.Sprintf("%-17s %-10s %-10s %-4d %s\n", - r.Time.Local().Format("01-02 15:04:05"), r.Agent, outcome, r.Attempts, truncate(firstLine(r.Prompt), m.taskWidth()))) - } - return b.String() -} - -func (m Model) benchView() string { - if len(m.benches) == 0 { - return dimSty.Render("no benchmarks yet — try `orchestra benchmark \"\"`") - } - var b strings.Builder - b.WriteString(headSty.Render(fmt.Sprintf("%-17s %-10s %-6s %-6s %-8s %s", "WHEN", "AGENT", "WON", "VALID", "TIME", "TASK"))) - b.WriteString("\n") - for _, r := range m.benches { - if m.linesShown(&b) { - break - } - won := dimSty.Render("") - if r.Won { - won = okSty.Render("★") - } - valid := badSty.Render("✗") - if r.Valid { - valid = okSty.Render("✓") - } - b.WriteString(fmt.Sprintf("%-17s %-10s %-6s %-6s %-8s %s\n", - r.Time.Local().Format("01-02 15:04:05"), r.Agent, won, valid, - r.Duration.Round(time.Second/10).String(), truncate(firstLine(r.Task), m.taskWidth()))) - } - return b.String() -} - -// --- helpers --- - -func lastUserMsg(msgs []chatLine) string { - for i := len(msgs) - 1; i >= 0; i-- { - if msgs[i].role == "you" { - return msgs[i].text + case tabHistory: + if m.histSel < len(m.runs) { + idx = m.histSel } } - return "" -} - -func (m Model) taskWidth() int { - w := m.width - 52 - if w < 20 { - return 20 - } - if w > 60 { - return 60 - } - return w -} - -func (m Model) linesShown(b *strings.Builder) bool { - if m.height <= 0 { - return false + if idx < 0 { + return } - return strings.Count(b.String(), "\n") >= m.height-8 -} - -func caps(a agent.Agent) string { - out := make([]string, 0, len(a.Capabilities())) - for _, c := range a.Capabilities() { - out = append(out, string(c)) + r := m.runs[idx] + if r.Diff == "" { + m.setStatus("no diff recorded for this run") + return } - return strings.Join(out, ",") + m.browse = newReviewer(r.Diff, reviewMeta{ + Agent: r.Agent, Task: r.Prompt, Attempts: r.Attempts, + Outcome: r.Outcome, When: r.Time, ReadOnly: true, + }, m.width, m.bodyHeight()) + m.browsing = true } -func firstLine(s string) string { - if i := strings.IndexByte(s, '\n'); i >= 0 { - return s[:i] +func (m *Model) logEvent(kind, text string) { + m.logs = append(m.logs, logEntry{time: time.Now(), kind: kind, text: text}) + if len(m.logs) > 300 { + m.logs = m.logs[len(m.logs)-300:] } - return s } -func truncate(s string, n int) string { - r := []rune(s) - if len(r) > n { - if n < 1 { - return "" - } - return string(r[:n-1]) + "…" - } - return s -} +// bodyHeight is the space between the header (2 lines) and status bar (1). +func (m Model) bodyHeight() int { return max(m.height-3, 5) } -func max(a, b int) int { - if a > b { - return a +func (m Model) View() string { + if !m.ready { + return "loading…" } - return b + var body string + switch { + case m.browsing: + body = m.browse.view() + case m.active == tabChat: + body = m.chatView() + case m.active == tabChanges: + body = m.changesView() + case m.active == tabHistory: + body = m.historyView() + case m.active == tabAgents: + body = m.agentsView() + case m.active == tabBench: + body = m.benchView() + case m.active == tabLogs: + body = m.logsView() + } + return m.header() + "\n" + padLines(body, m.bodyHeight(), m.width) + "\n" + m.statusBar() } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index c6abb4c..52e3bf2 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -2,35 +2,95 @@ package tui import ( "context" + "os" + "path/filepath" "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/crossben/orchestra-code/internal/agent" "github.com/crossben/orchestra-code/internal/config" "github.com/crossben/orchestra-code/internal/engine" + "github.com/crossben/orchestra-code/internal/memory" + "github.com/crossben/orchestra-code/internal/validate" ) -func testModel() Model { - reg := agent.NewRegistry() - reg.Add(agent.New("alpha", "true", nil, "", []agent.Capability{agent.CapImplement})) - reg.Add(agent.New("beta", "true", nil, "", []agent.Capability{agent.CapReview})) +func testModelIn(dir string, reg *agent.Registry, stages []validate.Stage, w, h int) Model { + if reg == nil { + reg = agent.NewRegistry() + reg.Add(agent.New("alpha", "true", nil, "", []agent.Capability{agent.CapImplement})) + reg.Add(agent.New("beta", "true", nil, "", []agent.Capability{agent.CapReview})) + } cfg := &config.Config{DefaultAgent: "alpha"} - m := New(Deps{Ctx: context.Background(), Cfg: cfg, Reg: reg, Mem: nil, Dir: ".", DefaultAgent: "alpha"}) - nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) // make it ready + size viewport/input + m := New(Deps{Ctx: context.Background(), Cfg: cfg, Reg: reg, Dir: dir, DefaultAgent: "alpha", + Stages: stages, MaxRetries: 2, Timeout: 30 * time.Second}) + nm, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: h}) return nm.(Model) } -func key(s string) tea.KeyMsg { +func testModel() Model { return testModelIn(".", nil, nil, 100, 30) } + +func keyMsg(s string) tea.KeyMsg { + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + } return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } -func TestViewRendersAgentsTab(t *testing.T) { +func press(m Model, keys ...string) Model { + for _, k := range keys { + nm, _ := m.Update(keyMsg(k)) + m = nm.(Model) + } + return m +} + +func TestOpensOnChat(t *testing.T) { m := testModel() - m.active = tabAgents + if m.active != tabChat { + t.Fatalf("dashboard should open on Chat, got tab %d", m.active) + } + v := m.View() + for _, want := range []string{"ORCHESTRA", "1 Chat", "2 Changes", "What should we build?", "READY", "enter send"} { + if !strings.Contains(v, want) { + t.Fatalf("chat view missing %q\n---\n%s", want, v) + } + } +} + +// Every frame is exactly the window height, and no line is wider than it. +func TestViewFitsWindow(t *testing.T) { + for _, size := range [][2]int{{80, 24}, {100, 30}, {140, 40}} { + m := testModelIn(".", nil, nil, size[0], size[1]) + for tb := tab(0); tb < numTabs; tb++ { + m = m.switchTab(tb) + lines := strings.Split(m.View(), "\n") + if len(lines) != size[1] { + t.Fatalf("%dx%d tab %s: %d lines", size[0], size[1], tabNames[tb], len(lines)) + } + for i, l := range lines { + if w := lipgloss.Width(l); w > size[0] { + t.Fatalf("%dx%d tab %s line %d is %d wide:\n%s", size[0], size[1], tabNames[tb], i, w, l) + } + } + } + } +} + +func TestViewRendersAgentsTab(t *testing.T) { + m := testModel().switchTab(tabAgents) view := m.View() - for _, want := range []string{"ORCHESTRA", "Agents", "History", "Benchmarks", "AGENT", "alpha", "beta", "quit"} { + for _, want := range []string{"Agents", "History", "Benchmarks", "AGENT", "alpha ★", "beta", "p probe", "quit"} { if !strings.Contains(view, want) { t.Fatalf("agents view missing %q\n---\n%s", want, view) } @@ -38,105 +98,64 @@ func TestViewRendersAgentsTab(t *testing.T) { } func TestTabSwitching(t *testing.T) { - m := testModel() - // switch to History (key "3") - nm, _ := m.Update(key("3")) - m = nm.(Model) - if m.active != tabHistory { - t.Fatalf("expected History tab, got %d", m.active) + m := press(testModel().switchTab(tabAgents), "3") + if m.active != tabHistory || !strings.Contains(m.View(), "No run history yet") { + t.Fatalf("expected History empty state, tab %d:\n%s", m.active, m.View()) } - if !strings.Contains(m.View(), "no run history") { - t.Fatalf("history view should show empty-state, got:\n%s", m.View()) + m = press(m, "tab") + if m.active != tabAgents { + t.Fatalf("tab should move to Agents, got %d", m.active) } - // tab key cycles to Benchmarks - nm, _ = m.Update(key("tab")) - m = nm.(Model) - if m.active != tabBench { - t.Fatalf("expected Benchmarks after tab, got %d", m.active) + m = press(m, "5") + if !strings.Contains(m.View(), "No benchmarks yet") { + t.Fatalf("benchmarks empty state missing:\n%s", m.View()) } - if !strings.Contains(m.View(), "no benchmarks") { - t.Fatalf("benchmarks view should show empty-state, got:\n%s", m.View()) + m = press(m, "1") + if m.active != tabChat { + t.Fatalf("1 should select Chat, got %d", m.active) } } -func TestQuitKey(t *testing.T) { - m := testModel() - _, cmd := m.Update(key("q")) +func TestQuitKeyOutsideChat(t *testing.T) { + m := testModel().switchTab(tabAgents) + _, cmd := m.Update(keyMsg("q")) if cmd == nil { - t.Fatal("q should return a command (quit)") + t.Fatal("q should quit outside chat") } if _, ok := cmd().(tea.QuitMsg); !ok { t.Fatalf("q should return tea.QuitMsg, got %T", cmd()) } } -func TestWindowResizeApplied(t *testing.T) { - m := testModel() - nm, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - m = nm.(Model) - if m.width != 120 || m.height != 40 { - t.Fatalf("resize not applied: %dx%d", m.width, m.height) - } -} - -func TestChatTabTypingAndBackspace(t *testing.T) { - m := testModel() - m.active = tabChat - nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hi")}) - m = nm.(Model) - nm, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) - m = nm.(Model) - nm, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) - m = nm.(Model) - if m.ta.Value() != "hi x" { - t.Fatalf("expected input %q, got %q", "hi x", m.ta.Value()) - } - nm, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) - m = nm.(Model) - if m.ta.Value() != "hi " { - t.Fatalf("backspace failed, got %q", m.ta.Value()) +// In Chat, q and digits are text, not shortcuts. +func TestChatTypingIsText(t *testing.T) { + m := press(testModel(), "q", "1", "x") + if m.ta.Value() != "q1x" || m.active != tabChat { + t.Fatalf("typing should stay in the input: value=%q tab=%d", m.ta.Value(), m.active) } - if !strings.Contains(m.View(), "Chat") { - t.Fatal("chat view should render") + nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + if got := nm.(Model).ta.Value(); got != "q1" { + t.Fatalf("backspace failed, got %q", got) } } -func TestChatEscLeavesTab(t *testing.T) { - m := testModel() - m.active = tabChat - nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) - m = nm.(Model) - if m.active != tabAgents { - t.Fatalf("esc should leave chat to Agents, got %d", m.active) +func TestWindowResizeApplied(t *testing.T) { + nm, _ := testModel().Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m := nm.(Model) + if m.width != 120 || m.height != 40 || !m.wide() { + t.Fatalf("resize not applied: %dx%d wide=%v", m.width, m.height, m.wide()) } } func TestChatEmptySubmitNoop(t *testing.T) { - m := testModel() - m.active = tabChat - _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) // empty input = noop + _, cmd := testModel().Update(keyMsg("enter")) if cmd != nil { t.Fatal("submitting empty input should not launch a command") } } -func TestChatTabInNav(t *testing.T) { - m := testModel() - nm, _ := m.Update(key("6")) - m = nm.(Model) - if m.active != tabChat { - t.Fatalf("key 6 should select Chat, got %d", m.active) - } -} - -// tab must navigate away even while typing (the reported bug). func TestChatTabNavigatesWhileTyping(t *testing.T) { - m := testModel() - m.active = tabChat - nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")}) - m = nm.(Model) - nm, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab}) - m = nm.(Model) + m := press(testModel(), "hello", "tab") if m.active == tabChat { t.Fatal("tab should navigate out of chat while typing") } @@ -147,45 +166,310 @@ func TestChatTabNavigatesWhileTyping(t *testing.T) { func TestChatSubmitEntersRunning(t *testing.T) { m := testModel() - m.active = tabChat m.ta.SetValue("do a thing") nm, cmd := m.submitChat() m = nm.(Model) - if m.cstate != chatRunning { - t.Fatalf("expected running state, got %d", m.cstate) - } - if cmd == nil { - t.Fatal("submit should return a command (spinner+produce)") + defer m.run.cancel() + if m.cstate != chatRunning || cmd == nil || m.run == nil { + t.Fatalf("expected running state with a command, got state=%d", m.cstate) } if len(m.messages) != 1 || m.messages[0].role != "you" { t.Fatalf("user message not recorded: %+v", m.messages) } + if v := m.View(); !strings.Contains(v, "RUNNING") || !strings.Contains(v, "esc cancel") { + t.Fatalf("running view should show mode and cancel hint:\n%s", v) + } } func TestOnTurnStates(t *testing.T) { - // no changes → idle + agent message - m := testModel() + running := func() Model { + m := testModel() + m.cstate = chatRunning + m.run = &liveRun{task: "x", start: time.Now(), agent: "alpha"} + return m + } + // no changes → idle + agent message, recorded as no-change + nm, _ := running().onTurn(turnMsg{turn: engine.Turn{AgentText: "here is an explanation"}, agent: "alpha", started: true}) + m := nm.(Model) + if m.cstate != chatIdle || m.messages[len(m.messages)-1].role != "agent" || m.runs[0].Outcome != "no-change" { + t.Fatalf("no-change turn: state=%d runs=%+v", m.cstate, m.runs) + } + // changes → reviewing with the routed agent's name + nm, _ = running().onTurn(turnMsg{turn: engine.Turn{HadChanges: true, Attempts: 1, + Diff: "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-x\n+y\n"}, agent: "beta", started: true}) + m = nm.(Model) + if m.cstate != chatReviewing || m.pendAg != "beta" || len(m.rv.files) != 1 { + t.Fatalf("changed turn should review beta's diff, got state=%d agent=%q", m.cstate, m.pendAg) + } + // error before running → idle + sys message, nothing recorded + nm, _ = running().onTurn(turnMsg{turn: engine.Turn{Err: errDirty}}) + m = nm.(Model) + if m.cstate != chatIdle || m.messages[len(m.messages)-1].role != "sys" || len(m.runs) != 0 { + t.Fatal("error turn should go idle with a sys message and no history") + } +} + +func TestParseDiff(t *testing.T) { + diff := `diff --git a/new.go b/new.go +new file mode 100644 +--- /dev/null ++++ b/new.go +@@ -0,0 +1,2 @@ ++package x ++// ---- not a header +diff --git a/old.txt b/old.txt +deleted file mode 100644 +--- a/old.txt ++++ /dev/null +@@ -1 +0,0 @@ +-bye +diff --git a/img.png b/img.png +Binary files a/img.png and b/img.png differ +diff --git a/m.go b/m.go +--- a/m.go ++++ b/m.go +@@ -1,2 +1,2 @@ + keep +-old ++new +` + files := parseDiff(diff) + if len(files) != 4 { + t.Fatalf("want 4 files, got %d: %+v", len(files), files) + } + want := []struct { + path string + status byte + a, d int + binary bool + }{{"new.go", 'A', 2, 0, false}, {"old.txt", 'D', 0, 1, false}, {"img.png", 'M', 0, 0, true}, {"m.go", 'M', 1, 1, false}} + for i, w := range want { + f := files[i] + if f.Path != w.path || f.Status != w.status || f.Added != w.a || f.Removed != w.d || f.Binary != w.binary { + t.Fatalf("file %d = %+v, want %+v", i, f, w) + } + } + if a, d := diffTotals(files); a != 3 || d != 2 { + t.Fatalf("totals = +%d -%d", a, d) + } +} + +func TestReviewerNavigation(t *testing.T) { + diff := "diff --git a/a.go b/a.go\n--- a/a.go\n+++ b/a.go\n@@ -1 +1 @@\n-1\n+2\n" + + "diff --git a/b.go b/b.go\n--- a/b.go\n+++ b/b.go\n@@ -1 +1,2 @@\n x\n+y\n" + rep := validate.Report{Stages: []validate.StageResult{{Name: "build", Passed: true}, {Name: "test", Passed: false, Output: "FAIL TestX\n"}}} + r := newReviewer(diff, reviewMeta{Agent: "alpha", Attempts: 3, Max: 3, Report: &rep}, 120, 30) + v := r.view() + for _, want := range []string{"REVIEW", "alpha", "attempt 3/3", "build ✓", "test ✗", "2 files", "files", "a.go", "b.go", "FAIL TestX"} { + if !strings.Contains(v, want) { + t.Fatalf("review view missing %q\n%s", want, v) + } + } + if len(strings.Split(v, "\n")) != 30 { + t.Fatalf("review view should fill its height, got %d lines", len(strings.Split(v, "\n"))) + } + r, _, _ = r.update(keyMsg("]")) + if r.sel != 1 || !strings.Contains(r.view(), "2/2 b.go") { + t.Fatalf("] should select the second file:\n%s", r.view()) + } + r, _, _ = r.update(keyMsg("a")) + if !r.all || r.showList() { + t.Fatal("a should switch to the all-files view") + } + if _, _, handled := r.update(keyMsg("y")); handled { + t.Fatal("y must be left to the caller (accept)") + } +} + +func TestFitAlignsStyledCells(t *testing.T) { + for _, s := range []string{okSty.Render("✓"), badSty.Render("✗ " + strings.Repeat("x", 40)), "plain"} { + if w := lipgloss.Width(fit(s, 12)); w != 12 { + t.Fatalf("fit(%q) width = %d, want 12", s, w) + } + } +} + +func TestLiveRunPanel(t *testing.T) { + m := testModelIn(".", nil, []validate.Stage{{Name: "build", Command: "x"}, {Name: "test", Command: "y"}}, 140, 40) m.cstate = chatRunning - nm, _ := m.onTurn(engine.Turn{HadChanges: false, AgentText: "here is an explanation"}, "") + m.run = &liveRun{start: time.Now(), agent: "alpha", reason: "implementation"} + for _, e := range []engine.Event{ + {Kind: engine.EventAttempt, Attempt: 1, Max: 3}, + {Kind: engine.EventAgentDone, Attempt: 1, Max: 3, ExitCode: 0, Duration: time.Second}, + {Kind: engine.EventStageStart, Stage: "build"}, + {Kind: engine.EventStageDone, Stage: "build", Passed: false}, + {Kind: engine.EventAttempt, Attempt: 2, Max: 3}, + {Kind: engine.EventStageStart, Stage: "build"}, + } { + m.applyEvent(e) + } + m.run.addLines("compiling…", "wrote main.go") + v := m.View() + for _, want := range []string{"Run", "routed → alpha", "attempt 2/3", "agent working", "build", "test", "retry 1/2", "wrote main.go"} { + if !strings.Contains(v, want) { + t.Fatalf("run panel missing %q\n%s", want, v) + } + } +} + +func TestLineWriterAndCleanLine(t *testing.T) { + ch := make(chan tea.Msg, 8) + w := &lineWriter{ch: ch} + w.Write([]byte("\x1b[32mhello\x1b[0m\npart")) + w.Write([]byte("ial\r50%\r100%\nlast")) + w.flush() + close(ch) + var got []string + for msg := range ch { + got = append(got, msg.(outputMsg).lines...) + } + if strings.Join(got, "|") != "hello|100%|last" { + t.Fatalf("lines = %q", got) + } +} + +func TestHistoryEnterOpensReview(t *testing.T) { + m := testModel() + m.runs = []memory.Run{ + {Agent: "alpha", Prompt: "plain run", Outcome: "no-change"}, + {Agent: "beta", Prompt: "with diff", Outcome: "accepted", Diff: "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b\n"}, + } + m.reload() + m = press(m.switchTab(tabHistory), "enter") + if m.browsing || !strings.Contains(m.status, "no diff") { + t.Fatal("a run without a diff should not open") + } + m = press(m, "down", "enter") + if !m.browsing || !strings.Contains(m.View(), "ACCEPTED") || !strings.Contains(m.View(), "esc back") { + t.Fatalf("enter should open the run's diff read-only:\n%s", m.View()) + } + m = press(m, "esc") + if m.browsing { + t.Fatal("esc should close the review") + } + if m = m.switchTab(tabChanges); !strings.Contains(m.View(), "with diff") || strings.Contains(m.View(), "plain run") { + t.Fatalf("Changes should list only runs with a diff:\n%s", m.View()) + } +} + +// drive feeds worker messages into the model until the run finishes. +func drive(t *testing.T, m Model) Model { + t.Helper() + go runTurn(m.run.ctx, m.d, m.run.task, m.run.ch) + deadline := time.After(20 * time.Second) + for m.cstate == chatRunning { + select { + case msg, ok := <-m.run.ch: + if !ok { + t.Fatal("worker closed without a final turn") + } + nm, _ := m.Update(msg) + m = nm.(Model) + case <-deadline: + t.Fatal("run did not finish") + } + } + return m +} + +func fakeAgent(t *testing.T, script string) *agent.Registry { + t.Helper() + path := filepath.Join(t.TempDir(), "agent.sh") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o755); err != nil { + t.Fatal(err) + } + reg := agent.NewRegistry() + reg.Add(agent.New("alpha", path, nil, "", []agent.Capability{agent.CapImplement})) + return reg +} + +// End to end through the real worker: a fake agent edits a plain folder, the +// check passes, the diff is reviewed and accepted, and the run lands in +// Changes with its diff. +func TestChatRunReviewAccept(t *testing.T) { + dir := t.TempDir() + reg := fakeAgent(t, "echo 'working on it'\necho hello > greeting.txt\n") + m := testModelIn(dir, reg, []validate.Stage{{Name: "test", Command: "grep -q hello greeting.txt"}}, 140, 40) + m.ta.SetValue("write a greeting") + nm, _ := m.submitChat() + m = drive(t, nm.(Model)) + + if m.cstate != chatReviewing { + t.Fatalf("expected review, got state %d; messages=%+v", m.cstate, m.messages) + } + if !strings.Contains(strings.Join(m.run.lines, "\n"), "working on it") { + t.Fatalf("agent output was not streamed: %q", m.run.lines) + } + v := m.View() + for _, want := range []string{"REVIEW", "greeting.txt", "test ✓", "y accept"} { + if !strings.Contains(v, want) { + t.Fatalf("review missing %q\n%s", want, v) + } + } + m = press(m, "y") + if m.cstate != chatIdle { + t.Fatal("accept should return to idle") + } + if b, err := os.ReadFile(filepath.Join(dir, "greeting.txt")); err != nil || !strings.Contains(string(b), "hello") { + t.Fatalf("accepted file missing: %v", err) + } + if len(m.runs) != 1 || m.runs[0].Outcome != "accepted" || m.runs[0].Agent != "alpha" || m.runs[0].Diff == "" { + t.Fatalf("accepted run not recorded with its diff: %+v", m.runs) + } + if v := m.switchTab(tabChanges).View(); !strings.Contains(v, "write a greeting") || !strings.Contains(v, "2 Changes 1") { + t.Fatalf("Changes should list the accepted run:\n%s", v) + } +} + +func TestChatRunReject(t *testing.T) { + dir := t.TempDir() + reg := fakeAgent(t, "echo x > made.txt\n") + m := testModelIn(dir, reg, nil, 100, 30) + m.ta.SetValue("make a file") + nm, _ := m.submitChat() + m = press(drive(t, nm.(Model)), "n") + if _, err := os.Stat(filepath.Join(dir, "made.txt")); !os.IsNotExist(err) { + t.Fatal("reject should remove the created file") + } + if m.runs[0].Outcome != "rejected" { + t.Fatalf("reject not recorded: %+v", m.runs) + } +} + +// esc while running cancels the agent and reverts what it already wrote. +func TestChatRunCancelReverts(t *testing.T) { + dir := t.TempDir() + reg := fakeAgent(t, "echo partial > half.txt\nsleep 20\n") + m := testModelIn(dir, reg, nil, 100, 30) + m.ta.SetValue("slow task") + nm, _ := m.submitChat() m = nm.(Model) - if m.cstate != chatIdle || m.messages[len(m.messages)-1].role != "agent" { - t.Fatalf("no-change turn should go idle with agent msg, got state=%d", m.cstate) - } - // changes → reviewing - m2 := testModel() - m2.cstate = chatRunning - nm2, _ := m2.onTurn(engine.Turn{HadChanges: true, Diff: "diff --git a b\n+x"}, "") - m2 = nm2.(Model) - if m2.cstate != chatReviewing { - t.Fatalf("changed turn should enter reviewing, got %d", m2.cstate) - } - // error → idle + sys message - m3 := testModel() - m3.cstate = chatRunning - nm3, _ := m3.onTurn(engine.Turn{Err: errDirty}, "") - m3 = nm3.(Model) - if m3.cstate != chatIdle || m3.messages[len(m3.messages)-1].role != "sys" { - t.Fatal("error turn should go idle with sys msg") + go runTurn(m.run.ctx, m.d, m.run.task, m.run.ch) + + // Wait until the agent has written its file, then cancel. + for i := 0; ; i++ { + if _, err := os.Stat(filepath.Join(dir, "half.txt")); err == nil { + break + } + if i > 200 { + t.Fatal("agent never started") + } + time.Sleep(20 * time.Millisecond) + } + m = press(m, "esc") + start := time.Now() + for m.cstate == chatRunning { + nm, _ := m.Update(<-m.run.ch) + m = nm.(Model) + } + if time.Since(start) > 10*time.Second { + t.Fatal("cancel did not stop the agent promptly") + } + if _, err := os.Stat(filepath.Join(dir, "half.txt")); !os.IsNotExist(err) { + t.Fatal("cancel should revert the partial change") + } + if m.run.result != "cancelled" || !strings.Contains(m.messages[len(m.messages)-1].text, "cancelled") { + t.Fatalf("cancel not reported: result=%q", m.run.result) } } diff --git a/internal/tui/views.go b/internal/tui/views.go new file mode 100644 index 0000000..80e3cd7 --- /dev/null +++ b/internal/tui/views.go @@ -0,0 +1,187 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/crossben/orchestra-code/internal/agent" +) + +func outcomeCell(o string) string { + switch o { + case "accepted": + return okSty.Render("✓ accepted") + case "rejected": + return badSty.Render("✗ rejected") + case "failed": + return badSty.Render("! failed") + case "cancelled": + return warnSty.Render("↺ cancelled") + case "no-change": + return dimSty.Render("○ no change") + } + return dimSty.Render(o) +} + +func whenCell(t time.Time) string { + if t.IsZero() { + return "" + } + t = t.Local() + if y, mo, d := time.Now().Date(); t.Year() == y && t.Month() == mo && t.Day() == d { + return t.Format("15:04:05") + } + return t.Format("Jan 02 15:04") +} + +// selRow marks the selected row with a pointer and accent colour. +func selRow(selected bool, row string) string { + if selected { + return selSty.Render("▸ ") + row + } + return " " + row +} + +func (m Model) changesView() string { + idx := m.changeIdx() + h := m.bodyHeight() + if len(idx) == 0 { + return emptyState("No changes yet", "Accepted and rejected diffs from Chat show up here — and stay after a restart.", m.width, h) + } + t := table{width: m.width - 2, cols: []col{{"WHEN", 12}, {"AGENT", 12}, {"OUTCOME", 12}, {"FILES", 5}, {"LINES", 12}, {"TASK", 0}}} + var b strings.Builder + b.WriteString(" " + t.header() + "\n") + start, end := listWindow(len(idx), m.changeSel, h-1) + for n := start; n < end; n++ { + i := idx[n] + r, st := m.runs[i], m.runStats[i] + b.WriteString(selRow(n == m.changeSel, t.row( + dimSty.Render(whenCell(r.Time)), youSty.Render(r.Agent), outcomeCell(r.Outcome), + fmt.Sprint(st.files), counts(st.added, st.removed), firstLine(r.Prompt))) + "\n") + } + return b.String() +} + +func (m Model) historyView() string { + h := m.bodyHeight() + if len(m.runs) == 0 { + return emptyState("No run history yet", "Runs from Chat, orchestra run and orchestra do are listed here.", m.width, h) + } + t := table{width: m.width - 2, cols: []col{{"WHEN", 12}, {"AGENT", 12}, {"OUTCOME", 12}, {"TRIES", 5}, {"CHECKS", 7}, {"LINES", 12}, {"TASK", 0}}} + var b strings.Builder + b.WriteString(" " + t.header() + "\n") + start, end := listWindow(len(m.runs), m.histSel, h-1) + for i := start; i < end; i++ { + r, st := m.runs[i], m.runStats[i] + checks := dimSty.Render("–") + if r.Passed { + checks = okSty.Render("✓") + } + lines := dimSty.Render("–") + if st.files > 0 { + lines = counts(st.added, st.removed) + } + b.WriteString(selRow(i == m.histSel, t.row( + dimSty.Render(whenCell(r.Time)), youSty.Render(r.Agent), outcomeCell(r.Outcome), + fmt.Sprint(r.Attempts), checks, lines, firstLine(r.Prompt))) + "\n") + } + return b.String() +} + +func (m Model) agentsView() string { + t := table{width: m.width - 2, cols: []col{{"AGENT", 14}, {"INSTALLED", 10}, {"LIVE PROBE", 26}, {"RUNS", 5}, {"ACCEPTED", 9}, {"LAST USED", 13}, {"CAN", 0}}} + var b strings.Builder + b.WriteString(" " + t.header() + "\n") + for _, a := range m.d.Reg.All() { + installed := okSty.Render("✓ yes") + if a.Health() != nil { + installed = dimSty.Render("✗ no") + } + probe := dimSty.Render("press p") + if m.probing { + probe = warnSty.Render(spin(m.frame) + " probing…") + } + if res, ok := m.probed[a.Name()]; ok { + if res.OK { + probe = okSty.Render("✓ works") + } else { + probe = badSty.Render("✗ " + res.Detail) + } + } + name := youSty.Render(a.Name()) + if a.Name() == m.d.DefaultAgent { + name += warnSty.Render(" ★") + } + st := m.stats[a.Name()] + runs, rate, last := dimSty.Render("–"), dimSty.Render("–"), dimSty.Render("never") + if st.Runs > 0 { + runs = fmt.Sprint(st.Runs) + rate = fmt.Sprintf("%d%%", st.Accepted*100/st.Runs) + last = dimSty.Render(whenCell(st.LastUsed)) + } + b.WriteString(" " + t.row(name, installed, probe, runs, rate, last, dimSty.Render(caps(a))) + "\n") + } + b.WriteString("\n " + dimSty.Render("★ default agent · ACCEPTED = share of runs whose changes you kept · p probes whether each agent can actually run")) + return b.String() +} + +func (m Model) benchView() string { + h := m.bodyHeight() + if len(m.benches) == 0 { + return emptyState("No benchmarks yet", "Run orchestra benchmark \"\" to race every agent on one task.", m.width, h) + } + t := table{width: m.width - 2, cols: []col{{"WHEN", 12}, {"AGENT", 12}, {"WON", 4}, {"VALID", 6}, {"TIME", 8}, {"RETRIES", 8}, {"LINES", 12}, {"TASK", 0}}} + var b strings.Builder + b.WriteString(" " + t.header() + "\n") + for i, r := range m.benches { + if i >= h-1 { + break + } + won := "" + if r.Won { + won = warnSty.Render("★") + } + valid := badSty.Render("✗") + if r.Valid { + valid = okSty.Render("✓") + } + b.WriteString(" " + t.row(dimSty.Render(whenCell(r.Time)), youSty.Render(r.Agent), won, valid, + r.Duration.Round(time.Second/10).String(), fmt.Sprint(r.Retries), counts(r.Added, r.Removed), firstLine(r.Task)) + "\n") + } + return b.String() +} + +func (m Model) logsView() string { + h := m.bodyHeight() + if len(m.logs) == 0 { + return emptyState("Nothing logged yet", "Routing, runs, retries, probes and errors from this session appear here.", m.width, h) + } + t := table{width: m.width - 2, cols: []col{{"TIME", 9}, {"KIND", 6}, {"MESSAGE", 0}}} + var b strings.Builder + b.WriteString(" " + t.header() + "\n") + start := max(len(m.logs)-(h-1), 0) + for _, e := range m.logs[start:] { + kind := dimSty.Render(e.kind) + switch e.kind { + case "error": + kind = badSty.Render(e.kind) + case "turn": + kind = okSty.Render(e.kind) + case "route": + kind = youSty.Render(e.kind) + case "probe": + kind = warnSty.Render(e.kind) + } + b.WriteString(" " + t.row(dimSty.Render(e.time.Local().Format("15:04:05")), kind, e.text) + "\n") + } + return b.String() +} + +func caps(a agent.Agent) string { + out := make([]string, 0, len(a.Capabilities())) + for _, c := range a.Capabilities() { + out = append(out, string(c)) + } + return strings.Join(out, ", ") +} From adfbb48a378dfc3bc8c8b269526c295cac868135 Mon Sep 17 00:00:00 2001 From: crossben Date: Fri, 25 Sep 2026 14:36:23 +0000 Subject: [PATCH 5/6] tui: ctrl+c mid-run reverts before quitting; runner treats lingering pipes as a clean exit - First ctrl+c during a run cancels it, reverts the agent's partial edits, then quits; a second ctrl+c forces. - runner.RunProbe: an agent that exits 0 while a spawned child keeps the output pipe open is a clean exit (was reported as a failure after WaitDelay). - Welcome hint about questions only when AI routing is on; cache the installed-agent count instead of probing PATH on every render. Co-Authored-By: Claude Opus 5.5 --- internal/runner/runner.go | 6 +++++ internal/runner/runner_test.go | 13 +++++++++++ internal/tui/chat.go | 18 +++++++-------- internal/tui/tui.go | 18 ++++++++++++++- internal/tui/tui_test.go | 42 ++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 8148491..41c42b6 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -6,6 +6,7 @@ package runner import ( "bytes" "context" + "errors" "fmt" "io" "os" @@ -195,6 +196,11 @@ func RunProbe(ctx context.Context, spec Spec) (string, Result, error) { if exitErr, ok := err.(*exec.ExitError); ok { return out, Result{ExitCode: exitErr.ExitCode(), Duration: dur}, nil } + // The agent exited 0 but something it spawned kept the output pipe open + // past WaitDelay: the run itself succeeded. + if errors.Is(err, exec.ErrWaitDelay) { + return out, Result{ExitCode: 0, Duration: dur}, nil + } if err != nil { return out, Result{ExitCode: -1, Duration: dur}, err } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 1bc1761..7c74b67 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -43,3 +43,16 @@ func TestRunProbeCancelKillsChildren(t *testing.T) { t.Fatalf("cancel took %s", d) } } + +// An agent that exits 0 but leaves a background child holding the output +// pipe must still count as a clean, prompt exit. +func TestRunProbeLingeringChildIsCleanExit(t *testing.T) { + start := time.Now() + _, res, err := RunProbe(context.Background(), Spec{Bin: "sh", Args: []string{"-c", "sleep 30 & echo started"}, Dir: t.TempDir()}) + if err != nil || res.ExitCode != 0 { + t.Fatalf("want clean exit, got code=%d err=%v", res.ExitCode, err) + } + if d := time.Since(start); d > 10*time.Second { + t.Fatalf("waited %s on a lingering child", d) + } +} diff --git a/internal/tui/chat.go b/internal/tui/chat.go index 3c0dc02..7aa9a50 100644 --- a/internal/tui/chat.go +++ b/internal/tui/chat.go @@ -184,6 +184,9 @@ func (m Model) onTurn(msg turnMsg) (tea.Model, tea.Cmd) { } m.layout() m.setChatContent() + if m.quitting { + return m, tea.Quit + } return m, nil } @@ -310,7 +313,9 @@ func (m Model) welcome() string { dimSty.Render("the checks run, and nothing is kept until you accept the diff."), "", keySty.Render("try ") + "add a /health endpoint with a test", - keySty.Render(" ") + "why is the build slow?" + dimSty.Render(" (questions get answers)"), + } + if m.d.RoutingOn && m.d.Router != nil { + lines = append(lines, keySty.Render(" ")+"why is the build slow?"+dimSty.Render(" (questions get answers)")) } return strings.Join(lines, "\n") } @@ -329,14 +334,9 @@ func (m Model) sessionCard(w, h int) string { if m.d.RoutingOn && m.d.Router != nil { routing = okSty.Render("AI") + " picks the agent" } - ready, total := 0, 0 + total := 0 if m.d.Reg != nil { - for _, a := range m.d.Reg.All() { - total++ - if a.Health() == nil { - ready++ - } - } + total = len(m.d.Reg.All()) } checks := warnSty.Render("none — changes are unverified") var names []string @@ -351,7 +351,7 @@ func (m Model) sessionCard(w, h int) string { lines := []string{ row("folder", folder), row("routing", routing), - row("agents", fmt.Sprintf("%d of %d installed", ready, total)), + row("agents", fmt.Sprintf("%d of %d installed", m.installed, total)), row("checks", checks), row("retries", fmt.Sprintf("%d", m.d.MaxRetries)+dimSty.Render(fmt.Sprintf(" · timeout %s", m.d.Timeout))), "", diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d385232..a067d9d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -125,6 +125,9 @@ type Model struct { logs []logEntry + quitting bool // ctrl+c during a run: quit once it has been reverted + installed int // agents found on PATH (cached for the session card) + status string statusAt time.Time } @@ -158,6 +161,13 @@ func New(d Deps) Model { if m.inRepo { m.branch = gitutil.Branch(d.Dir) } + if d.Reg != nil { + for _, a := range d.Reg.All() { + if a.Health() == nil { + m.installed++ + } + } + } m.reload() m.setChatContent() return m @@ -293,8 +303,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": - if m.run != nil && m.run.cancel != nil { + // Mid-run, cancel first so the agent's partial edits are reverted, + // then quit when the run reports back. A second ctrl+c forces it. + if m.cstate == chatRunning && !m.quitting { + m.quitting = true + m.run.cancelled = true m.run.cancel() + m.setStatus("cancelling and reverting before quitting… (ctrl+c again to force)") + return m, nil } return m, tea.Quit case "tab": diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 52e3bf2..a259247 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -482,3 +482,45 @@ func TestRenderMarkdown(t *testing.T) { t.Fatalf("markdown not rendered (raw ** present):\n%s", out) } } + +// ctrl+c mid-run must not strand the agent's partial edits: it cancels, +// reverts, and only then quits. +func TestCtrlCDuringRunRevertsThenQuits(t *testing.T) { + dir := t.TempDir() + reg := fakeAgent(t, "echo partial > half.txt\nsleep 20\n") + m := testModelIn(dir, reg, nil, 100, 30) + m.ta.SetValue("slow task") + nm, _ := m.submitChat() + m = nm.(Model) + go runTurn(m.run.ctx, m.d, m.run.task, m.run.ch) + for i := 0; ; i++ { + if _, err := os.Stat(filepath.Join(dir, "half.txt")); err == nil { + break + } + if i > 200 { + t.Fatal("agent never started") + } + time.Sleep(20 * time.Millisecond) + } + nm, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + m = nm.(Model) + if cmd != nil || !m.quitting { + t.Fatal("first ctrl+c during a run should cancel, not quit yet") + } + for { + nm, cmd = m.Update(<-m.run.ch) + m = nm.(Model) + if m.cstate != chatRunning { + break + } + } + if cmd == nil { + t.Fatal("should quit once the run is reverted") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatal("expected a quit command") + } + if _, err := os.Stat(filepath.Join(dir, "half.txt")); !os.IsNotExist(err) { + t.Fatal("partial change should be reverted before quitting") + } +} From aa260a1ae2222239cc1db1d18fb5c00c828a0137 Mon Sep 17 00:00:00 2001 From: crossben Date: Fri, 25 Sep 2026 14:36:23 +0000 Subject: [PATCH 6/6] docs: describe the new dashboard (README, AGENTS.md, plan) Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 9 ++++++--- README.md | 34 ++++++++++++++++++++++------------ plan.md | 9 ++++++--- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6c1eaa0..ed2f59c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Binary is at `bin/orchestra` (gitignored). - `internal/validate/` — build → lint → test pipeline, auto-detects toolchain, stops on first failure. - `internal/config/` — YAML config + built-in agent defaults + toolchain auto-detection. - `internal/memory/` — SQLite history at `~/.orchestra/orchestra.db` (never in the repo tree). -- `internal/tui/` — Bubble Tea dashboard (agents, history, benchmarks, chat tabs). +- `internal/tui/` — Bubble Tea dashboard: Chat (live run panel + file-by-file review), Changes, History, Agents, Benchmarks, Logs. One file per concern (chat, live, review, views, chrome, theme, table). - `internal/ui/` — terminal styling (gradients, spinners, diffs). TTY-aware. - `internal/shell/` — interactive chat REPL. - `internal/review/` — diff display + accept/reject prompt. @@ -117,9 +117,12 @@ The dashboard (`internal/tui/`) uses Charm's ecosystem: - **Bubble Tea** — Elm-architecture TUI framework - **Lipgloss** — terminal styling - **Glamour** — markdown rendering -- **Chroma** — syntax-highlighted diffs -These are not interchangeable with other TUI frameworks. Tests are headless (`tui_test.go` renders views without a TTY by injecting `WindowSizeMsg`). +Diffs are coloured by a small built-in renderer (`internal/tui/highlight.go`). Colours are `lipgloss.AdaptiveColor`s defined once in `theme.go` — add new styles there, not inline. + +Live runs: the dashboard calls `engine.Produce` with `OnEvent` (attempt / agent-exit / validation-stage events) and `Output` (streamed agent output), forwarded to Bubble Tea over a channel (`live.go`). Both hooks are optional — `run`, `do` and the shell leave them nil. + +These are not interchangeable with other TUI frameworks. Tests are headless (`tui_test.go` renders views without a TTY by injecting `WindowSizeMsg`); end-to-end chat tests drive the real worker with fake-agent shell scripts. ## Router is CLI-based diff --git a/README.md b/README.md index 6a2cc64..093a9ab 100644 --- a/README.md +++ b/README.md @@ -113,23 +113,33 @@ orchestra history --all # across all projects ### Dashboard (TUI) -A full-screen dashboard over your agents, run history, and benchmark results: +A full-screen dashboard where you send tasks, watch agents work, and review their changes: ```sh orchestra dashboard # or: orchestra dash ``` -Tabs: **Agents** (installed + live `p`robe of whether each can actually run), **History** (recent runs), -**Benchmarks** (past leaderboards), and **Chat**. Keys: `tab` switch • `p` probe • `r` refresh • `q` quit. - -In the **Chat** tab you type in a proper input box; the agent runs **in the background** (spinner, no -screen flip), then the **syntax-highlighted diff** appears in a **scrollable pane** for you to **accept** -(`y`, commits) or **reject** (`n`, reverts). Validation + self-correction run first. When the AI router is -enabled, chat **auto-routes** each message to the best agent (or answers plain questions inline) with a -`↳ routed to …` note; otherwise it uses the default agent. The transcript scrolls (`↑/↓`, `pgup/pgdn`); -`tab` navigates away anytime (even mid-typing); `esc` leaves chat. Agent replies are rendered as -**markdown**. (Built on Charm's `viewport`/`textinput` for redraw, `glamour` for markdown, and `chroma` -for diff highlighting.) +It opens on **Chat**. Type a task (`enter` sends, `ctrl+j` adds a newline) and a **live run panel** +shows routing, each attempt, the agent's output as it streams, and every validation stage +(`build → lint → test`) as it passes or fails. Failed checks retry automatically. `esc` cancels a run and +reverts whatever the agent already changed. The panel sits beside the chat on wide terminals and stacks +below it on narrow ones. + +When the agent is done you get a **review screen**: a file list with `+/−` counts, the selected file's +diff, validation badges, and the failing check's output if there is one. `[` / `]` move between files, `a` +shows all files, `↑↓` scrolls, then `y` accepts (commits in a git repo) or `n` rejects (reverts). With the +AI router enabled, each message is routed to the best agent, and plain questions are answered inline. + +The other tabs: + +- **Changes**: every diff you accepted or rejected. Reopen any of them with `enter`, even after a restart. +- **History**: all runs with outcome, attempts and checks. +- **Agents**: installed, live `p`robe, runs, accept rate and last use. +- **Benchmarks**: past leaderboards. +- **Logs**: this session's events. + +Keys: `tab` / `1`–`6` switch tabs (digits outside Chat), `r` refresh, `q` quit (outside Chat), `ctrl+c` quit. +Colors adapt to light and dark terminals. ### Benchmark agents diff --git a/plan.md b/plan.md index d56e559..262df91 100644 --- a/plan.md +++ b/plan.md @@ -233,15 +233,18 @@ The nice-to-haves that make the project stand out — build only once the core i with reveal animation, TTY-gated spinners during routing/planning/answering, colored diffs and status lines. Auto-disables color/animation when piped or `NO_COLOR` is set (regression stays green). The full-screen dashboard below is still the later, larger effort. -- **TUI dashboard (Bubble Tea) 🟡 MOSTLY DONE:** `orchestra dashboard` — full-screen tabbed UI: +- **TUI dashboard (Bubble Tea) ✅ DONE:** `orchestra dashboard` — full-screen tabbed UI: Logs, Agents (+ live health probe), History, Benchmarks, Changes (per-file diff stats), and a **Chat** tab. Chat hands the terminal to the full supervised engine via `tea.Exec` (route → run → validate → accept/reject, like the shell), then returns and refreshes. Keyboard nav, gradient styling. **Chat is now in-pane:** the agent runs in the background (spinner), then the diff renders inside the dashboard for accept (`y`, commits) / reject (`n`, reverts) — via `engine.Produce` (quiet run) + `agent.RunQuiet`, on bubbles `viewport`/`textinput` with **chroma** diff highlighting, and **in-pane AI routing** (quiet classify/answer so nothing corrupts - the render). **Follow-up:** live *in-render* streaming of in-run waves/steps/logs (engine event emission) - — would also tidy interleaved output during `do --parallel`/`benchmark`. + the render). **Live runs** (2026-09): the engine emits optional progress events and streams agent + output (`engine.Options.OnEvent`/`Output`), shown in a run panel; a file-by-file review screen is shared + by Chat, Changes and History; diffs persist in memory so past changes reopen after a restart + (design: docs/superpowers/specs/2026-09-25-dashboard-ui-design.md). **Follow-up:** the same events could + tidy the interleaved output of `do --parallel`/`benchmark`. - **Benchmark mode ✅ DONE:** `orchestra benchmark ""` runs the task through every available agent, each in an isolated git worktree (parallel), then ranks a leaderboard by validation → retries → speed → diff size, offers to merge the winner, and records results to SQLite (`benchmarks` table)