fix(board): a question is a question — show it, answer it in the terminal

- Claude's AskUserQuestion also goes through the permission dialog, and the
  PermissionRequest that follows its PreToolUse turned the question into a
  permission prompt: the card offered Allow on a multiple choice. The hook
  now reads a question tool's permission request as a question, and the
  state machine keeps a waiting question through one (for older hooks).
- The hook carries the question and its options (tool_input.questions) as
  AgentAsk, and names a bare permission prompt after what it would run
  ('Bash: cargo test').
- A waiting question shows its text and numbered answers on the card and
  in the panel, with Answer in terminal (the picker takes arrow keys, which
  the board does not fake); a permission prompt keeps Allow. No reply box
  while waiting: typed words answer neither.
- Output no longer puts a space between the characters of CJK text: the
  spacer cell of a wide character is skipped.
This commit is contained in:
l0ng-ai
2026-09-29 10:21:05 +08:00
parent cf9c830c55
commit 8a218e208d
14 changed files with 365 additions and 80 deletions
+111
View File
@@ -56,6 +56,19 @@ fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<
let tool = payload.get("tool_name").and_then(|t| t.as_str())?;
return QUESTION_TOOLS.contains(&tool).then_some("question-asked");
}
// Claude puts its question tool through the permission dialog as well,
// right after the `PreToolUse` that said it was a question. Read as a
// permission request it would turn the question into a yes/no — and a
// board would offer to "allow" a multiple choice.
if event == "permission-request"
&& let Ok(payload) = serde_json::from_str::<serde_json::Value>(stdin_json)
&& payload
.get("tool_name")
.and_then(|t| t.as_str())
.is_some_and(|tool| QUESTION_TOOLS.contains(&tool))
{
return Some("question-asked");
}
// Antigravity has no turn-start event, only `PreInvocation` before every
// model call. The first call of a turn starts it; later ones are the same
// turn still working, and must not count as new prompts. `Stop` with
@@ -158,9 +171,74 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec<u8> {
{
body["prompt"] = serde_json::Value::String(prompt);
}
if let Some(ask) = question_of(&payload) {
body["ask"] = ask;
}
// A permission prompt that says nothing of itself — Claude's
// `PermissionRequest` carries the tool and its input, not a message —
// is named after what it would run.
if body.get("message").is_none()
&& let Some(tool) = payload.get("tool_name").and_then(|t| t.as_str())
&& !QUESTION_TOOLS.contains(&tool)
{
let input = payload.get("tool_input");
let detail = ["command", "file_path", "url", "pattern", "path"]
.iter()
.find_map(|k| input.and_then(|i| i.get(*k)).and_then(|v| v.as_str()));
let text = match detail {
Some(detail) => format!("{tool}: {}", clip(detail, ASK_TEXT_MAX)),
None => tool.to_string(),
};
body["message"] = serde_json::Value::String(text);
}
format!("\x1b]777;notify;{AGENT_EVENT_SENTINEL};{body}\x07").into_bytes()
}
/// How long the question and each of its answers may be. The whole event
/// rides one OSC, which a tokenizer drops outright past 8 KiB.
const ASK_TEXT_MAX: usize = 300;
const ASK_OPTIONS_MAX: usize = 8;
fn clip(text: &str, max: usize) -> String {
let text = text.trim();
match text.chars().count() > max {
true => format!("{}…", text.chars().take(max - 1).collect::<String>()),
false => text.to_string(),
}
}
/// The first question a question tool's call asks, with its answers — Claude's
/// `AskUserQuestion` and Codex's `request_user_input` both spell them as
/// `tool_input.questions[]` of `{question, options[{label}], multiSelect}`.
fn question_of(payload: &serde_json::Value) -> Option<serde_json::Value> {
let q = payload
.get("tool_input")?
.get("questions")?
.as_array()?
.first()?;
let question = q.get("question").and_then(|v| v.as_str())?;
let options: Vec<String> = q
.get("options")
.and_then(|o| o.as_array())
.map(|o| {
o.iter()
.filter_map(|opt| {
opt.get("label")
.and_then(|l| l.as_str())
.or_else(|| opt.as_str())
})
.take(ASK_OPTIONS_MAX)
.map(|l| clip(l, 80))
.collect()
})
.unwrap_or_default();
Some(serde_json::json!({
"question": clip(question, ASK_TEXT_MAX),
"options": options,
"multi": q.get("multiSelect").and_then(|m| m.as_bool()).unwrap_or(false),
}))
}
/// How much of a prompt rides back to the terminal.
///
/// Two reasons it is short. The payload goes out as an OSC, and the tokenizer
@@ -1994,6 +2072,39 @@ mod tests {
assert_eq!(effective_agent("grok", false), "grok");
}
#[test]
fn claudes_question_through_the_permission_dialog_stays_a_question() {
let input = r#"{"tool_name":"AskUserQuestion","tool_input":{}}"#;
assert_eq!(
effective_event("claude", "permission-request", input),
Some("question-asked")
);
assert_eq!(
effective_event("claude", "permission-request", r#"{"tool_name":"Bash"}"#),
Some("permission-request")
);
}
#[test]
fn a_question_carries_its_options_and_a_permission_what_it_would_run() {
let input = r#"{"tool_name":"AskUserQuestion","tool_input":{"questions":[
{"question":"Keep which feature?","header":"One","multiSelect":false,
"options":[{"label":"SSH","description":"x"},{"label":"Hooks"}]}]}}"#;
let ev = round_trip("claude", "question-asked", input);
let ask = ev.ask.expect("the question rides along");
assert_eq!(ask.question, "Keep which feature?");
assert_eq!(ask.options, vec!["SSH", "Hooks"]);
assert!(!ask.multi);
let ev = round_trip(
"claude",
"permission-request",
r#"{"tool_name":"Bash","tool_input":{"command":"cargo test"}}"#,
);
assert_eq!(ev.message.as_deref(), Some("Bash: cargo test"));
assert!(ev.ask.is_none());
}
#[test]
fn tool_start_only_speaks_for_question_tools() {
for (agent, tool) in [
+61
View File
@@ -984,6 +984,22 @@ pub struct AgentSessionState {
/// anything offering a one-click "allow" has to tell them apart.
#[serde(default)]
pub question: bool,
/// The question itself, when the agent said what it is — from the
/// tool call that asks it, not from the screen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ask: Option<AgentAsk>,
}
/// A question an agent put to the user, as its tool call spelled it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentAsk {
pub question: String,
/// The answers it offers, by label; empty for a free-text question.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<String>,
/// Any number of the options may be picked.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub multi: bool,
}
impl AgentStatus {
@@ -1054,11 +1070,20 @@ impl AgentSessionState {
AgentEventKind::PromptSubmit => {
self.status = AgentStatus::Working;
self.message = None;
self.ask = None;
}
// Claude sends its question tool through the permission dialog
// too, right after the `PreToolUse` that said it was a question.
// The hook reads that one as a question itself; this covers a
// hook from an older build. Nothing else can come between: the
// question is answered first, which fires `ToolComplete`.
AgentEventKind::PermissionRequest
if self.status == AgentStatus::Waiting && self.question => {}
AgentEventKind::PermissionRequest | AgentEventKind::QuestionAsked => {
self.status = AgentStatus::Waiting;
self.message = ev.message.clone();
self.question = ev.kind == AgentEventKind::QuestionAsked;
self.ask = ev.ask.clone().filter(|_| self.question);
}
AgentEventKind::Notification => {
if self.status == AgentStatus::Working {
@@ -1072,6 +1097,7 @@ impl AgentSessionState {
if self.status == AgentStatus::Waiting || guessed {
self.status = AgentStatus::Working;
self.message = None;
self.ask = None;
}
}
AgentEventKind::Stop => {
@@ -1080,6 +1106,7 @@ impl AgentSessionState {
}
self.status = AgentStatus::Done;
self.message = ev.message.clone();
self.ask = None;
}
AgentEventKind::SessionEnd => {
self.status = AgentStatus::Idle;
@@ -1117,6 +1144,8 @@ pub struct AgentEvent {
/// Separate from `message`, which carries what the *agent* said and is
/// deliberately cleared when a turn starts.
pub prompt: Option<String>,
/// The question, on a `QuestionAsked` whose tool call spelled one out.
pub ask: Option<AgentAsk>,
}
pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
@@ -1140,6 +1169,8 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
cwd: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
ask: Option<AgentAsk>,
}
let w: Wire = serde_json::from_slice(json).ok()?;
@@ -1152,6 +1183,7 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
message: nonempty(w.message),
cwd: nonempty(w.cwd).map(std::path::PathBuf::from),
prompt: nonempty(w.prompt),
ask: w.ask.filter(|a| !a.question.trim().is_empty()),
})
}
@@ -1606,6 +1638,27 @@ mod tests {
);
}
#[test]
fn the_permission_dialog_a_question_goes_through_does_not_unmake_it() {
let ev = |kind| AgentEvent {
agent: Some(CLIAgent::Claude),
kind,
session_id: None,
message: None,
cwd: None,
prompt: None,
ask: None,
};
let mut s = AgentSessionState::default();
s.apply_event(&ev(AgentEventKind::PromptSubmit));
s.apply_event(&ev(AgentEventKind::QuestionAsked));
s.apply_event(&ev(AgentEventKind::PermissionRequest));
assert!(s.question);
s.apply_event(&ev(AgentEventKind::ToolComplete));
s.apply_event(&ev(AgentEventKind::PermissionRequest));
assert!(!s.question, "a real permission prompt later is one");
}
#[test]
fn a_waiting_agent_says_whether_it_asked_a_question_or_for_permission() {
let ev = |kind| AgentEvent {
@@ -1615,11 +1668,13 @@ mod tests {
message: None,
cwd: None,
prompt: None,
ask: 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::ToolComplete));
s.apply_event(&ev(AgentEventKind::PermissionRequest));
assert!(!s.question);
}
@@ -1636,6 +1691,7 @@ mod tests {
message: msg.map(String::from),
cwd: None,
prompt: None,
ask: None,
};
s.apply_event(&ev(AgentEventKind::SessionStart, None, Some("sid-1")));
@@ -1692,6 +1748,7 @@ mod tests {
message: None,
cwd: None,
prompt: None,
ask: None,
};
let mut s = AgentSessionState::default();
@@ -1726,6 +1783,7 @@ mod tests {
message: None,
cwd: None,
prompt: None,
ask: None,
};
let mut s = AgentSessionState::default();
@@ -1778,6 +1836,7 @@ mod tests {
message: None,
cwd: None,
prompt: None,
ask: None,
};
let mut s = AgentSessionState::default();
@@ -1815,6 +1874,7 @@ mod tests {
message: None,
cwd: None,
prompt: None,
ask: None,
};
let mut s = AgentSessionState::default();
@@ -1847,6 +1907,7 @@ mod tests {
message: None,
cwd: cwd.map(PathBuf::from),
prompt: None,
ask: None,
};
let mut s = AgentSessionState::default();
+1
View File
@@ -1742,6 +1742,7 @@ mod tests {
turns: 1,
inferred: false,
question: false,
ask: None,
},
}])),
ControlReply::Ok(ReplyOk::AgentStates(Vec::new())),
+2
View File
@@ -5465,6 +5465,7 @@ mod tests {
message: None,
cwd: None,
prompt: None,
ask: None,
}],
None,
);
@@ -5772,6 +5773,7 @@ mod tests {
turns: 0,
inferred: false,
question: false,
ask: None,
});
apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07"));
assert_eq!(
+1
View File
@@ -1907,6 +1907,7 @@ mod tests {
turns: 4,
inferred: false,
question: false,
ask: None,
})),
DaemonMsg::AgentStatus(None),
DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }),
+3
View File
@@ -6498,6 +6498,7 @@ mod tests {
turns: 0,
inferred: false,
question: false,
ask: None,
}))
.encode(daemon)
.unwrap();
@@ -6561,6 +6562,7 @@ mod tests {
turns: 0,
inferred: false,
question: false,
ask: None,
}))
.encode(&mut daemon_side)
.unwrap();
@@ -6613,6 +6615,7 @@ mod tests {
turns: 0,
inferred: false,
question: false,
ask: None,
}))
.encode(&mut daemon_side)
.unwrap();
+17 -1
View File
@@ -2280,7 +2280,19 @@ impl TerminalView {
let rows: Vec<String> = (0..grid.screen_lines() as i32)
.map(|line| {
let row = &grid[Line(line)];
(0..cols).map(|c| row[Column(c)].c).collect()
// A wide character (CJK, emoji) takes two cells, the second
// a spacer holding a blank; kept, it put a space between
// every character of Chinese text.
(0..cols)
.map(|c| &row[Column(c)])
.filter(|cell| {
!cell.flags.intersects(
alacritty_terminal::term::cell::Flags::WIDE_CHAR_SPACER
| alacritty_terminal::term::cell::Flags::LEADING_WIDE_CHAR_SPACER,
)
})
.map(|cell| cell.c)
.collect()
})
.collect();
output_tail(&rows, n)
@@ -10848,6 +10860,7 @@ mod gpui_tests {
turns: 0,
inferred: false,
question: false,
ask: None,
}))
.encode(daemon)
.unwrap();
@@ -10906,6 +10919,7 @@ mod gpui_tests {
turns: 0,
inferred: false,
question: false,
ask: None,
}))
.encode(&mut daemon)
.unwrap();
@@ -10975,6 +10989,7 @@ mod gpui_tests {
turns,
inferred: false,
question: false,
ask: None,
};
DaemonMsg::AgentStatus(Some(state.clone()))
.encode(daemon)
@@ -11434,6 +11449,7 @@ mod gpui_tests {
turns: 0,
inferred: false,
question: false,
ask: None,
}))
.encode(&mut daemon)
.unwrap();
+33 -5
View File
@@ -155,6 +155,8 @@ struct Card {
cwd: Option<PathBuf>,
/// What the agent is stopped on.
ask: Option<SharedString>,
/// The answers a waiting question offers, when its tool call named them.
options: Vec<String>,
question: bool,
diff: Option<(u32, u32)>,
/// When this card's current state began, in Unix seconds, if known.
@@ -175,6 +177,7 @@ struct TabFacts {
agent: CLIAgent,
live: Live,
ask: Option<String>,
options: Vec<String>,
question: bool,
cwd: Option<PathBuf>,
branch: Option<String>,
@@ -198,7 +201,7 @@ pub(crate) fn moves_from(from: Column) -> &'static [Column] {
/// What a move is called on the column it drops into.
fn move_verb(from: Column, to: Column, question: bool) -> L10nKey {
match (from, to) {
(Column::NeedsInput, Column::Running) if question => L10nKey::BoardVerbReply,
(Column::NeedsInput, Column::Running) if question => L10nKey::BoardAnswerInTerminal,
(Column::NeedsInput, Column::Running) => L10nKey::BoardVerbAllow,
(Column::Review, Column::Running) => L10nKey::BoardVerbChanges,
(_, Column::Queued) => L10nKey::BoardVerbPause,
@@ -342,7 +345,25 @@ impl Tty7App {
id: tab.tree_id.get(),
agent: view.agent()?,
live: Live::from(&session),
ask: waiting.then(|| session.message.clone()).flatten(),
// A question says itself; a permission prompt says what
// it would run.
ask: waiting
.then(|| {
session
.ask
.as_ref()
.map(|a| a.question.clone())
.or_else(|| session.message.clone())
})
.flatten(),
options: match waiting {
true => session
.ask
.as_ref()
.map(|a| a.options.clone())
.unwrap_or_default(),
false => Vec::new(),
},
question: waiting && session.question,
cwd: view.cwd(),
branch: git.as_ref().map(|g| g.branch.clone()),
@@ -408,6 +429,7 @@ impl Tty7App {
branch: open.and_then(|f| f.branch.clone()),
cwd,
ask: open.and_then(|f| f.ask.clone()).map(Into::into),
options: open.map(|f| f.options.clone()).unwrap_or_default(),
question: open.is_some_and(|f| f.question),
diff: open.and_then(|f| f.diff),
since: match column {
@@ -443,6 +465,7 @@ impl Tty7App {
branch: f.branch.clone(),
cwd: f.cwd.clone(),
ask: f.ask.clone().map(Into::into),
options: f.options.clone(),
question: f.question,
diff: f.diff,
since: None,
@@ -636,9 +659,14 @@ impl Tty7App {
match (card.column, to) {
(Column::Queued, Column::Running) => self.start_card(&card, window, cx),
(_, Column::Queued) => self.pause_card(&card, window, cx),
(Column::NeedsInput, Column::Running) if !card.question => {
self.allow_card(&card, window, cx)
// A question is answered where it is asked: its picker takes
// arrow keys and a choice, which the board does not fake.
(Column::NeedsInput, Column::Running) if card.question => {
if let Some(tab) = card.tab {
self.open_card_tab(tab, window, cx)
}
}
(Column::NeedsInput, Column::Running) => self.allow_card(&card, window, cx),
(_, Column::Running) => self.focus_reply(key, window, cx),
(_, Column::Done) => self.mark_done(key, window, cx),
_ => {}
@@ -1249,7 +1277,7 @@ mod tests {
fn a_waiting_question_is_answered_not_allowed() {
assert_eq!(
move_verb(Column::NeedsInput, Column::Running, true),
L10nKey::BoardVerbReply
L10nKey::BoardAnswerInTerminal
);
assert_eq!(
move_verb(Column::NeedsInput, Column::Running, false),
+51 -15
View File
@@ -229,20 +229,38 @@ impl Tty7App {
)
});
let waiting = (card.column == Column::NeedsInput).then(|| {
section(
t(L10nKey::BoardPeekWaiting).into(),
theme.warning,
block()
let text = card
.ask
.clone()
.unwrap_or_else(|| t(L10nKey::BoardColNeedsInput).into());
let body = match card.question {
// A question reads as prose, its answers as a numbered list.
true => block()
.line_height(rems(TAB_TEXT * 1.5))
.text_color(fg)
.child(div().font_weight(gpui::FontWeight::MEDIUM).child(text))
.when(!card.options.is_empty(), |b| {
b.child(
v_flex().mt(px(6.)).gap(px(2.)).text_color(muted).children(
card.options
.iter()
.enumerate()
.map(|(i, o)| div().child(format!("{}. {o}", i + 1))),
),
)
}),
// A permission prompt is a command: set as one.
false => 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(),
.child(text),
};
section(
t(L10nKey::BoardPeekWaiting).into(),
theme.warning,
body.into_any_element(),
)
});
let files = match &self.board.files {
@@ -297,9 +315,11 @@ impl Tty7App {
// A line to the agent, while there is one open to hear it.
let can_reply = card.tab.is_some()
// A waiting agent is in a picker or a permission dialog, which
// typed words would not answer.
&& matches!(
card.column,
Column::Running | Column::NeedsInput | Column::Review | Column::Queued
Column::Running | Column::Review | Column::Queued
);
let reply = self
.board
@@ -426,9 +446,25 @@ impl Tty7App {
));
actions.extend(open_terminal(cx));
}
Column::NeedsInput => {
actions.extend(open_terminal(cx));
if !question {
Column::NeedsInput => match question {
true => {
actions.push(move_to(
"board-peek-pause",
L10nKey::BoardVerbPause,
Tone::Secondary,
Column::Queued,
cx,
));
actions.push(move_to(
"board-peek-answer",
L10nKey::BoardAnswerInTerminal,
Tone::Primary,
Column::Running,
cx,
));
}
false => {
actions.extend(open_terminal(cx));
actions.push(move_to(
"board-peek-allow",
L10nKey::BoardAllow,
@@ -437,7 +473,7 @@ impl Tty7App {
cx,
));
}
}
},
Column::Review => {
actions.extend(open_terminal(cx));
actions.push(move_to(
+81 -59
View File
@@ -28,6 +28,8 @@ const CARD_RADIUS: f32 = 9.;
const AVATAR: f32 = 16.;
const DROP_H: f32 = 44.;
const EMPTY_H: f32 = 64.;
/// Answers a waiting card lists before it says how many more there are.
const OPTIONS_SHOWN: usize = 4;
impl Tty7App {
/// The board over the terminal area, or `None` while the terminal shows.
@@ -565,63 +567,83 @@ impl Tty7App {
.text_color(muted)
.child(line)
});
// A waiting agent's question, and the two ways to answer it.
// A waiting agent's question and its answers, or the command a
// permission prompt would run — and the way to answer each.
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)
let action =
|which: usize, label: L10nKey, tone: Tone, allow: bool, 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 (allow, tab) {
(true, _) => this.board_move(key, Column::Running, window, cx),
(false, Some(tab)) => this.open_card_tab(tab, window, cx),
(false, None) => {}
}
(_, _, Some(tab)) => this.open_card_tab(tab, window, cx),
_ => {}
}
}),
)
.flex_1()
.h(px(24.))
}),
)
.flex_1()
.h(px(24.))
};
let text = card.ask.clone().map(|ask| match card.question {
true => div()
.text_size(rems(TAB_TEXT))
.line_height(rems(TAB_TEXT * 1.4))
.text_color(fg)
.line_clamp(3)
.child(ask),
false => 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),
});
let options = (!card.options.is_empty()).then(|| {
let more = card.options.len().saturating_sub(OPTIONS_SHOWN);
v_flex()
.gap(px(2.))
.text_size(rems(HEADING))
.text_color(muted)
.children(
card.options
.iter()
.take(OPTIONS_SHOWN)
.enumerate()
.map(|(i, o)| div().truncate().child(format!("{}. {o}", i + 1))),
)
.when(more > 0, |v| v.child(format!("+{more}")))
});
let buttons = match card.question {
true => h_flex().child(action(
0,
L10nKey::BoardAnswerInTerminal,
Tone::Primary,
false,
cx,
)),
false => h_flex()
.gap(px(6.))
.child(action(0, L10nKey::BoardAllow, Tone::Primary, true, cx))
.child(action(1, L10nKey::BoardOpen, Tone::Secondary, false, cx)),
};
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)),
)
.children(text)
.children(options)
.child(buttons)
});
let footer = {
let mut row = h_flex()
@@ -857,14 +879,14 @@ fn card_menu(menu: PopupMenu, c: &CardMenu, app: gpui::WeakEntity<Tty7App>) -> P
Column::Running => menu
.item(item(L10nKey::BoardReply, reply))
.item(item(L10nKey::BoardVerbPause, to(Column::Queued))),
Column::NeedsInput => {
let menu = match c.question {
true => menu,
false => menu.item(item(L10nKey::BoardAllow, to(Column::Running))),
};
menu.item(item(L10nKey::BoardReply, reply))
.item(item(L10nKey::BoardVerbPause, to(Column::Queued)))
}
Column::NeedsInput => match c.question {
true => menu
.item(item(L10nKey::BoardAnswerInTerminal, to(Column::Running)))
.item(item(L10nKey::BoardVerbPause, to(Column::Queued))),
false => menu
.item(item(L10nKey::BoardAllow, to(Column::Running)))
.item(item(L10nKey::BoardVerbPause, to(Column::Queued))),
},
Column::Review => menu
.item(item(L10nKey::BoardVerbChanges, reply))
.item(item(L10nKey::BoardVerbDone, to(Column::Done))),
+1
View File
@@ -1797,6 +1797,7 @@ 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::BoardAnswerInTerminal => "Answer in terminal",
L10nKey::BoardEdit => "Edit…",
L10nKey::BoardKeep => "Keep as task",
L10nKey::BoardToastKept => "Kept as a task · {title}",
+1
View File
@@ -1842,6 +1842,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
"このマシンのサーバーはまだタスクを保存できません。ボードを使うには更新してください"
}
L10nKey::BoardNow => "たった今",
L10nKey::BoardAnswerInTerminal => "ターミナルで回答",
L10nKey::BoardEdit => "編集…",
L10nKey::BoardKeep => "タスクとして残す",
L10nKey::BoardToastKept => "タスクとして残しました · {title}",
+1
View File
@@ -1525,6 +1525,7 @@ l10n_keys! {
BoardPromptCopied,
BoardUnavailable,
BoardNow,
BoardAnswerInTerminal,
BoardEdit,
BoardKeep,
BoardToastKept,
+1
View File
@@ -1669,6 +1669,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
"这台机器的 server 版本太旧,还不能保存任务——更新后即可使用看板"
}
L10nKey::BoardNow => "刚刚",
L10nKey::BoardAnswerInTerminal => "去终端回答",
L10nKey::BoardEdit => "编辑…",
L10nKey::BoardKeep => "保留为任务",
L10nKey::BoardToastKept => "已保留为任务 · {title}",