diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 54d8b01c..00562196 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -1598,19 +1598,13 @@ struct NewTask { /// A `--cwd` as the board keeps it: a path on the workspace's own host, where /// the GUI will later open the run — so one relative to wherever this command /// happened to be typed is made absolute here, while that still means -/// something. `local` says this CLI is on that host; across `-m` a relative -/// path names nothing either side, and is refused. +/// something. `local` says this CLI is on that host; across `-m` only a +/// full path on that machine will do — nothing makes a relative one, or a +/// `~`, absolute over there — and anything else is refused. fn task_cwd(cwd: String, local: bool) -> Result { - let dotted = cwd == "." - || cwd == ".." - || ["./", "../", ".\\", "..\\"] - .iter() - .any(|p| cwd.starts_with(p)); if !local { - if dotted { - bail!( - "--cwd {cwd:?} is relative to this computer, not the machine given with -m; give the full path there" - ); + if !remote_absolute(&cwd) { + bail!("--cwd {cwd:?} is not a full path; across -m give the full path on that machine"); } return Ok(cwd); } @@ -1636,6 +1630,18 @@ fn task_cwd(cwd: String, local: bool) -> Result { Ok(clean.to_string_lossy().into_owned()) } +/// Whether `path` is absolute on some machine this CLI cannot look at: a +/// Unix root, or a Windows drive or share. +fn remote_absolute(path: &str) -> bool { + let b = path.as_bytes(); + path.starts_with('/') + || path.starts_with("\\\\") + || (b.len() >= 3 + && b[0].is_ascii_alphabetic() + && b[1] == b':' + && matches!(b[2], b'/' | b'\\')) +} + fn task_add( spec: NewTask, explicit: Option<&str>, @@ -2945,6 +2951,16 @@ mod tests { ); } + #[test] + fn a_remote_task_cwd_must_be_a_full_path_there() { + for ok in ["/srv/repo", "C:\\repo", "d:/repo", "\\\\host\\share"] { + assert_eq!(task_cwd(ok.into(), false).unwrap(), ok); + } + for bad in ["src/api", "~/repo", "~", ".", "../x", "C:repo"] { + assert!(task_cwd(bad.into(), false).is_err(), "{bad}"); + } + } + #[test] fn task_ls_reads_each_column_off_the_agent_in_the_run_tab() { use tty7_core::core::cli_agent::{AgentSessionState, AgentStatus}; @@ -2960,6 +2976,7 @@ mod tests { session_id: None, started: 0, worktree: None, + bare: false, }); ws.tasks = vec![queued, running]; backend.replies.push_back(ReplyOk::AgentStates(vec![ @@ -2999,6 +3016,7 @@ mod tests { session_id: None, started: tty7_core::core::machine::unix_now(), worktree: None, + bare: false, }); ws.tasks = vec![fresh]; let out = json_of(run_cli( diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 13b0eb23..16f05df1 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -681,13 +681,23 @@ impl CLIAgent { /// `prompt`. `None` for an agent whose positional prompt means a one-shot, /// non-interactive run, or whose form is not known — the caller then /// starts the agent bare rather than guess. + /// + /// A prompt that starts with `-` would be read as an option, so it goes + /// after `--`, or glued to its flag with `=`. pub fn prompt_args(self, prompt: &str) -> Option> { + let dashed = prompt.starts_with('-'); + let flag = |short: &str, long: &str| match dashed { + true => vec![format!("{long}={prompt}")], + false => vec![short.to_string(), prompt.to_string()], + }; match self { - CLIAgent::Claude | CLIAgent::Codex | CLIAgent::Cursor => Some(vec![prompt.to_string()]), - CLIAgent::Gemini | CLIAgent::Qwen | CLIAgent::Copilot => { - Some(vec!["-i".to_string(), prompt.to_string()]) - } - CLIAgent::OpenCode => Some(vec!["--prompt".to_string(), prompt.to_string()]), + CLIAgent::Claude | CLIAgent::Codex | CLIAgent::Cursor => Some(match dashed { + true => vec!["--".to_string(), prompt.to_string()], + false => vec![prompt.to_string()], + }), + CLIAgent::Gemini | CLIAgent::Qwen => Some(flag("-i", "--prompt-interactive")), + CLIAgent::Copilot => Some(flag("-i", "--interactive")), + CLIAgent::OpenCode => Some(flag("--prompt", "--prompt")), _ => None, } } @@ -1053,6 +1063,7 @@ impl AgentSessionState { self.turns = self.turns.wrapping_add(1); self.status = AgentStatus::Done; self.message = None; + self.ask = None; self.inferred = true; } @@ -1065,6 +1076,7 @@ impl AgentSessionState { } self.status = AgentStatus::Idle; self.message = None; + self.ask = None; self.inferred = true; } @@ -1078,9 +1090,13 @@ impl AgentSessionState { self.cwd = Some(cwd.clone()); } match ev.kind { + // A session starting or ending leaves nothing asked: a question + // it was stopped on went with the session that asked it. AgentEventKind::SessionStart => { self.status = AgentStatus::Idle; self.message = None; + self.ask = None; + self.question = false; } AgentEventKind::PromptSubmit => { self.status = AgentStatus::Working; @@ -1126,6 +1142,8 @@ impl AgentSessionState { AgentEventKind::SessionEnd => { self.status = AgentStatus::Idle; self.message = None; + self.ask = None; + self.question = false; self.cwd = None; } } @@ -1674,6 +1692,59 @@ mod tests { assert!(!s.question, "a real permission prompt later is one"); } + #[test] + fn a_prompt_that_looks_like_an_option_is_not_read_as_one() { + let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(CLIAgent::Claude.prompt_args("fix it"), Some(v(&["fix it"]))); + assert_eq!( + CLIAgent::Claude.prompt_args("--dry-run is ignored"), + Some(v(&["--", "--dry-run is ignored"])) + ); + assert_eq!( + CLIAgent::Gemini.prompt_args("fix it"), + Some(v(&["-i", "fix it"])) + ); + assert_eq!( + CLIAgent::Gemini.prompt_args("-v breaks"), + Some(v(&["--prompt-interactive=-v breaks"])) + ); + assert_eq!( + CLIAgent::OpenCode.prompt_args("-v breaks"), + Some(v(&["--prompt=-v breaks"])) + ); + } + + #[test] + fn a_question_goes_with_the_session_that_asked_it() { + let ev = |kind| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: None, + message: None, + cwd: None, + prompt: None, + ask: Some(AgentAsk { + question: "Which one?".into(), + options: vec!["a".into(), "b".into()], + multi: false, + }), + }; + for end in [AgentEventKind::SessionEnd, AgentEventKind::SessionStart] { + let mut s = AgentSessionState::default(); + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + s.apply_event(&ev(AgentEventKind::QuestionAsked)); + assert!(s.ask.is_some()); + s.apply_event(&ev(end)); + assert_eq!(s.ask, None, "{end:?}"); + assert!(!s.question, "{end:?}"); + } + let mut s = AgentSessionState::default(); + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + s.apply_event(&ev(AgentEventKind::QuestionAsked)); + s.assume_interrupted(); + assert_eq!(s.ask, None, "an interrupt dismisses the question"); + } + #[test] fn a_waiting_agent_says_whether_it_asked_a_question_or_for_permission() { let ev = |kind| AgentEvent { diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 00fd09d3..fa6a2c51 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -932,20 +932,18 @@ impl MachineStore { origin: Option, ) -> io::Result<()> { task.check().map_err(refuse)?; - self.mutate(None, |m| { - let ws = find_workspace(m, workspace)?; - let Some(evicted) = crate::core::task::make_room(&ws.tasks, task.id).map_err(refuse)? - else { - return Ok(((), Vec::new())); - }; - ws.tasks.retain(|t| t.id != evicted); - Ok(( - (), - vec![(workspace, LayoutDelta::TaskRemoved { task: evicted })], - )) - })?; - self.mutate(origin, |m| { + // Room is made and the task put in one commit: a put that is then + // refused never costs a card. The card dropped to make room is news + // to the window that asked as well, so that part goes to everyone. + self.mutate_split(origin, Persist::Now, |m| { let ws = find_workspace(m, workspace)?; + let mut everyone = Vec::new(); + if let Some(evicted) = + crate::core::task::make_room(&ws.tasks, task.id).map_err(refuse)? + { + ws.tasks.retain(|t| t.id != evicted); + everyone.push((workspace, LayoutDelta::TaskRemoved { task: evicted })); + } match ws.tasks.iter().position(|t| t.id == task.id) { Some(i) => ws.tasks[i] = task.clone(), None if ws.tasks.len() >= crate::core::task::MAX_TASKS => { @@ -956,7 +954,11 @@ impl MachineStore { } None => ws.tasks.push(task.clone()), } - Ok(((), vec![(workspace, LayoutDelta::TaskPut { task })])) + Ok(( + (), + everyone, + vec![(workspace, LayoutDelta::TaskPut { task })], + )) }) } @@ -1279,8 +1281,30 @@ impl MachineStore { origin: Option, persist: Persist, op: impl FnOnce(&mut Machine) -> io::Result<(T, Vec<(WorkspaceId, LayoutDelta)>)>, + ) -> io::Result { + self.mutate_split(origin, persist, |m| { + op(m).map(|(value, deltas)| (value, Vec::new(), deltas)) + }) + } + + /// [`Self::mutate_with`] for a change that tells its own `origin` part + /// of what it did: the first list of deltas goes to every subscriber, + /// the origin included, then the second to everyone else — both from + /// one commit, so no subscriber sees the first without the second. + fn mutate_split( + &self, + origin: Option, + persist: Persist, + op: impl FnOnce( + &mut Machine, + ) -> io::Result<( + T, + Vec<(WorkspaceId, LayoutDelta)>, + Vec<(WorkspaceId, LayoutDelta)>, + )>, ) -> io::Result { let _order = self.notify_order.lock().unwrap_or_else(|e| e.into_inner()); + let everyone; let deltas; let value; { @@ -1295,8 +1319,9 @@ impl MachineStore { } Ok(out) }) { - Ok((v, d)) => { + Ok((v, all, d)) => { value = v; + everyone = all; deltas = d; } Err(e) => { @@ -1305,6 +1330,9 @@ impl MachineStore { } } } + if !everyone.is_empty() { + self.notify_all(&everyone, None); + } if !deltas.is_empty() { self.notify_all(&deltas, origin); } diff --git a/crates/tty7-core/src/core/task.rs b/crates/tty7-core/src/core/task.rs index e36de9af..f46056c7 100644 --- a/crates/tty7-core/src/core/task.rs +++ b/crates/tty7-core/src/core/task.rs @@ -153,6 +153,12 @@ pub struct Run { /// its work is, and what to clean up once it is merged. #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree: Option, + /// Its agent was not handed the task's words when it started: it takes + /// no first message, or it was already running (a resumed session, a + /// tab filed as a task). Such an agent before its first turn is waiting + /// on the user, not picking a prompt up. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub bare: bool, } /// The user said the task is finished. @@ -298,8 +304,15 @@ pub fn column(task: &Task, live: impl Fn(&Run) -> Option) -> Column { return Column::Queued; } // An agent open on a run that has not taken a turn yet was just started - // for this task and is picking its prompt up. - if states.iter().any(|&s| live_column(s) == Column::Queued) { + // for this task and is picking its prompt up — unless it was never + // given one, when it waits on the user like any agent that has stopped. + if task + .runs + .iter() + .filter(|run| !run.bare) + .filter_map(&live) + .any(|s| live_column(s) == Column::Queued) + { return Column::Running; } Column::Review @@ -536,6 +549,7 @@ mod tests { session_id: None, started: 0, worktree: None, + bare: false, }); } (t, tabs) @@ -701,6 +715,7 @@ mod tests { session_id: None, started: 11, worktree: None, + bare: false, }); assert_eq!( col(&t, &[(tab, live(AgentStatus::Done, 1))]), @@ -744,6 +759,18 @@ mod tests { ); } + #[test] + fn an_agent_never_handed_the_task_waits_on_the_user() { + let (mut t, tabs) = task_with(1); + assert_eq!(col(&t, &[(tabs[0], STARTING)]), Column::Running); + t.runs[0].bare = true; + assert_eq!(col(&t, &[(tabs[0], STARTING)]), Column::Review); + assert_eq!( + col(&t, &[(tabs[0], live(AgentStatus::Working, 0))]), + Column::Running + ); + } + #[test] fn a_start_in_the_future_is_not_a_start_forever() { let run = |started| Run { @@ -752,6 +779,7 @@ mod tests { session_id: None, started, worktree: None, + bare: false, }; assert!(just_started(&run(1_000), 1_000 + START_GRACE_SECS - 1)); assert!(!just_started(&run(1_000), 1_000 + START_GRACE_SECS)); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index ee334b80..f6d235c7 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -170,16 +170,6 @@ pub struct NativeSshParts { /// What a pane is called when nothing running in it has said otherwise. pub(crate) const DEFAULT_TITLE: &str = "tty7"; -/// What a pane is *saying* about itself, if anything — the reading behind -/// [`TerminalView::stated_title`], split out so it can be pinned without a -/// live pane. -/// -/// Anything but the placeholder counts. That is wider than "arrived over OSC -/// 0/2" on purpose: an SSH pane answers to the host it dialled and a workspace -/// pane to its workspace's name, and those are names tty7 gave the pane -/// deliberately (#438) rather than the absence of one. The literal string -/// `tty7` is the only title that says nothing, because it is the app's own -/// name standing in for a pane that has never introduced itself. /// The last `n` lines of a screen that are output rather than chrome. /// /// An agent's TUI keeps its own furniture at the bottom of the screen: an @@ -219,6 +209,16 @@ pub(crate) fn output_tail(rows: &[String], n: usize) -> Vec { out } +/// What a pane is *saying* about itself, if anything — the reading behind +/// [`TerminalView::stated_title`], split out so it can be pinned without a +/// live pane. +/// +/// Anything but the placeholder counts. That is wider than "arrived over OSC +/// 0/2" on purpose: an SSH pane answers to the host it dialled and a workspace +/// pane to its workspace's name, and those are names tty7 gave the pane +/// deliberately (#438) rather than the absence of one. The literal string +/// `tty7` is the only title that says nothing, because it is the app's own +/// name standing in for a pane that has never introduced itself. pub(crate) fn stated_title(title: &str) -> Option<&str> { match title.trim() { "" => None, diff --git a/src/ui/app.rs b/src/ui/app.rs index 5c157371..d98b7524 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -714,12 +714,7 @@ impl Tab { crate::core::cli_agent::AgentStatus, )> { use crate::core::cli_agent::AgentStatus; - let urgency = |s: AgentStatus| match s { - AgentStatus::Waiting => 3, - AgentStatus::Working => 2, - AgentStatus::Done => 1, - AgentStatus::Idle => 0, - }; + let urgency = crate::ui::tray::urgency; self.pane .terminals() .into_iter() diff --git a/src/ui/board/cleanup.rs b/src/ui/board/cleanup.rs index 6cfd5a76..868c89dd 100644 --- a/src/ui/board/cleanup.rs +++ b/src/ui/board/cleanup.rs @@ -18,7 +18,7 @@ use std::time::Duration; use gpui::{Context, Entity, PromptLevel, Window}; use gpui_component::WindowExt as _; use tty7_core::core::machine::TabId; -use tty7_core::core::task::{Task, TaskId}; +use tty7_core::core::task::{Column, Task, TaskId}; use tty7_core::core::worktree; use crate::terminal::view::{PaneBusy, TerminalView}; @@ -72,6 +72,9 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { + // Only what is still in Done: the ids were read off a frame that + // may be out of date by now. + let ids = self.still_done(ids, window, cx); let tasks: Vec = ids.iter().filter_map(|id| self.task(*id)).collect(); if tasks.is_empty() { return; @@ -116,12 +119,55 @@ impl Tty7App { ); cx.spawn_in(window, async move |this, cx| { if matches!(answer.await, Ok(0)) { - let _ = this.update_in(cx, |this, window, cx| this.clean_up_now(ids, window, cx)); + let _ = this.update_in(cx, |this, window, cx| { + this.clean_up_confirmed(ids, plan, window, cx) + }); } }) .detach(); } + /// Cleans up what the user just agreed to — as far as it still stands. + /// A card that left Done while the question was up (an agent put back + /// to work, a reopen from elsewhere) is left alone; and if cleaning up + /// the rest would now end something the question never named, it is + /// asked again rather than done. + fn clean_up_confirmed( + &mut self, + ids: Vec, + agreed: Plan, + window: &mut Window, + cx: &mut Context, + ) { + let ids = self.still_done(ids, window, cx); + let tasks: Vec = ids.iter().filter_map(|id| self.task(*id)).collect(); + if tasks.is_empty() { + return; + } + let plan = self.clean_up_plan(&tasks, cx); + let unnamed = plan.others > agreed.others + || plan + .running + .iter() + .any(|what| !agreed.running.contains(what)); + match unnamed { + true => self.ask_clean_up(ids, true, window, cx), + false => self.clean_up_now(ids, window, cx), + } + } + + /// Those of `ids` whose card is in Done now. + fn still_done(&self, ids: Vec, window: &Window, cx: &gpui::App) -> Vec { + let cards = self.all_cards(Some(window), false, cx); + ids.into_iter() + .filter(|id| { + cards + .iter() + .any(|c| c.key == super::CardRef::Task(*id) && c.column == Column::Done) + }) + .collect() + } + fn clean_up_plan(&self, tasks: &[Task], cx: &gpui::App) -> Plan { let mut worktrees: Vec = tasks .iter() @@ -274,6 +320,7 @@ impl Tty7App { (kept, saved, failed) }, move |this, (kept, saved, failed), window, cx| { + this.board.worktree_seen.borrow_mut().clear(); for (path, error) in failed { window.push_notification( t_fmt( diff --git a/src/ui/board/composer.rs b/src/ui/board/composer.rs index 42eca8a2..01996aa5 100644 --- a/src/ui/board/composer.rs +++ b/src/ui/board/composer.rs @@ -174,8 +174,14 @@ fn matching_places(places: Vec, query: &str, home: Option<&Path>) -> || p.cwd.to_string_lossy().to_lowercase().contains(&needle) }) .collect(); + // `~` and `~/…` are home; `~bob` is someone else's, which there is no + // telling from here. let path = match q.strip_prefix('~') { - Some(rest) => home.map(|home| PathBuf::from(format!("{}{rest}", home.display()))), + Some("") => home.map(Path::to_path_buf), + Some(rest) if rest.starts_with(['/', '\\']) => { + home.map(|home| PathBuf::from(format!("{}{rest}", home.display()))) + } + Some(_) => None, None => (q.starts_with('/') || q.contains(":\\")).then(|| PathBuf::from(q)), }; if let Some(path) = path @@ -1342,5 +1348,10 @@ mod tests { matching_places(places.clone(), "~/src", None).is_empty(), "no `~` without a home to put it in" ); + let bob = matching_places(places.clone(), "~bob", Some(Path::new("/home/me"))); + assert!( + bob.iter().all(|p| !p.cwd.starts_with("/home/me")), + "`~bob` is not under my home" + ); } } diff --git a/src/ui/board/mod.rs b/src/ui/board/mod.rs index 6cd9c577..6f962bcc 100644 --- a/src/ui/board/mod.rs +++ b/src/ui/board/mod.rs @@ -100,6 +100,10 @@ pub(crate) struct Board { /// The column each task was last drawn in, held while its tab's agent /// has gone quiet — see [`Held`]. held: RefCell>, + /// Whether each finished run's worktree was still on disk, and when that + /// was asked — so a drawn board stats each one every few seconds rather + /// than every frame. + worktree_seen: RefCell>, /// The zoom the active tab had when the board opened, put back when the /// board closes. zoom: Option<(TabId, Entity)>, @@ -131,6 +135,7 @@ impl Board { show_old_done: false, starting: HashSet::new(), held: RefCell::new(HashMap::new()), + worktree_seen: RefCell::new(HashMap::new()), zoom: None, menu: None, } @@ -319,6 +324,9 @@ struct Held { const HOLD_SECS: u64 = 5; +/// How long a worktree's being on disk, or not, is taken as read. +const WORKTREE_RECHECK: std::time::Duration = std::time::Duration::from_secs(5); + /// One card, read off a task or a tab for this frame. struct Card { key: CardRef, @@ -431,12 +439,7 @@ fn live_group_filter(filter: Option, pinned: impl Fn(GroupId) -> bool) /// what the card's buttons send goes to it, so a split tab with two agents /// never shows one and answers the other. fn speaking_leaf(tab: &Tab, cx: &App) -> Option> { - let urgency = |s: AgentStatus| match s { - AgentStatus::Waiting => 3, - AgentStatus::Working => 2, - AgentStatus::Done => 1, - AgentStatus::Idle => 0, - }; + let urgency = crate::ui::tray::urgency; tab.pane .terminals() .into_iter() @@ -655,7 +658,11 @@ impl Tty7App { /// Every card on the board this frame, filtered as the header says. fn board_cards(&self, window: Option<&Window>, full: bool, cx: &App) -> Vec { - let mut cards = self.all_cards(window, full, cx); + self.filter_cards(self.all_cards(window, full, cx)) + } + + /// `cards`, less those the header's filters hide. + fn filter_cards(&self, mut cards: Vec) -> Vec { if !self.board.agent_filter.is_empty() { cards.retain(|c| { c.agent @@ -681,6 +688,25 @@ impl Tty7App { // computer's disk; a remote one is taken to be. let local = full && self.can_spawn_locally(cx); let now = unix_now(); + // Each tab's agent status as the machine tree last recorded it, for a + // tab whose terminal cannot say yet — see `Held`. Read only once + // such a tab turns up. + let remembered = std::cell::OnceCell::>::new(); + let remembered_status = |tab: TabId| { + remembered + .get_or_init(|| { + crate::ui::machine_mirror::tab_views_for(cx, self.workspace) + .map(|(views, _)| { + views + .into_iter() + .filter_map(|v| Some((v.id, v.status?))) + .collect() + }) + .unwrap_or_default() + }) + .get(&tab) + .copied() + }; let mut held = self.board.held.borrow_mut(); let mut cards = Vec::new(); for task in &self.board.tasks { @@ -722,6 +748,24 @@ impl Tty7App { } h.column } + // Nothing drawn yet to hold — the app just started onto a tab + // still connecting. What the machine last heard its agent say + // stands in until the agent can be read, then is held. + (Some(tab), None) if self.tab_connecting(tab, cx) => { + let column = match remembered_status(tab) { + Some(AgentStatus::Waiting) => Column::NeedsInput, + Some(AgentStatus::Working) => Column::Running, + _ => computed, + }; + held.insert( + task.id, + Held { + column, + silent_since: None, + }, + ); + column + } (Some(_), None) => computed, (None, _) => { held.insert( @@ -775,7 +819,7 @@ impl Tty7App { && open.is_none() && last .and_then(|r| r.worktree.as_deref()) - .is_some_and(|p| !std::path::Path::new(p).exists()), + .is_some_and(|p| !self.worktree_exists(p)), starting: starting_now, }); } @@ -812,6 +856,20 @@ impl Tty7App { cards } + /// Whether worktree `path` is on this computer's disk, as asked at most + /// [`WORKTREE_RECHECK`] ago. + fn worktree_exists(&self, path: &str) -> bool { + let mut seen = self.board.worktree_seen.borrow_mut(); + match seen.get(path) { + Some(&(there, at)) if at.elapsed() < WORKTREE_RECHECK => there, + _ => { + let there = std::path::Path::new(path).exists(); + seen.insert(path.to_string(), (there, std::time::Instant::now())); + there + } + } + } + fn card(&self, key: CardRef, window: &Window, cx: &App) -> Option { self.all_cards(Some(window), true, cx) .into_iter() @@ -1242,7 +1300,14 @@ impl Tty7App { let Some(card) = self.card(key, window, cx) else { return false; }; + // Its agent's tab closed since the box was offered: say so rather + // than swallow what was typed, which stays in the box. let Some(view) = self.card_view(card.tab, card.leaf, cx) else { + self.flash( + t_fmt(L10nKey::BoardCardMoved, &[("title", &card.title)]), + None, + cx, + ); return false; }; let agent = card.agent.map_or("", |a| a.display_name()); @@ -1380,6 +1445,7 @@ impl Tty7App { session_id: None, started: unix_now(), worktree: None, + bare: true, }); Some(task) } @@ -1441,6 +1507,7 @@ impl Tty7App { task.push_run(Run { tab: Some(tab), started: unix_now(), + bare: true, ..run }); task::reopen(&mut task); @@ -1622,6 +1689,7 @@ impl Tty7App { session_id: None, started: unix_now(), worktree: worktree.map(|p| p.display().to_string()), + bare: agent.prompt_args(&one_line(task.ask())).is_none(), }); task.agent = Some(agent); task::reopen(&mut task); @@ -1646,31 +1714,23 @@ impl Tty7App { let now = unix_now(); let mut changed = Vec::new(); for task in &self.board.tasks { - let mut next = task.clone(); - let mut dirty = false; - for run in next.runs.iter_mut() { - let Some(view) = self.card_view(run.tab, None, cx) else { + let mut next: Option = None; + for (i, run) in task.runs.iter().enumerate() { + let Some(view) = self.run_view(run, cx) else { continue; }; let session = view.read(cx).agent_session(); - let id = session.as_ref().and_then(|s| s.session_id.clone()); - if id.is_some() && id != run.session_id { - run.session_id = id; - dirty = true; + let id = session.as_ref().and_then(|s| s.session_id.as_ref()); + if id.is_some() && id != run.session_id.as_ref() { + next.get_or_insert_with(|| task.clone()).runs[i].session_id = id.cloned(); } let working = session.is_some_and(|s| s.status == AgentStatus::Working); - if working - && next - .paused - .is_some_and(|at| now.saturating_sub(at) > PAUSE_GRACE_SECS) - { - next.paused = None; - dirty = true; + let paused = next.as_ref().unwrap_or(task).paused; + if working && paused.is_some_and(|at| now.saturating_sub(at) > PAUSE_GRACE_SECS) { + next.get_or_insert_with(|| task.clone()).paused = None; } } - if dirty { - changed.push(next); - } + changed.extend(next); } for task in changed { if crate::ui::tree_sync::push_task(cx, self.workspace, task.clone()) { @@ -1679,6 +1739,29 @@ impl Tty7App { } } + /// The pane `run`'s own agent runs in: one in its tab running the agent + /// it was started with — the one already known by its session, if that + /// is still there. Another agent started in the same tab, or beside it + /// in a split, is not this run's, and its session is not the one to + /// resume. + fn run_view(&self, run: &Run, cx: &App) -> Option> { + let tab = self + .tabs + .iter() + .find(|t| Some(t.tree_id.get()) == run.tab)?; + let mine: Vec<_> = tab + .pane + .terminals() + .into_iter() + .filter(|l| l.read(cx).agent() == Some(run.agent)) + .collect(); + let known = mine.iter().find(|l| { + run.session_id.is_some() + && l.read(cx).agent_session().and_then(|s| s.session_id) == run.session_id + }); + known.or(mine.first()).cloned() + } + // ---- toast -------------------------------------------------------------- /// Says what just happened. With `undo`, the change goes on the stack @@ -1912,6 +1995,7 @@ mod tests { session_id: Some("s".into()), started: 10, worktree: None, + bare: false, }); let Some(UndoStep::Save(put)) = undo.clone().step(Some(&now)) else { panic!("the card is still there to put back"); diff --git a/src/ui/board/view.rs b/src/ui/board/view.rs index b95b313a..bf64f291 100644 --- a/src/ui/board/view.rs +++ b/src/ui/board/view.rs @@ -56,7 +56,11 @@ impl Tty7App { let groups = &self.sidebar_groups; self.board.group_filter = live_group_filter(self.board.group_filter, |g| groups.contains(g)); - let cards = self.board_cards(Some(window), true, cx); + // Built once a frame: the header counts agents over every card, the + // columns draw the filtered ones. + let everyone = self.all_cards(Some(window), true, cx); + let agents = agent_counts(&everyone); + let cards = self.filter_cards(everyone); // A selection whose card is gone (removed, filtered out) goes too. if let Some(sel) = self.board.selected && !cards.iter().any(|c| c.key == sel) @@ -77,7 +81,7 @@ impl Tty7App { .selected .and_then(|sel| cards.iter().find(|c| c.key == sel)); self.sync_reply(selected, window, cx); - let header = self.render_board_header(&cards, cx); + let header = self.render_board_header(&cards, agents, cx); let filtered = !self.board.agent_filter.is_empty() || self.board.group_filter.is_some(); let body = match cards.is_empty() && !filtered && self.board.dragging.is_none() { true => self.render_board_empty(cx), @@ -137,7 +141,12 @@ impl Tty7App { ) } - fn render_board_header(&self, cards: &[Card], cx: &mut Context) -> AnyElement { + fn render_board_header( + &self, + cards: &[Card], + agents: Vec<(CLIAgent, usize)>, + cx: &mut Context, + ) -> AnyElement { let theme = cx.theme(); let muted = theme.muted_foreground; let count = |col: Column| cards.iter().filter(|c| c.column == col).count(); @@ -151,18 +160,6 @@ impl Tty7App { ); let app = cx.entity().downgrade(); - // Agents: a set to pick from, each with how many cards it has — - // counted over every card, or a picked agent would hide the rest - // from the very list that picks them. - let everyone = self.all_cards(None, false, cx); - let mut agents: Vec<(CLIAgent, usize)> = Vec::new(); - for agent in everyone.iter().filter_map(|c| c.agent) { - match agents.iter_mut().find(|(a, _)| *a == agent) { - Some((_, n)) => *n += 1, - None => agents.push((agent, 1)), - } - } - agents.sort_by_key(|(a, _)| a.display_name()); let picked = self.board.agent_filter.clone(); let agent_label: SharedString = match picked.as_slice() { [] => t(L10nKey::BoardAllAgents).into(), @@ -1125,3 +1122,18 @@ pub(super) fn column_dot(col: Column, cx: &App) -> (Hsla, bool) { Column::Done => (muted.opacity(0.6), false), } } + +/// Agents: a set to pick from, each with how many cards it has — counted +/// over every card, or a picked agent would hide the rest from the very list +/// that picks them. +fn agent_counts(everyone: &[Card]) -> Vec<(CLIAgent, usize)> { + let mut agents: Vec<(CLIAgent, usize)> = Vec::new(); + for agent in everyone.iter().filter_map(|c| c.agent) { + match agents.iter_mut().find(|(a, _)| *a == agent) { + Some((_, n)) => *n += 1, + None => agents.push((agent, 1)), + } + } + agents.sort_by_key(|(a, _)| a.display_name()); + agents +}