feat(board): v2 interactions — peek panel, legal drags, reply, toasts

Rewrites the board to the v2 interaction comp (src/ui/board/).

- Click a card for a peek panel beside the columns (agent, repo, branch,
  changes, the prompt, the agent's latest screen lines, what it waits on,
  files changed from `git diff --numstat HEAD`) with a reply line and the
  column's actions. Double-click / Enter opens its terminal; a queued task
  opens for editing.
- Drag a card only where its agent can go: start, pause (ESC + a `paused`
  mark that holds until the agent works again), allow & resume, request
  changes (focuses the reply), mark done. Targets say the verb; the rest
  fade.
- Waiting cards: Allow for a permission prompt, Reply for a question —
  `AgentSessionState::question` now tells the two apart.
- A toast says what happened, with Undo for what the board itself changed.
- ← → ↑ ↓ move between cards, Esc peels back reply → panel → selection →
  board, C (or N) opens New task.
- New task: "in" place chips from the sidebar, one text box (first line is
  the title), agent pills, and a footer showing the branch the run gets —
  click it to run in place instead.
- Agent filter is a multi-select with counts; empty columns say so.
This commit is contained in:
l0ng-ai
2026-09-29 09:11:56 +08:00
parent 9866e9765f
commit bc35088a76
16 changed files with 3277 additions and 1706 deletions
+26
View File
@@ -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();
+24
View File
@@ -92,6 +92,11 @@ pub struct Task {
pub runs: Vec<Run>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub done: Option<Done>,
/// 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<u64>,
}
/// One agent started for a task.
@@ -198,6 +203,9 @@ pub fn column(task: &Task, live: impl Fn(&Run) -> Option<Live>) -> 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);
+1
View File
@@ -1741,6 +1741,7 @@ mod tests {
activity: 3,
turns: 1,
inferred: false,
question: false,
},
}])),
ControlReply::Ok(ReplyOk::AgentStates(Vec::new())),
+1
View File
@@ -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!(
+1
View File
@@ -1906,6 +1906,7 @@ mod tests {
activity: 12,
turns: 4,
inferred: false,
question: false,
})),
DaemonMsg::AgentStatus(None),
DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }),
+3
View File
@@ -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();
+34
View File
@@ -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<String> {
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();
-1647
View File
File diff suppressed because it is too large Load Diff
+482
View File
@@ -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<TaskId>,
text: Entity<InputState>,
agent: Option<CLIAgent>,
cwd: Option<PathBuf>,
/// The pinned group the task is filed under, when its place is one.
group: Option<GroupId>,
worktree: bool,
_subs: Vec<Subscription>,
}
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::<String>()
.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<TaskId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
if let Some(c) = self.board.composer.as_mut() {
c.agent = Some(agent);
cx.notify();
}
}
fn toggle_composer_worktree(&mut self, cx: &mut Context<Self>) {
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<Self>) -> Option<AnyElement> {
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");
}
}
+1194
View File
File diff suppressed because it is too large Load Diff
+512
View File
@@ -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<Self>) {
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<Self>,
) {
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>,
) {
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<Self>) -> 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<Self>,
run: Box<dyn Fn(&mut Tty7App, &mut Window, &mut Context<Tty7App>)>| {
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<Self>| {
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<Self>| {
button(
id,
label,
tone,
cx,
Box::new(move |this, window, cx| this.board_move(key, to, window, cx)),
)
};
let mut actions: Vec<gpui::Stateful<gpui::Div>> = 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()
}
}
+808
View File
@@ -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<Self>,
) -> Option<AnyElement> {
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<Self>) -> 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<GroupId>| {
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<Self>) {
let everyone: Vec<CLIAgent> = {
let mut all: Vec<CLIAgent> = 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<Self>) -> 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<Self>) -> 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::<CardDrag>()
.is_some_and(|d| moves_from(d.from).contains(&col))
})
.drag_over::<CardDrag>(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<Self>) -> 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<String> = [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<Self>| {
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<Self>) -> Option<AnyElement> {
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<Self>) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().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<CLIAgent>,
}
impl Render for CardGhost {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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),
}
}
+48 -16
View File
@@ -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"
}
+48 -16
View File
@@ -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 リポジトリ内である必要があります"
}
+47 -13
View File
@@ -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,
+48 -14
View File
@@ -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 => "文档:三分之一宽",