From d43d04d26814aa18ec9dd3fe1214b6221fd84383 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Tue, 9 Jun 2026 21:58:07 +0900 Subject: [PATCH] feat: add Ask tool for agent-to-user questions during a turn --- crates/goat-agent/src/lib.rs | 113 ++++++++- crates/goat-protocol/src/lib.rs | 28 +++ crates/goat-tui/src/app/engine.rs | 14 +- crates/goat-tui/src/app/keys.rs | 107 ++++++++- crates/goat-tui/src/app/mod.rs | 8 +- crates/goat-tui/src/picker.rs | 373 +++++++++++++++++++++++++++++- crates/goat-tui/src/view.rs | 20 ++ 7 files changed, 649 insertions(+), 14 deletions(-) diff --git a/crates/goat-agent/src/lib.rs b/crates/goat-agent/src/lib.rs index e6f3108..8e44a8e 100644 --- a/crates/goat-agent/src/lib.rs +++ b/crates/goat-agent/src/lib.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, fmt::Write as _, path::Path, sync::{ @@ -14,9 +14,9 @@ use goat_auth::{ }; use goat_core::Engine; use goat_protocol::{ - AccountChoice, AccountEntry, AccountInfo, AuthMethod, Effort, Event, LoginCredential, - LoginProvider, ModelEntry, ModelTarget, NotifyKind, Op, SkillInfo, TaskId, ThreadSummary, - ToolCall, ToolCallId, ToolOutcome, TranscriptEntry, + AccountChoice, AccountEntry, AccountInfo, AskQuestion, AuthMethod, Effort, Event, + LoginCredential, LoginProvider, ModelEntry, ModelTarget, NotifyKind, Op, SkillInfo, TaskId, + ThreadSummary, ToolCall, ToolCallId, ToolOutcome, TranscriptEntry, }; use goat_provider::{ ContentBlock, Message, MessageRole, Provider, Request, StreamEvent, ToolDefinition, @@ -26,7 +26,7 @@ use goat_store::{NewMessage, NewThread, NewToolCall, NewTurn, Store}; use goat_tool::ToolContext; use goat_tools::ToolRegistry; use tokio::{ - sync::{Semaphore, mpsc}, + sync::{Mutex, Semaphore, mpsc, oneshot}, task::JoinHandle, }; use tokio_util::sync::CancellationToken; @@ -39,6 +39,7 @@ pub use agent::{AgentRegistry, AgentSpec, ToolSelection}; const MAX_TOOL_ROUNDS: usize = 20; const MAX_CONCURRENT_AGENTS: usize = 8; const AGENT_TOOL_NAME: &str = "Agent"; +const ASK_TOOL_NAME: &str = "Ask"; const CHILD_ID_BASE: u64 = 1 << 32; const SYSTEM_PROMPT: &str = "You are Goat, an expert software engineering assistant. You help users understand, build, and improve software by reading code, running tools, and providing accurate, actionable guidance. When using tools, prefer targeted reads and searches over broad exploration. Always verify your understanding before making changes."; @@ -122,6 +123,7 @@ struct Ctx<'a> { instructions: Option<&'a str>, semaphore: &'a Arc, child_ids: &'a AtomicU64, + asks: &'a Mutex>>>, } enum Flow { @@ -322,6 +324,7 @@ async fn run(agent: GoatAgent, mut ops: mpsc::Receiver, events: mpsc::Sender let project_instructions = instructions::load_project_instructions(&cwd); let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_AGENTS)); let child_ids = AtomicU64::new(CHILD_ID_BASE); + let asks: Mutex>>> = Mutex::new(HashMap::new()); let _ = events .send(Event::SkillsChanged { skills: skills.clone(), @@ -342,6 +345,7 @@ async fn run(agent: GoatAgent, mut ops: mpsc::Receiver, events: mpsc::Sender instructions: project_instructions.as_deref(), semaphore: &semaphore, child_ids: &child_ids, + asks: &asks, }; if let Flow::Shutdown = handle_turn( &ctx, @@ -357,7 +361,7 @@ async fn run(agent: GoatAgent, mut ops: mpsc::Receiver, events: mpsc::Sender break; } } - Op::Interrupt { .. } | Op::SetTheme { .. } => {} + Op::Interrupt { .. } | Op::SetTheme { .. } | Op::Answer { .. } => {} Op::Clear => { history.clear(); thread_id = None; @@ -923,7 +927,9 @@ async fn execute_tool( tool_ctx: &ToolContext, token: &CancellationToken, ) -> ToolExecResult { - let step = if prep.name == AGENT_TOOL_NAME && env.allow_delegate { + let step = if prep.name == ASK_TOOL_NAME && env.allow_delegate { + Some(run_ask(ctx, run, prep.input_json, ToolCallId(prep.tui_id), token).await) + } else if prep.name == AGENT_TOOL_NAME && env.allow_delegate { match ctx.semaphore.acquire().await { Ok(_permit) if !token.is_cancelled() => { Some(run_delegation(ctx, env, prep.input_json, run.id, token).await) @@ -1346,12 +1352,50 @@ fn build_tool_defs( input_schema: spec.parameters, }) .collect(); - if allow_delegate && !ctx.agents.is_empty() { - defs.push(agent_tool_def(ctx)); + if allow_delegate { + if !ctx.agents.is_empty() { + defs.push(agent_tool_def(ctx)); + } + defs.push(ask_tool_def()); } defs } +fn ask_tool_def() -> ToolDefinition { + ToolDefinition { + name: ASK_TOOL_NAME.to_owned(), + description: "Pause execution and ask the user one or more questions, each with optional choice options. Returns the user's answers as a JSON array of strings in the same order as the questions. Use when you need the user's input or a decision before proceeding.".to_owned(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "questions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "question": { "type": "string" }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { "type": "string" }, + "description": { "type": "string" } + }, + "required": ["label"] + } + } + }, + "required": ["question"] + } + } + }, + "required": ["questions"] + }), + } +} + fn agent_tool_def(ctx: &Ctx<'_>) -> ToolDefinition { let names: Vec = ctx.agents.names(); let mut description = String::from( @@ -1529,6 +1573,52 @@ async fn run_delegation( result } +async fn run_ask( + ctx: &Ctx<'_>, + run: &Run<'_>, + input_json: &str, + call_id: ToolCallId, + token: &CancellationToken, +) -> Result { + #[derive(serde::Deserialize)] + struct Input { + questions: Vec, + } + let args: Input = + serde_json::from_str(input_json).map_err(|err| format!("invalid Ask input: {err}"))?; + if args.questions.is_empty() { + return Err("questions must not be empty".to_owned()); + } + let (tx, rx) = oneshot::channel::>(); + ctx.asks.lock().await.insert(call_id, tx); + let _ = ctx + .events + .send(Event::AskStarted { + id: run.id, + call: call_id, + questions: args.questions, + }) + .await; + let result = tokio::select! { + biased; + () = token.cancelled() => { + ctx.asks.lock().await.remove(&call_id); + let _ = ctx + .events + .send(Event::AskDismissed { id: run.id, call: call_id }) + .await; + return Err("interrupted".to_owned()); + } + res = rx => res, + }; + match result { + Ok(answers) => { + serde_json::to_string(&answers).map_err(|err| format!("serialize error: {err}")) + } + Err(_) => Err("answer channel closed".to_owned()), + } +} + fn delegation_label(prompt: &str) -> String { let line = prompt.lines().next().unwrap_or("").trim(); if line.chars().count() > 50 { @@ -1837,6 +1927,11 @@ async fn handle_turn( biased; result = &mut core => break result, maybe_op = ops.recv() => match maybe_op { + Some(Op::Answer { call, answers, .. }) => { + if let Some(tx) = ctx.asks.lock().await.remove(&call) { + let _ = tx.send(answers); + } + } Some(Op::Interrupt { id: target_id }) if target_id == id => token.cancel(), Some(Op::Shutdown) | None => { shutdown = true; diff --git a/crates/goat-protocol/src/lib.rs b/crates/goat-protocol/src/lib.rs index 576946a..47ec960 100644 --- a/crates/goat-protocol/src/lib.rs +++ b/crates/goat-protocol/src/lib.rs @@ -199,6 +199,11 @@ pub enum Op { RenameThread { title: String, }, + Answer { + id: TaskId, + call: ToolCallId, + answers: Vec, + }, Shutdown, } @@ -286,4 +291,27 @@ pub enum Event { kind: NotifyKind, message: String, }, + AskStarted { + id: TaskId, + call: ToolCallId, + questions: Vec, + }, + AskDismissed { + id: TaskId, + call: ToolCallId, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AskOption { + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AskQuestion { + pub question: String, + #[serde(default)] + pub options: Vec, } diff --git a/crates/goat-tui/src/app/engine.rs b/crates/goat-tui/src/app/engine.rs index 231cd20..d62a9ef 100644 --- a/crates/goat-tui/src/app/engine.rs +++ b/crates/goat-tui/src/app/engine.rs @@ -1,7 +1,7 @@ use goat_protocol::{Event as EngineEvent, Op, TaskId, TranscriptEntry}; use super::{App, Overlay, ResumeIntent}; -use crate::picker::ThreadPicker; +use crate::picker::{AskPicker, ThreadPicker}; impl App { #[allow(clippy::too_many_lines)] @@ -142,6 +142,18 @@ impl App { self.toasts.push(crate::toast::Toast::new(kind, message)); self.dirty = true; } + EngineEvent::AskStarted { + call, questions, .. + } => { + self.overlay = Overlay::Ask(AskPicker::new(questions), call); + self.dirty = true; + } + EngineEvent::AskDismissed { .. } => { + if matches!(self.overlay, Overlay::Ask(..)) { + self.overlay = Overlay::None; + self.dirty = true; + } + } } if self.follow { self.scroll = u16::MAX; diff --git a/crates/goat-tui/src/app/keys.rs b/crates/goat-tui/src/app/keys.rs index 2e43fd0..7f70478 100644 --- a/crates/goat-tui/src/app/keys.rs +++ b/crates/goat-tui/src/app/keys.rs @@ -5,7 +5,7 @@ use super::{App, Overlay, QUIT_ARM_TICKS}; use crate::{ config::ConfigOutcome, keymap, - picker::{EffortOutcome, PickerOutcome, ThreadOutcome}, + picker::{AskOutcome, EffortOutcome, PickerOutcome, ThreadOutcome}, }; impl App { @@ -17,6 +17,7 @@ impl App { Overlay::Thread(_) => return self.on_thread_picker_key(key), Overlay::Config(_) => return self.on_config_key(key), Overlay::Agents(_) => return self.on_agent_selector_key(key), + Overlay::Ask(_, _) => return self.on_ask_picker_key(key), Overlay::Commands(_) => { if let Some(result) = self.on_command_menu_key(key) { return result; @@ -370,8 +371,112 @@ impl App { Vec::new() } + pub(crate) fn on_ask_picker_key(&mut self, key: KeyEvent) -> Vec { + self.dirty = true; + if let Some(ch) = keymap::ctrl_key(&key) { + if ch == 'c' { + self.overlay = Overlay::None; + if let Some(id) = self.active { + return vec![Op::Interrupt { id }]; + } + } + return Vec::new(); + } + match key.code { + KeyCode::Esc => return self.ask_esc(), + KeyCode::Up => { + if let Overlay::Ask(ref mut picker, _) = self.overlay { + picker.move_up(); + } + } + KeyCode::Down => { + if let Overlay::Ask(ref mut picker, _) = self.overlay { + picker.move_down(); + } + } + KeyCode::Left => { + if let Overlay::Ask(ref mut picker, _) = self.overlay { + picker.go_back(); + } + } + KeyCode::Right => { + let outcome = if let Overlay::Ask(ref mut picker, call) = self.overlay { + match picker.skip() { + AskOutcome::Submit(answers) => Some((call, answers)), + AskOutcome::Pending | AskOutcome::NoOp => None, + } + } else { + None + }; + if let Some((call, answers)) = outcome { + self.overlay = Overlay::None; + if let Some(id) = self.active { + return vec![Op::Answer { id, call, answers }]; + } + } + } + KeyCode::Backspace => { + if let Overlay::Ask(ref mut picker, _) = self.overlay { + picker.backspace(); + } + } + KeyCode::Enter => return self.ask_enter(), + KeyCode::Char(c) => { + if let Overlay::Ask(ref mut picker, _) = self.overlay { + picker.on_char(c); + } + } + _ => {} + } + Vec::new() + } + + fn ask_esc(&mut self) -> Vec { + let handled = if let Overlay::Ask(ref mut picker, _) = self.overlay { + picker.is_confirming() || picker.is_typing() + } else { + false + }; + if handled { + if let Overlay::Ask(ref mut picker, _) = self.overlay { + picker.go_back(); + } + return Vec::new(); + } + self.overlay = Overlay::None; + if let Some(id) = self.active { + return vec![Op::Interrupt { id }]; + } + Vec::new() + } + + fn ask_enter(&mut self) -> Vec { + let submit = if let Overlay::Ask(ref mut picker, call) = self.overlay { + match picker.choose() { + AskOutcome::Submit(answers) => Some((call, answers)), + AskOutcome::Pending | AskOutcome::NoOp => None, + } + } else { + None + }; + if let Some((call, answers)) = submit { + self.overlay = Overlay::None; + if let Some(id) = self.active { + return vec![Op::Answer { id, call, answers }]; + } + } + Vec::new() + } + pub(crate) fn on_ctrl_c(&mut self) -> Vec { self.dirty = true; + if matches!(self.overlay, Overlay::Ask(..)) { + self.overlay = Overlay::None; + if let Some(id) = self.active { + return vec![Op::Interrupt { id }]; + } + return Vec::new(); + } if let Some(id) = self.active { return vec![Op::Interrupt { id }]; } diff --git a/crates/goat-tui/src/app/mod.rs b/crates/goat-tui/src/app/mod.rs index de7e671..9a1b9e9 100644 --- a/crates/goat-tui/src/app/mod.rs +++ b/crates/goat-tui/src/app/mod.rs @@ -7,7 +7,7 @@ use crossterm::event::{Event as CtEvent, EventStream, KeyEventKind, MouseEventKi use futures::StreamExt; use goat_commands::{CommandEffect, CommandRegistry}; use goat_protocol::{ - AccountEntry, Effort, Event as EngineEvent, ModelEntry, ModelTarget, Op, TaskId, + AccountEntry, Effort, Event as EngineEvent, ModelEntry, ModelTarget, Op, TaskId, ToolCallId, }; use ratatui::DefaultTerminal; use tokio::sync::mpsc::{Receiver, Sender}; @@ -17,7 +17,7 @@ use crate::{ composer::Composer, config::{Config, ConfigOutcome}, highlight::SyntectHighlighter, - picker::{EffortPicker, Picker, ThreadPicker}, + picker::{AskPicker, EffortPicker, Picker, ThreadPicker}, symbols, theme::Theme, transcript::Transcript, @@ -50,6 +50,7 @@ pub(crate) enum Overlay { Config(Config), Commands(CommandMenu), Agents(usize), + Ask(AskPicker, ToolCallId), } const TICK: Duration = Duration::from_millis(120); @@ -158,6 +159,9 @@ impl App { config.on_char(ch); } } + Overlay::Ask(picker, _) => { + picker.insert_str(&text); + } _ => { self.composer.insert_str(&text); self.update_command_menu(); diff --git a/crates/goat-tui/src/picker.rs b/crates/goat-tui/src/picker.rs index 5a08398..143a2ef 100644 --- a/crates/goat-tui/src/picker.rs +++ b/crates/goat-tui/src/picker.rs @@ -3,8 +3,10 @@ use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, text::{Line, Span}, - widgets::Paragraph, + widgets::{Block, BorderType, Paragraph}, }; +use unicode_normalization::UnicodeNormalization; +use unicode_width::UnicodeWidthStr; use crate::{ overlay::{centered_rect, clamp_u16, overflow_hint, overlay_frame, selection_row}, @@ -594,6 +596,375 @@ fn render_account(frame: &mut Frame, inner: Rect, theme: Theme, account: &Accoun ); } +pub enum AskOutcome { + NoOp, + Pending, + Submit(Vec), +} + +pub struct AskPicker { + pub questions: Vec, + pub cursor: usize, + current_q: usize, + answers: Vec>, + typing: bool, + input: String, + confirming: bool, +} + +impl AskPicker { + pub fn new(questions: Vec) -> Self { + let count = questions.len(); + Self { + questions, + cursor: 0, + current_q: 0, + answers: vec![None; count], + typing: false, + input: String::new(), + confirming: false, + } + } + + pub fn is_confirming(&self) -> bool { + self.confirming + } + + pub fn is_typing(&self) -> bool { + self.typing + } + + pub fn insert_str(&mut self, text: &str) { + let on_input_row = self.cursor == self.questions[self.current_q].options.len(); + if self.typing || on_input_row { + self.typing = true; + for ch in text.nfc() { + self.input.push(ch); + } + } + } + + pub fn move_up(&mut self) { + if self.confirming { + return; + } + if self.typing { + self.typing = false; + } + self.cursor = self.cursor.saturating_sub(1); + } + + pub fn move_down(&mut self) { + if self.confirming { + return; + } + if self.typing { + self.typing = false; + } + let max = self.questions[self.current_q].options.len(); + if self.cursor < max { + self.cursor += 1; + } + } + + pub fn on_char(&mut self, ch: char) { + if self.confirming { + return; + } + let on_input_row = self.cursor == self.questions[self.current_q].options.len(); + if self.typing || on_input_row { + self.typing = true; + self.input.push(ch); + } + } + + pub fn backspace(&mut self) { + if self.typing { + self.input.pop(); + } + } + + pub fn choose(&mut self) -> AskOutcome { + if self.confirming { + return self.finish(); + } + let type_own_idx = self.questions[self.current_q].options.len(); + if self.cursor == type_own_idx { + if !self.input.is_empty() { + let answer = std::mem::take(&mut self.input); + self.typing = false; + return self.record_answer(answer); + } + self.typing = true; + return AskOutcome::Pending; + } + if let Some(opt) = self.questions[self.current_q].options.get(self.cursor) { + let answer = opt.label.clone(); + self.cursor = 0; + return self.record_answer(answer); + } + AskOutcome::NoOp + } + + pub fn skip(&mut self) -> AskOutcome { + if self.confirming { + return AskOutcome::NoOp; + } + self.typing = false; + self.input.clear(); + self.advance() + } + + pub fn go_back(&mut self) { + if self.confirming { + self.confirming = false; + let last = self.questions.len().saturating_sub(1); + self.current_q = last; + self.restore_cursor(last); + return; + } + if self.typing { + self.typing = false; + return; + } + if self.current_q > 0 { + self.current_q -= 1; + self.restore_cursor(self.current_q); + } + } + + fn restore_cursor(&mut self, q_idx: usize) { + self.typing = false; + self.input.clear(); + self.cursor = 0; + if let Some(Some(saved)) = self.answers.get(q_idx).cloned() { + let pos = self.questions[q_idx] + .options + .iter() + .position(|o| o.label == saved); + if let Some(pos) = pos { + self.cursor = pos; + } else { + self.input = saved; + self.cursor = self.questions[q_idx].options.len(); + } + } + } + + fn record_answer(&mut self, answer: String) -> AskOutcome { + self.answers[self.current_q] = Some(answer); + self.advance() + } + + fn advance(&mut self) -> AskOutcome { + if self.current_q + 1 < self.questions.len() { + self.current_q += 1; + self.cursor = 0; + self.typing = false; + self.input.clear(); + AskOutcome::Pending + } else if self.questions.len() > 1 { + self.confirming = true; + AskOutcome::Pending + } else { + self.finish() + } + } + + fn finish(&self) -> AskOutcome { + let answers = self + .answers + .iter() + .map(|a| a.clone().unwrap_or_default()) + .collect(); + AskOutcome::Submit(answers) + } + + pub fn desired_height(&self) -> u16 { + if self.confirming { + let rows = clamp_u16(self.questions.len() * 2); + rows.saturating_add(6) + } else { + let q = &self.questions[self.current_q]; + let rows = clamp_u16(q.options.len() + 1).min(12); + rows.saturating_add(6) + } + } + + pub fn render(&self, frame: &mut Frame, area: Rect, theme: Theme) { + let panel_h = self.desired_height(); + let [_, outer] = + Layout::vertical([Constraint::Min(1), Constraint::Length(panel_h)]).areas(area); + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(theme.border()) + .style(theme.base()); + let inner = block.inner(outer); + frame.render_widget(block, outer); + if self.confirming { + self.render_confirm(frame, inner, theme); + } else { + let [title_area, _, list_area, _, hint_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); + self.render_title(frame, title_area, theme); + self.render_list(frame, list_area, theme); + self.render_hint(frame, hint_area, theme); + } + } + + fn render_title(&self, frame: &mut Frame, area: Rect, theme: Theme) { + let q = &self.questions[self.current_q]; + let total = self.questions.len(); + let dots = if total > 1 { + let dot_str: Vec<&str> = (0..total) + .map(|i| { + if i == self.current_q { + symbols::ui::DOT_FULL + } else { + symbols::ui::DOT_EMPTY + } + }) + .collect(); + format!(" {}", dot_str.join(" ")) + } else { + String::new() + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!(" {}", q.question), theme.base()), + Span::styled(dots, theme.muted()), + ])), + area, + ); + } + + fn render_list(&self, frame: &mut Frame, area: Rect, theme: Theme) { + let q = &self.questions[self.current_q]; + let width = usize::from(area.width); + let type_own_idx = q.options.len(); + let mut lines: Vec = Vec::new(); + for (i, opt) in q.options.iter().enumerate() { + let selected = i == self.cursor; + let label_style = theme.base(); + let right = opt + .description + .as_deref() + .map(|d| Span::styled(d.to_owned(), theme.muted())); + lines.push(selection_row( + theme, + selected, + width, + vec![Span::styled(opt.label.clone(), label_style)], + right, + )); + } + let input_selected = self.cursor == type_own_idx; + let input_content = if self.typing || !self.input.is_empty() { + Span::styled(format!(" {}", self.input), theme.base()) + } else { + Span::styled(" type your answer", theme.muted()) + }; + lines.push(selection_row( + theme, + input_selected, + width, + vec![input_content], + None, + )); + frame.render_widget(Paragraph::new(lines), area); + if self.typing && input_selected { + let row_y = area.y + clamp_u16(type_own_idx); + let col = 4 + UnicodeWidthStr::width(self.input.as_str()); + let x = area.x + clamp_u16(col); + frame.set_cursor_position((x.min(area.right().saturating_sub(1)), row_y)); + } + } + + fn render_confirm(&self, frame: &mut Frame, area: Rect, theme: Theme) { + let [title_area, _, list_area, _, hint_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(area); + frame.render_widget( + Paragraph::new(Line::from(Span::styled(" Confirm", theme.muted()))), + title_area, + ); + let mut lines: Vec = Vec::new(); + for (i, q) in self.questions.iter().enumerate() { + let answer = self.answers[i].as_deref().filter(|s| !s.is_empty()); + let (answer_text, answer_style) = match answer { + Some(a) => (a.to_owned(), theme.base()), + None => ("—".to_owned(), theme.muted()), + }; + lines.push(Line::from(Span::styled( + format!(" {}", q.question), + theme.muted(), + ))); + lines.push(Line::from(Span::styled( + format!(" {answer_text}"), + answer_style, + ))); + } + frame.render_widget(Paragraph::new(lines), list_area); + frame.render_widget( + Paragraph::new(hint_line( + &[ + (symbols::key::ENTER, " submit"), + ("←", " edit"), + ("esc", " cancel"), + ], + symbols::ui::SEPARATOR, + theme, + )), + hint_area, + ); + } + + fn render_hint(&self, frame: &mut Frame, area: Rect, theme: Theme) { + let total = self.questions.len(); + let hint = if self.typing { + hint_line( + &[(symbols::key::ENTER, " confirm"), ("esc", " back")], + symbols::ui::SEPARATOR, + theme, + ) + } else if total > 1 { + hint_line( + &[ + (symbols::key::ARROWS_UPDOWN, " navigate"), + (symbols::key::ENTER, " next"), + ("→", " skip"), + ("←", " back"), + ("esc", " cancel"), + ], + symbols::ui::SEPARATOR, + theme, + ) + } else { + hint_line( + &[ + (symbols::key::ARROWS_UPDOWN, " navigate"), + (symbols::key::ENTER, " select"), + ("esc", " cancel"), + ], + symbols::ui::SEPARATOR, + theme, + ) + }; + frame.render_widget(Paragraph::new(hint), area); + } +} + #[cfg(test)] mod tests { use goat_protocol::{AccountChoice, ModelEntry, ModelTarget}; diff --git a/crates/goat-tui/src/view.rs b/crates/goat-tui/src/view.rs index 2c110ab..2cc289e 100644 --- a/crates/goat-tui/src/view.rs +++ b/crates/goat-tui/src/view.rs @@ -24,6 +24,26 @@ pub fn render(frame: &mut Frame, app: &mut App) { let composer_h = app.composer_height(area.width); + if let Overlay::Ask(picker, _) = app.overlay() { + let panel_h = picker + .desired_height() + .min(area.height.saturating_sub(2)) + .max(3); + let [header, transcript_area, _panel] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(panel_h), + ]) + .areas(area); + render_header(frame, header, app, theme); + render_transcript(frame, transcript_area, app, theme); + if let Overlay::Ask(picker, _) = app.overlay() { + picker.render(frame, area, theme); + } + render_toasts(frame, area, app, theme); + return; + } + if let Overlay::Commands(menu) = app.overlay() { let panel_h = menu .desired_height()