diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 09de41d4..860d1154 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -978,6 +978,12 @@ pub struct AgentSessionState { /// the turn back on [`AgentStatus::Working`]. #[serde(default)] pub inferred: bool, + /// While [`AgentStatus::Waiting`]: the agent asked the user a question, + /// rather than for permission to go ahead. The two want different + /// answers — a permission prompt takes a yes, a question takes words — so + /// anything offering a one-click "allow" has to tell them apart. + #[serde(default)] + pub question: bool, } impl AgentStatus { @@ -1052,11 +1058,13 @@ impl AgentSessionState { AgentEventKind::PermissionRequest | AgentEventKind::QuestionAsked => { self.status = AgentStatus::Waiting; self.message = ev.message.clone(); + self.question = ev.kind == AgentEventKind::QuestionAsked; } AgentEventKind::Notification => { if self.status == AgentStatus::Working { self.status = AgentStatus::Waiting; self.message = ev.message.clone(); + self.question = false; } } AgentEventKind::ToolComplete => { @@ -1598,6 +1606,24 @@ mod tests { ); } + #[test] + fn a_waiting_agent_says_whether_it_asked_a_question_or_for_permission() { + let ev = |kind| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: None, + message: None, + cwd: None, + prompt: None, + }; + let mut s = AgentSessionState::default(); + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + s.apply_event(&ev(AgentEventKind::QuestionAsked)); + assert!(s.question); + s.apply_event(&ev(AgentEventKind::PermissionRequest)); + assert!(!s.question); + } + #[test] fn session_state_machine_follows_the_turn() { let mut s = AgentSessionState::default(); diff --git a/crates/tty7-core/src/core/task.rs b/crates/tty7-core/src/core/task.rs index cac5a1a7..1cd58803 100644 --- a/crates/tty7-core/src/core/task.rs +++ b/crates/tty7-core/src/core/task.rs @@ -92,6 +92,11 @@ pub struct Task { pub runs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub done: Option, + /// The user paused it — interrupted its agent and sent the card back to + /// the queue — at this Unix second. It holds while no agent on it is in + /// the middle of a turn; whoever puts one back to work clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub paused: Option, } /// One agent started for a task. @@ -198,6 +203,9 @@ pub fn column(task: &Task, live: impl Fn(&Run) -> Option) -> Column { if states.iter().any(|s| s.status == AgentStatus::Working) { return Column::Running; } + if task.paused.is_some() { + return Column::Queued; + } if let Some(done) = task.done && turns(&states) <= done.turns { @@ -240,6 +248,7 @@ impl Task { created: crate::core::machine::unix_now(), runs: Vec::new(), done: None, + paused: None, } } @@ -420,6 +429,21 @@ mod tests { assert_eq!(col(&t, &at(live(AgentStatus::Done, 2))), Column::Review); } + #[test] + fn a_paused_task_waits_in_the_queue_until_its_agent_works_again() { + let (mut t, tabs) = task_with(1); + t.paused = Some(1); + assert_eq!( + col(&t, &[(tabs[0], live(AgentStatus::Done, 3))]), + Column::Queued + ); + assert_eq!(col(&t, &[]), Column::Queued); + assert_eq!( + col(&t, &[(tabs[0], live(AgentStatus::Working, 3))]), + Column::Running + ); + } + #[test] fn a_task_marked_done_before_it_ever_ran_is_done() { let (mut t, _) = task_with(0); diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index e8a08944..c0faaa3a 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -1741,6 +1741,7 @@ mod tests { activity: 3, turns: 1, inferred: false, + question: false, }, }])), ControlReply::Ok(ReplyOk::AgentStates(Vec::new())), diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 9a23b1eb..afe09439 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -5771,6 +5771,7 @@ mod tests { activity: 0, turns: 0, inferred: false, + question: false, }); apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07")); assert_eq!( diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 65ea1a58..19295b6d 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -1906,6 +1906,7 @@ mod tests { activity: 12, turns: 4, inferred: false, + question: false, })), DaemonMsg::AgentStatus(None), DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }), diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index c9c3b0e9..4e78790b 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -6497,6 +6497,7 @@ mod tests { activity: 0, turns: 0, inferred: false, + question: false, })) .encode(daemon) .unwrap(); @@ -6559,6 +6560,7 @@ mod tests { activity: 0, turns: 0, inferred: false, + question: false, })) .encode(&mut daemon_side) .unwrap(); @@ -6610,6 +6612,7 @@ mod tests { activity: 0, turns: 0, inferred: false, + question: false, })) .encode(&mut daemon_side) .unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 72c9a9d7..0298882a 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2225,6 +2225,36 @@ impl TerminalView { .filter(|t| !t.trim().is_empty()) } + /// Bytes typed into the pane as if at the keyboard — `\r` to take a + /// highlighted choice, ESC to interrupt an agent's turn. + pub fn send_keys(&self, bytes: &[u8]) { + self.terminal.write(bytes.to_vec()); + } + + /// The last `n` lines on screen that carry any text, top to bottom — what + /// the board shows of a pane without opening it. Lines of nothing but + /// punctuation and box drawing (a TUI's borders and rules) are skipped: + /// they say nothing about what the program is doing. + pub fn screen_tail(&self, n: usize) -> Vec { + let term = self.terminal.term.lock(); + let grid = term.grid(); + let cols = grid.columns(); + let mut out = Vec::new(); + for line in (0..grid.screen_lines() as i32).rev() { + if out.len() == n { + break; + } + let row = &grid[Line(line)]; + let text: String = (0..cols).map(|c| row[Column(c)].c).collect(); + let text = text.trim(); + if text.chars().any(char::is_alphanumeric) { + out.push(text.to_string()); + } + } + out.reverse(); + out + } + pub fn send_agent_prompt(&self, prompt: &str) { self.terminal .write(crate::core::agent_prompt::submit_bytes(prompt)); @@ -10748,6 +10778,7 @@ mod gpui_tests { activity: 0, turns: 0, inferred: false, + question: false, })) .encode(daemon) .unwrap(); @@ -10805,6 +10836,7 @@ mod gpui_tests { activity: 0, turns: 0, inferred: false, + question: false, })) .encode(&mut daemon) .unwrap(); @@ -10873,6 +10905,7 @@ mod gpui_tests { activity: 0, turns, inferred: false, + question: false, }; DaemonMsg::AgentStatus(Some(state.clone())) .encode(daemon) @@ -11331,6 +11364,7 @@ mod gpui_tests { activity: 0, turns: 0, inferred: false, + question: false, })) .encode(&mut daemon) .unwrap(); diff --git a/src/ui/board.rs b/src/ui/board.rs deleted file mode 100644 index 9457b1a2..00000000 --- a/src/ui/board.rs +++ /dev/null @@ -1,1647 +0,0 @@ -//! The board: the window's agents and the tasks handed to them, in five -//! columns by where each one stands (#1010). -//! -//! It is the main area's second view — the sidebar stays, the terminal steps -//! aside — and it only ever shows this window's workspace, the same scope as -//! the sidebar beside it. -//! -//! Every card is one of two things. A *task* is kept on the machine tree -//! ([`tty7_core::core::task`]): something the user wrote down, maybe started, -//! maybe finished. Any other tab running an agent is a *loose* card, drawn -//! from the tab itself and kept nowhere, so an agent started by hand shows up -//! without anyone filing it. Where either sits is worked out from the agent -//! on every frame; nothing here stores a column. - -use std::path::PathBuf; - -use gpui::{ - AnyElement, App, Context, Entity, FocusHandle, Hsla, SharedString, Subscription, Window, div, - prelude::*, px, rems, -}; -use gpui_component::button::{Button, ButtonVariants as _}; -use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; -use gpui_component::{ActiveTheme as _, Icon, Sizable as _, WindowExt as _, h_flex, v_flex}; -use tty7_core::core::group_key::{GroupId, PinnedGroup}; -use tty7_core::core::machine::TabId; -use tty7_core::core::task::{self, Column, Done, Live, Run, Task, TaskId}; - -use crate::core::cli_agent::{AgentStatus, CLIAgent}; -use crate::core::config::{Config, unix_now}; -use crate::ui::app::Tty7App; -use crate::ui::dialog::{self, Tone}; -use crate::ui::i18n::{L10nKey, t, t_fmt}; -use crate::ui::right_panel::{HEADING, META, META_MONO, TAB_TEXT, TEXT}; -use crate::ui::tab_sidebar::TaskPlace; - -/// What the main area is showing. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum MainView { - #[default] - Terminal, - Board, -} - -/// The board's own state on the window. -pub(crate) struct Board { - /// This workspace's tasks, as the machine last said — and as this window - /// has since changed them, since its own edits never come back to it. - pub tasks: Vec, - /// Only this agent's cards, or everyone's. - pub agent_filter: Option, - /// Only this pinned group's cards, or every group's. - pub group_filter: Option, - pub composer: Option, - /// What the worktree switch last said, so the next task starts there. - pub last_worktree: bool, - pub focus: FocusHandle, -} - -impl Board { - pub(crate) fn new(cx: &mut App) -> Board { - Board { - tasks: Vec::new(), - agent_filter: None, - group_filter: None, - last_worktree: true, - composer: None, - focus: cx.focus_handle(), - } - } -} - -/// The New task card, or the same card open on a task being edited. -pub(crate) struct Composer { - editing: Option, - title: Entity, - prompt: Entity, - agent: Option, - cwd: Option, - /// A pinned sidebar group, or `None` for the automatic one by repo. - group: Option, - worktree: bool, - _subs: Vec, -} - -/// Board geometry, from the v7 board comp. -const HEADER_H: f32 = 48.; -const PAD_X: f32 = 20.; -const COLUMN_GAP: f32 = 12.; -const COLUMN_HEAD_H: f32 = 28.; -const CARD_GAP: f32 = 8.; -const CARD_RADIUS: f32 = 9.; -const AVATAR: f32 = 16.; -const COMPOSER_W: f32 = 560.; -const PROMPT_H: f32 = 132.; - -/// Which card a click or a menu is about. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum CardRef { - Task(TaskId), - /// A tab running an agent that no task claims. - Loose(TabId), -} - -/// One card, read off a task or a tab for this frame. -struct Card { - key: CardRef, - column: Column, - agent: Option, - status: Option, - title: SharedString, - /// `repo · branch`, or just the repo. - place: Option, - /// What the agent says it is doing, from its terminal title. - doing: Option, - /// What the agent is stopped on. - ask: Option, - diff: Option<(u32, u32)>, - /// When this card's current state began, in Unix seconds, if known. - since: Option, - /// The open tab a run of this card lives in. - tab: Option, - /// A finished run this card could pick back up. - resumable: bool, - /// The pinned group it is filed under. - group: Option, -} - -/// What the board reads off one open tab. -struct TabFacts { - id: TabId, - agent: CLIAgent, - live: Live, - ask: Option, - doing: Option, - cwd: Option, - branch: Option, - diff: Option<(u32, u32)>, - label: Option, - group: Option, -} - -impl Tty7App { - pub(crate) fn board_open(&self) -> bool { - self.main_view == MainView::Board - } - - pub(crate) fn toggle_board(&mut self, window: &mut Window, cx: &mut Context) { - match self.main_view { - MainView::Board => self.close_board(window, cx), - MainView::Terminal => self.open_board(window, cx), - } - } - - pub(crate) fn open_board(&mut self, window: &mut Window, cx: &mut Context) { - self.main_view = MainView::Board; - self.maximized = None; - self.focus_active(window, cx); - cx.notify(); - } - - pub(crate) fn close_board(&mut self, window: &mut Window, cx: &mut Context) { - if self.main_view == MainView::Terminal { - return; - } - self.main_view = MainView::Terminal; - self.board.composer = None; - self.focus_active(window, cx); - cx.notify(); - } - - /// Takes the tasks a tree pull brought in. - pub(crate) fn adopt_board_tasks(&mut self, tasks: Vec, cx: &mut Context) { - if self.board.tasks != tasks { - self.board.tasks = tasks; - cx.notify(); - } - } - - pub(crate) fn board_task_put(&mut self, task: Task) { - match self.board.tasks.iter_mut().find(|t| t.id == task.id) { - Some(slot) => *slot = task, - None => self.board.tasks.push(task), - } - } - - pub(crate) fn board_task_removed(&mut self, task: TaskId) { - self.board.tasks.retain(|t| t.id != task); - } - - /// How many cards are stopped on the user — the sidebar row's count. - pub(crate) fn board_needs_input(&self, cx: &App) -> usize { - self.board_cards(None, false, cx) - .iter() - .filter(|c| c.column == Column::NeedsInput) - .count() - } - - /// Saves `task` here and on the machine. False, with the reason on - /// screen, when the machine would not take it. - fn save_task(&mut self, task: Task, window: &mut Window, cx: &mut Context) -> bool { - if let Err(why) = task.check() { - window.push_notification(why, cx); - return false; - } - if !crate::ui::tree_sync::push_task(cx, self.workspace, task.clone()) { - window.push_notification(t(L10nKey::BoardUnavailable), cx); - return false; - } - self.board_task_put(task); - cx.notify(); - true - } - - fn delete_task(&mut self, id: TaskId, window: &mut Window, cx: &mut Context) { - if !crate::ui::tree_sync::remove_task(cx, self.workspace, id) { - window.push_notification(t(L10nKey::BoardUnavailable), cx); - return; - } - self.board_task_removed(id); - cx.notify(); - } - - /// Everything the board knows about the open tabs that run an agent. - /// - /// `full` also reads what only a drawn card shows — the tab's label, its - /// git counts, its terminal title. The sidebar's count asks every frame - /// and needs none of that. - fn tab_facts(&self, window: Option<&Window>, full: bool, cx: &App) -> Vec { - let urgency = |s: AgentStatus| match s { - AgentStatus::Waiting => 3, - AgentStatus::Working => 2, - AgentStatus::Done => 1, - AgentStatus::Idle => 0, - }; - self.tabs - .iter() - .filter_map(|tab| { - // The tab's most urgent agent pane speaks for it, the same - // pane its sidebar row reports. - let leaf = tab - .pane - .terminals() - .into_iter() - .filter(|l| l.read(cx).agent().is_some()) - .max_by_key(|l| l.read(cx).agent_session().map_or(0, |s| urgency(s.status)))?; - let view = leaf.read(cx); - let session = view.agent_session().unwrap_or_default(); - let git = full.then(|| view.git_status(cx)).flatten(); - Some(TabFacts { - id: tab.tree_id.get(), - agent: view.agent()?, - live: Live::from(&session), - ask: (session.status == AgentStatus::Waiting) - .then(|| session.message.clone()) - .flatten(), - doing: full - .then(|| view.stated_title().map(str::to_string)) - .flatten(), - cwd: view.cwd(), - branch: git.as_ref().map(|g| g.branch.clone()), - diff: git - .as_ref() - .map(|g| (g.added, g.removed)) - .filter(|&(a, r)| a > 0 || r > 0), - label: full.then(|| self.full_tab_label(tab, window, cx)).flatten(), - group: tab.group.get().filter(|g| self.sidebar_groups.contains(*g)), - }) - }) - .collect() - } - - /// Every card on the board this frame, filtered by agent. - fn board_cards(&self, window: Option<&Window>, full: bool, cx: &App) -> Vec { - let facts = self.tab_facts(window, full, cx); - let find = |tab: Option| tab.and_then(|id| facts.iter().find(|f| f.id == id)); - let mut cards = Vec::new(); - for task in &self.board.tasks { - let column = task::column(task, |run| find(run.tab).map(|f| f.live)); - // The run this card reports: the newest one still open, else the - // newest one at all. - let open = task.runs.iter().rev().find_map(|r| find(r.tab)); - let last = task.runs.last(); - let agent = open - .map(|f| f.agent) - .or(last.map(|r| r.agent)) - .or(task.agent); - let cwd = open - .and_then(|f| f.cwd.clone()) - .or_else(|| task.cwd.as_ref().map(PathBuf::from)); - cards.push(Card { - key: CardRef::Task(task.id), - column, - agent, - status: open.map(|f| f.live.status), - title: task.title.clone().into(), - place: place(cwd.as_deref(), open.and_then(|f| f.branch.as_deref())), - doing: open - .filter(|_| column == Column::Running) - .and_then(|f| f.doing.clone()) - .map(Into::into), - ask: open.and_then(|f| f.ask.clone()).map(Into::into), - diff: open.and_then(|f| f.diff), - since: match column { - Column::Done => task.done.map(|d| d.at), - Column::Queued => Some(task.created), - _ => last.map(|r| r.started), - }, - tab: open.map(|f| f.id), - resumable: open.is_none() && last.is_some_and(|r| r.session_id.is_some()), - group: task.group.filter(|g| self.sidebar_groups.contains(*g)), - }); - } - let claimed = |id: TabId| self.board.tasks.iter().any(|t| t.runs_in(id)); - for f in facts.iter().filter(|f| !claimed(f.id)) { - let column = task::live_column(f.live); - cards.push(Card { - key: CardRef::Loose(f.id), - column, - agent: Some(f.agent), - status: Some(f.live.status), - title: f - .label - .clone() - .unwrap_or_else(|| f.agent.display_name().to_string()) - .into(), - place: place(f.cwd.as_deref(), f.branch.as_deref()), - doing: (column == Column::Running) - .then(|| f.doing.clone()) - .flatten() - .filter(|d| Some(d) != f.label.as_ref()) - .map(Into::into), - ask: f.ask.clone().map(Into::into), - diff: f.diff, - since: None, - tab: Some(f.id), - resumable: false, - group: f.group, - }); - } - if let Some(only) = self.board.agent_filter { - cards.retain(|c| c.agent == Some(only)); - } - if let Some(only) = self.board.group_filter { - cards.retain(|c| c.group == Some(only)); - } - cards - } - - /// Jumps to the tab a card's agent runs in. - fn open_card_tab(&mut self, tab: TabId, window: &mut Window, cx: &mut Context) { - let Some(index) = self.tabs.iter().position(|t| t.tree_id.get() == tab) else { - return; - }; - self.main_view = MainView::Terminal; - self.board.composer = None; - self.activate(index, window, cx); - self.focus_active(window, cx); - cx.notify(); - } - - fn click_card(&mut self, key: CardRef, window: &mut Window, cx: &mut Context) { - let cards = self.board_cards(Some(window), false, cx); - let Some(card) = cards.iter().find(|c| c.key == key) else { - return; - }; - match (card.tab, key) { - (Some(tab), _) => self.open_card_tab(tab, window, cx), - (None, CardRef::Task(id)) => self.open_composer(Some(id), window, cx), - (None, CardRef::Loose(_)) => {} - } - } - - /// Starts an agent on `task`, told what to do — in a worktree of its own - /// when the task asks for one. - fn start_task( - &mut self, - id: TaskId, - agent: Option, - window: &mut Window, - cx: &mut Context, - ) { - let Some(task) = self.board.tasks.iter().find(|t| t.id == id).cloned() else { - return; - }; - let Some(agent) = agent - .or(task.agent) - .or_else(|| self.offered_agents(cx).first().copied()) - else { - window.push_notification(t(L10nKey::BoardNoAgent), cx); - return; - }; - let overrides = &cx.global::().agent_launch; - let launch = agent.launch_command(overrides); - let command = match task::prompt_flag(agent) { - Some(flag) => { - let ask = crate::core::shell_quote::quote_for_shell(&one_line(task.ask()), None); - match flag { - Some(flag) => format!("{launch} {flag} {ask}"), - None => format!("{launch} {ask}"), - } - } - // The agent takes no prompt it would stay open after answering, - // so it starts bare and the prompt waits on the clipboard. - None => { - cx.write_to_clipboard(gpui::ClipboardItem::new_string(task.ask().to_string())); - window.push_notification( - t_fmt( - L10nKey::BoardPromptCopied, - &[("agent", agent.display_name())], - ), - cx, - ); - launch - } - }; - let cwd = task.cwd.as_ref().map(PathBuf::from); - if !task.worktree { - self.launch_run(task, agent, command, cwd, None, window, cx); - return; - } - // The worktree is cut first, off the thread — it is a `git worktree - // add` on whichever machine the workspace is on — and the run opens - // in it once it exists. - let Some(cwd) = cwd else { - window.push_notification(t(L10nKey::BoardWorktreeNeedsRepo), cx); - return; - }; - let host_id = self.spawn_host(cx); - let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, host_id) else { - window.push_notification(t(L10nKey::BoardUnavailable), cx); - return; - }; - let slug = task::branch_slug(&task.title); - crate::ui::host_ops::HostOps::run_in( - host, - window, - cx, - move |h| crate::core::worktree::create_for(h, &cwd, slug.as_deref()), - move |this, made, window, cx| match made { - Ok(wt) => { - let path = wt.path.clone(); - this.launch_run( - task, - agent, - command, - Some(path.clone()), - Some(path), - window, - cx, - ) - } - Err(e) => window - .push_notification(t_fmt(L10nKey::BoardWorktreeFailed, &[("error", &e)]), cx), - }, - ); - } - - /// Opens the tab a run of `task` works in and starts `agent` there. - /// - /// The window stays on the board: the card moving to Running is the - /// answer, and whoever wants to watch can click it. - #[allow(clippy::too_many_arguments)] - fn launch_run( - &mut self, - mut task: Task, - agent: CLIAgent, - command: String, - cwd: Option, - worktree: Option, - window: &mut Window, - cx: &mut Context, - ) { - let on_board = self.board_open(); - let Some(slot) = self.new_tab_slot(cwd, None, window, cx) else { - return; - }; - // The tab is named for the task, so its sidebar row says what it is - // for rather than whatever the agent titles itself, and it is filed - // where the task is. - let group = task.group.filter(|g| self.sidebar_groups.contains(*g)); - let tab = &mut self.tabs[self.active]; - tab.name = Some(task.title.clone()); - if group.is_some() { - tab.group.set(group); - } - let tab_id = tab.tree_id.get(); - crate::ui::agent_launch::run_when_ready(&slot, command, cx); - if on_board { - self.main_view = MainView::Board; - self.focus_active(window, cx); - } - task.push_run(Run { - agent, - tab: Some(tab_id), - session_id: None, - started: unix_now(), - worktree: worktree.map(|p| p.display().to_string()), - }); - task.agent = Some(agent); - task.done = None; - self.save_task(task, window, cx); - self.save_session(cx); - } - - fn mark_done(&mut self, key: CardRef, window: &mut Window, cx: &mut Context) { - let facts = self.tab_facts(Some(window), false, cx); - let live = |run: &Run| { - run.tab - .and_then(|id| facts.iter().find(|f| f.id == id)) - .map(|f| f.live) - }; - let task = match key { - CardRef::Task(id) => { - let Some(mut task) = self.board.tasks.iter().find(|t| t.id == id).cloned() else { - return; - }; - task.done = Some(Done { - at: unix_now(), - turns: task::live_turns(&task, live), - }); - task - } - CardRef::Loose(tab) => { - let Some(mut task) = self.loose_task(tab, window, cx) else { - return; - }; - task.done = Some(Done { - at: unix_now(), - turns: task::live_turns(&task, live), - }); - task - } - }; - self.save_task(task, window, cx); - } - - fn reopen(&mut self, id: TaskId, window: &mut Window, cx: &mut Context) { - let Some(mut task) = self.board.tasks.iter().find(|t| t.id == id).cloned() else { - return; - }; - task.done = None; - self.save_task(task, window, cx); - } - - /// A task for a loose card, holding the tab it runs in. - fn loose_task(&self, tab: TabId, window: &Window, cx: &App) -> Option { - let facts = self.tab_facts(Some(window), true, cx); - let f = facts.into_iter().find(|f| f.id == tab)?; - let title = f - .label - .clone() - .unwrap_or_else(|| f.agent.display_name().to_string()); - let title: String = title.chars().take(task::MAX_TITLE_CHARS).collect(); - let mut task = Task::new(title); - task.cwd = f.cwd.map(|p| p.display().to_string()); - task.agent = Some(f.agent); - task.group = f.group; - task.push_run(Run { - agent: f.agent, - tab: Some(tab), - session_id: None, - started: unix_now(), - worktree: None, - }); - Some(task) - } - - fn keep_loose(&mut self, tab: TabId, window: &mut Window, cx: &mut Context) { - if let Some(task) = self.loose_task(tab, window, cx) { - self.save_task(task, window, cx); - } - } - - fn resume_task(&mut self, id: TaskId, window: &mut Window, cx: &mut Context) { - let Some(task) = self.board.tasks.iter().find(|t| t.id == id).cloned() else { - return; - }; - let Some(run) = task.runs.last().cloned() else { - return; - }; - let Some(session) = run.session_id.clone() else { - return; - }; - let cwd = task.cwd.as_ref().map(PathBuf::from); - let before = self.tabs.len(); - self.resume_session(run.agent, &session, cwd, false, window, cx); - // Nothing opened, and the reason is on screen already; the run must - // not be pinned to whichever tab happened to be active. - if self.tabs.len() == before { - return; - } - let Some(tab) = self.tabs.get(self.active) else { - return; - }; - let mut task = task; - task.push_run(Run { - tab: Some(tab.tree_id.get()), - started: unix_now(), - ..run - }); - task.done = None; - self.save_task(task, window, cx); - } - - /// Writes down the session each open run's agent reported, so a run whose - /// tab closes can still be resumed. Called on every frame, board or not — - /// a tab can close while the terminal is showing — and only sends - /// anything when a session is new. Quiet on failure: a window whose tree - /// has not landed tries again next frame, and saying so every frame - /// would bury the screen in toasts. - fn note_run_sessions(&mut self, cx: &mut Context) { - 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(tab) = run - .tab - .and_then(|id| self.tabs.iter().find(|t| t.tree_id.get() == id)) - else { - continue; - }; - let session = tab - .pane - .terminals() - .into_iter() - .find_map(|l| l.read(cx).agent_session().and_then(|s| s.session_id)); - if session.is_some() && session != run.session_id { - run.session_id = session; - dirty = true; - } - } - if dirty { - changed.push(next); - } - } - for task in changed { - if crate::ui::tree_sync::push_task(cx, self.workspace, task.clone()) { - self.board_task_put(task); - } - } - } - - // ---- composer --------------------------------------------------------- - - pub(crate) fn open_composer( - &mut self, - editing: Option, - window: &mut Window, - cx: &mut Context, - ) { - let existing = editing.and_then(|id| self.board.tasks.iter().find(|t| t.id == id).cloned()); - let (title, prompt) = existing - .as_ref() - .map(|t| (t.title.clone(), t.prompt.clone())) - .unwrap_or_default(); - let title = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(t(L10nKey::BoardTitlePlaceholder)) - .default_value(title) - }); - let prompt = cx.new(|cx| { - InputState::new(window, cx) - .multi_line(true) - .placeholder(t(L10nKey::BoardPromptPlaceholder)) - .default_value(prompt) - }); - title.update(cx, |state, cx| state.focus(window, cx)); - let subs = vec![ - cx.subscribe_in( - &title, - window, - |this, _, ev: &InputEvent, window, cx| match ev { - InputEvent::PressEnter { - secondary: true, .. - } => this.submit_composer(true, window, cx), - InputEvent::PressEnter { .. } => this.submit_composer(false, window, cx), - InputEvent::Change => cx.notify(), - _ => {} - }, - ), - cx.subscribe_in(&prompt, window, |this, _, ev: &InputEvent, window, cx| { - if let InputEvent::PressEnter { - secondary: true, .. - } = ev - { - this.submit_composer(true, window, cx); - } - }), - ]; - let (places, here) = self.task_places(cx); - let here = here.and_then(|i| places.get(i)); - let agent = existing - .as_ref() - .and_then(|t| t.agent) - .or_else(|| self.offered_agents(cx).first().copied()); - // A new task goes to the group the board is filtered to, else to the - // place the active tab is in. - let (group, cwd) = match &existing { - Some(t) => (t.group, t.cwd.as_ref().map(PathBuf::from)), - None => { - let filtered = self - .board - .group_filter - .and_then(|g| places.iter().find(|p| p.group == Some(g))); - match filtered.or(here) { - Some(p) => (p.group, Some(p.cwd.clone())), - None => (None, None), - } - } - }; - let group = group.filter(|g| self.sidebar_groups.contains(*g)); - let worktree = existing - .as_ref() - .map_or(self.board.last_worktree, |t| t.worktree); - self.board.composer = Some(Composer { - editing, - title, - prompt, - agent, - cwd, - group, - worktree, - _subs: subs, - }); - cx.notify(); - } - - fn close_composer(&mut self, window: &mut Window, cx: &mut Context) { - if self.board.composer.take().is_some() { - self.focus_active(window, cx); - cx.notify(); - } - } - - /// Saves the composer's task, and with `start`, starts it too. - fn submit_composer(&mut self, start: bool, window: &mut Window, cx: &mut Context) { - let Some(c) = self.board.composer.as_ref() else { - return; - }; - let title = c.title.read(cx).value().trim().to_string(); - if title.is_empty() { - window.push_notification(t(L10nKey::BoardNeedsTitle), cx); - return; - } - let prompt = c.prompt.read(cx).value().trim().to_string(); - let mut task = c - .editing - .and_then(|id| self.board.tasks.iter().find(|t| t.id == id).cloned()) - .unwrap_or_else(|| Task::new(String::new())); - task.title = title; - task.prompt = prompt; - task.agent = c.agent; - task.cwd = c.cwd.as_ref().map(|p| p.display().to_string()); - task.group = c.group; - task.worktree = c.worktree; - self.board.last_worktree = c.worktree; - let (id, agent) = (task.id, task.agent); - if !self.save_task(task, window, cx) { - return; - } - self.board.composer = None; - if start { - self.start_task(id, agent, window, cx); - } else { - self.focus_active(window, cx); - } - cx.notify(); - } - - /// Where the composer's task runs, and the pinned group it is filed - /// under when the place is one. - fn set_composer_place(&mut self, place: TaskPlace, cx: &mut Context) { - if let Some(c) = self.board.composer.as_mut() { - c.group = place.group; - c.cwd = Some(place.cwd); - cx.notify(); - } - } - - fn toggle_composer_worktree(&mut self, cx: &mut Context) { - if let Some(c) = self.board.composer.as_mut() { - c.worktree = !c.worktree; - cx.notify(); - } - } - - fn set_composer_agent(&mut self, agent: CLIAgent, cx: &mut Context) { - if let Some(c) = self.board.composer.as_mut() { - c.agent = Some(agent); - cx.notify(); - } - } - - // ---- drawing ---------------------------------------------------------- - - /// The board over the terminal area, or `None` while the terminal shows. - pub(crate) fn render_board( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Option { - self.note_run_sessions(cx); - if self.main_view != MainView::Board { - return None; - } - let cards = self.board_cards(Some(window), true, cx); - let header = self.render_board_header(&cards, cx); - let filtered = self.board.agent_filter.is_some() || self.board.group_filter.is_some(); - let body = match cards.is_empty() && !filtered { - true => self.render_board_empty(cx), - false => h_flex() - .flex_1() - .min_h_0() - .items_start() - .gap(px(COLUMN_GAP)) - .px(px(PAD_X)) - .pt(px(4.)) - .pb(px(PAD_X)) - .children(Column::ALL.into_iter().map(|col| { - let in_col: Vec<&Card> = cards.iter().filter(|c| c.column == col).collect(); - self.render_column(col, &in_col, cx) - })) - .into_any_element(), - }; - Some( - v_flex() - .id("board") - .track_focus(&self.board.focus) - .key_context("Board") - .absolute() - .inset_0() - .occlude() - .bg(crate::ui::theme::overlay_background(cx)) - .children(crate::ui::app::overlay_surface_layers(cx)) - .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { - let k = &ev.keystroke; - if this.board.composer.is_some() { - if k.key == "escape" { - this.close_composer(window, cx); - } - return; - } - let bare = !k.modifiers.modified(); - match k.key.as_str() { - "escape" => this.close_board(window, cx), - "n" if bare => this.open_composer(None, window, cx), - _ => return, - } - cx.stop_propagation(); - })) - .child(header) - .child(body) - .into_any_element(), - ) - } - - fn render_board_header(&self, cards: &[Card], 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(); - let summary = t_fmt( - L10nKey::BoardSummary, - &[ - ("running", &count(Column::Running).to_string()), - ("input", &count(Column::NeedsInput).to_string()), - ("review", &count(Column::Review).to_string()), - ], - ); - let filter_label: SharedString = match self.board.agent_filter { - Some(agent) => agent.display_name().into(), - None => t(L10nKey::BoardAllAgents).into(), - }; - let mut agents: Vec = self - .board - .tasks - .iter() - .filter_map(|t| t.agent) - .chain(self.tab_facts(None, false, cx).iter().map(|f| f.agent)) - .collect(); - agents.sort_by_key(|a| a.display_name()); - agents.dedup(); - let app = cx.entity().downgrade(); - let rungs = dialog::popover_rungs(cx); - let groups: Vec<(GroupId, String)> = self - .sidebar_groups - .pinned - .iter() - .map(|g| (g.id, group_label(g))) - .collect(); - let group_label_now: SharedString = self - .board - .group_filter - .and_then(|id| groups.iter().find(|(g, _)| *g == id)) - .map(|(_, name)| name.clone().into()) - .unwrap_or_else(|| t(L10nKey::BoardAllGroups).into()); - let group_app = app.clone(); - let group_filter = (!groups.is_empty()).then(|| { - Button::new("board-group-filter") - .label(group_label_now) - .ghost() - .small() - .dropdown_caret(true) - .dropdown_menu(move |menu: PopupMenu, _window, _cx| { - let pick = |label: SharedString, g: Option| { - let a = group_app.clone(); - PopupMenuItem::new(label).on_click(move |_, _, cx| { - let _ = a.update(cx, |this, cx| { - this.board.group_filter = g; - cx.notify(); - }); - }) - }; - let mut menu = menu.item(pick(t(L10nKey::BoardAllGroups).into(), None)); - for (id, name) in groups.clone() { - menu = menu.item(pick(name.into(), Some(id))); - } - menu - }) - }); - h_flex() - .flex_none() - .h(px(HEADER_H)) - .px(px(PAD_X)) - .items_center() - .gap(px(12.)) - .child( - div() - .text_size(rems(TEXT)) - .font_weight(gpui::FontWeight::SEMIBOLD) - .child(t(L10nKey::BoardTitle)), - ) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_size(rems(META)) - .text_color(muted) - .child(summary), - ) - .children(group_filter) - .child( - Button::new("board-agent-filter") - .label(filter_label) - .ghost() - .small() - .dropdown_caret(true) - .dropdown_menu(move |menu: PopupMenu, _window, _cx| { - let a0 = app.clone(); - let mut menu = - menu.item(PopupMenuItem::new(t(L10nKey::BoardAllAgents)).on_click( - move |_, _, cx| { - let _ = a0.update(cx, |this, cx| { - this.board.agent_filter = None; - cx.notify(); - }); - }, - )); - for agent in agents.clone() { - let a = app.clone(); - menu = menu.item(PopupMenuItem::new(agent.display_name()).on_click( - move |_, _, cx| { - let _ = a.update(cx, |this, cx| { - this.board.agent_filter = Some(agent); - cx.notify(); - }); - }, - )); - } - menu - }), - ) - .child(dialog::button( - "board-new-task", - t(L10nKey::BoardNewTask), - Tone::Primary, - true, - rungs, - cx, - cx.listener(|this, _, window, cx| this.open_composer(None, window, cx)), - )) - .into_any_element() - } - - fn render_board_empty(&self, cx: &mut Context) -> AnyElement { - let muted = cx.theme().muted_foreground; - v_flex() - .flex_1() - .items_center() - .justify_center() - .gap(px(6.)) - .child( - div() - .text_size(rems(TEXT)) - .font_weight(gpui::FontWeight::MEDIUM) - .child(t(L10nKey::BoardEmpty)), - ) - .child( - div() - .max_w(px(420.)) - .text_center() - .text_size(rems(META)) - .text_color(muted) - .child(t(L10nKey::BoardEmptyHint)), - ) - .into_any_element() - } - - fn render_column(&self, col: Column, cards: &[&Card], cx: &mut Context) -> AnyElement { - let theme = cx.theme(); - let (fg, muted) = (theme.foreground, theme.muted_foreground); - let (dot, hollow) = column_dot(col, cx); - let label = t(match col { - Column::Queued => L10nKey::BoardColQueued, - Column::Running => L10nKey::BoardColRunning, - Column::NeedsInput => L10nKey::BoardColNeedsInput, - Column::Review => L10nKey::BoardColReview, - Column::Done => L10nKey::BoardColDone, - }); - let head = h_flex() - .flex_none() - .h(px(COLUMN_HEAD_H)) - .px(px(4.)) - .items_center() - .gap(px(8.)) - .text_size(rems(META)) - .child( - div() - .size(px(7.)) - .rounded_full() - .when(hollow, |d| d.border_1().border_color(dot)) - .when(!hollow, |d| d.bg(dot)), - ) - .child( - div() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(fg) - .child(label), - ) - .child( - div() - .text_color(muted) - .font_features(crate::ui::tab_sidebar::tabular()) - .child(cards.len().to_string()), - ); - let list = v_flex() - .id(("board-column", col as usize)) - .flex_1() - .min_h_0() - .overflow_y_scroll() - .gap(px(CARD_GAP)) - .pb(px(4.)) - .children(cards.iter().map(|c| self.render_card(c, cx))); - v_flex() - .flex_1() - .min_w_0() - .h_full() - .gap(px(CARD_GAP)) - .child(head) - .child(list) - .into_any_element() - } - - fn render_card(&self, card: &Card, cx: &mut Context) -> AnyElement { - let theme = cx.theme(); - let (fg, muted, border) = (theme.foreground, theme.muted_foreground, theme.border); - let surface = theme.popover; - let well: Hsla = gpui::rgb(dialog::popover_rungs(cx).hover).into(); - let mono = theme.mono_font_family.clone(); - let key = card.key; - let id = card_element_id(key); - let avatar = crate::ui::tab_strip::avatar( - SharedString::from(format!("{id}-avatar")), - crate::ui::search::Avatar { - agent: card.agent, - status: card.status, - unread: 0, - ssh: None, - }, - AVATAR, - cx, - ); - let title_row = h_flex() - .items_start() - .gap(px(9.)) - .child(div().mt(px(1.)).child(avatar)) - .child( - div() - .flex_1() - .min_w_0() - .text_size(rems(TAB_TEXT)) - .line_height(rems(TAB_TEXT * 1.4)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(fg) - .line_clamp(3) - .child(card.title.clone()), - ); - let group = card - .group - .and_then(|g| self.sidebar_groups.get(g)) - .map(group_label); - let place = card.place.clone().map(|p| { - div() - .truncate() - .text_size(rems(HEADING)) - .text_color(muted) - .child(p) - }); - let doing = card.doing.clone().map(|d| { - div() - .truncate() - .px(px(7.)) - .py(px(4.)) - .rounded(px(5.)) - .bg(well) - .font_family(mono.clone()) - .text_size(rems(META_MONO)) - .text_color(muted) - .child(d) - }); - let ask = (card.column == Column::NeedsInput).then(|| { - let tab = card.tab; - v_flex() - .gap(px(8.)) - .when_some(card.ask.clone(), |v, ask| { - v.child( - div() - .px(px(8.)) - .py(px(6.)) - .rounded(px(5.)) - .bg(well) - .font_family(mono.clone()) - .text_size(rems(META_MONO)) - .line_height(rems(META_MONO * 1.5)) - .text_color(fg) - .line_clamp(4) - .child(ask), - ) - }) - .when_some(tab, |v, tab| { - v.child( - dialog::button( - SharedString::from(format!("{id}-open")), - t(L10nKey::BoardOpen), - Tone::Primary, - true, - dialog::popover_rungs(cx), - cx, - cx.listener(move |this, _, window, cx| { - cx.stop_propagation(); - this.open_card_tab(tab, window, cx) - }), - ) - .w_full() - .h(px(24.)), - ) - }) - }); - let footer = { - let mut row = h_flex() - .items_center() - .gap(px(8.)) - .text_size(rems(META_MONO)) - .text_color(muted) - .font_features(crate::ui::tab_sidebar::tabular()); - if let Some((added, removed)) = card.diff { - row = row.child( - h_flex() - .gap(px(4.)) - .child(div().text_color(theme.success).child(format!("+{added}"))) - .child(div().text_color(theme.danger).child(format!("−{removed}"))), - ); - } - if card.resumable { - row = row.child(t(L10nKey::BoardResumable)); - } - row = row.child(div().flex_1()); - if let Some(since) = card.since { - row = row.child(ago(since)); - } - row - }; - let done = card.column == Column::Done; - let app = cx.entity().downgrade(); - let (column, tab, resumable) = (card.column, card.tab, card.resumable); - v_flex() - .id(id) - .gap(px(8.)) - .pt(px(11.)) - .px(px(12.)) - .pb(px(10.)) - .rounded(px(CARD_RADIUS)) - .bg(surface) - .border_1() - .border_color(border.opacity(0.6)) - .hover(|s| s.border_color(border)) - .when(done, |c| c.opacity(0.6)) - .cursor_pointer() - .on_click(cx.listener(move |this, _, window, cx| this.click_card(key, window, cx))) - .child(title_row) - .when_some(group, |c, name| { - c.child( - h_flex().child( - div() - .px(px(6.)) - .h(px(18.)) - .flex() - .items_center() - .rounded(px(4.)) - .bg(well) - .text_size(rems(META_MONO)) - .text_color(muted) - .child(name), - ), - ) - }) - .children(place) - .children(doing) - .children(ask) - .child(footer) - .context_menu(move |menu, _window, _cx| { - card_menu(menu, key, column, tab, resumable, app.clone()) - }) - .into_any_element() - } - - /// The New task card, over the whole window like every other sheet. - pub(crate) fn render_composer(&self, cx: &mut Context) -> Option { - let c = self.board.composer.as_ref()?; - let theme = cx.theme(); - let muted = theme.muted_foreground; - let rungs = dialog::popover_rungs(cx); - let agents = self.offered_agents(cx); - let agent_label: SharedString = c - .agent - .map(|a| a.display_name().into()) - .unwrap_or_else(|| t(L10nKey::BoardNoAgent).into()); - let app = cx.entity().downgrade(); - let where_ = c - .cwd - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - let has_title = !c.title.read(cx).value().trim().is_empty(); - let editing = c.editing.is_some(); - let (places, _) = self.task_places(cx); - let chosen = places - .iter() - .find(|p| p.group.is_some() && p.group == c.group) - .or_else(|| places.iter().find(|p| Some(&p.cwd) == c.cwd.as_ref())); - let place_now: SharedString = match (chosen, &c.cwd) { - (Some(p), _) => p.name.clone().into(), - (None, Some(cwd)) => cwd.display().to_string().into(), - (None, None) => t(L10nKey::BoardNoPlace).into(), - }; - let place_app = cx.entity().downgrade(); - let group_picker = Button::new("board-composer-place") - .label(place_now) - .small() - .ghost() - .dropdown_caret(true) - .dropdown_menu(move |menu: PopupMenu, _window, _cx| { - let mut menu = menu; - let mut in_groups = true; - for place in places.clone() { - // The groups first, as the sidebar lists them; then, - // past a divider, the folders of tabs in no group. - let label: SharedString = match place.grouped { - true => format!("{} — {}", place.name, place.cwd.display()).into(), - false => place.name.clone().into(), - }; - if in_groups && !place.grouped { - in_groups = false; - menu = menu.separator(); - } - let a = place_app.clone(); - menu = menu.item(PopupMenuItem::new(label).on_click(move |_, _, cx| { - let _ = a.update(cx, |this, cx| this.set_composer_place(place.clone(), cx)); - })); - } - menu - }); - let worktree_switch = gpui_component::checkbox::Checkbox::new("board-composer-worktree") - .label(t(L10nKey::BoardWorktree)) - .small() - .checked(c.worktree) - .on_click(cx.listener(|this, _, _, cx| this.toggle_composer_worktree(cx))); - let well = dialog::well_fill(cx); - let prompt_field = div() - .h(px(PROMPT_H)) - .rounded(crate::ui::rounding::ROW_RADIUS) - .bg(well) - .py(px(2.)) - .child(Input::new(&c.prompt).appearance(false).small().h_full()); - let agent_picker = Button::new("board-composer-agent") - .label(agent_label) - .small() - .ghost() - .dropdown_caret(true) - .when_some(c.agent, |b, agent| { - b.icon(Icon::default().path(agent.icon_path()).size(px(12.))) - }) - .dropdown_menu(move |menu: PopupMenu, _window, _cx| { - let mut menu = menu; - for agent in agents.clone() { - let a = app.clone(); - menu = menu.item(PopupMenuItem::new(agent.display_name()).on_click( - move |_, _, cx| { - let _ = a.update(cx, |this, cx| this.set_composer_agent(agent, cx)); - }, - )); - } - menu - }); - let card = dialog::card(COMPOSER_W, cx) - .child(dialog::header( - t(if editing { - L10nKey::BoardEditTask - } else { - L10nKey::BoardNewTask - }), - cx, - )) - .child( - dialog::body() - .child(dialog::labelled( - t(L10nKey::BoardFieldTitle), - Input::new(&c.title), - cx, - )) - .child( - v_flex() - .gap(px(6.)) - .child(dialog::label(t(L10nKey::BoardFieldPrompt), cx)) - .child(prompt_field), - ) - .child( - h_flex() - .items_center() - .gap(px(10.)) - .child(dialog::label(t(L10nKey::BoardFieldAgent), cx)) - .child(agent_picker) - .child(div().w(px(8.))) - .child(dialog::label(t(L10nKey::BoardFieldWhere), cx)) - .child(group_picker), - ) - .child( - h_flex() - .items_center() - .gap(px(10.)) - .child(worktree_switch) - .child(div().flex_1()) - .child( - div() - .min_w_0() - .max_w(px(260.)) - .truncate() - .font_family(theme.mono_font_family.clone()) - .text_size(rems(META_MONO)) - .text_color(muted) - .child(where_), - ), - ), - ) - .child( - dialog::footer(cx) - .child(dialog::button( - "board-composer-cancel", - t(L10nKey::Cancel), - Tone::Secondary, - true, - rungs, - cx, - cx.listener(|this, _, window, cx| this.close_composer(window, cx)), - )) - .child(dialog::button( - "board-composer-save", - t(if editing { - L10nKey::BoardSave - } else { - L10nKey::BoardAddToQueue - }), - Tone::Secondary, - has_title, - rungs, - cx, - cx.listener(|this, _, window, cx| this.submit_composer(false, window, cx)), - )) - .child(dialog::button( - "board-composer-start", - t(L10nKey::BoardStartNow), - Tone::Primary, - has_title && c.agent.is_some(), - rungs, - cx, - cx.listener(|this, _, window, cx| this.submit_composer(true, window, cx)), - )), - ); - Some( - div() - .absolute() - .inset_0() - .occlude() - .bg(crate::ui::presets::scrim_fill(cx)) - .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { - if ev.keystroke.key == "escape" { - cx.stop_propagation(); - this.close_composer(window, cx); - } - })) - .on_mouse_down( - gpui::MouseButton::Left, - cx.listener(|this, _: &gpui::MouseDownEvent, window, cx| { - this.close_composer(window, cx) - }), - ) - .flex() - .flex_col() - .items_center() - .justify_start() - .pt(px(crate::ui::switcher::CARD_TOP)) - .child( - card.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()), - ) - .into_any_element(), - ) - } - - /// The sidebar's Board row, under the search box. - pub(crate) fn render_board_nav(&self, cx: &mut Context) -> AnyElement { - let sf = cx.global::().rail; - let active = self.board_open(); - let theme = cx.theme(); - let needs = self.board_needs_input(cx); - let warn = theme.warning; - h_flex() - .id("sidebar-board") - .w_full() - .flex_shrink_0() - .h(px(30.)) - .px(px(8.)) - .gap(px(10.)) - .items_center() - .rounded(crate::ui::rounding::ROW_RADIUS) - .cursor_pointer() - .when(active, |s| { - s.bg(gpui::rgb(sf.selected)) - .text_color(gpui::rgb(sf.text_selected)) - .font_weight(gpui::FontWeight::MEDIUM) - }) - .when(!active, |s| { - s.text_color(gpui::rgb(sf.text_resting)) - .hover(|s| s.bg(gpui::rgb(sf.hover))) - }) - .child( - Icon::empty() - .path("icons/board.svg") - .size(px(14.)) - .text_color(theme.muted_foreground), - ) - .child(div().flex_1().child(t(L10nKey::BoardTitle))) - .when(needs > 0, |row| { - row.child( - div() - .min_w(px(18.)) - .h(px(18.)) - .px(px(5.)) - .rounded_full() - .bg(warn) - .flex() - .items_center() - .justify_center() - .text_size(rems(11. / 16.)) - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(gpui::white()) - .child(needs.min(99).to_string()), - ) - }) - .on_click(cx.listener(|this, _, window, cx| this.toggle_board(window, cx))) - .into_any_element() - } -} - -fn card_element_id(key: CardRef) -> SharedString { - match key { - CardRef::Task(id) => format!("board-task-{id}").into(), - CardRef::Loose(id) => format!("board-tab-{id}").into(), - } -} - -fn card_menu( - menu: PopupMenu, - key: CardRef, - column: Column, - tab: Option, - resumable: bool, - app: gpui::WeakEntity, -) -> PopupMenu { - let item = |label: L10nKey, - f: Box)>| { - let app = app.clone(); - PopupMenuItem::new(t(label)).on_click(move |_, window, cx| { - let _ = app.update(cx, |this, cx| f(this, window, cx)); - }) - }; - let mut menu = menu; - if let Some(tab) = tab { - menu = menu.item(item( - L10nKey::BoardOpen, - Box::new(move |this, window, cx| this.open_card_tab(tab, window, cx)), - )); - } - match key { - CardRef::Task(id) => { - if tab.is_none() { - menu = menu.item(item( - L10nKey::BoardStart, - Box::new(move |this, window, cx| this.start_task(id, None, window, cx)), - )); - } - if resumable { - menu = menu.item(item( - L10nKey::BoardResume, - Box::new(move |this, window, cx| this.resume_task(id, window, cx)), - )); - } - menu = match column { - Column::Done => menu.item(item( - L10nKey::BoardReopen, - Box::new(move |this, window, cx| this.reopen(id, window, cx)), - )), - _ => menu.item(item( - L10nKey::BoardMarkDone, - Box::new(move |this, window, cx| this.mark_done(key, window, cx)), - )), - }; - menu.separator() - .item(item( - L10nKey::BoardEdit, - Box::new(move |this, window, cx| this.open_composer(Some(id), window, cx)), - )) - .item(item( - L10nKey::BoardDelete, - Box::new(move |this, window, cx| this.delete_task(id, window, cx)), - )) - } - CardRef::Loose(tab) => menu - .item(item( - L10nKey::BoardKeep, - Box::new(move |this, window, cx| this.keep_loose(tab, window, cx)), - )) - .item(item( - L10nKey::BoardMarkDone, - Box::new(move |this, window, cx| this.mark_done(key, window, cx)), - )), - } -} - -/// What a pinned group is called: its given name, else its folder's last -/// component — the name its sidebar header shows. -fn group_label(group: &PinnedGroup) -> String { - group - .given_name() - .map(str::to_string) - .or_else(|| { - group - .folder_path() - .and_then(|p| p.file_name()) - .map(|n| n.to_string_lossy().into_owned()) - }) - .unwrap_or_else(|| t(L10nKey::BoardFieldGroup).to_string()) -} - -/// The dot a column heads itself with, and whether it is drawn as a ring. -/// The colours are the agent status dots the sidebar already uses, so a card -/// and its tab's row say a state the same way. -fn column_dot(col: Column, cx: &App) -> (Hsla, bool) { - let muted = cx.theme().muted_foreground; - let status = |s: AgentStatus| -> Hsla { gpui::rgb(s.dot_rgb().unwrap_or(0x888888)).into() }; - match col { - Column::Queued => (muted, true), - Column::Running => (status(AgentStatus::Working), false), - Column::NeedsInput => (status(AgentStatus::Waiting), true), - Column::Review => (status(AgentStatus::Done), false), - Column::Done => (muted.opacity(0.6), false), - } -} - -/// `repo · branch` for a card, from where its agent runs. -fn place(cwd: Option<&std::path::Path>, branch: Option<&str>) -> Option { - let repo = cwd - .and_then(|p| p.file_name()) - .map(|n| n.to_string_lossy().into_owned()); - match (repo, branch) { - (Some(repo), Some(branch)) => Some(format!("{repo} · {branch}").into()), - (Some(repo), None) => Some(repo.into()), - (None, Some(branch)) => Some(branch.to_string().into()), - (None, None) => None, - } -} - -/// A prompt as one command-line argument. The command is typed into a shell, -/// where a newline would submit it half-way; the agent reads the paragraphs -/// joined just as well. -fn one_line(text: &str) -> String { - text.lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .collect::>() - .join(" ") -} - -/// How long ago `since` was, in the board's short form: `now`, `12m`, `3h`, -/// `2d`. -fn ago(since: u64) -> String { - let secs = unix_now().saturating_sub(since); - match secs { - 0..60 => t(L10nKey::BoardNow).to_string(), - 60..3600 => format!("{}m", secs / 60), - 3600..86400 => format!("{}h", secs / 3600), - _ => format!("{}d", secs / 86400), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_prompt_goes_on_one_line() { - assert_eq!(one_line("Fix it.\n\n Then test.\n"), "Fix it. Then test."); - assert_eq!(one_line("one"), "one"); - } - - #[test] - fn a_place_names_what_it_knows() { - let p = std::path::Path::new("/src/tty7"); - assert_eq!( - place(Some(p), Some("main")).as_deref(), - Some("tty7 · main") - ); - assert_eq!(place(Some(p), None).as_deref(), Some("tty7")); - assert_eq!(place(None, None), None); - } -} diff --git a/src/ui/board/composer.rs b/src/ui/board/composer.rs new file mode 100644 index 00000000..de32cef3 --- /dev/null +++ b/src/ui/board/composer.rs @@ -0,0 +1,482 @@ +//! The New task sheet: where it runs, what to do, which agent. +//! +//! One box for the words — the first line is the card's title, all of it is +//! what the agent is told — so writing a task is writing a message, not +//! filling a form. + +use std::path::PathBuf; + +use gpui::{ + AnyElement, Context, Entity, SharedString, Subscription, Window, div, prelude::*, px, rems, +}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::{ActiveTheme as _, Icon, WindowExt as _, h_flex, v_flex}; +use tty7_core::core::group_key::GroupId; +use tty7_core::core::task::{self, Task, TaskId}; + +use super::{CardRef, Undo}; +use crate::core::cli_agent::CLIAgent; +use crate::ui::app::Tty7App; +use crate::ui::dialog::{self, Tone}; +use crate::ui::i18n::{L10nKey, t, t_fmt}; +use crate::ui::right_panel::{META, META_MONO, TAB_TEXT}; +use crate::ui::tab_sidebar::TaskPlace; + +/// The New task card, or the same card open on a task being edited. +pub(crate) struct Composer { + editing: Option, + text: Entity, + agent: Option, + cwd: Option, + /// The pinned group the task is filed under, when its place is one. + group: Option, + worktree: bool, + _subs: Vec, +} + +const WIDTH: f32 = 600.; +const TOP: f32 = 120.; +const HEAD_H: f32 = 44.; +const TEXT_H: f32 = 112.; +const FOOT_H: f32 = 48.; +const PILL_H: f32 = 26.; +/// A title is the first line, cut to what a card shows in two lines. +const TITLE_CHARS: usize = 48; + +/// The card's title and the agent's prompt, out of what was typed. +fn split(text: &str) -> Option<(String, String)> { + let text = text.trim(); + let first = text.lines().map(str::trim).find(|l| !l.is_empty())?; + let title = match first.chars().count() > TITLE_CHARS { + true => format!( + "{}…", + first + .chars() + .take(TITLE_CHARS - 1) + .collect::() + .trim_end() + ), + false => first.to_string(), + }; + // A one-line task is all title: the agent is told the title. + let prompt = match text == first { + true => String::new(), + false => text.to_string(), + }; + Some((title, prompt)) +} + +impl Tty7App { + pub(crate) fn open_composer( + &mut self, + editing: Option, + window: &mut Window, + cx: &mut Context, + ) { + let existing = editing.and_then(|id| self.task(id)); + let seed = existing.as_ref().map_or(String::new(), |t| { + if t.prompt.is_empty() { + t.title.clone() + } else if t.prompt.starts_with(t.title.trim_end_matches('…')) { + t.prompt.clone() + } else { + format!("{}\n\n{}", t.title, t.prompt) + } + }); + let text = cx.new(|cx| { + InputState::new(window, cx) + .multi_line(true) + .placeholder(t(L10nKey::BoardComposerPlaceholder)) + .default_value(seed) + }); + text.update(cx, |state, cx| state.focus(window, cx)); + let subs = + vec![cx.subscribe_in( + &text, + window, + |this, _, ev: &InputEvent, window, cx| match ev { + InputEvent::PressEnter { + secondary: true, .. + } => this.submit_composer(true, window, cx), + InputEvent::Change => cx.notify(), + _ => {} + }, + )]; + let (places, here) = self.task_places(cx); + let here = here.and_then(|i| places.get(i)); + // A new task goes to the group the board is filtered to, else to the + // place the active tab is in. + let (group, cwd) = match &existing { + Some(t) => (t.group, t.cwd.as_ref().map(PathBuf::from)), + None => { + let filtered = self + .board + .group_filter + .and_then(|g| places.iter().find(|p| p.group == Some(g))); + match filtered.or(here) { + Some(p) => (p.group, Some(p.cwd.clone())), + None => (None, None), + } + } + }; + let agent = existing + .as_ref() + .and_then(|t| t.agent) + .or_else(|| self.offered_agents(cx).first().copied()); + let worktree = existing + .as_ref() + .map_or(self.board.last_worktree, |t| t.worktree); + self.board.composer = Some(Composer { + editing, + text, + agent, + cwd, + group: group.filter(|g| self.sidebar_groups.contains(*g)), + worktree, + _subs: subs, + }); + cx.notify(); + } + + fn close_composer(&mut self, window: &mut Window, cx: &mut Context) { + if self.board.composer.take().is_some() { + self.focus_active(window, cx); + cx.notify(); + } + } + + /// Saves the composer's task, and with `start`, starts it too. + fn submit_composer(&mut self, start: bool, window: &mut Window, cx: &mut Context) { + let Some(c) = self.board.composer.as_ref() else { + return; + }; + let Some((title, prompt)) = split(&c.text.read(cx).value()) else { + window.push_notification(t(L10nKey::BoardNeedsTitle), cx); + return; + }; + let editing = c.editing; + let mut task = editing + .and_then(|id| self.task(id)) + .unwrap_or_else(|| Task::new(String::new())); + let before = editing.and_then(|id| self.task(id)); + task.title = title.clone(); + task.prompt = prompt; + task.agent = c.agent; + task.cwd = c.cwd.as_ref().map(|p| p.display().to_string()); + task.group = c.group; + task.worktree = c.worktree; + self.board.last_worktree = c.worktree; + let (id, agent) = (task.id, task.agent); + if !self.save_task(task, window, cx) { + return; + } + self.board.composer = None; + self.board.selected = Some(CardRef::Task(id)); + if start { + self.start_task(id, agent, window, cx); + } else { + let undo = match before { + Some(before) => Undo::Put(before), + None => Undo::Remove(id), + }; + self.flash( + t_fmt(L10nKey::BoardToastQueued, &[("title", &title)]), + Some(undo), + cx, + ); + self.focus_active(window, cx); + } + cx.notify(); + } + + fn set_composer_place(&mut self, place: TaskPlace, cx: &mut Context) { + if let Some(c) = self.board.composer.as_mut() { + c.group = place.group; + c.cwd = Some(place.cwd); + cx.notify(); + } + } + + fn set_composer_agent(&mut self, agent: CLIAgent, cx: &mut Context) { + if let Some(c) = self.board.composer.as_mut() { + c.agent = Some(agent); + cx.notify(); + } + } + + fn toggle_composer_worktree(&mut self, cx: &mut Context) { + if let Some(c) = self.board.composer.as_mut() { + c.worktree = !c.worktree; + cx.notify(); + } + } + + /// The New task sheet, over the whole window like every other sheet. + pub(crate) fn render_composer(&self, cx: &mut Context) -> Option { + let c = self.board.composer.as_ref()?; + let theme = cx.theme(); + let (fg, muted) = (theme.foreground, theme.muted_foreground); + let rungs = dialog::popover_rungs(cx); + let (hover, picked) = (gpui::rgb(rungs.hover), gpui::rgb(rungs.selected)); + let text = c.text.read(cx).value().to_string(); + let ready = split(&text).is_some(); + let editing = c.editing.is_some(); + + // Where: the sidebar's places as chips, the chosen one lit. + let (places, _) = self.task_places(cx); + let chosen = places + .iter() + .position(|p| p.group.is_some() && p.group == c.group) + .or_else(|| { + places + .iter() + .position(|p| p.group.is_none() && Some(&p.cwd) == c.cwd.as_ref()) + }); + let chips = places.into_iter().enumerate().map(|(i, place)| { + let on = chosen == Some(i); + let tip = place.cwd.display().to_string(); + let name = place.name.clone(); + div() + .id(("board-place", i)) + .flex_none() + .h(px(22.)) + .px(px(8.)) + .flex() + .items_center() + .rounded(px(5.)) + .text_size(rems(META)) + .text_color(if on { fg } else { muted }) + .when(on, |d| d.bg(picked)) + .when(!on, |d| d.hover(move |s| s.bg(hover))) + .cursor_pointer() + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(tip.clone()).build(window, cx) + }) + .on_click( + cx.listener(move |this, _, _, cx| this.set_composer_place(place.clone(), cx)), + ) + .child(name) + }); + let head = h_flex() + .flex_none() + .min_h(px(HEAD_H)) + .py(px(8.)) + .px(px(16.)) + .gap(px(6.)) + .flex_wrap() + .items_center() + .text_size(rems(META)) + .child( + div() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(fg) + .child(t(if editing { + L10nKey::BoardEditTask + } else { + L10nKey::BoardNewTask + })), + ) + .child(div().text_color(muted).child(t(L10nKey::BoardComposerIn))) + .children(chips); + + let body = div() + .h(px(TEXT_H)) + .px(px(8.)) + .text_size(rems(15. / 16.)) + .child(Input::new(&c.text).appearance(false).h_full()); + + // Which agent: one pill each, the chosen one raised. + let pills = self.offered_agents(cx).into_iter().map(|agent| { + let on = c.agent == Some(agent); + let avatar = crate::ui::tab_strip::avatar( + SharedString::from(format!("board-pick-{}", agent.slug())), + crate::ui::search::Avatar { + agent: Some(agent), + ..Default::default() + }, + 16., + cx, + ); + div() + .id(SharedString::from(format!("board-agent-{}", agent.slug()))) + .flex_none() + .h(px(PILL_H)) + .pl(px(5.)) + .pr(px(9.)) + .flex() + .items_center() + .gap(px(6.)) + .rounded(px(PILL_H / 2.)) + .text_size(rems(META)) + .text_color(if on { fg } else { muted }) + .when(on, |d| d.bg(picked).border_1().border_color(theme.border)) + .when(!on, |d| d.hover(move |s| s.bg(hover))) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| this.set_composer_agent(agent, cx))) + .child(avatar) + .child(short_name(agent)) + }); + let agents = h_flex() + .flex_wrap() + .gap(px(6.)) + .px(px(16.)) + .pt(px(6.)) + .pb(px(14.)) + .children(pills); + + // Where the work lands: a new branch in its own worktree, or the + // checkout it runs in. Clicking the line switches between the two. + let first_line = split(&text).map(|(title, _)| title).unwrap_or_default(); + let lands: SharedString = match c.worktree { + true => task::branch_slug(&first_line) + .unwrap_or_else(|| t(L10nKey::BoardBranchAuto).to_string()) + .into(), + false => t_fmt( + L10nKey::BoardInPlace, + &[( + "cwd", + &c.cwd + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default(), + )], + ) + .into(), + }; + let lands_line = h_flex() + .id("board-composer-worktree") + .flex_1() + .min_w_0() + .h(px(28.)) + .px(px(6.)) + .gap(px(8.)) + .items_center() + .rounded(px(6.)) + .hover(move |s| s.bg(hover)) + .cursor_pointer() + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t(L10nKey::BoardWorktreeTip)) + .build(window, cx) + }) + .on_click(cx.listener(|this, _, _, cx| this.toggle_composer_worktree(cx))) + .child( + Icon::empty() + .path("icons/git-branch.svg") + .size(px(12.)) + .text_color(muted), + ) + .child( + div() + .min_w_0() + .truncate() + .font_family(theme.mono_font_family.clone()) + .text_size(rems(META_MONO)) + .text_color(muted) + .child(lands), + ); + let foot = h_flex() + .flex_none() + .h(px(FOOT_H)) + .pl(px(10.)) + .pr(px(12.)) + .gap(px(8.)) + .items_center() + .border_t_1() + .border_color(theme.border) + .child(lands_line) + .child(dialog::button( + "board-composer-queue", + t(if editing { + L10nKey::BoardSave + } else { + L10nKey::BoardAddToQueue + }), + Tone::Secondary, + ready, + rungs, + cx, + cx.listener(|this, _, window, cx| this.submit_composer(false, window, cx)), + )) + .child(dialog::button( + "board-composer-start", + t(L10nKey::BoardStartNow), + Tone::Primary, + ready && c.agent.is_some(), + rungs, + cx, + cx.listener(|this, _, window, cx| this.submit_composer(true, window, cx)), + )); + + let card = v_flex() + .occlude() + .w(px(WIDTH)) + .map(|panel| crate::ui::theme::floating_surface(panel, cx)) + .rounded(px(dialog::CARD_RADIUS)) + .overflow_hidden() + .text_size(rems(TAB_TEXT)) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(head) + .child(body) + .child(agents) + .child(foot); + Some( + div() + .absolute() + .inset_0() + .occlude() + .bg(crate::ui::presets::scrim_fill(cx)) + .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { + if ev.keystroke.key == "escape" { + cx.stop_propagation(); + this.close_composer(window, cx); + } + })) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _: &gpui::MouseDownEvent, window, cx| { + this.close_composer(window, cx) + }), + ) + .flex() + .flex_col() + .items_center() + .pt(px(TOP)) + .child(card) + .into_any_element(), + ) + } +} + +/// An agent's name short enough for a pill: `Claude Code` is `Claude`. +fn short_name(agent: CLIAgent) -> &'static str { + let name = agent.display_name(); + match name.split_once(' ') { + Some((first, rest)) if matches!(rest, "Code" | "CLI" | "Agent") => first, + _ => name, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_first_line_is_the_title_and_the_rest_the_prompt() { + assert_eq!(split(" \n"), None); + assert_eq!( + split("Fix the build"), + Some(("Fix the build".into(), String::new())) + ); + let (title, prompt) = split("Fix the build\n\nIt fails on Windows.").unwrap(); + assert_eq!(title, "Fix the build"); + assert_eq!(prompt, "Fix the build\n\nIt fails on Windows."); + let (title, _) = split(&"x".repeat(80)).unwrap(); + assert_eq!(title.chars().count(), TITLE_CHARS); + assert!(title.ends_with('…')); + } + + #[test] + fn a_pill_drops_the_product_suffix() { + assert_eq!(short_name(CLIAgent::Claude), "Claude"); + } +} diff --git a/src/ui/board/mod.rs b/src/ui/board/mod.rs new file mode 100644 index 00000000..d2f1a6df --- /dev/null +++ b/src/ui/board/mod.rs @@ -0,0 +1,1194 @@ +//! The board: the window's agents and the tasks handed to them, in five +//! columns by where each one stands (#1010). +//! +//! It is the main area's second view — the sidebar stays, the terminal steps +//! aside — and it only ever shows this window's workspace, the same scope as +//! the sidebar beside it. +//! +//! Every card is one of two things. A *task* is kept on the machine tree +//! ([`tty7_core::core::task`]): something the user wrote down, maybe started, +//! maybe finished. Any other tab running an agent is a *loose* card, drawn +//! from the tab itself and kept nowhere, so an agent started by hand shows up +//! without anyone filing it. Where either sits is worked out from the agent +//! on every frame; nothing here stores a column. +//! +//! Moving a card is asking its agent to do something, so only the moves an +//! agent can make are offered: start a queued task, pause a running one, +//! allow what a waiting one asks, send a finished one back with changes, or +//! call it done. A drag, a button on the card and the peek panel all end in +//! [`Tty7App::board_move`]. + +mod composer; +mod peek; +mod view; + +use std::path::PathBuf; + +use gpui::{App, Context, Entity, FocusHandle, Focusable as _, SharedString, Subscription, Window}; +use gpui_component::WindowExt as _; +use gpui_component::input::InputState; +use tty7_core::core::group_key::GroupId; +use tty7_core::core::machine::TabId; +use tty7_core::core::task::{self, Column, Done, Live, Run, Task, TaskId}; + +use crate::core::cli_agent::{AgentStatus, CLIAgent}; +use crate::core::config::{Config, unix_now}; +use crate::ui::app::Tty7App; +use crate::ui::i18n::{L10nKey, t, t_fmt}; + +pub(crate) use composer::Composer; + +/// What the main area is showing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum MainView { + #[default] + Terminal, + Board, +} + +/// The board's own state on the window. +pub(crate) struct Board { + /// This workspace's tasks, as the machine last said — and as this window + /// has since changed them, since its own edits never come back to it. + pub tasks: Vec, + /// Only these agents' cards; empty is everyone's. + pub agent_filter: Vec, + /// Only this pinned group's cards, or every group's. + pub group_filter: Option, + pub composer: Option, + /// What the worktree switch last said, so the next task starts there. + pub last_worktree: bool, + pub focus: FocusHandle, + selected: Option, + /// The panel beside the columns is open on [`Self::selected`]. + peek: bool, + reply: Option>, + _reply_sub: Option, + /// The selected card's changed files, once `git diff` has answered. + files: Option<(CardRef, Result, String>)>, + /// The card being dragged, while one is. + dragging: Option, + toast: Option, + toast_seq: u64, +} + +impl Board { + pub(crate) fn new(cx: &mut App) -> Board { + Board { + tasks: Vec::new(), + agent_filter: Vec::new(), + group_filter: None, + composer: None, + last_worktree: true, + focus: cx.focus_handle(), + selected: None, + peek: false, + reply: None, + _reply_sub: None, + files: None, + dragging: None, + toast: None, + toast_seq: 0, + } + } +} + +/// Which card a click, a drag or a key is about. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CardRef { + Task(TaskId), + /// A tab running an agent that no task claims. + Loose(TabId), +} + +/// What a dragged card carries. +#[derive(Clone, Debug)] +pub(crate) struct CardDrag { + key: CardRef, + from: Column, + /// It is waiting on a question, which a drop into Running answers with + /// words rather than a yes. + question: bool, + title: SharedString, + agent: Option, +} + +/// One changed file, from `git diff --numstat`. +#[derive(Clone, Debug, PartialEq)] +struct FileChange { + path: String, + added: u32, + removed: u32, +} + +/// The note at the bottom of the board saying what just happened, with the +/// way back when there is one. +struct Toast { + text: SharedString, + undo: Option, +} + +/// Putting the board back the way it was. Only for what the board itself +/// changed: a keystroke already sent to an agent cannot be taken back. +#[derive(Clone)] +enum Undo { + Put(Task), + Remove(TaskId), +} + +/// One card, read off a task or a tab for this frame. +struct Card { + key: CardRef, + column: Column, + agent: Option, + status: Option, + title: SharedString, + /// The task's own words, for the peek panel. + prompt: Option, + group: Option, + repo: Option, + branch: Option, + cwd: Option, + /// What the agent is stopped on. + ask: Option, + question: bool, + diff: Option<(u32, u32)>, + /// When this card's current state began, in Unix seconds, if known. + since: Option, + /// The open tab a run of this card lives in. + tab: Option, + /// A finished run this card could pick back up. + resumable: bool, + paused: bool, +} + +/// What the board reads off one open tab. +struct TabFacts { + id: TabId, + agent: CLIAgent, + live: Live, + ask: Option, + question: bool, + cwd: Option, + branch: Option, + diff: Option<(u32, u32)>, + label: Option, + group: Option, +} + +/// The moves a card may make, by the column it is in. Done is where a card +/// rests; reopening it is a deliberate act in the peek panel, not a drag. +pub(crate) fn moves_from(from: Column) -> &'static [Column] { + match from { + Column::Queued => &[Column::Running], + Column::Running => &[Column::Queued], + Column::NeedsInput => &[Column::Running, Column::Queued], + Column::Review => &[Column::Running, Column::Done], + Column::Done => &[], + } +} + +/// What a move is called on the column it drops into. +fn move_verb(from: Column, to: Column, question: bool) -> L10nKey { + match (from, to) { + (Column::NeedsInput, Column::Running) if question => L10nKey::BoardVerbReply, + (Column::NeedsInput, Column::Running) => L10nKey::BoardVerbAllow, + (Column::Review, Column::Running) => L10nKey::BoardVerbChanges, + (_, Column::Queued) => L10nKey::BoardVerbPause, + (_, Column::Done) => L10nKey::BoardVerbDone, + _ => L10nKey::BoardVerbStart, + } +} + +/// What a paused task's agent is told when it is started again. +const CONTINUE: &str = "Continue where you left off."; + +/// How long a pause holds against an agent still reporting work: an +/// interrupted turn can land a last tool result after the ESC that stopped it. +const PAUSE_GRACE_SECS: u64 = 3; + +const TOAST_MS: u64 = 4500; + +impl Tty7App { + pub(crate) fn board_open(&self) -> bool { + self.main_view == MainView::Board + } + + pub(crate) fn toggle_board(&mut self, window: &mut Window, cx: &mut Context) { + match self.main_view { + MainView::Board => self.close_board(window, cx), + MainView::Terminal => self.open_board(window, cx), + } + } + + pub(crate) fn open_board(&mut self, window: &mut Window, cx: &mut Context) { + self.main_view = MainView::Board; + self.maximized = None; + self.focus_active(window, cx); + cx.notify(); + } + + pub(crate) fn close_board(&mut self, window: &mut Window, cx: &mut Context) { + if self.main_view == MainView::Terminal { + return; + } + self.main_view = MainView::Terminal; + self.board.composer = None; + self.board.peek = false; + self.focus_active(window, cx); + cx.notify(); + } + + /// Takes the tasks a tree pull brought in. + pub(crate) fn adopt_board_tasks(&mut self, tasks: Vec, cx: &mut Context) { + if self.board.tasks != tasks { + self.board.tasks = tasks; + cx.notify(); + } + } + + pub(crate) fn board_task_put(&mut self, task: Task) { + match self.board.tasks.iter_mut().find(|t| t.id == task.id) { + Some(slot) => *slot = task, + None => self.board.tasks.push(task), + } + } + + pub(crate) fn board_task_removed(&mut self, task: TaskId) { + self.board.tasks.retain(|t| t.id != task); + } + + /// How many cards are stopped on the user — the sidebar row's count. + pub(crate) fn board_needs_input(&self, cx: &App) -> usize { + self.board_cards(None, false, cx) + .iter() + .filter(|c| c.column == Column::NeedsInput) + .count() + } + + fn task(&self, id: TaskId) -> Option { + self.board.tasks.iter().find(|t| t.id == id).cloned() + } + + /// Saves `task` here and on the machine. False, with the reason on + /// screen, when the machine would not take it. + fn save_task(&mut self, task: Task, window: &mut Window, cx: &mut Context) -> bool { + if let Err(why) = task.check() { + window.push_notification(why, cx); + return false; + } + if !crate::ui::tree_sync::push_task(cx, self.workspace, task.clone()) { + window.push_notification(t(L10nKey::BoardUnavailable), cx); + return false; + } + self.board_task_put(task); + cx.notify(); + true + } + + fn delete_task(&mut self, id: TaskId, window: &mut Window, cx: &mut Context) -> bool { + if !crate::ui::tree_sync::remove_task(cx, self.workspace, id) { + window.push_notification(t(L10nKey::BoardUnavailable), cx); + return false; + } + self.board_task_removed(id); + if self.board.selected == Some(CardRef::Task(id)) { + self.board.selected = None; + self.board.peek = false; + } + cx.notify(); + true + } + + // ---- reading the board ------------------------------------------------ + + /// Everything the board knows about the open tabs that run an agent. + /// + /// `full` also reads what only a drawn card shows — the tab's label and + /// its git counts. The sidebar's count asks every frame and needs neither. + fn tab_facts(&self, window: Option<&Window>, full: bool, cx: &App) -> Vec { + let urgency = |s: AgentStatus| match s { + AgentStatus::Waiting => 3, + AgentStatus::Working => 2, + AgentStatus::Done => 1, + AgentStatus::Idle => 0, + }; + self.tabs + .iter() + .filter_map(|tab| { + // The tab's most urgent agent pane speaks for it, the same + // pane its sidebar row reports. + let leaf = tab + .pane + .terminals() + .into_iter() + .filter(|l| l.read(cx).agent().is_some()) + .max_by_key(|l| l.read(cx).agent_session().map_or(0, |s| urgency(s.status)))?; + let view = leaf.read(cx); + let session = view.agent_session().unwrap_or_default(); + let waiting = session.status == AgentStatus::Waiting; + let git = full.then(|| view.git_status(cx)).flatten(); + Some(TabFacts { + id: tab.tree_id.get(), + agent: view.agent()?, + live: Live::from(&session), + ask: waiting.then(|| session.message.clone()).flatten(), + question: waiting && session.question, + cwd: view.cwd(), + branch: git.as_ref().map(|g| g.branch.clone()), + diff: git + .as_ref() + .map(|g| (g.added, g.removed)) + .filter(|&(a, r)| a > 0 || r > 0), + label: full.then(|| self.full_tab_label(tab, window, cx)).flatten(), + group: tab.group.get().filter(|g| self.sidebar_groups.contains(*g)), + }) + }) + .collect() + } + + /// 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 facts = self.tab_facts(window, full, cx); + let find = |tab: Option| tab.and_then(|id| facts.iter().find(|f| f.id == id)); + let mut cards = Vec::new(); + for task in &self.board.tasks { + let column = task::column(task, |run| find(run.tab).map(|f| f.live)); + // The run this card reports: the newest one still open, else the + // newest one at all. + let open = task.runs.iter().rev().find_map(|r| find(r.tab)); + let last = task.runs.last(); + let cwd = open + .and_then(|f| f.cwd.clone()) + .or_else(|| last.and_then(|r| r.worktree.as_ref().map(PathBuf::from))) + .or_else(|| task.cwd.as_ref().map(PathBuf::from)); + cards.push(Card { + key: CardRef::Task(task.id), + column, + agent: open + .map(|f| f.agent) + .or(last.map(|r| r.agent)) + .or(task.agent), + status: open.map(|f| f.live.status), + title: task.title.clone().into(), + prompt: Some(task.ask().to_string().into()), + group: task.group.filter(|g| self.sidebar_groups.contains(*g)), + repo: repo_name(cwd.as_deref()), + branch: open.and_then(|f| f.branch.clone()), + cwd, + ask: open.and_then(|f| f.ask.clone()).map(Into::into), + question: open.is_some_and(|f| f.question), + diff: open.and_then(|f| f.diff), + since: match column { + Column::Done => task.done.map(|d| d.at), + Column::Queued => task.paused.or(Some(task.created)), + _ => last.map(|r| r.started), + }, + tab: open.map(|f| f.id), + resumable: open.is_none() && last.is_some_and(|r| r.session_id.is_some()), + paused: task.paused.is_some(), + }); + } + let claimed = |id: TabId| self.board.tasks.iter().any(|t| t.runs_in(id)); + for f in facts.iter().filter(|f| !claimed(f.id)) { + cards.push(Card { + key: CardRef::Loose(f.id), + column: task::live_column(f.live), + agent: Some(f.agent), + status: Some(f.live.status), + title: f + .label + .clone() + .unwrap_or_else(|| f.agent.display_name().to_string()) + .into(), + prompt: None, + group: f.group, + repo: repo_name(f.cwd.as_deref()), + branch: f.branch.clone(), + cwd: f.cwd.clone(), + ask: f.ask.clone().map(Into::into), + question: f.question, + diff: f.diff, + since: None, + tab: Some(f.id), + resumable: false, + paused: false, + }); + } + if !self.board.agent_filter.is_empty() { + cards.retain(|c| { + c.agent + .is_some_and(|a| self.board.agent_filter.contains(&a)) + }); + } + if let Some(only) = self.board.group_filter { + cards.retain(|c| c.group == Some(only)); + } + cards + } + + fn card(&self, key: CardRef, window: &Window, cx: &App) -> Option { + self.board_cards(Some(window), true, cx) + .into_iter() + .find(|c| c.key == key) + } + + /// The terminal the card's agent runs in, if it is open. + fn card_view( + &self, + tab: Option, + cx: &App, + ) -> Option> { + let tab = self.tabs.iter().find(|t| Some(t.tree_id.get()) == tab)?; + tab.pane + .terminals() + .into_iter() + .find(|l| l.read(cx).agent().is_some()) + } + + // ---- selection, peek, keys -------------------------------------------- + + fn select_card( + &mut self, + key: CardRef, + peek: bool, + window: &mut Window, + cx: &mut Context, + ) { + let changed = self.board.selected != Some(key); + self.board.selected = Some(key); + if peek { + self.board.peek = true; + if changed || self.board.files.as_ref().map(|(k, _)| *k) != Some(key) { + self.load_card_files(key, window, cx); + } + self.ensure_reply(window, cx); + if let Some(card) = self.card(key, window, cx) { + self.set_reply_placeholder(&card, window, cx); + } + } + cx.notify(); + } + + fn close_peek(&mut self, window: &mut Window, cx: &mut Context) { + self.board.peek = false; + window.focus(&self.board.focus, cx); + cx.notify(); + } + + /// Jumps to the tab a card's agent runs in. + fn open_card_tab(&mut self, tab: TabId, window: &mut Window, cx: &mut Context) { + let Some(index) = self.tabs.iter().position(|t| t.tree_id.get() == tab) else { + return; + }; + self.main_view = MainView::Terminal; + self.board.composer = None; + self.board.peek = false; + self.activate(index, window, cx); + self.focus_active(window, cx); + cx.notify(); + } + + /// Enter on a card, or a double click: into its terminal when there is + /// one. + fn open_card(&mut self, key: CardRef, window: &mut Window, cx: &mut Context) { + match (self.card(key, window, cx).and_then(|c| c.tab), key) { + (Some(tab), _) => self.open_card_tab(tab, window, cx), + // Nothing running to look at: a task that has not run yet opens + // for editing. + (None, CardRef::Task(id)) => self.open_composer(Some(id), window, cx), + (None, CardRef::Loose(_)) => self.select_card(key, true, window, cx), + } + } + + fn board_key(&mut self, ev: &gpui::KeyDownEvent, window: &mut Window, cx: &mut Context) { + let k = &ev.keystroke; + let typing = self + .board + .reply + .as_ref() + .is_some_and(|r| r.focus_handle(cx).is_focused(window)); + if k.key == "escape" { + if typing { + window.focus(&self.board.focus, cx); + } else if self.board.peek { + self.close_peek(window, cx); + } else if self.board.selected.take().is_some() { + cx.notify(); + } else { + self.close_board(window, cx); + } + cx.stop_propagation(); + return; + } + if typing || k.modifiers.modified() { + return; + } + match k.key.as_str() { + "c" | "n" => self.open_composer(None, window, cx), + "enter" => match self.board.selected { + Some(key) => self.open_card(key, window, cx), + None => return, + }, + "up" | "down" | "left" | "right" => self.move_selection(&k.key, window, cx), + _ => return, + } + cx.stop_propagation(); + } + + /// Arrow keys: along a column, or to the nearest card in the next column + /// that has one. + fn move_selection(&mut self, key: &str, window: &mut Window, cx: &mut Context) { + let cards = self.board_cards(Some(window), false, cx); + let grid: Vec> = Column::ALL + .iter() + .map(|col| { + cards + .iter() + .filter(|c| c.column == *col) + .map(|c| c.key) + .collect() + }) + .collect(); + let at = self.board.selected.and_then(|sel| { + grid.iter() + .enumerate() + .find_map(|(c, col)| col.iter().position(|k| *k == sel).map(|r| (c, r))) + }); + let next = match at { + None => grid.iter().position(|c| !c.is_empty()).map(|c| (c, 0)), + Some((c, r)) => match key { + "down" => Some((c, (r + 1).min(grid[c].len() - 1))), + "up" => Some((c, r.saturating_sub(1))), + _ => { + let step: isize = if key == "right" { 1 } else { -1 }; + let mut n = c as isize + step; + while n >= 0 && (n as usize) < grid.len() && grid[n as usize].is_empty() { + n += step; + } + match n >= 0 && (n as usize) < grid.len() { + true => Some((n as usize, r.min(grid[n as usize].len() - 1))), + false => Some((c, r)), + } + } + }, + }; + if let Some((c, r)) = next { + let key = grid[c][r]; + let peek = self.board.peek; + self.select_card(key, peek, window, cx); + } + } + + // ---- moves -------------------------------------------------------------- + + /// Moves a card to `to`, by asking its agent for whatever that move means. + pub(crate) fn board_move( + &mut self, + key: CardRef, + to: Column, + window: &mut Window, + cx: &mut Context, + ) { + let Some(card) = self.card(key, window, cx) else { + return; + }; + if !moves_from(card.column).contains(&to) { + return; + } + match (card.column, to) { + (Column::Queued, Column::Running) => self.start_card(&card, window, cx), + (_, Column::Queued) => self.pause_card(&card, window, cx), + (Column::NeedsInput, Column::Running) if !card.question => { + self.allow_card(&card, window, cx) + } + (_, Column::Running) => self.focus_reply(key, window, cx), + (_, Column::Done) => self.mark_done(key, window, cx), + _ => {} + } + } + + /// Starts a queued card: a paused task's agent is told to carry on, a + /// task nobody has run gets a run, and an agent open with nothing to do + /// needs telling what to do first. + fn start_card(&mut self, card: &Card, window: &mut Window, cx: &mut Context) { + match card.key { + CardRef::Task(id) => match (card.tab, self.task(id)) { + (Some(_), Some(mut task)) => { + if let Some(view) = self.card_view(card.tab, cx) { + view.read(cx).send_agent_prompt(CONTINUE); + } + task.paused = None; + task.done = None; + let title = task.title.clone(); + if self.save_task(task, window, cx) { + self.flash( + t_fmt(L10nKey::BoardToastStarted, &[("title", &title)]), + None, + cx, + ); + } + } + (None, Some(_)) => self.start_task(id, None, window, cx), + _ => {} + }, + CardRef::Loose(_) => self.focus_reply(card.key, window, cx), + } + } + + /// Interrupts the card's agent and sends the card back to the queue. + fn pause_card(&mut self, card: &Card, window: &mut Window, cx: &mut Context) { + let mut task = match card.key { + CardRef::Task(id) => self.task(id), + CardRef::Loose(tab) => self.loose_task(tab, window, cx), + }; + let Some(task) = task.as_mut() else { + return; + }; + if let Some(view) = self.card_view(card.tab, cx) { + view.read(cx).send_keys(b"\x1b"); + } + task.paused = Some(unix_now()); + let title = task.title.clone(); + if self.save_task(task.clone(), window, cx) { + self.flash( + t_fmt(L10nKey::BoardToastPaused, &[("title", &title)]), + None, + cx, + ); + } + } + + /// Takes the choice a permission prompt has highlighted, which is "yes" + /// for every agent the board can start. + fn allow_card(&mut self, card: &Card, window: &mut Window, cx: &mut Context) { + let Some(view) = self.card_view(card.tab, cx) else { + return; + }; + view.read(cx).send_keys(b"\r"); + self.unpause(card.key, window, cx); + self.flash( + t_fmt(L10nKey::BoardToastAllowed, &[("title", &card.title)]), + None, + cx, + ); + } + + /// Sends `text` to the card's agent, as if typed at its prompt. + fn send_reply( + &mut self, + key: CardRef, + text: &str, + window: &mut Window, + cx: &mut Context, + ) { + let text = text.trim(); + if text.is_empty() { + return; + } + let Some(card) = self.card(key, window, cx) else { + return; + }; + let Some(view) = self.card_view(card.tab, cx) else { + return; + }; + view.read(cx).send_agent_prompt(text); + self.unpause(key, window, cx); + let agent = card.agent.map_or("", |a| a.display_name()); + self.flash( + t_fmt( + L10nKey::BoardToastSent, + &[("agent", agent), ("title", &card.title)], + ), + None, + cx, + ); + } + + fn unpause(&mut self, key: CardRef, window: &mut Window, cx: &mut Context) { + if let CardRef::Task(id) = key + && let Some(mut task) = self.task(id) + && (task.paused.is_some() || task.done.is_some()) + { + task.paused = None; + task.done = None; + self.save_task(task, window, cx); + } + } + + fn mark_done(&mut self, key: CardRef, window: &mut Window, cx: &mut Context) { + let facts = self.tab_facts(Some(window), false, cx); + let live = |run: &Run| { + run.tab + .and_then(|id| facts.iter().find(|f| f.id == id)) + .map(|f| f.live) + }; + let (mut task, undo) = match key { + CardRef::Task(id) => { + let Some(task) = self.task(id) else { + return; + }; + (task.clone(), Undo::Put(task)) + } + CardRef::Loose(tab) => { + let Some(task) = self.loose_task(tab, window, cx) else { + return; + }; + let id = task.id; + (task, Undo::Remove(id)) + } + }; + task.paused = None; + task.done = Some(Done { + at: unix_now(), + turns: task::live_turns(&task, live), + }); + let title = task.title.clone(); + if self.save_task(task, window, cx) { + self.flash( + t_fmt(L10nKey::BoardToastDone, &[("title", &title)]), + Some(undo), + cx, + ); + } + } + + fn reopen(&mut self, id: TaskId, window: &mut Window, cx: &mut Context) { + let Some(mut task) = self.task(id) else { + return; + }; + let before = task.clone(); + task.done = None; + let title = task.title.clone(); + if self.save_task(task, window, cx) { + self.flash( + t_fmt(L10nKey::BoardToastReopened, &[("title", &title)]), + Some(Undo::Put(before)), + cx, + ); + } + } + + fn remove_task(&mut self, id: TaskId, window: &mut Window, cx: &mut Context) { + let Some(task) = self.task(id) else { + return; + }; + let title = task.title.clone(); + if self.delete_task(id, window, cx) { + self.flash( + t_fmt(L10nKey::BoardToastRemoved, &[("title", &title)]), + Some(Undo::Put(task)), + cx, + ); + } + } + + /// A task for a loose card, holding the tab it runs in. + fn loose_task(&self, tab: TabId, window: &Window, cx: &App) -> Option { + let facts = self.tab_facts(Some(window), true, cx); + let f = facts.into_iter().find(|f| f.id == tab)?; + let title = f + .label + .clone() + .unwrap_or_else(|| f.agent.display_name().to_string()); + let title: String = title.chars().take(task::MAX_TITLE_CHARS).collect(); + let mut task = Task::new(title); + task.cwd = f.cwd.map(|p| p.display().to_string()); + task.agent = Some(f.agent); + task.group = f.group; + task.push_run(Run { + agent: f.agent, + tab: Some(tab), + session_id: None, + started: unix_now(), + worktree: None, + }); + Some(task) + } + + fn resume_task(&mut self, id: TaskId, window: &mut Window, cx: &mut Context) { + let Some(mut task) = self.task(id) else { + return; + }; + let Some(run) = task.runs.last().cloned() else { + return; + }; + let Some(session) = run.session_id.clone() else { + return; + }; + let cwd = run + .worktree + .as_ref() + .or(task.cwd.as_ref()) + .map(PathBuf::from); + let before = self.tabs.len(); + self.resume_session(run.agent, &session, cwd, false, window, cx); + // Nothing opened, and the reason is on screen already; the run must + // not be pinned to whichever tab happened to be active. + if self.tabs.len() == before { + return; + } + let Some(tab) = self.tabs.get(self.active) else { + return; + }; + task.push_run(Run { + tab: Some(tab.tree_id.get()), + started: unix_now(), + ..run + }); + task.done = None; + task.paused = None; + self.save_task(task, window, cx); + } + + /// Starts an agent on `task`, told what to do — in a worktree of its own + /// when the task asks for one. + fn start_task( + &mut self, + id: TaskId, + agent: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(task) = self.task(id) else { + return; + }; + let Some(agent) = agent + .or(task.agent) + .or_else(|| self.offered_agents(cx).first().copied()) + else { + window.push_notification(t(L10nKey::BoardNoAgent), cx); + return; + }; + let overrides = &cx.global::().agent_launch; + let launch = agent.launch_command(overrides); + let command = match task::prompt_flag(agent) { + Some(flag) => { + let ask = crate::core::shell_quote::quote_for_shell(&one_line(task.ask()), None); + match flag { + Some(flag) => format!("{launch} {flag} {ask}"), + None => format!("{launch} {ask}"), + } + } + // The agent takes no prompt it would stay open after answering, + // so it starts bare and the prompt waits on the clipboard. + None => { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(task.ask().to_string())); + window.push_notification( + t_fmt( + L10nKey::BoardPromptCopied, + &[("agent", agent.display_name())], + ), + cx, + ); + launch + } + }; + let cwd = task.cwd.as_ref().map(PathBuf::from); + if !task.worktree { + self.launch_run(task, agent, command, cwd, None, window, cx); + return; + } + // The worktree is cut first, off the thread — it is a `git worktree + // add` on whichever machine the workspace is on — and the run opens + // in it once it exists. + let Some(cwd) = cwd else { + window.push_notification(t(L10nKey::BoardWorktreeNeedsRepo), cx); + return; + }; + let host_id = self.spawn_host(cx); + let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, host_id) else { + window.push_notification(t(L10nKey::BoardUnavailable), cx); + return; + }; + let slug = task::branch_slug(&task.title); + crate::ui::host_ops::HostOps::run_in( + host, + window, + cx, + move |h| crate::core::worktree::create_for(h, &cwd, slug.as_deref()), + move |this, made, window, cx| match made { + Ok(wt) => { + let path = wt.path.clone(); + this.launch_run( + task, + agent, + command, + Some(path.clone()), + Some(path), + window, + cx, + ) + } + Err(e) => window + .push_notification(t_fmt(L10nKey::BoardWorktreeFailed, &[("error", &e)]), cx), + }, + ); + } + + /// Opens the tab a run of `task` works in and starts `agent` there. + /// + /// The window stays on the board: the card moving to Running is the + /// answer, and whoever wants to watch can open it. + #[allow(clippy::too_many_arguments)] + fn launch_run( + &mut self, + mut task: Task, + agent: CLIAgent, + command: String, + cwd: Option, + worktree: Option, + window: &mut Window, + cx: &mut Context, + ) { + let on_board = self.board_open(); + let Some(slot) = self.new_tab_slot(cwd, None, window, cx) else { + return; + }; + // The tab is named for the task, so its sidebar row says what it is + // for rather than whatever the agent titles itself, and it is filed + // where the task is. + let group = task.group.filter(|g| self.sidebar_groups.contains(*g)); + let tab = &mut self.tabs[self.active]; + tab.name = Some(task.title.clone()); + if group.is_some() { + tab.group.set(group); + } + let tab_id = tab.tree_id.get(); + crate::ui::agent_launch::run_when_ready(&slot, command, cx); + if on_board { + self.main_view = MainView::Board; + self.focus_active(window, cx); + } + task.push_run(Run { + agent, + tab: Some(tab_id), + session_id: None, + started: unix_now(), + worktree: worktree.map(|p| p.display().to_string()), + }); + task.agent = Some(agent); + task.done = None; + task.paused = None; + let title = task.title.clone(); + if self.save_task(task, window, cx) { + self.flash( + t_fmt(L10nKey::BoardToastStarted, &[("title", &title)]), + None, + cx, + ); + } + self.save_session(cx); + } + + /// Keeps each open run's session id written down, so a run whose tab + /// closes can still be resumed, and lifts a pause once its agent is + /// working again. Called on every frame, board or not; only sends + /// anything when something changed, and quietly: a window whose tree has + /// not landed tries again next frame. + fn note_runs(&mut self, cx: &mut Context) { + 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, 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 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; + } + } + if dirty { + changed.push(next); + } + } + for task in changed { + if crate::ui::tree_sync::push_task(cx, self.workspace, task.clone()) { + self.board_task_put(task); + } + } + } + + // ---- toast -------------------------------------------------------------- + + fn flash(&mut self, text: String, undo: Option, cx: &mut Context) { + self.board.toast_seq += 1; + let seq = self.board.toast_seq; + self.board.toast = Some(Toast { + text: text.into(), + undo, + }); + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(TOAST_MS)) + .await; + let _ = this.update(cx, |this, cx| { + if this.board.toast_seq == seq { + this.board.toast = None; + cx.notify(); + } + }); + }) + .detach(); + cx.notify(); + } + + fn undo(&mut self, window: &mut Window, cx: &mut Context) { + let Some(undo) = self.board.toast.take().and_then(|t| t.undo) else { + return; + }; + match undo { + Undo::Put(task) => { + self.save_task(task, window, cx); + } + Undo::Remove(id) => { + self.delete_task(id, window, cx); + } + } + cx.notify(); + } + + // ---- files -------------------------------------------------------------- + + /// Asks git which files the card's checkout has changed, for the peek + /// panel. Against HEAD, so a worktree shows what its run has done. + fn load_card_files(&mut self, key: CardRef, window: &mut Window, cx: &mut Context) { + self.board.files = None; + let Some(cwd) = self.card(key, window, cx).and_then(|c| c.cwd) else { + return; + }; + let host_id = self.spawn_host(cx); + let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, host_id) else { + return; + }; + crate::ui::host_ops::HostOps::run_in( + host, + window, + cx, + move |h| match h.git(&cwd, &["diff", "--numstat", "HEAD"]) { + Ok(out) if out.success() => { + Ok(parse_numstat(&String::from_utf8_lossy(&out.stdout))) + } + Ok(out) => Err(String::from_utf8_lossy(&out.stderr).trim().to_string()), + Err(e) => Err(e.to_string()), + }, + move |this, files, _window, cx| { + if this.board.selected == Some(key) { + this.board.files = Some((key, files)); + cx.notify(); + } + }, + ); + } +} + +fn repo_name(cwd: Option<&std::path::Path>) -> Option { + cwd.and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) +} + +/// `git diff --numstat`: `addedremovedpath` a line, `-` for the +/// counts of a binary file. +fn parse_numstat(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let mut parts = line.splitn(3, '\t'); + let added = parts.next()?.parse().unwrap_or(0); + let removed = parts.next()?.parse().unwrap_or(0); + let path = parts.next()?.to_string(); + Some(FileChange { + path, + added, + removed, + }) + }) + .collect() +} + +/// A prompt as one command-line argument. The command is typed into a shell, +/// where a newline would submit it half-way; the agent reads the paragraphs +/// joined just as well. +fn one_line(text: &str) -> String { + text.lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect::>() + .join(" ") +} + +/// How long ago `since` was, in the board's short form: `now`, `12m`, `3h`, +/// `2d`. +fn ago(since: u64) -> String { + let secs = unix_now().saturating_sub(since); + match secs { + 0..60 => t(L10nKey::BoardNow).to_string(), + 60..3600 => format!("{}m", secs / 60), + 3600..86400 => format!("{}h", secs / 3600), + _ => format!("{}d", secs / 86400), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_prompt_goes_on_one_line() { + assert_eq!(one_line("Fix it.\n\n Then test.\n"), "Fix it. Then test."); + assert_eq!(one_line("one"), "one"); + } + + #[test] + fn numstat_reads_counts_and_binary_files() { + let files = parse_numstat("3\t1\tsrc/a.rs\n-\t-\tlogo.png\n"); + assert_eq!( + files, + vec![ + FileChange { + path: "src/a.rs".into(), + added: 3, + removed: 1 + }, + FileChange { + path: "logo.png".into(), + added: 0, + removed: 0 + }, + ] + ); + } + + #[test] + fn only_the_moves_an_agent_can_make_are_offered() { + assert_eq!(moves_from(Column::Queued), &[Column::Running]); + assert!(moves_from(Column::Done).is_empty()); + assert!(moves_from(Column::Review).contains(&Column::Done)); + assert!(!moves_from(Column::Running).contains(&Column::Done)); + } + + #[test] + fn a_waiting_question_is_answered_not_allowed() { + assert_eq!( + move_verb(Column::NeedsInput, Column::Running, true), + L10nKey::BoardVerbReply + ); + assert_eq!( + move_verb(Column::NeedsInput, Column::Running, false), + L10nKey::BoardVerbAllow + ); + } +} diff --git a/src/ui/board/peek.rs b/src/ui/board/peek.rs new file mode 100644 index 00000000..90ff4a5e --- /dev/null +++ b/src/ui/board/peek.rs @@ -0,0 +1,512 @@ +//! The panel beside the columns: one card up close, without leaving the +//! board — what it was asked, what its agent is printing, what it is waiting +//! on, which files it touched — and a line to answer it. + +use gpui::{AnyElement, Context, Hsla, SharedString, Window, div, prelude::*, px, rems}; +use gpui_component::button::ButtonVariants as _; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; +use tty7_core::core::task::Column; + +use super::{Card, CardRef, ago}; +use crate::ui::app::Tty7App; +use crate::ui::dialog::{self, Tone}; +use crate::ui::i18n::{L10nKey, t, t_fmt}; +use crate::ui::right_panel::{HEADING, META, META_MONO, TAB_TEXT, TEXT}; + +const WIDTH: f32 = 360.; +const INSET: f32 = 12.; +const RADIUS: f32 = 11.; +/// Lines of the agent's screen the Output block shows. +const OUTPUT_LINES: usize = 6; + +impl Tty7App { + /// The reply box, made once per window the first time the panel opens. + pub(super) fn ensure_reply(&mut self, window: &mut Window, cx: &mut Context) { + if self.board.reply.is_some() { + return; + } + let reply = cx.new(|cx| InputState::new(window, cx)); + let sub = cx.subscribe_in( + &reply, + window, + |this, input, ev: &InputEvent, window, cx| { + if let InputEvent::PressEnter { .. } = ev + && let Some(key) = this.board.selected + { + let text = input.read(cx).value().to_string(); + if text.trim().is_empty() { + return; + } + input.update(cx, |s, cx| s.set_value("", window, cx)); + this.send_reply(key, &text, window, cx); + } + }, + ); + self.board.reply = Some(reply); + self._set_reply_sub(sub); + } + + fn _set_reply_sub(&mut self, sub: gpui::Subscription) { + self.board._reply_sub = Some(sub); + } + + /// Words the reply box greets `card` with: an answer, a change request, + /// a follow-up, or — for an agent sitting idle — its first instruction. + pub(super) fn set_reply_placeholder( + &mut self, + card: &Card, + window: &mut Window, + cx: &mut Context, + ) { + let Some(reply) = self.board.reply.clone() else { + return; + }; + let agent = card.agent.map_or("", |a| a.display_name()); + let placeholder: SharedString = match card.column { + Column::NeedsInput => t_fmt(L10nKey::BoardReplyTo, &[("agent", agent)]).into(), + Column::Review => t(L10nKey::BoardReplyChanges).into(), + Column::Queued => t_fmt(L10nKey::BoardReplyStart, &[("agent", agent)]).into(), + _ => t(L10nKey::BoardReplyFollowUp).into(), + }; + reply.update(cx, |s, cx| s.set_placeholder(placeholder, window, cx)); + } + + /// Opens the panel on `key` with the reply box focused: Reply on a card, + /// or a drag that needs words (sending a finished run back with changes). + pub(super) fn focus_reply( + &mut self, + key: CardRef, + window: &mut Window, + cx: &mut Context, + ) { + self.select_card(key, true, window, cx); + if let Some(reply) = self.board.reply.clone() { + reply.update(cx, |s, cx| s.focus(window, cx)); + } + } + + pub(super) fn render_peek(&self, card: &Card, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (fg, muted, border) = (theme.foreground, theme.muted_foreground, theme.border); + let well: Hsla = gpui::rgb(dialog::popover_rungs(cx).hover).into(); + let mono = theme.mono_font_family.clone(); + let rungs = dialog::popover_rungs(cx); + let key = card.key; + let (dot, hollow) = super::view::column_dot(card.column, cx); + let status = t(match (card.column, card.paused) { + (Column::Queued, true) => L10nKey::BoardStatusPaused, + (Column::Queued, false) => L10nKey::BoardColQueued, + (Column::Running, _) => L10nKey::BoardColRunning, + (Column::NeedsInput, _) => L10nKey::BoardColNeedsInput, + (Column::Review, _) => L10nKey::BoardStatusReview, + (Column::Done, _) => L10nKey::BoardColDone, + }); + let head = h_flex() + .flex_none() + .items_start() + .gap(px(10.)) + .pt(px(14.)) + .pr(px(12.)) + .pb(px(12.)) + .pl(px(16.)) + .border_b_1() + .border_color(border) + .child(crate::ui::tab_strip::avatar( + "board-peek-avatar", + crate::ui::search::Avatar { + agent: card.agent, + ..Default::default() + }, + 20., + cx, + )) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap(px(5.)) + .child( + div() + .text_size(rems(TEXT)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .line_height(rems(TEXT * 1.35)) + .child(card.title.clone()), + ) + .child( + h_flex() + .gap(px(6.)) + .items_center() + .text_size(rems(HEADING)) + .text_color(muted) + .child( + div() + .size(px(7.)) + .rounded_full() + .when(hollow, |d| d.border_1().border_color(dot)) + .when(!hollow, |d| d.bg(dot)), + ) + .child(status) + .when_some(card.since, |row, since| row.child("·").child(ago(since))), + ), + ) + .child( + gpui_component::button::Button::new("board-peek-close") + .icon(IconName::Close) + .ghost() + .xsmall() + .tooltip(t(L10nKey::BoardPeekClose)) + .on_click(cx.listener(|this, _, window, cx| this.close_peek(window, cx))), + ); + + let field = |name: L10nKey, value: String| { + h_flex() + .h(px(26.)) + .items_center() + .child(div().w(px(72.)).text_color(muted).child(t(name))) + .child(div().flex_1().min_w_0().truncate().child(value)) + }; + let changes = card + .diff + .map_or("—".to_string(), |(a, r)| format!("+{a} −{r}")); + let facts = v_flex() + .px(px(4.)) + .child(field( + L10nKey::BoardPeekAgent, + card.agent + .map_or("—".into(), |a| a.display_name().to_string()), + )) + .child(field( + L10nKey::BoardPeekRepo, + card.repo.clone().unwrap_or_else(|| "—".into()), + )) + .child(field( + L10nKey::BoardPeekBranch, + card.branch.clone().unwrap_or_else(|| "—".into()), + )) + .child(field(L10nKey::BoardPeekChanges, changes)); + + let section = |name: SharedString, name_color: Hsla, body: AnyElement| { + v_flex() + .gap(px(6.)) + .child( + div() + .px(px(4.)) + .text_size(rems(HEADING)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(name_color) + .child(name), + ) + .child(body) + }; + let block = || div().px(px(10.)).py(px(8.)).rounded(px(7.)).bg(well); + + let prompt = card.prompt.clone().map(|p| { + section( + t(L10nKey::BoardPeekPrompt).into(), + muted, + block() + .line_height(rems(TAB_TEXT * 1.5)) + .child(p) + .into_any_element(), + ) + }); + let output = self + .card_view(card.tab, cx) + .map(|v| v.read(cx).screen_tail(OUTPUT_LINES)) + .filter(|lines| !lines.is_empty()) + .map(|lines| { + section( + t(L10nKey::BoardPeekOutput).into(), + muted, + block() + .font_family(mono.clone()) + .text_size(rems(META_MONO)) + .line_height(rems(META_MONO * 1.65)) + .text_color(muted) + .children(lines.into_iter().map(|l| div().truncate().child(l))) + .into_any_element(), + ) + }); + let waiting = (card.column == Column::NeedsInput).then(|| { + section( + t(L10nKey::BoardPeekWaiting).into(), + theme.warning, + block() + .font_family(mono.clone()) + .text_size(rems(META_MONO)) + .line_height(rems(META_MONO * 1.5)) + .text_color(fg) + .child( + card.ask + .clone() + .unwrap_or_else(|| t(L10nKey::BoardColNeedsInput).into()), + ) + .into_any_element(), + ) + }); + let files = match &self.board.files { + Some((k, Ok(files))) if *k == key && !files.is_empty() => Some(section( + t_fmt(L10nKey::BoardPeekFiles, &[("n", &files.len().to_string())]).into(), + muted, + v_flex() + .children(files.iter().map(|f| { + h_flex() + .h(px(26.)) + .px(px(4.)) + .gap(px(8.)) + .items_center() + .rounded(px(6.)) + .child(div().flex_1().min_w_0().truncate().child(f.path.clone())) + .child( + h_flex() + .gap(px(4.)) + .text_size(rems(META_MONO)) + .font_features(crate::ui::tab_sidebar::tabular()) + .child( + div() + .text_color(theme.success) + .child(format!("+{}", f.added)), + ) + .child( + div() + .text_color(theme.danger) + .child(format!("−{}", f.removed)), + ), + ) + })) + .into_any_element(), + )), + _ => None, + }; + let body = v_flex() + .id("board-peek-body") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .pt(px(12.)) + .px(px(12.)) + .pb(px(16.)) + .gap(px(16.)) + .text_size(rems(TAB_TEXT)) + .child(facts) + .children(prompt) + .children(output) + .children(waiting) + .children(files); + + // A line to the agent, while there is one open to hear it. + let can_reply = card.tab.is_some() + && matches!( + card.column, + Column::Running | Column::NeedsInput | Column::Review | Column::Queued + ); + let reply = self + .board + .reply + .as_ref() + .filter(|_| can_reply) + .map(|reply| { + div().px(px(12.)).pb(px(10.)).child( + h_flex() + .items_center() + .gap(px(6.)) + .pl(px(4.)) + .pr(px(6.)) + .py(px(4.)) + .rounded(px(8.)) + .bg(well) + .child( + div() + .flex_1() + .min_w_0() + .child(Input::new(reply).appearance(false).small()), + ) + .child( + div() + .id("board-peek-send") + .size(px(26.)) + .flex_none() + .flex() + .items_center() + .justify_center() + .rounded(px(6.)) + .bg(fg) + .cursor_pointer() + .on_click(cx.listener(move |this, _, window, cx| { + let Some(reply) = this.board.reply.clone() else { + return; + }; + let text = reply.read(cx).value().to_string(); + reply.update(cx, |s, cx| s.set_value("", window, cx)); + this.send_reply(key, &text, window, cx); + })) + .child( + Icon::new(IconName::ArrowUp) + .size(px(12.)) + .text_color(theme.background), + ), + ), + ) + }); + + // What can be done from here, the move it would be first. + let (tab, question, resumable, column) = + (card.tab, card.question, card.resumable, card.column); + let button = + |id: &'static str, + label: L10nKey, + tone: Tone, + cx: &mut Context, + run: Box)>| { + dialog::button( + id, + t(label), + tone, + true, + rungs, + cx, + cx.listener(move |this, _, window, cx| run(this, window, cx)), + ) + .flex_1() + }; + let open_terminal = |cx: &mut Context| { + tab.map(|tab| { + button( + "board-peek-open", + L10nKey::BoardOpenTerminal, + Tone::Secondary, + cx, + Box::new(move |this, window, cx| this.open_card_tab(tab, window, cx)), + ) + }) + }; + let move_to = + |id: &'static str, label: L10nKey, tone: Tone, to: Column, cx: &mut Context| { + button( + id, + label, + tone, + cx, + Box::new(move |this, window, cx| this.board_move(key, to, window, cx)), + ) + }; + let mut actions: Vec> = Vec::new(); + match column { + Column::Queued => { + if let CardRef::Task(id) = key { + actions.push(button( + "board-peek-remove", + L10nKey::BoardRemove, + Tone::Secondary, + cx, + Box::new(move |this, window, cx| this.remove_task(id, window, cx)), + )); + } + actions.push(move_to( + "board-peek-start", + L10nKey::BoardPeekStart, + Tone::Primary, + Column::Running, + cx, + )); + } + Column::Running => { + actions.push(move_to( + "board-peek-pause", + L10nKey::BoardVerbPause, + Tone::Secondary, + Column::Queued, + cx, + )); + actions.extend(open_terminal(cx)); + } + Column::NeedsInput => { + actions.extend(open_terminal(cx)); + if !question { + actions.push(move_to( + "board-peek-allow", + L10nKey::BoardAllow, + Tone::Primary, + Column::Running, + cx, + )); + } + } + Column::Review => { + actions.extend(open_terminal(cx)); + actions.push(move_to( + "board-peek-done", + L10nKey::BoardVerbDone, + Tone::Primary, + Column::Done, + cx, + )); + } + Column::Done => { + if let CardRef::Task(id) = key { + actions.push(button( + "board-peek-reopen", + L10nKey::BoardReopen, + Tone::Secondary, + cx, + Box::new(move |this, window, cx| this.reopen(id, window, cx)), + )); + if resumable { + actions.push(button( + "board-peek-resume", + L10nKey::BoardResume, + Tone::Primary, + cx, + Box::new(move |this, window, cx| this.resume_task(id, window, cx)), + )); + } + } + actions.extend(open_terminal(cx)); + } + } + if resumable + && column != Column::Done + && let CardRef::Task(id) = key + { + actions.insert( + 0, + button( + "board-peek-resume", + L10nKey::BoardResume, + Tone::Secondary, + cx, + Box::new(move |this, window, cx| this.resume_task(id, window, cx)), + ), + ); + } + let foot = h_flex() + .flex_none() + .gap(px(6.)) + .pt(px(10.)) + .px(px(12.)) + .pb(px(12.)) + .border_t_1() + .border_color(border) + .children(actions); + + v_flex() + .id("board-peek") + .occlude() + .absolute() + .top(px(super::view::HEADER_H)) + .right(px(INSET)) + .bottom(px(INSET)) + .w(px(WIDTH)) + .map(|panel| crate::ui::theme::floating_surface(panel, cx)) + .rounded(px(RADIUS)) + .overflow_hidden() + .text_size(rems(META)) + .on_click(|_, _, cx| cx.stop_propagation()) + .child(head) + .child(body) + .children(reply) + .child(foot) + .into_any_element() + } +} diff --git a/src/ui/board/view.rs b/src/ui/board/view.rs new file mode 100644 index 00000000..1216f6f0 --- /dev/null +++ b/src/ui/board/view.rs @@ -0,0 +1,808 @@ +//! Drawing the board: the header, five columns of cards, the drop targets a +//! drag lights up, and the note at the bottom saying what just happened. + +use gpui::{ + AnyElement, App, Context, Hsla, IntoElement, Render, SharedString, Window, div, prelude::*, px, + rems, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}; +use gpui_component::{ActiveTheme as _, Icon, Sizable as _, h_flex, v_flex}; +use tty7_core::core::group_key::{GroupId, PinnedGroup}; +use tty7_core::core::task::Column; + +use super::{Card, CardDrag, CardRef, MainView, ago, move_verb, moves_from}; +use crate::core::cli_agent::{AgentStatus, CLIAgent}; +use crate::ui::app::Tty7App; +use crate::ui::dialog::{self, Tone}; +use crate::ui::i18n::{L10nKey, t, t_fmt}; +use crate::ui::right_panel::{HEADING, META, META_MONO, TAB_TEXT, TEXT}; + +pub(super) const HEADER_H: f32 = 48.; +const PAD_X: f32 = 20.; +const COLUMN_GAP: f32 = 12.; +const COLUMN_HEAD_H: f32 = 28.; +const CARD_GAP: f32 = 8.; +const CARD_RADIUS: f32 = 9.; +const AVATAR: f32 = 16.; +const DROP_H: f32 = 44.; +const EMPTY_H: f32 = 64.; + +impl Tty7App { + /// The board over the terminal area, or `None` while the terminal shows. + pub(crate) fn render_board( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + self.note_runs(cx); + if self.main_view != MainView::Board { + return None; + } + // A drag that ended anywhere — a drop, a release outside every + // target, the pointer leaving the window — leaves no drag behind. + if !cx.has_active_drag() { + self.board.dragging = None; + } + let cards = self.board_cards(Some(window), true, cx); + // 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) + { + self.board.selected = None; + self.board.peek = false; + } + let header = self.render_board_header(&cards, 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), + false => h_flex() + .flex_1() + .min_h_0() + .items_start() + .gap(px(COLUMN_GAP)) + .px(px(PAD_X)) + .pt(px(4.)) + .pb(px(PAD_X)) + .children(Column::ALL.into_iter().map(|col| { + let in_col: Vec<&Card> = cards.iter().filter(|c| c.column == col).collect(); + self.render_column(col, &in_col, cx) + })) + .into_any_element(), + }; + let peek = self + .board + .selected + .filter(|_| self.board.peek) + .and_then(|sel| cards.iter().find(|c| c.key == sel)) + .map(|card| self.render_peek(card, cx)); + let toast = self.render_toast(cx); + Some( + v_flex() + .id("board") + .track_focus(&self.board.focus) + .key_context("Board") + .absolute() + .inset_0() + .occlude() + .bg(crate::ui::theme::overlay_background(cx)) + .children(crate::ui::app::overlay_surface_layers(cx)) + .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { + this.board_key(ev, window, cx) + })) + // A click on the board's own ground closes the panel. Cards + // and the panel keep their clicks to themselves. + .on_click(cx.listener(|this, _: &gpui::ClickEvent, window, cx| { + if this.board.peek { + this.close_peek(window, cx); + } else { + window.focus(&this.board.focus, cx); + } + })) + .child(header) + .child(body) + .children(peek) + .children(toast) + .into_any_element(), + ) + } + + fn render_board_header(&self, cards: &[Card], 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(); + let summary = t_fmt( + L10nKey::BoardSummary, + &[ + ("running", &count(Column::Running).to_string()), + ("input", &count(Column::NeedsInput).to_string()), + ("review", &count(Column::Review).to_string()), + ], + ); + let app = cx.entity().downgrade(); + + // Agents: a set to pick from, each with how many cards it has. + let everyone = self.board_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(), + [one] => one.display_name().into(), + many => t_fmt(L10nKey::BoardAgentsCount, &[("n", &many.len().to_string())]).into(), + }; + let agent_app = app.clone(); + let agent_filter = Button::new("board-agent-filter") + .label(agent_label) + .ghost() + .small() + .dropdown_caret(true) + .dropdown_menu(move |menu: PopupMenu, _window, _cx| { + let mut menu = menu; + for (agent, n) in agents.clone() { + let a = agent_app.clone(); + let on = picked.is_empty() || picked.contains(&agent); + menu = menu.item( + PopupMenuItem::new(format!("{} {n}", agent.display_name())) + .checked(on) + .on_click(move |_, _, cx| { + let _ = + a.update(cx, |this, cx| this.toggle_agent_filter(agent, cx)); + }), + ); + } + let a = agent_app.clone(); + menu.separator() + .item(PopupMenuItem::new(t(L10nKey::BoardShowAllAgents)).on_click( + move |_, _, cx| { + let _ = a.update(cx, |this, cx| { + this.board.agent_filter.clear(); + cx.notify(); + }); + }, + )) + }); + + // Groups: only once the sidebar has pinned one. + let groups: Vec<(GroupId, String)> = self + .sidebar_groups + .pinned + .iter() + .map(|g| (g.id, group_label(g))) + .collect(); + let group_now: SharedString = self + .board + .group_filter + .and_then(|id| groups.iter().find(|(g, _)| *g == id)) + .map(|(_, name)| name.clone().into()) + .unwrap_or_else(|| t(L10nKey::BoardAllGroups).into()); + let group_app = app.clone(); + let group_filter = (!groups.is_empty()).then(|| { + Button::new("board-group-filter") + .label(group_now) + .ghost() + .small() + .dropdown_caret(true) + .dropdown_menu(move |menu: PopupMenu, _window, _cx| { + let pick = |label: SharedString, g: Option| { + let a = group_app.clone(); + PopupMenuItem::new(label).on_click(move |_, _, cx| { + let _ = a.update(cx, |this, cx| { + this.board.group_filter = g; + cx.notify(); + }); + }) + }; + let mut menu = menu.item(pick(t(L10nKey::BoardAllGroups).into(), None)); + for (id, name) in groups.clone() { + menu = menu.item(pick(name.into(), Some(id))); + } + menu + }) + }); + + let rungs = dialog::popover_rungs(cx); + let new_task = h_flex().gap(px(7.)).child(t(L10nKey::BoardNewTask)).child( + div() + .px(px(3.)) + .min_w(px(16.)) + .h(px(16.)) + .flex() + .items_center() + .justify_center() + .rounded(px(3.)) + .border_1() + .border_color(theme.background.opacity(0.45)) + .text_size(rems(10.5 / 16.)) + .opacity(0.7) + .child("C"), + ); + h_flex() + .flex_none() + .h(px(HEADER_H)) + .px(px(PAD_X)) + .items_center() + .gap(px(8.)) + .child( + div() + .text_size(rems(TEXT)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(t(L10nKey::BoardTitle)), + ) + .child( + div() + .flex_1() + .min_w_0() + .ml(px(4.)) + .truncate() + .text_size(rems(META)) + .text_color(muted) + .child(summary), + ) + .children(group_filter) + .child(agent_filter) + .child( + dialog::button( + "board-new-task", + "", + Tone::Primary, + true, + rungs, + cx, + cx.listener(|this, _, window, cx| this.open_composer(None, window, cx)), + ) + .h(px(26.)) + .px(px(10.)) + .child(new_task), + ) + .into_any_element() + } + + fn toggle_agent_filter(&mut self, agent: CLIAgent, cx: &mut Context) { + let everyone: Vec = { + let mut all: Vec = self + .board_cards(None, false, cx) + .iter() + .filter_map(|c| c.agent) + .collect(); + all.sort_by_key(|a| a.display_name()); + all.dedup(); + all + }; + let filter = &mut self.board.agent_filter; + if filter.is_empty() { + *filter = everyone.clone(); + } + match filter.iter().position(|a| *a == agent) { + Some(i) => { + filter.remove(i); + } + None => filter.push(agent), + } + // Everyone, or no one, is the same as no filter at all. + if filter.is_empty() || everyone.iter().all(|a| filter.contains(a)) { + filter.clear(); + } + cx.notify(); + } + + fn render_board_empty(&self, cx: &mut Context) -> AnyElement { + let muted = cx.theme().muted_foreground; + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap(px(6.)) + .child( + div() + .text_size(rems(TEXT)) + .font_weight(gpui::FontWeight::MEDIUM) + .child(t(L10nKey::BoardEmpty)), + ) + .child( + div() + .max_w(px(420.)) + .text_center() + .text_size(rems(META)) + .text_color(muted) + .child(t(L10nKey::BoardEmptyHint)), + ) + .into_any_element() + } + + fn render_column(&self, col: Column, cards: &[&Card], cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (fg, muted, border) = (theme.foreground, theme.muted_foreground, theme.border); + let (dot, hollow) = column_dot(col, cx); + let label = t(match col { + Column::Queued => L10nKey::BoardColQueued, + Column::Running => L10nKey::BoardColRunning, + Column::NeedsInput => L10nKey::BoardColNeedsInput, + Column::Review => L10nKey::BoardColReview, + Column::Done => L10nKey::BoardColDone, + }); + // While a card is dragged: where it may go says what the drop would + // do; where it may not fades back. + let drag = self.board.dragging.as_ref(); + let valid = drag.is_some_and(|d| moves_from(d.from).contains(&col)); + let home = drag.is_some_and(|d| d.from == col); + let zone = drag.filter(|_| valid).map(|d| { + div() + .flex_none() + .h(px(DROP_H)) + .flex() + .items_center() + .justify_center() + .rounded(px(8.)) + .border_1() + .border_color(border) + .text_size(rems(META)) + .text_color(muted) + .child(t(move_verb(d.from, col, d.question))) + }); + let empty = (cards.is_empty() && !valid).then(|| { + div() + .flex_none() + .h(px(EMPTY_H)) + .flex() + .items_center() + .justify_center() + .rounded(px(CARD_RADIUS)) + .border_1() + .border_color(border.opacity(0.6)) + .text_size(rems(META)) + .text_color(muted.opacity(0.8)) + .child(t(match col { + Column::Queued => L10nKey::BoardEmptyQueued, + Column::Running => L10nKey::BoardEmptyRunning, + Column::NeedsInput => L10nKey::BoardEmptyNeedsInput, + Column::Review => L10nKey::BoardEmptyReview, + Column::Done => L10nKey::BoardEmptyDone, + })) + }); + let head = h_flex() + .flex_none() + .h(px(COLUMN_HEAD_H)) + .px(px(4.)) + .items_center() + .gap(px(8.)) + .text_size(rems(META)) + .child( + div() + .size(px(7.)) + .rounded_full() + .when(hollow, |d| d.border_1().border_color(dot)) + .when(!hollow, |d| d.bg(dot)), + ) + .child( + div() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(fg) + .child(label), + ) + .child( + div() + .text_color(muted) + .font_features(crate::ui::tab_sidebar::tabular()) + .child(cards.len().to_string()), + ); + let list = v_flex() + .id(("board-column-list", col as usize)) + .flex_1() + .min_h_0() + .overflow_y_scroll() + .gap(px(CARD_GAP)) + .pb(px(4.)) + .children(zone) + .children(cards.iter().map(|c| self.render_card(c, cx))) + .children(empty); + let over: Hsla = gpui::rgb(dialog::popover_rungs(cx).hover).into(); + v_flex() + .id(("board-column", col as usize)) + .flex_1() + .min_w_0() + .h_full() + .gap(px(CARD_GAP)) + .px(px(4.)) + .mx(px(-4.)) + .pb(px(4.)) + .rounded(px(10.)) + .when(drag.is_some() && !valid && !home, |c| c.opacity(0.4)) + .can_drop(move |value, _, _| { + value + .downcast_ref::() + .is_some_and(|d| moves_from(d.from).contains(&col)) + }) + .drag_over::(move |style, _, _, _| style.bg(over)) + .on_drop(cx.listener(move |this, drag: &CardDrag, window, cx| { + this.board.dragging = None; + this.board_move(drag.key, col, window, cx); + })) + .child(head) + .child(list) + .into_any_element() + } + + fn render_card(&self, card: &Card, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (fg, muted, border) = (theme.foreground, theme.muted_foreground, theme.border); + let (surface, success, danger) = (theme.popover, theme.success, theme.danger); + let mono = theme.mono_font_family.clone(); + let well: Hsla = gpui::rgb(dialog::popover_rungs(cx).hover).into(); + let rungs = dialog::popover_rungs(cx); + let key = card.key; + let id = card_element_id(key); + let selected = self.board.selected == Some(key); + let dragged = self.board.dragging.as_ref().is_some_and(|d| d.key == key); + + let title_row = h_flex() + .items_start() + .gap(px(9.)) + .child(div().mt(px(1.)).child(crate::ui::tab_strip::avatar( + SharedString::from(format!("{id}-avatar")), + crate::ui::search::Avatar { + agent: card.agent, + status: card.status, + unread: 0, + ssh: None, + }, + AVATAR, + cx, + ))) + .child( + div() + .flex_1() + .min_w_0() + .text_size(rems(TAB_TEXT)) + .line_height(rems(TAB_TEXT * 1.4)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(fg) + .line_clamp(3) + .child(card.title.clone()), + ); + let group = card + .group + .and_then(|g| self.sidebar_groups.get(g)) + .map(group_label); + let place: Vec = [group, card.repo.clone(), card.branch.clone()] + .into_iter() + .flatten() + .collect(); + let place = (!place.is_empty()).then(|| { + div() + .truncate() + .text_size(rems(HEADING)) + .text_color(muted) + .child(place.join(" · ")) + }); + // A running agent's newest line of output. + let output = (card.column == Column::Running) + .then(|| self.card_view(card.tab, cx)) + .flatten() + .and_then(|v| v.read(cx).screen_tail(1).pop()) + .map(|line| { + div() + .truncate() + .px(px(7.)) + .py(px(5.)) + .rounded(px(5.)) + .bg(well) + .font_family(mono.clone()) + .text_size(rems(META_MONO)) + .text_color(muted) + .child(line) + }); + // A waiting agent's question, and the two ways to answer it. + let ask = (card.column == Column::NeedsInput).then(|| { + let (first, second) = match card.question { + // A question takes words; a permission prompt takes a yes. + true => ((L10nKey::BoardReply, true), (L10nKey::BoardOpen, false)), + false => ((L10nKey::BoardAllow, false), (L10nKey::BoardReply, true)), + }; + let tab = card.tab; + let action = |which: usize, + (label, reply): (L10nKey, bool), + tone: Tone, + cx: &mut Context| { + dialog::button( + SharedString::from(format!("{id}-act-{which}")), + t(label), + tone, + true, + rungs, + cx, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + match (label, reply, tab) { + (_, true, _) => this.focus_reply(key, window, cx), + (L10nKey::BoardAllow, _, _) => { + this.board_move(key, Column::Running, window, cx) + } + (_, _, Some(tab)) => this.open_card_tab(tab, window, cx), + _ => {} + } + }), + ) + .flex_1() + .h(px(24.)) + }; + v_flex() + .gap(px(8.)) + .when_some(card.ask.clone(), |v, ask| { + v.child( + div() + .px(px(8.)) + .py(px(6.)) + .rounded(px(5.)) + .bg(well) + .font_family(mono.clone()) + .text_size(rems(META_MONO)) + .line_height(rems(META_MONO * 1.5)) + .text_color(fg) + .line_clamp(4) + .child(ask), + ) + }) + .child( + h_flex() + .gap(px(6.)) + .child(action(0, first, Tone::Primary, cx)) + .child(action(1, second, Tone::Secondary, cx)), + ) + }); + let footer = { + let mut row = h_flex() + .items_center() + .gap(px(8.)) + .text_size(rems(META_MONO)) + .text_color(muted) + .font_features(crate::ui::tab_sidebar::tabular()); + if let Some((added, removed)) = card.diff.filter(|_| card.column != Column::Running) { + row = row.child( + h_flex() + .gap(px(4.)) + .child(div().text_color(success).child(format!("+{added}"))) + .child(div().text_color(danger).child(format!("−{removed}"))), + ); + } + if card.paused { + row = row.child(t(L10nKey::BoardStatusPaused)); + } else if card.resumable { + row = row.child(t(L10nKey::BoardResumable)); + } + row = row.child(div().flex_1()); + if let Some(since) = card.since { + row = row.child(ago(since)); + } + row + }; + let drag = CardDrag { + key, + from: card.column, + question: card.question, + title: card.title.clone(), + agent: card.agent, + }; + let app = cx.entity().downgrade(); + v_flex() + .id(SharedString::from(id.clone())) + .flex_none() + .gap(px(8.)) + .pt(px(11.)) + .px(px(12.)) + .pb(px(10.)) + .rounded(px(CARD_RADIUS)) + .bg(surface) + .border_1() + .when(selected, |c| c.border_color(fg)) + .when(!selected, |c| { + c.border_color(border.opacity(0.6)) + .hover(move |s| s.border_color(border)) + }) + .when(card.column == Column::Done, |c| c.opacity(0.6)) + .when(dragged, |c| c.opacity(0.35)) + .on_click(cx.listener(move |this, ev: &gpui::ClickEvent, window, cx| { + cx.stop_propagation(); + if ev.click_count() >= 2 { + this.open_card(key, window, cx); + } else { + this.select_card(key, true, window, cx); + window.focus(&this.board.focus, cx); + } + })) + .when(card.column != Column::Done, |c| { + c.on_drag(drag, move |drag, _, _, cx| { + let _ = app.update(cx, |this, cx| { + this.board.dragging = Some(drag.clone()); + this.board.selected = Some(drag.key); + cx.notify(); + }); + let ghost = CardGhost { + title: drag.title.clone(), + agent: drag.agent, + }; + cx.new(|_| ghost) + }) + }) + .child(title_row) + .children(place) + .children(output) + .children(ask) + .child(footer) + .into_any_element() + } + + fn render_toast(&self, cx: &mut Context) -> Option { + let toast = self.board.toast.as_ref()?; + let rungs = dialog::popover_rungs(cx); + let undo = toast.undo.is_some().then(|| { + dialog::button( + "board-toast-undo", + t(L10nKey::BoardUndo), + Tone::Secondary, + true, + rungs, + cx, + cx.listener(|this, _, window, cx| this.undo(window, cx)), + ) + .h(px(24.)) + .px(px(9.)) + }); + Some( + h_flex() + .absolute() + .bottom(px(24.)) + .left_0() + .right_0() + .justify_center() + .child( + h_flex() + .occlude() + .h(px(36.)) + .pl(px(14.)) + .pr(px(8.)) + .gap(px(14.)) + .items_center() + .map(|panel| crate::ui::theme::floating_surface(panel, cx)) + .rounded(px(9.)) + .text_size(rems(TAB_TEXT)) + .child(toast.text.clone()) + .children(undo), + ) + .into_any_element(), + ) + } + + /// The sidebar's Board row, under the search box. + pub(crate) fn render_board_nav(&self, cx: &mut Context) -> AnyElement { + let sf = cx.global::().rail; + let active = self.board_open(); + let theme = cx.theme(); + let needs = self.board_needs_input(cx); + let warn = theme.warning; + h_flex() + .id("sidebar-board") + .w_full() + .flex_shrink_0() + .h(px(30.)) + .px(px(8.)) + .gap(px(10.)) + .items_center() + .rounded(crate::ui::rounding::ROW_RADIUS) + .cursor_pointer() + .when(active, |s| { + s.bg(gpui::rgb(sf.selected)) + .text_color(gpui::rgb(sf.text_selected)) + .font_weight(gpui::FontWeight::MEDIUM) + }) + .when(!active, |s| { + s.text_color(gpui::rgb(sf.text_resting)) + .hover(|s| s.bg(gpui::rgb(sf.hover))) + }) + .child( + Icon::empty() + .path("icons/board.svg") + .size(px(14.)) + .text_color(theme.muted_foreground), + ) + .child(div().flex_1().child(t(L10nKey::BoardTitle))) + .when(needs > 0, |row| { + row.child( + div() + .min_w(px(18.)) + .h(px(18.)) + .px(px(5.)) + .rounded_full() + .bg(warn) + .flex() + .items_center() + .justify_center() + .text_size(rems(11. / 16.)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::white()) + .child(needs.min(99).to_string()), + ) + }) + .on_click(cx.listener(|this, _, window, cx| this.toggle_board(window, cx))) + .into_any_element() + } +} + +/// What follows the pointer while a card is dragged: its title on a card. +struct CardGhost { + title: SharedString, + agent: Option, +} + +impl Render for CardGhost { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .w(px(240.)) + .gap(px(9.)) + .px(px(12.)) + .py(px(10.)) + .rounded(px(CARD_RADIUS)) + .map(|panel| crate::ui::theme::floating_surface(panel, cx)) + .text_size(rems(TAB_TEXT)) + .font_weight(gpui::FontWeight::MEDIUM) + .child(crate::ui::tab_strip::avatar( + "board-ghost-avatar", + crate::ui::search::Avatar { + agent: self.agent, + ..Default::default() + }, + AVATAR, + cx, + )) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(self.title.clone()), + ) + } +} + +fn card_element_id(key: CardRef) -> String { + match key { + CardRef::Task(id) => format!("board-task-{id}"), + CardRef::Loose(id) => format!("board-tab-{id}"), + } +} + +/// What a pinned group is called: its given name, else its folder's last +/// component — the name its sidebar header shows. +pub(super) fn group_label(group: &PinnedGroup) -> String { + group + .given_name() + .map(str::to_string) + .or_else(|| { + group + .folder_path() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| t(L10nKey::BoardFieldGroup).to_string()) +} + +/// The dot a column heads itself with, and whether it is drawn as a ring. +/// The colours are the agent status dots the sidebar already uses, so a card +/// and its tab's row say a state the same way. +pub(super) fn column_dot(col: Column, cx: &App) -> (Hsla, bool) { + let muted = cx.theme().muted_foreground; + let status = |s: AgentStatus| -> Hsla { gpui::rgb(s.dot_rgb().unwrap_or(0x888888)).into() }; + match col { + Column::Queued => (muted, true), + Column::Running => (status(AgentStatus::Working), false), + Column::NeedsInput => (status(AgentStatus::Waiting), false), + Column::Review => (cx.theme().foreground, true), + Column::Done => (muted.opacity(0.6), false), + } +} diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 9c5bde2c..71fc985f 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1779,24 +1779,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::BoardSummary => "{running} running · {input} need you · {review} to review", L10nKey::BoardEmpty => "Nothing on the board yet", L10nKey::BoardEmptyHint => { - "Add a task with New task (N), or start an agent in any tab — it shows up here on its own." + "Add a task with New task (C), or start an agent in any tab — it shows up here on its own." } L10nKey::BoardOpen => "Open", - L10nKey::BoardStart => "Start", L10nKey::BoardResume => "Resume", L10nKey::BoardResumable => "Resumable", - L10nKey::BoardMarkDone => "Mark done", L10nKey::BoardReopen => "Reopen", - L10nKey::BoardEdit => "Edit", - L10nKey::BoardDelete => "Delete", - L10nKey::BoardKeep => "Keep as task", - L10nKey::BoardFieldTitle => "Title", - L10nKey::BoardFieldPrompt => "Prompt", - L10nKey::BoardFieldAgent => "Agent", - L10nKey::BoardTitlePlaceholder => "What should the agent do?", - L10nKey::BoardPromptPlaceholder => { - "Details, context, how to check it’s done… (optional — the title is sent if this is empty)" - } L10nKey::BoardSave => "Save", L10nKey::BoardAddToQueue => "Add to queue", L10nKey::BoardStartNow => "Start", @@ -1809,11 +1797,55 @@ pub fn translate_en(key: L10nKey) -> &'static str { "This machine’s server can’t keep tasks yet — update it to use the board" } L10nKey::BoardNow => "now", + L10nKey::BoardComposerPlaceholder => "Describe the task. The first line becomes its title.", + L10nKey::BoardComposerIn => "in", + L10nKey::BoardBranchAuto => "new worktree, name picked for you", + L10nKey::BoardInPlace => "in place · {cwd}", + L10nKey::BoardWorktreeTip => "Switch between a new git worktree and running in place", + L10nKey::BoardToastStarted => "Started · {title}", + L10nKey::BoardToastQueued => "Queued · {title}", + L10nKey::BoardToastPaused => "Paused · {title}", + L10nKey::BoardToastAllowed => "Allowed · {title}", + L10nKey::BoardToastSent => "Sent to {agent} · {title}", + L10nKey::BoardToastDone => "Done · {title}", + L10nKey::BoardToastReopened => "Reopened · {title}", + L10nKey::BoardToastRemoved => "Removed · {title}", + L10nKey::BoardUndo => "Undo", + L10nKey::BoardVerbStart => "Start", + L10nKey::BoardVerbPause => "Pause", + L10nKey::BoardVerbAllow => "Allow & resume", + L10nKey::BoardVerbReply => "Reply", + L10nKey::BoardVerbChanges => "Request changes", + L10nKey::BoardVerbDone => "Mark done", + L10nKey::BoardAllow => "Allow", + L10nKey::BoardReply => "Reply", + L10nKey::BoardOpenTerminal => "Open terminal", + L10nKey::BoardRemove => "Remove", + L10nKey::BoardPeekStart => "Start now", + L10nKey::BoardStatusPaused => "Paused", + L10nKey::BoardStatusReview => "Ready for review", + L10nKey::BoardPeekAgent => "agent", + L10nKey::BoardPeekRepo => "repo", + L10nKey::BoardPeekBranch => "branch", + L10nKey::BoardPeekChanges => "changes", + L10nKey::BoardPeekPrompt => "Prompt", + L10nKey::BoardPeekOutput => "Output", + L10nKey::BoardPeekWaiting => "Waiting for you", + L10nKey::BoardPeekFiles => "Files changed · {n}", + L10nKey::BoardPeekClose => "Close (esc)", + L10nKey::BoardReplyTo => "Reply to {agent}…", + L10nKey::BoardReplyChanges => "Request changes…", + L10nKey::BoardReplyFollowUp => "Send a follow-up…", + L10nKey::BoardReplyStart => "Tell {agent} what to do…", + L10nKey::BoardEmptyQueued => "Nothing queued", + L10nKey::BoardEmptyRunning => "No agents running", + L10nKey::BoardEmptyNeedsInput => "Nothing needs you", + L10nKey::BoardEmptyReview => "Nothing to review", + L10nKey::BoardEmptyDone => "Nothing done yet", + L10nKey::BoardShowAllAgents => "Show all agents", + L10nKey::BoardAgentsCount => "{n} agents", L10nKey::BoardFieldGroup => "Group", - L10nKey::BoardFieldWhere => "Where", - L10nKey::BoardNoPlace => "Pick where it runs", L10nKey::BoardAllGroups => "All groups", - L10nKey::BoardWorktree => "Run in a new git worktree", L10nKey::BoardWorktreeNeedsRepo => { "A worktree needs the task to run inside a git repository" } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index cce2cb21..74cfdb50 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1824,24 +1824,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::BoardSummary => "実行中 {running} · 入力待ち {input} · レビュー待ち {review}", L10nKey::BoardEmpty => "ボードはまだ空です", L10nKey::BoardEmptyHint => { - "「新しいタスク」(N) でタスクを追加するか、どれかのタブでエージェントを起動してください。自動的にここに表示されます。" + "「新しいタスク」(C) でタスクを追加するか、どれかのタブでエージェントを起動してください。自動的にここに表示されます。" } L10nKey::BoardOpen => "開く", - L10nKey::BoardStart => "開始", L10nKey::BoardResume => "再開", L10nKey::BoardResumable => "再開可能", - L10nKey::BoardMarkDone => "完了にする", L10nKey::BoardReopen => "再度開く", - L10nKey::BoardEdit => "編集", - L10nKey::BoardDelete => "削除", - L10nKey::BoardKeep => "タスクとして残す", - L10nKey::BoardFieldTitle => "タイトル", - L10nKey::BoardFieldPrompt => "プロンプト", - L10nKey::BoardFieldAgent => "エージェント", - L10nKey::BoardTitlePlaceholder => "エージェントに何をさせますか?", - L10nKey::BoardPromptPlaceholder => { - "詳細、背景、完了の確認方法など(任意 — 空ならタイトルを送ります)" - } L10nKey::BoardSave => "保存", L10nKey::BoardAddToQueue => "キューに追加", L10nKey::BoardStartNow => "開始", @@ -1854,11 +1842,55 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "このマシンのサーバーはまだタスクを保存できません。ボードを使うには更新してください" } L10nKey::BoardNow => "たった今", + L10nKey::BoardComposerPlaceholder => "タスクを書いてください。1 行目がタイトルになります。", + L10nKey::BoardComposerIn => "場所", + L10nKey::BoardBranchAuto => "新しい worktree(名前は自動)", + L10nKey::BoardInPlace => "その場で実行 · {cwd}", + L10nKey::BoardWorktreeTip => "新しい git worktree とその場での実行を切り替え", + L10nKey::BoardToastStarted => "開始しました · {title}", + L10nKey::BoardToastQueued => "キューに追加 · {title}", + L10nKey::BoardToastPaused => "一時停止 · {title}", + L10nKey::BoardToastAllowed => "許可しました · {title}", + L10nKey::BoardToastSent => "{agent} に送信 · {title}", + L10nKey::BoardToastDone => "完了 · {title}", + L10nKey::BoardToastReopened => "再度開きました · {title}", + L10nKey::BoardToastRemoved => "削除しました · {title}", + L10nKey::BoardUndo => "元に戻す", + L10nKey::BoardVerbStart => "開始", + L10nKey::BoardVerbPause => "一時停止", + L10nKey::BoardVerbAllow => "許可して再開", + L10nKey::BoardVerbReply => "返信", + L10nKey::BoardVerbChanges => "修正を依頼", + L10nKey::BoardVerbDone => "完了にする", + L10nKey::BoardAllow => "許可", + L10nKey::BoardReply => "返信", + L10nKey::BoardOpenTerminal => "ターミナルを開く", + L10nKey::BoardRemove => "削除", + L10nKey::BoardPeekStart => "今すぐ開始", + L10nKey::BoardStatusPaused => "一時停止中", + L10nKey::BoardStatusReview => "レビュー待ち", + L10nKey::BoardPeekAgent => "エージェント", + L10nKey::BoardPeekRepo => "リポジトリ", + L10nKey::BoardPeekBranch => "ブランチ", + L10nKey::BoardPeekChanges => "変更", + L10nKey::BoardPeekPrompt => "プロンプト", + L10nKey::BoardPeekOutput => "出力", + L10nKey::BoardPeekWaiting => "あなたの対応待ち", + L10nKey::BoardPeekFiles => "変更されたファイル · {n}", + L10nKey::BoardPeekClose => "閉じる (esc)", + L10nKey::BoardReplyTo => "{agent} に返信…", + L10nKey::BoardReplyChanges => "修正内容を書く…", + L10nKey::BoardReplyFollowUp => "追加の指示…", + L10nKey::BoardReplyStart => "{agent} にやることを伝える…", + L10nKey::BoardEmptyQueued => "キューは空です", + L10nKey::BoardEmptyRunning => "実行中のエージェントはありません", + L10nKey::BoardEmptyNeedsInput => "対応待ちはありません", + L10nKey::BoardEmptyReview => "レビュー待ちはありません", + L10nKey::BoardEmptyDone => "完了したものはまだありません", + L10nKey::BoardShowAllAgents => "すべてのエージェントを表示", + L10nKey::BoardAgentsCount => "{n} 個のエージェント", L10nKey::BoardFieldGroup => "グループ", - L10nKey::BoardFieldWhere => "実行場所", - L10nKey::BoardNoPlace => "実行場所を選択", L10nKey::BoardAllGroups => "すべてのグループ", - L10nKey::BoardWorktree => "新しい git worktree で実行", L10nKey::BoardWorktreeNeedsRepo => { "worktree を使うには、タスクの実行場所が git リポジトリ内である必要があります" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 559fcdd8..e0b78049 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1514,19 +1514,9 @@ l10n_keys! { BoardEmpty, BoardEmptyHint, BoardOpen, - BoardStart, BoardResume, BoardResumable, - BoardMarkDone, BoardReopen, - BoardEdit, - BoardDelete, - BoardKeep, - BoardFieldTitle, - BoardFieldPrompt, - BoardFieldAgent, - BoardTitlePlaceholder, - BoardPromptPlaceholder, BoardSave, BoardAddToQueue, BoardStartNow, @@ -1535,11 +1525,55 @@ l10n_keys! { BoardPromptCopied, BoardUnavailable, BoardNow, + BoardComposerPlaceholder, + BoardComposerIn, + BoardBranchAuto, + BoardInPlace, + BoardWorktreeTip, + BoardToastStarted, + BoardToastQueued, + BoardToastPaused, + BoardToastAllowed, + BoardToastSent, + BoardToastDone, + BoardToastReopened, + BoardToastRemoved, + BoardUndo, + BoardVerbStart, + BoardVerbPause, + BoardVerbAllow, + BoardVerbReply, + BoardVerbChanges, + BoardVerbDone, + BoardAllow, + BoardReply, + BoardOpenTerminal, + BoardRemove, + BoardPeekStart, + BoardStatusPaused, + BoardStatusReview, + BoardPeekAgent, + BoardPeekRepo, + BoardPeekBranch, + BoardPeekChanges, + BoardPeekPrompt, + BoardPeekOutput, + BoardPeekWaiting, + BoardPeekFiles, + BoardPeekClose, + BoardReplyTo, + BoardReplyChanges, + BoardReplyFollowUp, + BoardReplyStart, + BoardEmptyQueued, + BoardEmptyRunning, + BoardEmptyNeedsInput, + BoardEmptyReview, + BoardEmptyDone, + BoardShowAllAgents, + BoardAgentsCount, BoardFieldGroup, - BoardFieldWhere, - BoardNoPlace, BoardAllGroups, - BoardWorktree, BoardWorktreeNeedsRepo, BoardWorktreeFailed, CmdDocumentWidthThird, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 39bdb2bc..5f31b767 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1651,22 +1651,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::BoardSummary => "{running} 运行中 · {input} 等你 · {review} 待审阅", L10nKey::BoardEmpty => "看板上还没有东西", L10nKey::BoardEmptyHint => { - "用「新任务」(N) 添加一个任务,或者在任意标签页里启动 Agent——它会自己出现在这里。" + "用「新任务」(C) 添加一个任务,或者在任意标签页里启动 Agent——它会自己出现在这里。" } L10nKey::BoardOpen => "打开", - L10nKey::BoardStart => "开始", L10nKey::BoardResume => "恢复会话", L10nKey::BoardResumable => "可恢复", - L10nKey::BoardMarkDone => "标记完成", L10nKey::BoardReopen => "重新打开", - L10nKey::BoardEdit => "编辑", - L10nKey::BoardDelete => "删除", - L10nKey::BoardKeep => "保留为任务", - L10nKey::BoardFieldTitle => "标题", - L10nKey::BoardFieldPrompt => "提示词", - L10nKey::BoardFieldAgent => "使用的 Agent", - L10nKey::BoardTitlePlaceholder => "要 Agent 做什么?", - L10nKey::BoardPromptPlaceholder => "细节、上下文、怎么算完成……(可选,留空则发送标题)", L10nKey::BoardSave => "保存", L10nKey::BoardAddToQueue => "加入排队", L10nKey::BoardStartNow => "开始", @@ -1679,11 +1669,55 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "这台机器的 server 版本太旧,还不能保存任务——更新后即可使用看板" } L10nKey::BoardNow => "刚刚", + L10nKey::BoardComposerPlaceholder => "描述任务,第一行会作为标题。", + L10nKey::BoardComposerIn => "位置", + L10nKey::BoardBranchAuto => "新 worktree,自动命名", + L10nKey::BoardInPlace => "原地运行 · {cwd}", + L10nKey::BoardWorktreeTip => "在新 git worktree 与原地运行之间切换", + L10nKey::BoardToastStarted => "已开始 · {title}", + L10nKey::BoardToastQueued => "已加入排队 · {title}", + L10nKey::BoardToastPaused => "已暂停 · {title}", + L10nKey::BoardToastAllowed => "已允许 · {title}", + L10nKey::BoardToastSent => "已发给 {agent} · {title}", + L10nKey::BoardToastDone => "已完成 · {title}", + L10nKey::BoardToastReopened => "已重新打开 · {title}", + L10nKey::BoardToastRemoved => "已删除 · {title}", + L10nKey::BoardUndo => "撤销", + L10nKey::BoardVerbStart => "开始", + L10nKey::BoardVerbPause => "暂停", + L10nKey::BoardVerbAllow => "允许并继续", + L10nKey::BoardVerbReply => "回复", + L10nKey::BoardVerbChanges => "要求修改", + L10nKey::BoardVerbDone => "标记完成", + L10nKey::BoardAllow => "允许", + L10nKey::BoardReply => "回复", + L10nKey::BoardOpenTerminal => "打开终端", + L10nKey::BoardRemove => "删除", + L10nKey::BoardPeekStart => "立即开始", + L10nKey::BoardStatusPaused => "已暂停", + L10nKey::BoardStatusReview => "等待审阅", + L10nKey::BoardPeekAgent => "Agent", + L10nKey::BoardPeekRepo => "仓库", + L10nKey::BoardPeekBranch => "分支", + L10nKey::BoardPeekChanges => "改动", + L10nKey::BoardPeekPrompt => "任务描述", + L10nKey::BoardPeekOutput => "输出", + L10nKey::BoardPeekWaiting => "等你处理", + L10nKey::BoardPeekFiles => "改动的文件 · {n}", + L10nKey::BoardPeekClose => "关闭 (esc)", + L10nKey::BoardReplyTo => "回复 {agent}…", + L10nKey::BoardReplyChanges => "写下要修改的地方…", + L10nKey::BoardReplyFollowUp => "继续补充…", + L10nKey::BoardReplyStart => "告诉 {agent} 要做什么…", + L10nKey::BoardEmptyQueued => "没有排队的任务", + L10nKey::BoardEmptyRunning => "没有运行中的 Agent", + L10nKey::BoardEmptyNeedsInput => "没有等你处理的", + L10nKey::BoardEmptyReview => "没有待审阅的", + L10nKey::BoardEmptyDone => "还没有完成的", + L10nKey::BoardShowAllAgents => "显示全部 Agent", + L10nKey::BoardAgentsCount => "{n} 个 Agent", L10nKey::BoardFieldGroup => "分组", - L10nKey::BoardFieldWhere => "运行位置", - L10nKey::BoardNoPlace => "选择运行位置", L10nKey::BoardAllGroups => "全部分组", - L10nKey::BoardWorktree => "在新的 git worktree 中运行", L10nKey::BoardWorktreeNeedsRepo => "要用 worktree,任务的运行目录必须在 git 仓库里", L10nKey::BoardWorktreeFailed => "创建 worktree 失败:{error}", L10nKey::CmdDocumentWidthThird => "文档:三分之一宽",