mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 08:15:35 +00:00
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:
@@ -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 [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1742,6 +1742,7 @@ mod tests {
|
||||
turns: 1,
|
||||
inferred: false,
|
||||
question: false,
|
||||
ask: None,
|
||||
},
|
||||
}])),
|
||||
ControlReply::Ok(ReplyOk::AgentStates(Vec::new())),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -1907,6 +1907,7 @@ mod tests {
|
||||
turns: 4,
|
||||
inferred: false,
|
||||
question: false,
|
||||
ask: None,
|
||||
})),
|
||||
DaemonMsg::AgentStatus(None),
|
||||
DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))),
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -1842,6 +1842,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
"このマシンのサーバーはまだタスクを保存できません。ボードを使うには更新してください"
|
||||
}
|
||||
L10nKey::BoardNow => "たった今",
|
||||
L10nKey::BoardAnswerInTerminal => "ターミナルで回答",
|
||||
L10nKey::BoardEdit => "編集…",
|
||||
L10nKey::BoardKeep => "タスクとして残す",
|
||||
L10nKey::BoardToastKept => "タスクとして残しました · {title}",
|
||||
|
||||
@@ -1525,6 +1525,7 @@ l10n_keys! {
|
||||
BoardPromptCopied,
|
||||
BoardUnavailable,
|
||||
BoardNow,
|
||||
BoardAnswerInTerminal,
|
||||
BoardEdit,
|
||||
BoardKeep,
|
||||
BoardToastKept,
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user