From 8a218e208df7783af895784cc4f9a9e5d9530a7f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:21:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(board):=20a=20question=20is=20a=20question?= =?UTF-8?q?=20=E2=80=94=20show=20it,=20answer=20it=20in=20the=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- crates/tty7-core/src/core/agent_hooks.rs | 111 ++++++++++++++++++ crates/tty7-core/src/core/cli_agent.rs | 61 ++++++++++ crates/tty7-core/src/daemon/control.rs | 1 + crates/tty7-core/src/daemon/pane.rs | 2 + crates/tty7-core/src/daemon/protocol.rs | 1 + src/terminal/remote.rs | 3 + src/terminal/view.rs | 18 ++- src/ui/board/mod.rs | 38 +++++- src/ui/board/peek.rs | 66 ++++++++--- src/ui/board/view.rs | 140 +++++++++++++---------- src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/zh.rs | 1 + 14 files changed, 365 insertions(+), 80 deletions(-) diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index afac7e88..f6aa2125 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -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::(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 { { 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::()), + 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 { + 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 = 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 [ diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 860d1154..1c0c57b8 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -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, +} + +/// 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, + /// 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, + /// The question, on a `QuestionAsked` whose tool call spelled one out. + pub ask: Option, } pub fn parse_agent_event(payload: &[u8]) -> Option { @@ -1140,6 +1169,8 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { cwd: Option, #[serde(default)] prompt: Option, + #[serde(default)] + ask: Option, } let w: Wire = serde_json::from_slice(json).ok()?; @@ -1152,6 +1183,7 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { 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(); diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index c0faaa3a..34961be5 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -1742,6 +1742,7 @@ mod tests { turns: 1, inferred: false, question: false, + ask: None, }, }])), ControlReply::Ok(ReplyOk::AgentStates(Vec::new())), diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index afe09439..d6c2d439 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -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!( diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 19295b6d..976efe86 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -1907,6 +1907,7 @@ mod tests { turns: 4, inferred: false, question: false, + ask: None, })), DaemonMsg::AgentStatus(None), DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }), diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 4e78790b..17b590fa 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -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(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 8e86c90e..e86ed3fb 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2280,7 +2280,19 @@ impl TerminalView { let rows: Vec = (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(); diff --git a/src/ui/board/mod.rs b/src/ui/board/mod.rs index 00d23333..0c01ecbb 100644 --- a/src/ui/board/mod.rs +++ b/src/ui/board/mod.rs @@ -155,6 +155,8 @@ struct Card { cwd: Option, /// What the agent is stopped on. ask: Option, + /// The answers a waiting question offers, when its tool call named them. + options: Vec, 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, + options: Vec, question: bool, cwd: Option, branch: Option, @@ -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), diff --git a/src/ui/board/peek.rs b/src/ui/board/peek.rs index d6d2d738..62cf2525 100644 --- a/src/ui/board/peek.rs +++ b/src/ui/board/peek.rs @@ -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( diff --git a/src/ui/board/view.rs b/src/ui/board/view.rs index c335318f..e9f0a33e 100644 --- a/src/ui/board/view.rs +++ b/src/ui/board/view.rs @@ -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| { - 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| { + 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) -> 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))), diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 4421da8d..021eb098 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -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}", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 007631db..2cf472a8 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1842,6 +1842,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "このマシンのサーバーはまだタスクを保存できません。ボードを使うには更新してください" } L10nKey::BoardNow => "たった今", + L10nKey::BoardAnswerInTerminal => "ターミナルで回答", L10nKey::BoardEdit => "編集…", L10nKey::BoardKeep => "タスクとして残す", L10nKey::BoardToastKept => "タスクとして残しました · {title}", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 124645eb..17989161 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1525,6 +1525,7 @@ l10n_keys! { BoardPromptCopied, BoardUnavailable, BoardNow, + BoardAnswerInTerminal, BoardEdit, BoardKeep, BoardToastKept, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 27e15e99..b921a807 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -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}",