mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1741,6 +1741,7 @@ mod tests {
|
||||
activity: 3,
|
||||
turns: 1,
|
||||
inferred: false,
|
||||
question: false,
|
||||
},
|
||||
}])),
|
||||
ControlReply::Ok(ReplyOk::AgentStates(Vec::new())),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -1906,6 +1906,7 @@ mod tests {
|
||||
activity: 12,
|
||||
turns: 4,
|
||||
inferred: false,
|
||||
question: false,
|
||||
})),
|
||||
DaemonMsg::AgentStatus(None),
|
||||
DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 => "文档:三分之一宽",
|
||||
|
||||
Reference in New Issue
Block a user