diff --git a/crates/goat-agent/src/lib.rs b/crates/goat-agent/src/lib.rs index e50e2a9..f8418cd 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; @@ -40,6 +40,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."; @@ -123,6 +124,7 @@ struct Ctx<'a> { instructions: Option<&'a str>, semaphore: &'a Arc, child_ids: &'a AtomicU64, + asks: &'a Mutex>>>, rl_cache: &'a std::sync::Mutex, rl_path: Option<&'a std::path::Path>, } @@ -331,6 +333,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(), @@ -370,6 +373,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, rl_cache: &rl_cache, rl_path: rl_path.as_deref(), }; @@ -387,7 +391,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; @@ -973,7 +977,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) @@ -1438,12 +1444,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( @@ -1621,6 +1665,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 { @@ -1929,6 +2019,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 ae35c34..bf11cb2 100644 --- a/crates/goat-protocol/src/lib.rs +++ b/crates/goat-protocol/src/lib.rs @@ -219,6 +219,11 @@ pub enum Op { RenameThread { title: String, }, + Answer { + id: TaskId, + call: ToolCallId, + answers: Vec, + }, Shutdown, } @@ -306,6 +311,15 @@ pub enum Event { kind: NotifyKind, message: String, }, + AskStarted { + id: TaskId, + call: ToolCallId, + questions: Vec, + }, + AskDismissed { + id: TaskId, + call: ToolCallId, + }, Usage { id: TaskId, usage: Usage, @@ -318,3 +332,17 @@ pub enum Event { cached_at: i64, }, } + +#[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 14585cd..2944205 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; + } + } EngineEvent::Usage { id: _, usage, diff --git a/crates/goat-tui/src/app/keys.rs b/crates/goat-tui/src/app/keys.rs index 0e55ae8..d4f2d02 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; @@ -371,8 +372,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 f449a7f..a1b2961 100644 --- a/crates/goat-tui/src/app/mod.rs +++ b/crates/goat-tui/src/app/mod.rs @@ -8,7 +8,7 @@ use futures::StreamExt; use goat_commands::{CommandEffect, CommandRegistry}; use goat_protocol::{ AccountEntry, Effort, Event as EngineEvent, ModelEntry, ModelTarget, Op, RateLimitSnapshot, - TaskId, Usage, + TaskId, ToolCallId, Usage, }; use ratatui::DefaultTerminal; use tokio::sync::mpsc::{Receiver, Sender}; @@ -18,7 +18,7 @@ use crate::{ composer::Composer, config::{Config, ConfigOutcome}, highlight::SyntectHighlighter, - picker::{EffortPicker, Picker, ThreadPicker}, + picker::{AskPicker, EffortPicker, Picker, ThreadPicker}, symbols, theme::Theme, transcript::Transcript, @@ -53,6 +53,7 @@ pub(crate) enum Overlay { Config(Config), Commands(CommandMenu), Agents(usize), + Ask(AskPicker, ToolCallId), Usage, } @@ -170,6 +171,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 1895ca2..65588ee 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()