mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 08:15:35 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user