From 9fd3cceec182edb6329925401d102b6427f286c8 Mon Sep 17 00:00:00 2001 From: Rahul Tyagi Date: Fri, 10 Jul 2026 22:03:58 +0530 Subject: [PATCH 1/4] fix(crew): auto-trust project MCP so the seeded first message sends Adding .mcp.json means every fresh Claude session in a new crew worktree hits the one-time 'trust MCP servers?' prompt. The blind seed-typing in pty.rs then types the task into that dialog instead of the input box, so the first message is never sent. Launch/resume claude with --settings '{"enableAllProjectMcpServers":true}' to auto-enable the project's .mcp.json servers, so no prompt appears and the seed reaches the REPL. --- tui/src/app.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tui/src/app.rs b/tui/src/app.rs index 2e3c0c9..abf8e46 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -1273,13 +1273,20 @@ pub fn interactive_argv(harness: &str, model: &str) -> Vec { } } match harness { - "claude" => vec!["claude".into(), "--model".into(), model.into()], + // auto-trust this repo's .mcp.json so the "trust MCP servers?" prompt + // doesn't intercept the seeded first message in a fresh worktree. + "claude" => vec!["claude".into(), "--model".into(), model.into(), + "--settings".into(), CLAUDE_MCP_SETTINGS.into()], "opencode" => vec!["opencode".into()], "cursor" => vec!["cursor-agent".into(), "--model".into(), model.into()], _ => vec!["claude".into()], } } +/// Inline settings that auto-enable project (.mcp.json) MCP servers so Claude +/// Code never shows the one-time trust prompt (which would swallow our seed). +const CLAUDE_MCP_SETTINGS: &str = "{\"enableAllProjectMcpServers\":true}"; + /// argv to RESUME a harness in an existing worktree (harness restores the prior /// session). Overridable via `NEXUM_RESUME_CMD_`. pub fn resume_argv(harness: &str, model: &str) -> Vec { @@ -1289,7 +1296,8 @@ pub fn resume_argv(harness: &str, model: &str) -> Vec { } } match harness { - "claude" => vec!["claude".into(), "--continue".into(), "--model".into(), model.into()], + "claude" => vec!["claude".into(), "--continue".into(), "--model".into(), model.into(), + "--settings".into(), CLAUDE_MCP_SETTINGS.into()], "opencode" => vec!["opencode".into(), "--continue".into()], "cursor" => vec!["cursor-agent".into(), "--resume".into()], _ => interactive_argv(harness, model), @@ -1685,6 +1693,14 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn claude_mcp_settings_auto_trusts_project_servers() { + // valid JSON with the exact key Claude Code reads (wired into the claude + // launch/resume argv so the trust prompt never swallows the seed). + let v: serde_json::Value = serde_json::from_str(CLAUDE_MCP_SETTINGS).unwrap(); + assert_eq!(v["enableAllProjectMcpServers"], serde_json::json!(true)); + } + #[test] fn on_path_detects_binaries() { assert!(on_path("sh"), "sh should be on PATH"); From 9c0faed7ea70df46e101713d38ebf345bfc627ff Mon Sep 17 00:00:00 2001 From: Rahul Tyagi Date: Fri, 10 Jul 2026 23:22:48 +0530 Subject: [PATCH 2/4] fix(crew): load delegate MCP explicitly (--mcp-config --strict) to kill the trust prompt The --settings enableAllProjectMcpServers approach did not reliably suppress the 'found new MCP in project nexum-delegate' trust prompt, which still swallowed the seeded first message. Instead pass the repo's .mcp.json to claude via --mcp-config --strict-mcp-config: a CLI-provided server is trusted (no discovery prompt) and strict mode ignores auto-discovered configs. When the repo has no .mcp.json, no MCP flags are added (nothing is discovered, so no prompt). Verified the flags load headlessly (exit 0). --- tui/src/app.rs | 67 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/tui/src/app.rs b/tui/src/app.rs index abf8e46..39f6427 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -887,6 +887,13 @@ impl App { Ok(id) } + /// Absolute path to this repo's `.mcp.json` if present — passed to claude so + /// its MCP servers load trusted (no discovery prompt). + fn mcp_config_path(&self) -> Option { + let p = self.store.repo_root.join(".mcp.json"); + if p.is_file() { Some(p.to_string_lossy().into_owned()) } else { None } + } + /// Launch an interactive PTY agent from the form. Task is required. pub fn spawn_from_form(&mut self) { if self.new_form.task_text().trim().is_empty() { @@ -925,7 +932,8 @@ impl App { ) }; - let argv = interactive_argv(&harness, &model); + let mcp = self.mcp_config_path(); + let argv = interactive_argv(&harness, &model, mcp.as_deref()); let task_text = self.new_form.task_text(); match AgentProc::spawn( id.clone(), harness.clone(), model.clone(), prompt, worktree.clone(), &argv, rows, cols, @@ -973,7 +981,8 @@ impl App { fn resume(&mut self, id: &str) { let Some(rec) = self.registry.iter().find(|r| r.id == id).cloned() else { return }; let (cols, rows) = self.term_size; - let argv = resume_argv(&rec.harness, &rec.model); + let mcp = self.mcp_config_path(); + let argv = resume_argv(&rec.harness, &rec.model, mcp.as_deref()); // no task seed on resume — the harness restores the prior conversation match AgentProc::spawn( rec.id.clone(), rec.harness.clone(), rec.model.clone(), String::new(), @@ -1264,43 +1273,56 @@ fn merge_keep(mut rows: Vec) -> Vec { rows } -/// argv for a harness's INTERACTIVE REPL. Overridable via -/// `NEXUM_INTERACTIVE_CMD_` (whitespace-split) so tests inject a stub. -pub fn interactive_argv(harness: &str, model: &str) -> Vec { +/// Claude MCP flags: load the delegation server explicitly from `mcp` and +/// ignore auto-discovered project/user configs. Passing the server on the CLI +/// marks it trusted, so the "found new MCP in project" prompt never appears (it +/// would otherwise swallow our seeded first message). Empty when there's no +/// `.mcp.json` — then nothing is discovered, so no prompt either. +fn claude_mcp_flags(mcp: Option<&str>) -> Vec { + match mcp { + Some(path) => vec!["--mcp-config".into(), path.into(), "--strict-mcp-config".into()], + None => Vec::new(), + } +} + +/// argv for a harness's INTERACTIVE REPL. `mcp` = path to a `.mcp.json` to load +/// explicitly (claude). Overridable via `NEXUM_INTERACTIVE_CMD_` +/// (whitespace-split) so tests inject a stub. +pub fn interactive_argv(harness: &str, model: &str, mcp: Option<&str>) -> Vec { if let Ok(over) = std::env::var(format!("NEXUM_INTERACTIVE_CMD_{}", harness.to_uppercase())) { if !over.trim().is_empty() { return over.split_whitespace().map(|s| s.to_string()).collect(); } } match harness { - // auto-trust this repo's .mcp.json so the "trust MCP servers?" prompt - // doesn't intercept the seeded first message in a fresh worktree. - "claude" => vec!["claude".into(), "--model".into(), model.into(), - "--settings".into(), CLAUDE_MCP_SETTINGS.into()], + "claude" => { + let mut v = vec!["claude".into(), "--model".into(), model.into()]; + v.extend(claude_mcp_flags(mcp)); + v + } "opencode" => vec!["opencode".into()], "cursor" => vec!["cursor-agent".into(), "--model".into(), model.into()], _ => vec!["claude".into()], } } -/// Inline settings that auto-enable project (.mcp.json) MCP servers so Claude -/// Code never shows the one-time trust prompt (which would swallow our seed). -const CLAUDE_MCP_SETTINGS: &str = "{\"enableAllProjectMcpServers\":true}"; - /// argv to RESUME a harness in an existing worktree (harness restores the prior /// session). Overridable via `NEXUM_RESUME_CMD_`. -pub fn resume_argv(harness: &str, model: &str) -> Vec { +pub fn resume_argv(harness: &str, model: &str, mcp: Option<&str>) -> Vec { if let Ok(over) = std::env::var(format!("NEXUM_RESUME_CMD_{}", harness.to_uppercase())) { if !over.trim().is_empty() { return over.split_whitespace().map(|s| s.to_string()).collect(); } } match harness { - "claude" => vec!["claude".into(), "--continue".into(), "--model".into(), model.into(), - "--settings".into(), CLAUDE_MCP_SETTINGS.into()], + "claude" => { + let mut v = vec!["claude".into(), "--continue".into(), "--model".into(), model.into()]; + v.extend(claude_mcp_flags(mcp)); + v + } "opencode" => vec!["opencode".into(), "--continue".into()], "cursor" => vec!["cursor-agent".into(), "--resume".into()], - _ => interactive_argv(harness, model), + _ => interactive_argv(harness, model, mcp), } } @@ -1694,11 +1716,12 @@ mod tests { } #[test] - fn claude_mcp_settings_auto_trusts_project_servers() { - // valid JSON with the exact key Claude Code reads (wired into the claude - // launch/resume argv so the trust prompt never swallows the seed). - let v: serde_json::Value = serde_json::from_str(CLAUDE_MCP_SETTINGS).unwrap(); - assert_eq!(v["enableAllProjectMcpServers"], serde_json::json!(true)); + fn claude_mcp_flags_load_explicitly_and_strict() { + // with a .mcp.json → pass it on the CLI (trusted, no prompt) + strict + let f = claude_mcp_flags(Some("/repo/.mcp.json")); + assert_eq!(f, vec!["--mcp-config", "/repo/.mcp.json", "--strict-mcp-config"]); + // without one → no MCP flags (nothing discovered, no prompt) + assert!(claude_mcp_flags(None).is_empty()); } #[test] From 7c8bda9ff5a7d87cafd604eb6d6098291f5acb3f Mon Sep 17 00:00:00 2001 From: Rahul Tyagi Date: Mon, 13 Jul 2026 12:01:04 +0530 Subject: [PATCH 3/4] fix(harness): pass --verbose with claude --print (stream-json requires it) claude errors out when --output-format stream-json is used with --print unless --verbose is set. Update build_command + its unittest/self-check. --- scripts/harness.py | 5 +++-- tests/test_harness.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/harness.py b/scripts/harness.py index 622a097..42e9901 100644 --- a/scripts/harness.py +++ b/scripts/harness.py @@ -50,7 +50,8 @@ def build_command(harness: str, model: str, prompt: str, cwd: str) -> List[str]: return shlex.split(override) + [prompt] if harness == "claude": - return ["claude", "-p", prompt, "--output-format", "stream-json", "--model", model] + # --verbose is mandatory with --print + stream-json, else claude errors out. + return ["claude", "-p", prompt, "--output-format", "stream-json", "--verbose", "--model", model] if harness == "opencode": return ["opencode", "run", prompt, "--model", model, "--format", "json"] if harness == "cursor": @@ -198,7 +199,7 @@ def _demo() -> None: """Self-check: build_command shapes, parse_stream, and a fail-open run against a nonexistent binary. Run: python3 scripts/harness.py""" cmd = build_command("claude", "sonnet", "hello", "/tmp") - assert cmd == ["claude", "-p", "hello", "--output-format", "stream-json", "--model", "sonnet"], cmd + assert cmd == ["claude", "-p", "hello", "--output-format", "stream-json", "--verbose", "--model", "sonnet"], cmd parsed = parse_stream("claude", ['{"type":"result","tokens":5,"cost_usd":0.0}']) assert parsed["status"] == "done" and parsed["tokens"] == 5, parsed diff --git a/tests/test_harness.py b/tests/test_harness.py index 7f8590b..a23ce62 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -16,7 +16,7 @@ def test_claude_shape(self): cmd = harness.build_command("claude", "sonnet", "do the thing", "/repo") self.assertEqual( cmd, - ["claude", "-p", "do the thing", "--output-format", "stream-json", "--model", "sonnet"], + ["claude", "-p", "do the thing", "--output-format", "stream-json", "--verbose", "--model", "sonnet"], ) def test_opencode_shape(self): From ddc0f63fe3237d8d4576fb3ed5f6e093d61f0f3a Mon Sep 17 00:00:00 2001 From: Rahul Tyagi Date: Mon, 13 Jul 2026 12:01:04 +0530 Subject: [PATCH 4/4] feat(crew): non-blocking render loop + fleet features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perf: move every blocking probe off the render thread — gh PR poll, the per-row dirty scan (<=20 git), and the selected-worktree status (git) now run on background threads drained via try_recv; skip the engine python spawn when standalone (has_engine gate); adaptive event-loop tick (33ms chatting / 100ms running / 200ms idle). Features: command palette (:), broadcast to marked live agents (b), pin/favorite persisted to .crew/pins.json (p), spend budget guard with header warning, markdown fleet report export (E), filter query tokens (status:/harness:), pause auto-refresh (z), reset view (0), jump-running (}/{), mark-all-failed (F), yank task/id (T/i), live-agent uptime, about splash (=). --- tui/src/app.rs | 694 +++++++++++++++++++++++++++----- tui/src/main.rs | 221 ++++++++-- tui/src/pty.rs | 146 ++++++- tui/src/ui.rs | 175 +++++++- tui/tests/fixtures/fake_repl.sh | 7 + 5 files changed, 1075 insertions(+), 168 deletions(-) create mode 100755 tui/tests/fixtures/fake_repl.sh diff --git a/tui/src/app.rs b/tui/src/app.rs index 39f6427..6789f31 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -8,8 +8,11 @@ use crate::pty::AgentProc; use crate::store::{AgentRecord, Store}; use anyhow::Result; use std::collections::{HashMap, HashSet}; +use std::sync::mpsc::Receiver; use tui_textarea::TextArea; +type PrMap = HashMap; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Normal, @@ -37,6 +40,12 @@ pub enum Mode { Log, /// Pick a harness to re-delegate (retry) the selected row's task on. Retry, + /// Fuzzy command palette — type to filter actions, Enter to run. + Palette, + /// Type one message and send it to every marked live agent at once. + Broadcast, + /// Read-only about / version splash. + About, } pub const HARNESSES: [&str; 3] = ["claude", "opencode", "cursor"]; @@ -275,7 +284,27 @@ pub struct App { /// Cached remote state of the selected branch (not pushed / ↑a ↓b / up to date). pub sel_remote: Option, /// Open PRs by branch name → (number, url), polled from `gh` periodically. - pub pr_map: HashMap, + pub pr_map: PrMap, + /// Result channel for the in-flight background `gh` poll (network I/O must + /// not block the render thread). `Some` while a poll is running. + pr_rx: Option>, + /// Result channel for the in-flight background dirty scan (≤20 git spawns). + dirty_rx: Option>>, + /// Result channel for the selected-worktree status scan: (wt, summary, + /// clean, remote-state). Tagged with wt so a stale result can be dropped. + sel_rx: Option>, + /// Auto-refresh paused: the list/PR/dirty timers are frozen (still drains + /// in-flight results + repaints). Toggled with `z`. + pub paused: bool, + /// Pinned agent ids (persisted): float to the top of the list, marked ◆. + pub pins: HashSet, + /// Total spend ceiling in USD (0 = off); header warns past it. From prefs. + pub budget: f64, + /// Command-palette state: filter query + highlighted action index. + pub palette_query: String, + pub palette_sel: usize, + /// Broadcast composer buffer (Mode::Broadcast). + pub broadcast_buf: String, /// Per-agent-id: worktree has uncommitted changes (computed in refresh). pub dirty: HashMap, } @@ -285,6 +314,7 @@ impl App { let registry = store.load_registry(); let repo_branch = git_branch(&store.repo_root.to_string_lossy()); let prefs = store.load_prefs(); // remembered UI state + let pins = store.load_pins(); App { store, agents: Vec::new(), @@ -322,34 +352,59 @@ impl App { worktree_status: None, sel_remote: None, pr_map: HashMap::new(), + pr_rx: None, + dirty_rx: None, + sel_rx: None, + paused: false, + pins, + budget: prefs.budget_usd, + palette_query: String::new(), + palette_sel: 0, + broadcast_buf: String::new(), dirty: HashMap::new(), } } - /// Poll `gh` for open PRs and index them by head branch. Called on a slow - /// timer (gh is a network call). No-op without gh / GitHub remote. + /// Kick off a `gh` PR poll on a background thread. Called on a slow timer; + /// `gh` is a network call, so it must never run on the render thread. Result + /// is picked up by `poll_prs_result`. No-op without gh, or if one's in flight. pub fn poll_prs(&mut self) { - if !gh_available() { + if !gh_available() || self.pr_rx.is_some() { return; } let repo = self.store.repo_root.to_string_lossy().to_string(); - let (ok, out, _) = run_in(&repo, "gh", - &["pr", "list", "--state", "open", "--json", "number,headRefName,url", "--limit", "50"]); - if !ok { - return; - } - if let Ok(v) = serde_json::from_str::(&out) { - let mut m = HashMap::new(); - if let Some(arr) = v.as_array() { - for it in arr { - if let (Some(br), Some(n)) = - (it["headRefName"].as_str(), it["number"].as_i64()) - { - m.insert(br.to_string(), (n, it["url"].as_str().unwrap_or("").to_string())); + let (tx, rx) = std::sync::mpsc::channel(); + self.pr_rx = Some(rx); + std::thread::spawn(move || { + let (ok, out, _) = run_in(&repo, "gh", + &["pr", "list", "--state", "open", "--json", "number,headRefName,url", "--limit", "50"]); + let mut m = PrMap::new(); + if ok { + if let Ok(v) = serde_json::from_str::(&out) { + if let Some(arr) = v.as_array() { + for it in arr { + if let (Some(br), Some(n)) = + (it["headRefName"].as_str(), it["number"].as_i64()) + { + m.insert(br.to_string(), (n, it["url"].as_str().unwrap_or("").to_string())); + } + } } } } - self.pr_map = m; + let _ = tx.send(m); // receiver may be gone if the TUI quit — fine. + }); + } + + /// Non-blocking: if the background PR poll has finished, swap in its result. + /// Called every event-loop iteration; a bare `try_recv`, no I/O. + pub fn poll_prs_result(&mut self) { + if let Some(rx) = &self.pr_rx { + match rx.try_recv() { + Ok(m) => { self.pr_map = m; self.pr_rx = None; } + Err(std::sync::mpsc::TryRecvError::Disconnected) => self.pr_rx = None, + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } } } @@ -363,23 +418,77 @@ impl App { /// Refresh the cached git status for the selected worktree (cheap: 1–2 git /// calls; called on refresh and on selection move). /// Per-row uncommitted-changes flags (heavier: one `git status` per row). - /// Called on a slow timer, capped, and skipped while chatting. + /// Runs the scan on a background thread — up to 20 git spawns must not stall + /// the render loop. Called on a slow timer; no-op if a scan's in flight. pub fn compute_dirty(&mut self) { - self.dirty.clear(); - for r in self.all_rows.iter().take(20) { - if let Some(wt) = &r.worktree { - self.dirty.insert(r.id.clone(), !worktree_clean(wt)); + if self.dirty_rx.is_some() { + return; + } + let targets: Vec<(String, String)> = self + .all_rows + .iter() + .take(20) + .filter_map(|r| r.worktree.clone().map(|wt| (r.id.clone(), wt))) + .collect(); + let (tx, rx) = std::sync::mpsc::channel(); + self.dirty_rx = Some(rx); + std::thread::spawn(move || { + let m: HashMap = targets + .into_iter() + .map(|(id, wt)| (id, !worktree_clean(&wt))) + .collect(); + let _ = tx.send(m); + }); + } + + /// Non-blocking: swap in the background dirty scan's result once it lands. + pub fn compute_dirty_result(&mut self) { + if let Some(rx) = &self.dirty_rx { + match rx.try_recv() { + Ok(m) => { self.dirty = m; self.dirty_rx = None; } + Err(std::sync::mpsc::TryRecvError::Disconnected) => self.dirty_rx = None, + Err(std::sync::mpsc::TryRecvError::Empty) => {} } } } + /// Refresh the selected worktree's git status + remote state on a background + /// thread (2–4 git spawns must not stall the render loop). The result is + /// tagged with its worktree and dropped on arrival if the selection moved. pub fn update_sel_status(&mut self) { - let wt = self.selected_row().and_then(|r| r.worktree.clone()); - self.worktree_status = wt.as_ref().map(|wt| { - let (sum, clean) = worktree_summary(wt); - (wt.clone(), sum, clean) + let Some(wt) = self.selected_row().and_then(|r| r.worktree.clone()) else { + self.worktree_status = None; + self.sel_remote = None; + return; + }; + if self.sel_rx.is_some() { + return; // a scan is already in flight; it'll re-fire next tick + } + let (tx, rx) = std::sync::mpsc::channel(); + self.sel_rx = Some(rx); + std::thread::spawn(move || { + let (sum, clean) = worktree_summary(&wt); + let remote = remote_state(&wt); + let _ = tx.send((wt, sum, clean, remote)); }); - self.sel_remote = wt.as_deref().map(remote_state); + } + + /// Non-blocking: apply the selected-worktree scan, unless the selection has + /// since moved to a different worktree (stale result → drop it). + pub fn update_sel_status_result(&mut self) { + let Some(rx) = &self.sel_rx else { return }; + match rx.try_recv() { + Ok((wt, sum, clean, remote)) => { + self.sel_rx = None; + let cur = self.selected_row().and_then(|r| r.worktree.clone()); + if cur.as_deref() == Some(wt.as_str()) { + self.worktree_status = Some((wt, sum, clean)); + self.sel_remote = Some(remote); + } + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => self.sel_rx = None, + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } } // ── rename / label ────────────────────────────────────────────────────── @@ -415,7 +524,7 @@ impl App { self.mode = Mode::Settings; } pub fn settings_move(&mut self, d: i64) { - self.settings_field = (((self.settings_field as i64 + d) % 4 + 4) % 4) as usize; + self.settings_field = (((self.settings_field as i64 + d) % 5 + 5) % 5) as usize; } pub fn settings_change(&mut self, d: i64) { let Some(p) = self.settings.as_mut() else { return }; @@ -433,13 +542,15 @@ impl App { let n = WORKFLOWS.len() as i64; p.workflow_idx = (((p.workflow_idx as i64 + d) % n + n) % n) as usize; } - _ => p.worktree_new = !p.worktree_new, + 3 => p.worktree_new = !p.worktree_new, + _ => p.budget_usd = (p.budget_usd + d as f64 * 0.5).max(0.0), // $0.50 steps } } pub fn save_settings(&mut self) { if let Some(mut p) = self.settings.take() { p.model_idx = p.model_idx.min(model_presets(p.harness_idx).len() - 1); self.store.save_prefs(&p); + self.budget = p.budget_usd; self.status_msg = "settings saved".into(); } self.mode = Mode::Normal; @@ -501,13 +612,23 @@ impl App { Category::Agents => r.kind == crate::agent::Kind::Managed, Category::Sessions => r.kind == crate::agent::Kind::Observed, }; + // Space-separated tokens, all must match (AND). `status:` / `harness:` + // tokens filter that field; bare tokens are a substring over all fields. + let tokens: Vec = q.split_whitespace().map(String::from).collect(); let matches_text = |r: &Row| { - q.is_empty() - || r.display().to_lowercase().contains(&q) - || r.task.to_lowercase().contains(&q) - || r.harness.to_lowercase().contains(&q) - || r.id.to_lowercase().contains(&q) - || r.status.to_lowercase().contains(&q) + tokens.iter().all(|tok| { + if let Some(v) = tok.strip_prefix("status:") { + r.status.to_lowercase().contains(v) + } else if let Some(v) = tok.strip_prefix("harness:") { + r.harness.to_lowercase().contains(v) + } else { + r.display().to_lowercase().contains(tok) + || r.task.to_lowercase().contains(tok) + || r.harness.to_lowercase().contains(tok) + || r.id.to_lowercase().contains(tok) + || r.status.to_lowercase().contains(tok) + } + }) }; let mut rows: Vec = self .all_rows @@ -521,6 +642,10 @@ impl App { SortKey::Cost => rows.sort_by(|a, b| b.cost_usd.total_cmp(&a.cost_usd)), SortKey::Recent => rows.sort_by(|a, b| b.updated_ts.total_cmp(&a.updated_ts)), } + // pinned rows float to the top (stable — keeps the sort order within each group) + if !self.pins.is_empty() { + rows.sort_by_key(|r| !self.pins.contains(&r.id)); + } self.rows = rows; if self.selected >= self.rows.len() && !self.rows.is_empty() { self.selected = self.rows.len() - 1; @@ -539,6 +664,206 @@ impl App { self.status_msg = format!("sort: {}", self.sort.label()); } + // ── quality-of-life actions ───────────────────────────────────────────── + /// Freeze/thaw the auto-refresh timers (list/PR/dirty). Useful to hold a + /// snapshot steady while reading, without agents shifting under the cursor. + pub fn toggle_pause(&mut self) { + self.paused = !self.paused; + self.status_msg = if self.paused { "auto-refresh paused (z)".into() } + else { "auto-refresh resumed".into() }; + } + + /// Reset the view to defaults: no filter, all categories, status sort, no marks. + pub fn reset_view(&mut self) { + self.filter_text.clear(); + self.category = Category::All; + self.sort = SortKey::Status; + self.marked.clear(); + self.selected = 0; + self.apply_filter(); + self.status_msg = "view reset".into(); + } + + /// Pin/unpin the selected row (pinned rows float to the top; persisted). + pub fn toggle_pin(&mut self) { + let Some(id) = self.selected_row().map(|r| r.id.clone()) else { return }; + let pinned = if self.pins.remove(&id) { false } else { self.pins.insert(id.clone()); true }; + self.store.save_pins(&self.pins); + self.apply_filter(); + // keep the cursor on the same agent after it floats + if let Some(i) = self.rows.iter().position(|r| r.id == id) { + self.selected = i; + } + self.status_msg = if pinned { "pinned ◆".into() } else { "unpinned".into() }; + } + + /// Jump to the next/prev running agent (wraps). Mirrors `jump_failed`. + pub fn jump_running(&mut self, dir: i64) { + let n = self.rows.len(); + if n == 0 { return; } + for step in 1..=n { + let i = (self.selected as i64 + dir * step as i64).rem_euclid(n as i64) as usize; + if self.rows[i].status == "running" { + self.selected = i; + self.on_select_changed(); + return; + } + } + self.status_msg = "no running agents".into(); + } + + /// Mark every failed row (bulk triage → stop/remove/retry). + pub fn mark_all_failed(&mut self) { + let mut n = 0; + for r in &self.rows { + if r.status == "failed" { self.marked.insert(r.id.clone()); n += 1; } + } + self.status_msg = if n > 0 { format!("marked {} failed", n) } + else { "no failed agents".into() }; + } + + /// Copy the selected agent's task (or label) to the clipboard. + pub fn yank_task(&mut self) { + let Some(t) = self.selected_row().map(|r| r.display().to_string()) else { + self.status_msg = "nothing to copy".into(); + return; + }; + if t.is_empty() { self.status_msg = "task is empty".into(); return; } + self.yank_text(&t, "task"); + } + + /// Copy the selected agent's id to the clipboard. + pub fn yank_id(&mut self) { + let Some(id) = self.selected_row().map(|r| r.id.clone()) else { + self.status_msg = "no agent selected".into(); + return; + }; + self.yank_text(&id, "agent id"); + } + + /// Send one message to every marked live agent (or the selected one if no + /// marks), submitting it with Enter. Fleet broadcast — e.g. "run the tests". + pub fn send_broadcast(&mut self) { + let msg = std::mem::take(&mut self.broadcast_buf); + self.mode = Mode::Normal; + if msg.trim().is_empty() { return; } + let ids = self.target_ids(); + let mut n = 0; + for p in &mut self.agents { + if ids.contains(&p.id) && p.is_alive() { + p.send(msg.as_bytes()); + p.send(b"\r"); + n += 1; + } + } + self.status_msg = if n > 0 { format!("broadcast to {} agent(s)", n) } + else { "no live agents in selection".into() }; + } + + /// Write a markdown snapshot of the whole fleet to `.crew/report-.md` + /// and copy the path to the clipboard. A shareable status digest. + pub fn export_report(&mut self) { + let now = now_secs(); + let mut out = String::from("# crew fleet report\n\n"); + out.push_str(&format!("repo: {}\n\n", self.store.repo_root.display())); + let total: f64 = self.all_rows.iter().map(|r| r.cost_usd).sum(); + let running = self.all_rows.iter().filter(|r| r.status == "running").count(); + out.push_str(&format!("{} agents · {} running · ${:.3} total\n\n", + self.all_rows.len(), running, total)); + out.push_str("| status | harness | cost | branch | updated | task |\n"); + out.push_str("|---|---|---|---|---|---|\n"); + for r in &self.all_rows { + let branch = if r.branch.is_empty() { "-" } else { &r.branch }; + let task = r.display().replace('|', "\\|").replace('\n', " "); + out.push_str(&format!("| {} | {} | ${:.3} | {} | {} | {} |\n", + r.status, r.harness, r.cost_usd, branch, + crate::agent::rel_time(r.updated_ts, now), task)); + } + let ts = now as u64; + let path = self.store.repo_root.join(".crew").join(format!("report-{}.md", ts)); + if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } + match std::fs::write(&path, out) { + Ok(_) => { + let ps = path.to_string_lossy().to_string(); + self.yank_text(&ps, "report path"); + self.status_msg = format!("report written → {}", ps); + } + Err(e) => self.status_msg = format!("report failed: {}", e), + } + } + + // ── command palette ───────────────────────────────────────────────────── + /// Actions offered in the `:` palette: (label, keywords for the fuzzy match). + pub const PALETTE: &'static [(&'static str, &'static str)] = &[ + ("new agent", "n launch create"), + ("chat with selected", "enter open terminal"), + ("broadcast to marked", "b message send all"), + ("push + PR", "p ship commit"), + ("retry / re-delegate", "r redispatch"), + ("diff", "d changes"), + ("log", "l tail"), + ("stop selected", "s kill"), + ("stop all running", "S kill"), + ("remove selected", "x delete"), + ("clear finished", "c prune"), + ("pin / unpin", "p favorite bookmark"), + ("mark all failed", "F triage"), + ("export fleet report", "E markdown digest"), + ("reset view", "0 clear filter sort"), + ("toggle pause", "z freeze refresh"), + ("settings", "config defaults budget"), + ("toggle observed sessions", "h show hide"), + ("about", "version info"), + ]; + + /// Palette rows matching the current query (all when empty), in order. + pub fn palette_matches(&self) -> Vec { + let q = self.palette_query.to_lowercase(); + Self::PALETTE.iter().enumerate() + .filter(|(_, (label, kw))| q.is_empty() + || label.to_lowercase().contains(&q) || kw.contains(&q.as_str())) + .map(|(i, _)| i) + .collect() + } + + /// Run the palette action currently highlighted, then close the palette. + pub fn run_palette(&mut self) { + let matches = self.palette_matches(); + let Some(&idx) = matches.get(self.palette_sel) else { self.mode = Mode::Normal; return; }; + let label = Self::PALETTE[idx].0; + self.mode = Mode::Normal; + self.palette_query.clear(); + self.palette_sel = 0; + match label { + "new agent" => self.open_new_agent(), + "chat with selected" => self.open_selected(), + "broadcast to marked" => { self.broadcast_buf.clear(); self.mode = Mode::Broadcast; } + "push + PR" => self.open_push(), + "retry / re-delegate" => self.open_retry(), + "diff" => self.show_diff(), + "log" => self.show_log(), + "stop selected" => { + if self.selected_row().map(|r| r.interactive).unwrap_or(false) || !self.marked.is_empty() { + self.mode = Mode::ConfirmStop; + } + } + "stop all running" => if self.running_count() > 0 { self.mode = Mode::ConfirmStopAll; }, + "remove selected" => { + if self.marked.is_empty() { self.remove_selected(); } else { self.mode = Mode::ConfirmRemove; } + } + "clear finished" => self.clear_finished(), + "pin / unpin" => self.toggle_pin(), + "mark all failed" => self.mark_all_failed(), + "export fleet report" => self.export_report(), + "reset view" => self.reset_view(), + "toggle pause" => self.toggle_pause(), + "settings" => self.open_settings(), + "toggle observed sessions" => { self.show_observed = !self.show_observed; let _ = self.refresh(); } + "about" => self.mode = Mode::About, + _ => {} + } + } + pub fn refresh(&mut self) -> Result<()> { // remember which agent is selected so the cursor follows it across a // re-sort (statuses change → row order changes) instead of jumping. @@ -557,16 +882,21 @@ impl App { rows.push(Row::from_record(rec)); } } - // headless sub-agents delegated via dispatch/MCP (SQLite agents table) - for a in self.store.managed_agents() { - if seen.insert(a.agent_id.clone()) { - rows.push(Row::from_managed(&a)); + // Engine-only rows (headless delegated agents + observed sessions) come + // from a python subprocess. Skip the spawn entirely in standalone mode — + // has_engine() is a cheap file stat vs. a ~40ms python startup per tick. + if self.store.has_engine() { + // headless sub-agents delegated via dispatch/MCP (SQLite agents table) + for a in self.store.managed_agents() { + if seen.insert(a.agent_id.clone()) { + rows.push(Row::from_managed(&a)); + } + } + // observed plugin sessions (read-only, from the DB) + if self.show_observed { + let sessions = self.store.sessions().unwrap_or_default(); + rows.extend(sessions.iter().map(Row::from_session)); } - } - // observed plugin sessions (read-only, from the DB) - if self.show_observed { - let sessions = self.store.sessions().unwrap_or_default(); - rows.extend(sessions.iter().map(Row::from_session)); } rows = merge_keep(rows); self.all_rows = rows; @@ -611,13 +941,24 @@ impl App { } let n = self.rows.len() as i64; let i = (self.selected as i64 + delta).clamp(0, n - 1); + if i as usize == self.selected { + return; + } self.selected = i as usize; - // NB: don't probe git here — scrolling must stay smooth. The selected - // worktree's status refreshes on the next tick (≤800ms). + self.on_select_changed(); + } + + /// Selection moved: kick off a (non-blocking) status probe for the newly + /// selected worktree so the preview updates near-instantly, not on the tick. + fn on_select_changed(&mut self) { + if self.mode != Mode::Terminal { + self.update_sel_status(); + } } pub fn sel_top(&mut self) { self.selected = 0; + self.on_select_changed(); } /// Jump to the next/prev failed agent (triage), wrapping around. @@ -630,6 +971,7 @@ impl App { let i = (self.selected as i64 + dir * step as i64).rem_euclid(n as i64) as usize; if self.rows[i].status == "failed" { self.selected = i; + self.on_select_changed(); return; } } @@ -638,6 +980,7 @@ impl App { pub fn sel_bottom(&mut self) { if !self.rows.is_empty() { self.selected = self.rows.len() - 1; + self.on_select_changed(); } } @@ -825,13 +1168,9 @@ impl App { let _ = self.refresh(); } - /// Open the retry modal for the selected managed row: re-delegate its task to - /// a harness you pick (defaults to the row's current harness). + /// Open the retry modal for the selected managed row: relaunch its task as an + /// interactive agent on a harness you pick (defaults to the row's harness). pub fn open_retry(&mut self) { - if !self.store.has_engine() { - self.status_msg = "retry needs the nexum engine (dispatch.py) — not present".into(); - return; - } let Some(r) = self.selected_row().cloned() else { return }; if r.kind != crate::agent::Kind::Managed || r.task.trim().is_empty() { self.status_msg = "select a managed agent with a task to retry".into(); @@ -847,44 +1186,19 @@ impl App { self.retry_harness_idx = (((self.retry_harness_idx as i64 + d) % n + n) % n) as usize; } - /// Fire the retry: dispatch the task headless on the chosen harness in a - /// fresh worktree (detached — shows up as a new managed row). + /// Fire the retry: relaunch the failed task as an interactive agent you can + /// type in, on the chosen harness in a fresh worktree. Reuses the new-agent + /// spawn path, so errors surface (in the form) instead of a silent dead row. pub fn confirm_retry(&mut self) { - let harness = HARNESSES[self.retry_harness_idx.min(HARNESSES.len() - 1)]; - let model = model_presets(self.retry_harness_idx)[0]; - let task = self.retry_task.clone(); - match self.dispatch_detached(&task, harness, model) { - Ok(id) => self.status_msg = format!("retrying on {} → {}", harness, id), - Err(e) => self.status_msg = format!("retry failed: {}", e), - } + self.new_form.harness_idx = self.retry_harness_idx.min(HARNESSES.len() - 1); + self.new_form.model_idx = 0; + self.new_form.workflow_idx = 0; // plain chat, no plan/build wrapper + self.new_form.worktree_new = true; + self.new_form.images.clear(); + self.new_form.error = None; + self.new_form.task = TextArea::from(self.retry_task.lines().map(str::to_owned)); self.mode = Mode::Normal; - let _ = self.refresh(); - } - - /// Spawn dispatch.py detached to run `task` on `harness` in a new worktree. - /// Returns the chosen agent id. Mirrors the delegation MCP's async path. - fn dispatch_detached(&self, task: &str, harness: &str, model: &str) -> Result { - use std::process::{Command, Stdio}; - let id = format!("retry_{}", now_millis()); - let steps_dir = self.store.repo_root.join(".nexum-data").join("steps"); - std::fs::create_dir_all(&steps_dir)?; - let step_path = steps_dir.join(format!("{}.json", id)); - let step = serde_json::json!({ - "title": task.chars().take(80).collect::(), - "objective": task, "contract": "", "scope_deny": [], - "acceptance": "", "files": [], - }); - std::fs::write(&step_path, serde_json::to_string(&step)?)?; - let slug = slugify(task); - Command::new(&self.store.python) - .arg(self.store.scripts_dir.join("dispatch.py")) - .args(["--harness", harness, "--model", model, "--repo"]) - .arg(&self.store.repo_root) - .args(["--new-worktree", "--slug", &slug, "--agent-id", &id, "--step-file"]) - .arg(&step_path) - .stdout(Stdio::null()).stderr(Stdio::null()).stdin(Stdio::null()) - .spawn()?; - Ok(id) + self.spawn_from_form(); } /// Absolute path to this repo's `.mcp.json` if present — passed to claude so @@ -1045,6 +1359,16 @@ impl App { pub fn clear_marks(&mut self) { self.marked.clear(); } + /// How many live PTY agents the current action targets (marked set, or the + /// selected row). Used by the broadcast composer. Reads row status (which + /// tracks liveness) so it stays `&self` for the renderer. + pub fn target_live_count(&self) -> usize { + let ids = self.target_ids(); + self.rows.iter() + .filter(|r| ids.contains(&r.id) && r.interactive && r.status == "running") + .count() + } + /// Ids to act on: the marked set (intersected with visible rows) if any, /// else just the selected row. fn target_ids(&self) -> HashSet { @@ -1452,13 +1776,6 @@ pub fn on_path(bin: &str) -> bool { .unwrap_or(false) } -fn now_millis() -> u128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) -} - /// Current branch name of a git repo (empty if not a repo / detached). fn git_branch(repo: &str) -> String { std::process::Command::new("git") @@ -1497,6 +1814,14 @@ fn worktree_diff(wt: &str) -> String { } } +/// Unix seconds now (for relative times in the report / uptime). +fn now_secs() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + pub fn slugify(task: &str) -> String { let base: String = task .chars() @@ -1520,6 +1845,130 @@ mod tests { App::new(Store::new(PathBuf::from("/tmp/r"), PathBuf::from("/tmp/r/scripts"))) } + /// A bare managed row for list-logic tests. + fn mrow(id: &str, harness: &str, status: &str) -> Row { + use crate::agent::ManagedAgent; + Row::from_managed(&ManagedAgent { + agent_id: id.into(), harness: Some(harness.into()), model: None, + worktree: None, branch: None, status: Some(status.into()), + cost_usd: Some(1.0), task: Some(format!("task {id}")), step_index: None, + updated_ts: Some(1.0), pid: None, log_path: None, + }) + } + + #[test] + fn filter_query_tokens_narrow_by_field() { + let mut a = app(); + a.all_rows = vec![ + mrow("x", "claude", "failed"), + mrow("y", "cursor", "running"), + mrow("z", "claude", "done"), + ]; + a.filter_text = "harness:claude".into(); + a.apply_filter(); + assert_eq!(a.rows.len(), 2); + assert!(a.rows.iter().all(|r| r.harness == "claude")); + a.filter_text = "status:failed".into(); + a.apply_filter(); + assert_eq!(a.rows.len(), 1); + assert_eq!(a.rows[0].id, "x"); + // combined tokens AND together + a.filter_text = "claude status:done".into(); + a.apply_filter(); + assert_eq!(a.rows.len(), 1); + assert_eq!(a.rows[0].id, "z"); + } + + #[test] + fn pins_float_to_top_and_track_cursor() { + let mut a = app(); + a.all_rows = vec![mrow("a", "claude", "done"), mrow("b", "claude", "done"), mrow("c", "claude", "done")]; + a.apply_filter(); + a.selected = 2; // "c" + a.toggle_pin(); + assert!(a.pins.contains("c")); + assert_eq!(a.rows[0].id, "c", "pinned row should float to top"); + assert_eq!(a.rows[a.selected].id, "c", "cursor follows the pinned row"); + a.toggle_pin(); + assert!(!a.pins.contains("c"), "second toggle unpins"); + } + + #[test] + fn reset_view_restores_defaults() { + let mut a = app(); + a.all_rows = vec![mrow("a", "claude", "failed")]; + a.filter_text = "zzz".into(); + a.category = Category::Running; + a.sort = SortKey::Cost; + a.marked.insert("a".into()); + a.reset_view(); + assert!(a.filter_text.is_empty()); + assert_eq!(a.category, Category::All); + assert_eq!(a.sort, SortKey::Status); + assert!(a.marked.is_empty()); + } + + #[test] + fn jump_running_and_mark_all_failed() { + let mut a = app(); + a.all_rows = vec![mrow("a", "claude", "failed"), mrow("b", "claude", "running"), mrow("c", "claude", "failed")]; + a.apply_filter(); + a.selected = 0; + a.jump_running(1); + assert_eq!(a.rows[a.selected].status, "running"); + a.mark_all_failed(); + assert_eq!(a.marked.len(), 2); + assert!(a.marked.contains("a") && a.marked.contains("c")); + } + + #[test] + fn palette_filters_and_dispatches() { + let mut a = app(); + a.palette_query = "about".into(); + let m = a.palette_matches(); + assert_eq!(m.len(), 1); + a.palette_sel = 0; + a.run_palette(); + assert_eq!(a.mode, Mode::About); + assert!(a.palette_query.is_empty(), "palette clears on run"); + } + + #[test] + fn export_report_writes_markdown() { + let dir = std::env::temp_dir().join(format!("crew-report-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut a = App::new(Store::new(dir.clone(), dir.join("scripts"))); + a.all_rows = vec![mrow("a", "claude", "running"), mrow("b", "cursor", "done")]; + a.export_report(); + let report = std::fs::read_dir(dir.join(".crew")).unwrap() + .filter_map(|e| e.ok()) + .find(|e| e.file_name().to_string_lossy().starts_with("report-")) + .expect("a report file should be written"); + let body = std::fs::read_to_string(report.path()).unwrap(); + assert!(body.contains("# crew fleet report")); + assert!(body.contains("claude") && body.contains("cursor")); + assert!(body.contains("| running |")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn budget_persists_via_settings() { + let dir = std::env::temp_dir().join(format!("crew-budget-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut a = App::new(Store::new(dir.clone(), dir.join("scripts"))); + a.open_settings(); + a.settings_field = 4; // budget + a.settings_change(10); // +$5.00 + a.save_settings(); + assert_eq!(a.budget, 5.0); + // reloads from disk on a fresh App + let b = App::new(Store::new(dir.clone(), dir.join("scripts"))); + assert_eq!(b.budget, 5.0); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn model_picker_cycles_per_harness() { let mut a = app(); @@ -1655,6 +2104,59 @@ mod tests { let _ = std::fs::remove_dir_all(&repo); } + /// The async selected-worktree status probe lands off-thread and is applied + /// by the non-blocking drain — and a result for a worktree the selection has + /// since left is dropped (tagged stale), never shown against the wrong row. + #[test] + fn sel_status_lands_async_and_drops_stale() { + use std::process::Command; + use std::time::Duration; + let repo = std::env::temp_dir().join(format!("crew-selstat-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&repo); + std::fs::create_dir_all(&repo).unwrap(); + let g = |a: &[&str]| { Command::new("git").arg("-C").arg(&repo).args(a).output().unwrap(); }; + g(&["init", "-q"]); g(&["config", "user.email", "t@t"]); g(&["config", "user.name", "t"]); + std::fs::write(repo.join("seed.txt"), "x").unwrap(); + g(&["add", "seed.txt"]); g(&["commit", "-qm", "init"]); + let repo = std::fs::canonicalize(&repo).unwrap(); + + let mut a = App::new(Store::new(repo.clone(), repo.join("scripts"))); + let wt = a.store.create_worktree("sel-me").unwrap(); + a.registry.push(AgentRecord { + id: "s".into(), harness: "claude".into(), model: "m".into(), + worktree: wt.clone(), task: "t".into(), label: None, + }); + a.refresh().unwrap(); // refresh kicks off the first probe + a.selected = a.rows.iter().position(|r| r.id == "s").unwrap(); + + // drain until the background scan lands (bounded wait) + let deadline = std::time::Instant::now() + Duration::from_secs(5); + a.update_sel_status(); + while a.worktree_status.is_none() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + a.update_sel_status_result(); + } + let (path, _sum, clean) = a.worktree_status.clone().expect("status should land async"); + assert_eq!(path, wt); + assert!(clean, "fresh worktree is clean"); + + // stale-drop: start a probe, then move selection off the worktree before + // draining — the tagged result must be discarded, not shown on the new row. + a.worktree_status = None; + a.sel_remote = None; + a.update_sel_status(); + a.registry.push(AgentRecord { + id: "nowt".into(), harness: "claude".into(), model: "m".into(), + worktree: String::new(), task: "t2".into(), label: None, + }); + a.rows.push(Row::from_record(a.registry.last().unwrap())); + a.selected = a.rows.len() - 1; // a row with no worktree + std::thread::sleep(Duration::from_millis(400)); + a.update_sel_status_result(); + assert!(a.worktree_status.is_none(), "stale result must be dropped after selection moved"); + let _ = std::fs::remove_dir_all(&repo); + } + #[test] fn label_shows_in_list_and_persists() { let mut a = app(); diff --git a/tui/src/main.rs b/tui/src/main.rs index 3294dc5..16334f4 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -282,7 +282,18 @@ fn event_loop(app: &mut App, terminal: &mut Term) -> Result<()> { loop { terminal.draw(|f| ui::draw(f, app))?; - if event::poll(Duration::from_millis(60))? { + // Adaptive tick: a keypress always wakes poll() immediately, so this only + // bounds how fast *background* changes (PTY output, the spinner) repaint. + // Fast while chatting; match the 100ms spinner when agents run; idle + // slowly otherwise so a parked dashboard barely touches the CPU. + let tick = if app.mode == Mode::Terminal { + Duration::from_millis(33) // live PTY echo — feels like a real terminal + } else if app.rows.iter().any(|r| r.status == "running") { + Duration::from_millis(100) // spinner cadence — no wasted frames + } else { + Duration::from_millis(200) // parked — quiet CPU + }; + if event::poll(tick)? { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { if app.mode == Mode::Terminal { @@ -320,21 +331,26 @@ fn event_loop(app: &mut App, terminal: &mut Term) -> Result<()> { run_suspended(terminal, &cwd, &argv)?; } + // the Log viewer always live-follows; other periodic polls freeze on pause if last_refresh.elapsed() >= refresh_every { if app.mode == Mode::Log { app.tick_log(); // live-follow the open log (tail -f) - } else { + } else if !app.paused { let _ = app.refresh(); } last_refresh = Instant::now(); } - if last_pr.elapsed() >= pr_every && app.mode != Mode::Terminal { + // pick up any finished background polls (non-blocking try_recv) + app.poll_prs_result(); + app.compute_dirty_result(); + app.update_sel_status_result(); + if !app.paused && last_pr.elapsed() >= pr_every && app.mode != Mode::Terminal { app.poll_prs(); last_pr = Instant::now(); } - if last_dirty.elapsed() >= dirty_every && app.mode != Mode::Terminal { + if !app.paused && last_dirty.elapsed() >= dirty_every && app.mode != Mode::Terminal { app.compute_dirty(); last_dirty = Instant::now(); } @@ -405,6 +421,8 @@ fn handle_key(app: &mut App, key: KeyEvent) { KeyCode::PageUp => app.move_sel(-10), KeyCode::Char(']') => app.jump_failed(1), KeyCode::Char('[') => app.jump_failed(-1), + KeyCode::Char('}') => app.jump_running(1), + KeyCode::Char('{') => app.jump_running(-1), KeyCode::Char('y') => app.yank_worktree(), KeyCode::Char('r') => { let _ = app.refresh(); } KeyCode::Char('n') => app.open_new_agent(), @@ -459,6 +477,24 @@ fn handle_key(app: &mut App, key: KeyEvent) { KeyCode::Char(',') => app.open_settings(), KeyCode::Char('/') => app.mode = Mode::Filter, KeyCode::Char('?') => { app.help_scroll = 0; app.mode = Mode::Help; } + // ── new features ── + KeyCode::Char(':') => { app.palette_query.clear(); app.palette_sel = 0; app.mode = Mode::Palette; } + KeyCode::Char('b') => { + if app.agents.iter_mut().any(|a| a.is_alive()) { + app.broadcast_buf.clear(); + app.mode = Mode::Broadcast; + } else { + app.status_msg = "no live agents to broadcast to".into(); + } + } + KeyCode::Char('E') => app.export_report(), + KeyCode::Char('p') => app.toggle_pin(), + KeyCode::Char('z') => app.toggle_pause(), + KeyCode::Char('T') => app.yank_task(), + KeyCode::Char('i') => app.yank_id(), + KeyCode::Char('F') => app.mark_all_failed(), + KeyCode::Char('0') => app.reset_view(), + KeyCode::Char('=') => app.mode = Mode::About, KeyCode::Enter => app.open_selected(), _ => {} }, @@ -528,6 +564,29 @@ fn handle_key(app: &mut App, key: KeyEvent) { KeyCode::Char(c) => { app.filter_text.push(c); app.apply_filter(); } _ => {} }, + Mode::Palette => match code { + KeyCode::Esc => { app.palette_query.clear(); app.palette_sel = 0; app.mode = Mode::Normal; } + KeyCode::Enter => app.run_palette(), + KeyCode::Down | KeyCode::Char('\t') => { + let n = app.palette_matches().len().max(1); + app.palette_sel = (app.palette_sel + 1) % n; + } + KeyCode::Up => { + let n = app.palette_matches().len().max(1); + app.palette_sel = (app.palette_sel + n - 1) % n; + } + KeyCode::Backspace => { app.palette_query.pop(); app.palette_sel = 0; } + KeyCode::Char(c) => { app.palette_query.push(c); app.palette_sel = 0; } + _ => {} + }, + Mode::Broadcast => match code { + KeyCode::Esc => { app.broadcast_buf.clear(); app.mode = Mode::Normal; } + KeyCode::Enter => app.send_broadcast(), + KeyCode::Backspace => { app.broadcast_buf.pop(); } + KeyCode::Char(c) => app.broadcast_buf.push(c), + _ => {} + }, + Mode::About => { app.mode = Mode::Normal; } Mode::NewAgent => handle_new_agent_key(app, key), Mode::Terminal => {} } @@ -942,34 +1001,28 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } - /// e2e: retry/re-delegate from the TUI dispatches the task on the chosen - /// harness and records a new agent row (polled from the store). + /// e2e: retry relaunches the failed task as an interactive PTY agent you can + /// type in (not a headless detached dispatch), seeded with the original task. #[test] - fn e2e_retry_redelegates_and_records_agent() { + fn e2e_retry_relaunches_interactive_agent() { use agent::{ManagedAgent, Row}; use std::process::Command; - let manifest = env!("CARGO_MANIFEST_DIR"); - let root = PathBuf::from(manifest).parent().unwrap().to_path_buf(); - let scripts = root.join("scripts"); - let fake = root.join("tests/fixtures/fake_harness.py"); - if !fake.exists() { return; } - let py = std::env::var("NEXUM_PYTHON").unwrap_or_else(|_| "python3".into()); - - let repo = std::env::temp_dir().join(format!("nexum-retry-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&repo); - std::fs::create_dir_all(&repo).unwrap(); - let git = |a: &[&str]| { Command::new("git").arg("-C").arg(&repo).args(a).output().unwrap(); }; + let dir = std::env::temp_dir().join(format!("nexum-retry-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // retry forces a fresh worktree → needs a real git repo with a commit. + let git = |a: &[&str]| { Command::new("git").arg("-C").arg(&dir).args(a).output().unwrap(); }; git(&["init", "-q"]); git(&["config", "user.email", "t@t"]); git(&["config", "user.name", "t"]); - std::fs::write(repo.join("seed.txt"), "seed").unwrap(); + std::fs::write(dir.join("seed.txt"), "seed").unwrap(); git(&["add", "seed.txt"]); git(&["commit", "-qm", "init"]); + // `cat` stands in for the harness CLI: a live PTY that never exits. + std::env::set_var("NEXUM_INTERACTIVE_CMD_CLAUDE", "cat"); + let repo = std::fs::canonicalize(&dir).unwrap(); + let mut app = App::new(store::Store::new(repo.clone(), dir.join("scripts"))); + app.term_size = (80, 24); - let data = repo.join(".nexum-data"); - std::env::set_var("CLAUDE_PLUGIN_DATA", &data); - std::env::set_var("NEXUM_HARNESS_CMD_CURSOR", format!("{} {}", py, fake.display())); - - let mut app = App::new(store::Store::new(std::fs::canonicalize(&repo).unwrap(), scripts.clone())); let ma = ManagedAgent { - agent_id: "orig".into(), harness: Some("cursor".into()), model: None, + agent_id: "orig".into(), harness: Some("claude".into()), model: None, worktree: None, branch: None, status: Some("failed".into()), cost_usd: None, task: Some("retry writes a file".into()), step_index: None, updated_ts: None, pid: None, log_path: None, @@ -978,28 +1031,112 @@ mod tests { app.selected = 0; app.open_retry(); assert_eq!(app.mode, Mode::Retry); - assert_eq!(app.retry_harness_idx, 2); // cursor + assert_eq!(app.retry_harness_idx, 0); // claude + // run in the repo dir (no worktree.py in this stub setup) app.confirm_retry(); - assert_eq!(app.mode, Mode::Normal); - let id = app.status_msg.rsplit("→ ").next().unwrap().trim().to_string(); - assert!(id.starts_with("retry_"), "status: {}", app.status_msg); - // poll the store until the detached dispatch finishes - let deadline = std::time::Instant::now() + Duration::from_secs(20); - let mut done = false; + // interactive: dropped into the terminal on a live PTY agent + assert_eq!(app.mode, Mode::Terminal, "status: {}", app.status_msg); + assert_eq!(app.agents.len(), 1); + assert!(app.agents[0].is_alive()); + assert_eq!(app.agents[0].task, "retry writes a file"); + + app.stop_selected(); + std::env::remove_var("NEXUM_INTERACTIVE_CMD_CLAUDE"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Manual: drive the REAL `crew` release binary in a PTY exactly like a user + /// — press `n`, type a task, Ctrl-S to launch — then dump crew's screen to + /// SEE whether the spawned claude pane received the seed. Run: + /// cargo build --release + /// cargo test drive_real_crew -- --ignored --nocapture + #[test] + #[ignore] + fn drive_real_crew() { + use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + use std::io::{Read, Write}; + let bin = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/release/crew"); + if !bin.exists() { eprintln!("build release first"); return; } + + let pty = native_pty_system(); + let pair = pty.openpty(PtySize { rows: 50, cols: 200, pixel_width: 0, pixel_height: 0 }).unwrap(); + let mut cmd = CommandBuilder::new(&bin); + cmd.cwd(env!("CARGO_MANIFEST_DIR").to_owned() + "/.."); + cmd.env("TERM", "xterm-256color"); + let mut child = pair.slave.spawn_command(cmd).unwrap(); + drop(pair.slave); + let mut reader = pair.master.try_clone_reader().unwrap(); + let screen = std::sync::Arc::new(std::sync::Mutex::new(vt100::Parser::new(50, 200, 5000))); + { + let screen = screen.clone(); + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + while let Ok(n) = reader.read(&mut buf) { + if n == 0 { break; } + screen.lock().unwrap().process(&buf[..n]); + } + }); + } + let mut w = pair.master.take_writer().unwrap(); + let dump = |tag: &str| { + let s = screen.lock().unwrap().screen().contents(); + eprintln!("\n===== {} =====\n{}\n", tag, s); + }; + + std::thread::sleep(Duration::from_secs(2)); + dump("after launch"); + let _ = w.write_all(b"n"); let _ = w.flush(); // open new-agent form + std::thread::sleep(Duration::from_millis(800)); + let _ = w.write_all(b"reply with exactly the word PONG and nothing else"); let _ = w.flush(); + std::thread::sleep(Duration::from_millis(500)); + dump("form filled"); + let _ = w.write_all(&[0x13]); let _ = w.flush(); // Ctrl-S = launch + for i in 0..16 { + std::thread::sleep(Duration::from_secs(1)); + dump(&format!("t+{}s after launch", i + 1)); + } + let _ = child.kill(); + } + + /// e2e: the seeded first message actually reaches the launched agent — and a + /// WORKFLOW prompt (multi-line: /nx-plan wrapper + task) arrives whole, not + /// truncated at the first newline. Drives the real spawn_from_form path. + #[test] + fn e2e_workflow_prompt_reaches_agent() { + let repl = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/fake_repl.sh"); + if !repl.exists() { return; } + let dir = std::env::temp_dir().join(format!("nexum-seed-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("NEXUM_INTERACTIVE_CMD_CLAUDE", repl.to_string_lossy().to_string()); + + let mut app = App::new(store::Store::new(dir.clone(), dir.join("scripts"))); + app.term_size = (80, 24); + app.open_new_agent(); + app.new_form.task.insert_str("make the button blue"); + app.new_form.workflow_idx = 1; // plan → build: a multi-line seed prompt + app.new_form.worktree_new = false; // run in dir, no worktree.py needed + app.spawn_from_form(); + assert_eq!(app.mode, Mode::Terminal, "status: {}", app.status_msg); + + // The stub echoes the seeded input back onto its screen. Poll until the + // whole prompt (wrapper + task tail) has landed — proving it wasn't + // truncated at an embedded newline. + let deadline = std::time::Instant::now() + Duration::from_secs(8); + let mut screen = String::new(); while std::time::Instant::now() < deadline { - let out = Command::new(&py).arg(scripts.join("store.py")) - .args(["agent-get", "--id", &id]) - .env("CLAUDE_PLUGIN_DATA", &data).output().unwrap(); - let s = String::from_utf8_lossy(&out.stdout); - if s.contains("\"status\": \"done\"") { done = true; break; } - std::thread::sleep(Duration::from_millis(200)); + screen = app.agents[0].parser.lock().unwrap().screen().contents(); + if screen.contains("make the button blue") { break; } + std::thread::sleep(Duration::from_millis(100)); } - assert!(done, "retry agent never reached done"); - std::env::remove_var("CLAUDE_PLUGIN_DATA"); - std::env::remove_var("NEXUM_HARNESS_CMD_CURSOR"); - let _ = std::fs::remove_dir_all(&repo); + app.stop_selected(); + std::env::remove_var("NEXUM_INTERACTIVE_CMD_CLAUDE"); + let _ = std::fs::remove_dir_all(&dir); + + assert!(screen.contains("Run /nx-plan"), "workflow wrapper not delivered: {:?}", screen); + assert!(screen.contains("make the button blue"), "task tail not delivered: {:?}", screen); } /// Cross-boundary e2e: a delegated sub-agent recorded by dispatch.py (via the diff --git a/tui/src/pty.rs b/tui/src/pty.rs index 66eaf22..912efdc 100644 --- a/tui/src/pty.rs +++ b/tui/src/pty.rs @@ -23,7 +23,10 @@ pub struct AgentProc { pub started: f64, /// vt100 screen, fed by a background reader thread; read by the renderer. pub parser: Arc>, - writer: Box, + /// PTY master writer. Shared (Arc) so the first-message seeder thread and + /// interactive `send()` write through the same handle — the PTY only hands + /// out one writer, so a second `take_writer()` would fail. + writer: Arc>>, master: Box, child: Box, } @@ -57,7 +60,7 @@ impl AgentProc { drop(pair.slave); let mut reader = pair.master.try_clone_reader().context("clone reader")?; - let writer = pair.master.take_writer().context("take writer")?; + let writer = Arc::new(Mutex::new(pair.master.take_writer().context("take writer")?)); let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 2000))); { @@ -93,47 +96,89 @@ impl AgentProc { master: pair.master, child, }; - // Seed the first message. A fixed sleep races the harness's startup — - // if we type before its input box exists the keystrokes are dropped and - // the task never reaches the agent. Instead wait until the REPL has - // drawn something (screen goes non-empty), then a short settle, then type. + // Seed the first message. Two failure modes to avoid: + // 1. Timing — a fixed sleep races the harness's startup; typing before + // the input box exists silently drops the keystrokes. + // 2. Multi-line — workflow prompts contain newlines. Typed raw, each + // newline submits a fragment early, so the agent gets a partial task. + // So: wait until the REPL has painted, paste the whole prompt as one + // bracketed-paste block (newlines land as input, not submits), then send + // Enter to submit it. Finally verify it reached the agent and retry once + // if the input box wasn't ready in time. if !task.trim().is_empty() { let task = task.clone(); let parser = proc.parser.clone(); - let mut w = proc.try_clone_writer(); + let writer = proc.writer.clone(); thread::spawn(move || { + // Non-empty screen ≈ REPL has drawn its input box. Give slow + // harnesses (claude loads MCP servers first) up to 12s. let start = std::time::Instant::now(); loop { let ready = parser .lock() .map(|p| !p.screen().contents().trim().is_empty()) .unwrap_or(false); - if ready || start.elapsed() > std::time::Duration::from_secs(8) { + if ready || start.elapsed() > std::time::Duration::from_secs(12) { break; } thread::sleep(std::time::Duration::from_millis(50)); } - // let the input box finish painting before typing into it + thread::sleep(std::time::Duration::from_millis(600)); + + let payload = task.replace('\r', ""); + let write = |bytes: &[u8]| { + if let Ok(mut w) = writer.lock() { + let _ = w.write_all(bytes); + let _ = w.flush(); + } + }; + // Bracketed paste: the whole prompt (newlines and all) enters the + // input box as one block instead of submitting each line early. + let paste = || { + write(b"\x1b[200~"); + write(payload.as_bytes()); + write(b"\x1b[201~"); + }; + paste(); + // Give the harness time to fully ingest the paste before Enter — + // too short and Enter races the paste, landing as a newline inside + // the box so the message is typed but never submitted. thread::sleep(std::time::Duration::from_millis(400)); - if let Some(w) = w.as_mut() { - let _ = w.write_all(task.as_bytes()); - let _ = w.write_all(b"\r"); - let _ = w.flush(); + write(b"\r"); // submit + + // Verify against two failure modes: + // dropped — nothing on screen: input wasn't ready → re-paste. + // unsent — text on screen but still sitting in the input box + // (Enter raced the paste) → nudge Enter again. A + // redundant Enter on an already-sent prompt is a + // harmless no-op (claude ignores empty input). + let probe: String = payload.chars().take(24).collect(); + let probe = probe.trim().to_string(); + if !probe.is_empty() { + thread::sleep(std::time::Duration::from_millis(800)); + let landed = parser + .lock() + .map(|p| p.screen().contents().contains(&probe)) + .unwrap_or(true); + if landed { + write(b"\r"); // nudge submit in case it's unsent + } else { + paste(); + thread::sleep(std::time::Duration::from_millis(400)); + write(b"\r"); + } } }); } Ok(proc) } - /// A second writer handle (PTY master writers are independent clones). - fn try_clone_writer(&self) -> Option> { - self.master.take_writer().ok() - } - /// Forward raw bytes (a keypress) to the agent. pub fn send(&mut self, bytes: &[u8]) { - let _ = self.writer.write_all(bytes); - let _ = self.writer.flush(); + if let Ok(mut w) = self.writer.lock() { + let _ = w.write_all(bytes); + let _ = w.flush(); + } } /// Resize the PTY + emulator to fit the render area. Takes `&self` (both @@ -166,6 +211,43 @@ impl AgentProc { mod tests { use super::*; + /// Manual probe against the REAL claude CLI: launch it, let the seeder run, + /// then dump the screen so we can SEE whether the seeded prompt landed in the + /// input box and got submitted. Run: + /// cargo test real_claude_seed_probe -- --ignored --nocapture + #[test] + #[ignore] + fn real_claude_seed_probe() { + // Mirror the REAL app path: interactive_argv (--model + strict MCP flags) + // and run in the actual repo so .mcp.json is discovered exactly as the + // TUI does. NEXUM_PROBE_CWD overrides the working dir. + let dir = std::env::var("NEXUM_PROBE_CWD") + .unwrap_or_else(|_| "/Users/rahultyagi/work/nexum".into()); + let dir = std::path::PathBuf::from(dir); + let mcp = dir.join(".mcp.json"); + let mcp = if mcp.is_file() { Some(mcp.to_string_lossy().into_owned()) } else { None }; + let argv = crate::app::interactive_argv("claude", "sonnet", mcp.as_deref()); + eprintln!("ARGV: {:?}\nCWD: {}", argv, dir.display()); + // Multi-line workflow-shaped prompt: the reported failing case. + // Override with NEXUM_PROBE_TASK (empty = no seed, isolates launch). + let default_task = "Line ALPHA: do not answer yet.\n\nLine BRAVO: now reply with \ + exactly the two words ALPHA BRAVO and nothing else."; + let task = std::env::var("NEXUM_PROBE_TASK").unwrap_or_else(|_| default_task.into()); + let mut a = AgentProc::spawn( + "probe".into(), "claude".into(), "sonnet".into(), + task.into(), + dir.to_string_lossy().to_string(), &argv, 40, 120, + ) + .unwrap(); + // Watch the screen evolve for a while. + for i in 0..16 { + std::thread::sleep(std::time::Duration::from_millis(1000)); + let screen = a.parser.lock().unwrap().screen().contents(); + eprintln!("=== t+{}s ===\n{}\n", i + 1, screen); + } + a.kill(); + } + #[test] fn pty_captures_command_output() { // spawn a trivial command in a PTY and confirm vt100 captures its output. @@ -183,6 +265,30 @@ mod tests { a.kill(); } + #[test] + fn pty_seeds_multiline_first_message() { + // A banner makes the screen non-empty (so the readiness wait fires fast), + // then `cat` echoes the seeded bytes back. Proves a multi-line prompt — + // the workflow case — reaches the agent whole, not truncated at the first + // newline. + let dir = std::env::temp_dir(); + let argv = vec![ + "sh".to_string(), "-c".to_string(), "printf 'READY\\n'; cat".to_string(), + ]; + let mut a = AgentProc::spawn( + "t3".into(), "stub".into(), "m".into(), + "first line of task\nsecond line of task".into(), + dir.to_string_lossy().to_string(), &argv, 24, 80, + ) + .unwrap(); + // readiness wait (banner) + settle + paste; give it room. + std::thread::sleep(std::time::Duration::from_millis(1200)); + let screen = a.parser.lock().unwrap().screen().contents(); + assert!(screen.contains("first line of task"), "line 1 missing: {:?}", screen); + assert!(screen.contains("second line of task"), "line 2 missing (truncated at newline?): {:?}", screen); + a.kill(); + } + #[test] fn pty_forwards_input() { // `cat` echoes stdin back to stdout — prove send() reaches the process diff --git a/tui/src/ui.rs b/tui/src/ui.rs index d5999dd..d12d039 100644 --- a/tui/src/ui.rs +++ b/tui/src/ui.rs @@ -145,10 +145,80 @@ pub fn draw(f: &mut Frame, app: &App) { Mode::Settings => draw_settings(f, app), Mode::ConfirmRemove => draw_confirm_remove(f, app), Mode::Rename => draw_rename(f, app), + Mode::Palette => draw_palette(f, app), + Mode::Broadcast => draw_broadcast(f, app), + Mode::About => draw_about(f, app), _ => {} } } +/// Fuzzy command palette (`:`) — a filterable list of actions. +fn draw_palette(f: &mut Frame, app: &App) { + let area = centered(60, 70, f.area()); + f.render_widget(Clear, area); + let matches = app.palette_matches(); + let items: Vec = matches + .iter() + .map(|&i| { + let (label, _) = App::PALETTE[i]; + ListItem::new(Line::from(Span::raw(format!(" {}", label)))) + }) + .collect(); + let title = format!(" : {}_ ", app.palette_query); + let list = List::new(items) + .block(panel_focused(&title, ACCENT)) + .highlight_style(Style::default().bg(ACCENT).fg(Color::Black).add_modifier(Modifier::BOLD)) + .highlight_symbol("▶ "); + let mut state = ListState::default(); + if !matches.is_empty() { + state.select(Some(app.palette_sel.min(matches.len() - 1))); + } + f.render_stateful_widget(list, area, &mut state); +} + +/// Broadcast composer (`b`) — one message sent to every marked live agent. +fn draw_broadcast(f: &mut Frame, app: &App) { + let area = centered(64, 26, f.area()); + f.render_widget(Clear, area); + let n = app.target_live_count(); + let lines = vec![ + Line::from(""), + Line::from(Span::styled(format!(" Send to {} live agent(s):", n), + Style::default().fg(Color::Gray))), + Line::from(vec![Span::raw(" "), + Span::styled(format!("{}_", app.broadcast_buf), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))]), + Line::from(""), + Line::from(Span::styled(" [enter] send [esc] cancel", Style::default().fg(Color::DarkGray))), + ]; + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }) + .block(panel_focused(" broadcast ", Color::Green)), area); +} + +/// About / version splash (`=`). +fn draw_about(f: &mut Frame, app: &App) { + let area = centered(56, 46, f.area()); + f.render_widget(Clear, area); + let engine = if app.store.has_engine() { "connected" } else { "standalone" }; + let lines = vec![ + Line::from(""), + Line::from(Span::styled(format!(" crew {}", env!("CARGO_PKG_VERSION")), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD))), + Line::from(Span::styled(" a terminal dashboard for coding agents", Style::default().fg(Color::Gray))), + Line::from(""), + Line::from(vec![Span::styled(" repo ", Style::default().fg(Color::DarkGray)), + Span::raw(app.store.repo_root.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default())]), + Line::from(vec![Span::styled(" engine ", Style::default().fg(Color::DarkGray)), + Span::raw(engine.to_string())]), + Line::from(vec![Span::styled(" agents ", Style::default().fg(Color::DarkGray)), + Span::raw(app.all_rows.len().to_string())]), + Line::from(""), + Line::from(Span::styled(" [:] command palette [?] full keymap", Style::default().fg(Color::Gray))), + Line::from(Span::styled(" any key closes", Style::default().fg(Color::DarkGray))), + ]; + f.render_widget(Paragraph::new(lines).block(panel_focused(" about ", ACCENT)), area); +} + fn draw_confirm_remove(f: &mut Frame, app: &App) { let area = centered(52, 20, f.area()); f.render_widget(Clear, area); @@ -209,6 +279,7 @@ fn draw_settings(f: &mut Frame, app: &App) { row(1, "model", model), row(2, "workflow", workflow), row(3, "worktree", wt), + row(4, "budget", &if p.budget_usd > 0.0 { format!("${:.2}", p.budget_usd) } else { "off".into() }), Line::from(""), Line::from(Span::styled(" [tab] field [←→] change [enter] save [esc] cancel", Style::default().fg(Color::DarkGray))), @@ -305,10 +376,34 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { spans.push(Span::styled(format!("{}{} ", glyph, n), Style::default().fg(color))); } } - if total_cost > 0.0 { + if app.budget > 0.0 { + // spend vs. ceiling — greens → reds as it fills, red chip once over + let pct = total_cost / app.budget * 100.0; + let color = if total_cost > app.budget { Color::Red } + else if pct >= 80.0 { Color::Yellow } else { Color::Green }; + spans.push(Span::styled(format!(" ${:.2}/${:.2} ", total_cost, app.budget), + Style::default().fg(color).add_modifier(Modifier::BOLD))); + if total_cost > app.budget { + spans.push(Span::styled(" ⚠ over budget ", + Style::default().bg(Color::Red).fg(Color::White).add_modifier(Modifier::BOLD))); + } + } else if total_cost > 0.0 { spans.push(Span::styled(format!(" ${:.3} ", total_cost), Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))); } + let dirty_n = app.dirty.values().filter(|d| **d).count(); + if dirty_n > 0 { + spans.push(Span::styled(format!(" ±{} ", dirty_n), + Style::default().fg(Color::Yellow))); + } + let pinned_n = app.pins.len(); + if pinned_n > 0 { + spans.push(Span::styled(format!(" ◆{} ", pinned_n), Style::default().fg(Color::Yellow))); + } + if app.paused { + spans.push(Span::styled(" ⏸ paused ", + Style::default().bg(Color::Yellow).fg(Color::Black).add_modifier(Modifier::BOLD))); + } if !app.marked.is_empty() { spans.push(Span::styled(format!(" ◉ {} marked ", app.marked.len()), Style::default().bg(ACCENT).fg(Color::Black).add_modifier(Modifier::BOLD))); @@ -355,8 +450,13 @@ fn draw_list(f: &mut Frame, app: &App, area: Rect) { .iter() .map(|r| { let is_marked = app.marked.contains(&r.id); - let mark = Span::styled(if is_marked { "◉ " } else { " " }, - Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)); + let mark = if is_marked { + Span::styled("◉ ", Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)) + } else if app.pins.contains(&r.id) { + Span::styled("◆ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) + } else { + Span::raw(" ") + }; // running rows get an animated spinner; everything else a static glyph let glyph = if r.status == "running" { spinner() } else { status_icon(&r.status) }; let icon = Span::styled(format!("{} ", glyph), @@ -493,7 +593,11 @@ fn draw_preview(f: &mut Frame, app: &App, area: Rect) { Span::styled(meter(p, 12), Style::default().fg(color)), Span::styled(format!(" {:.0}%", p), Style::default().fg(color))])); } - if r.updated_ts > 0.0 { + // live PTY agents show a precise uptime (updated_ts holds the launch time) + if r.interactive && r.status == "running" && r.updated_ts > 0.0 { + lines.push(Line::from(vec![field("uptime"), + Span::styled(fmt_uptime(now_secs() - r.updated_ts), Style::default().fg(Color::Green))])); + } else if r.updated_ts > 0.0 { let label = if r.status == "running" { "running" } else { "updated" }; lines.push(Line::from(vec![field(label), Span::styled(rel_time(r.updated_ts, now_secs()), Style::default().fg(Color::DarkGray))])); @@ -524,7 +628,7 @@ fn draw_footer(f: &mut Frame, app: &App, area: Rect) { Mode::Terminal => "CHAT — type to talk · [shift-tab] cycle agent modes · [Ctrl-o] back", Mode::Diff => "DIFF — [j/k]scroll [space/pgdn]page · [q/esc] close", Mode::Log => "LOG — [j/k]scroll [space/pgdn]page [r]eload · [q/esc] close", - _ => "[q]uit [jk]move [n]ew [enter]chat [d]iff [l]og [e]dit [P]ush [s]top [x]rm [1-4]filter [,]settings [?]help", + _ => "[q]uit [jk]move [n]ew [enter]chat [b]roadcast [d]iff [l]og [P]ush [p]in [:]palette [1-4]filter [?]help", }; f.render_widget( Paragraph::new(Span::styled(keys, Style::default().fg(Color::DarkGray))), @@ -648,9 +752,10 @@ fn draw_help(f: &mut Frame, app: &App) { ("Launch & drive", &[ ("n", "new agent (workflow: chat / plan→build / plan-only)"), ("enter", "chat with a live agent, or resume a ▷ one"), + ("b", "broadcast one message to all marked live agents"), ("shift-tab", "(in chat) cycle the agent's modes, e.g. plan"), ("D", "duplicate into a prefilled new-agent form"), - ("Ctrl-o", "leave the terminal, back to the dashboard"), + (":", "command palette — fuzzy-run any action"), ]), ("Inspect", &[ ("d", "review the worktree diff (colored)"), @@ -668,13 +773,16 @@ fn draw_help(f: &mut Frame, app: &App) { ("Manage", &[ ("s / S", "stop selected/marked · stop ALL running"), ("x / c", "remove selected/marked · clear finished"), - ("L", "label / rename a crew-launched agent"), + ("p / F", "pin selected (floats to top) · mark all failed"), + ("z / E", "pause auto-refresh · export fleet report (md)"), + ("T / i / L", "yank task · yank id · label/rename"), ]), ("Filter & sort", &[ ("1 2 3 4", "all / running / agents / sessions"), - ("t", "cycle sort: status → cost → recent"), - ("/ · h", "search · toggle observed sessions"), - (",", "settings — default harness/model/workflow"), + ("] [ · } {", "jump failed · jump running"), + ("t · 0", "cycle sort · reset view (clear filter/sort/marks)"), + ("/", "search — try status:failed or harness:claude"), + (", · = · h", "settings (+budget) · about · toggle observed"), ]), ]; let mut lines: Vec = Vec::new(); @@ -876,6 +984,14 @@ fn truncate(s: &str, n: usize) -> String { } } +/// Compact uptime like "3m 12s" / "1h 04m" / "45s". +fn fmt_uptime(secs: f64) -> String { + let s = secs.max(0.0) as u64; + if s < 60 { format!("{}s", s) } + else if s < 3600 { format!("{}m {:02}s", s / 60, s % 60) } + else { format!("{}h {:02}m", s / 3600, (s % 3600) / 60) } +} + fn now_secs() -> f64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -947,6 +1063,45 @@ mod tests { assert!(text.contains("✗1") || text.contains('✗'), "failed legend missing"); } + #[test] + fn renders_palette_broadcast_about() { + let store = Store::new(PathBuf::from("/tmp/myrepo"), PathBuf::from("/tmp/myrepo/scripts")); + let mut app = App::new(store); + let render = |app: &App| { + let mut term = Terminal::new(TestBackend::new(120, 30)).unwrap(); + term.draw(|f| draw(f, app)).unwrap(); + buffer_text(&term) + }; + app.mode = crate::app::Mode::Palette; + app.palette_query = "push".into(); + assert!(render(&app).contains("push"), "palette should list matching actions"); + + app.mode = crate::app::Mode::Broadcast; + app.broadcast_buf = "run the tests".into(); + assert!(render(&app).contains("run the tests"), "broadcast echoes the buffer"); + + app.mode = crate::app::Mode::About; + assert!(render(&app).contains("crew"), "about shows the name/version"); + } + + #[test] + fn budget_over_shows_warning_in_header() { + use crate::agent::{ManagedAgent, Row}; + let store = Store::new(PathBuf::from("/tmp/myrepo"), PathBuf::from("/tmp/myrepo/scripts")); + let mut app = App::new(store); + app.budget = 1.0; + app.rows = vec![Row::from_managed(&ManagedAgent { + agent_id: "a".into(), harness: Some("claude".into()), model: None, + worktree: None, branch: None, status: Some("running".into()), + cost_usd: Some(2.5), task: Some("t".into()), step_index: None, + updated_ts: Some(1.0), pid: None, log_path: None, + })]; + let mut term = Terminal::new(TestBackend::new(120, 24)).unwrap(); + term.draw(|f| draw(f, &app)).unwrap(); + let text = buffer_text(&term); + assert!(text.contains("over budget"), "over-budget warning missing: {}", text); + } + #[test] fn renders_new_agent_form() { let store = Store::new(PathBuf::from("/tmp/myrepo"), PathBuf::from("/tmp/myrepo/scripts")); diff --git a/tui/tests/fixtures/fake_repl.sh b/tui/tests/fixtures/fake_repl.sh new file mode 100755 index 0000000..e46638f --- /dev/null +++ b/tui/tests/fixtures/fake_repl.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Stub interactive harness for seed tests: print a banner (so the TUI's +# readiness wait fires), then echo everything typed into it back out — so the +# seeded first message lands on the agent's vt100 screen where the test can +# assert on it. +printf 'READY>\n' +exec cat