fix(board): review fixes — recheck before cleanup, atomic task put, the run's own session

- Clean up re-reads what is still in Done after the dialog, and asks again
  if it would now end something the dialog never named.
- `tty7 -m … task add --cwd` takes only a full path on that machine.
- A tab still connecting at startup holds the column its agent last reported
  in the machine tree instead of flashing into Review.
- A run records only its own agent's session id, never a neighbour's.
- A question is cleared when its session starts, ends or is interrupted.
- task_put makes room and inserts in one commit.
- Runs whose agent was never handed the prompt (`Run::bare`) read as
  waiting on the user, not Running.
- A prompt starting with `-` goes after `--` or is glued to its flag.
- Build cards once per frame; stat worktrees at most every 5s.
- `~bob` is not the user's home; a reply whose tab closed says so;
  stated_title gets its doc back; reuse tray::urgency.
This commit is contained in:
l0ng-ai
2026-09-29 22:49:31 +08:00
parent 37a6eae424
commit 76c0b08239
10 changed files with 387 additions and 93 deletions
+29 -11
View File
@@ -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<String> {
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<String> {
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(
+76 -5
View File
@@ -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<Vec<String>> {
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::<Vec<_>>();
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 {
+43 -15
View File
@@ -932,20 +932,18 @@ impl MachineStore {
origin: Option<SubscriberId>,
) -> 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<SubscriberId>,
persist: Persist,
op: impl FnOnce(&mut Machine) -> io::Result<(T, Vec<(WorkspaceId, LayoutDelta)>)>,
) -> io::Result<T> {
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<T>(
&self,
origin: Option<SubscriberId>,
persist: Persist,
op: impl FnOnce(
&mut Machine,
) -> io::Result<(
T,
Vec<(WorkspaceId, LayoutDelta)>,
Vec<(WorkspaceId, LayoutDelta)>,
)>,
) -> io::Result<T> {
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);
}
+30 -2
View File
@@ -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<String>,
/// 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<Live>) -> 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));
+10 -10
View File
@@ -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<String> {
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,
+1 -6
View File
@@ -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()
+49 -2
View File
@@ -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<Self>,
) {
// 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<Task> = 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<TaskId>,
agreed: Plan,
window: &mut Window,
cx: &mut Context<Self>,
) {
let ids = self.still_done(ids, window, cx);
let tasks: Vec<Task> = 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<TaskId>, window: &Window, cx: &gpui::App) -> Vec<TaskId> {
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<PathBuf> = 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(
+12 -1
View File
@@ -174,8 +174,14 @@ fn matching_places(places: Vec<TaskPlace>, 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"
);
}
}
+110 -26
View File
@@ -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<HashMap<TaskId, Held>>,
/// 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<HashMap<String, (bool, std::time::Instant)>>,
/// The zoom the active tab had when the board opened, put back when the
/// board closes.
zoom: Option<(TabId, Entity<TerminalView>)>,
@@ -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<GroupId>, 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<Entity<TerminalView>> {
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<Card> {
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<Card>) -> Vec<Card> {
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::<HashMap<TabId, AgentStatus>>::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<Card> {
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<Task> = 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<Entity<TerminalView>> {
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");
+27 -15
View File
@@ -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<Self>) -> AnyElement {
fn render_board_header(
&self,
cards: &[Card],
agents: Vec<(CLIAgent, usize)>,
cx: &mut Context<Self>,
) -> 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
}