Add tool use to the orchestrator (#4)
Add tool use without sandboxing. Currently available tools are list dir, read file, write file and exec bash. Reviewed-on: #4 Co-authored-by: Drew Galbraith <drew@tiramisu.one> Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
parent
6b85ff3cb8
commit
797d7564b7
20 changed files with 1822 additions and 129 deletions
|
|
@ -11,11 +11,14 @@ use crate::core::types::{Role, UIEvent};
|
|||
/// This is non-blocking: it processes all currently-available events and returns
|
||||
/// immediately when the channel is empty.
|
||||
///
|
||||
/// | Event | Effect |
|
||||
/// |--------------------|------------------------------------------------------------|
|
||||
/// | `StreamDelta(s)` | Append `s` to last message if it's `Assistant`; else push new |
|
||||
/// | `TurnComplete` | No structural change; logged at debug level |
|
||||
/// | `Error(msg)` | Push `(Assistant, "[error] {msg}")` |
|
||||
/// | Event | Effect |
|
||||
/// |------------------------|------------------------------------------------------------|
|
||||
/// | `StreamDelta(s)` | Append `s` to last message if it's `Assistant`; else push |
|
||||
/// | `ToolApprovalRequest` | Set `pending_approval` in state |
|
||||
/// | `ToolExecuting` | Display tool execution info |
|
||||
/// | `ToolResult` | Display tool result |
|
||||
/// | `TurnComplete` | No structural change; logged at debug level |
|
||||
/// | `Error(msg)` | Push `(Assistant, "[error] {msg}")` |
|
||||
pub(super) fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mut AppState) {
|
||||
while let Ok(event) = event_rx.try_recv() {
|
||||
match event {
|
||||
|
|
@ -26,6 +29,36 @@ pub(super) fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mu
|
|||
state.messages.push((Role::Assistant, chunk));
|
||||
}
|
||||
}
|
||||
UIEvent::ToolApprovalRequest {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
input_summary,
|
||||
} => {
|
||||
state.pending_approval = Some(PendingApproval {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
input_summary,
|
||||
});
|
||||
}
|
||||
UIEvent::ToolExecuting {
|
||||
tool_name,
|
||||
input_summary,
|
||||
} => {
|
||||
state
|
||||
.messages
|
||||
.push((Role::Assistant, format!("[{tool_name}] {input_summary}")));
|
||||
}
|
||||
UIEvent::ToolResult {
|
||||
tool_name,
|
||||
output_summary,
|
||||
is_error,
|
||||
} => {
|
||||
let prefix = if is_error { "error" } else { "result" };
|
||||
state.messages.push((
|
||||
Role::Assistant,
|
||||
format!("[{tool_name} {prefix}] {output_summary}"),
|
||||
));
|
||||
}
|
||||
UIEvent::TurnComplete => {
|
||||
debug!("turn complete");
|
||||
}
|
||||
|
|
@ -38,6 +71,14 @@ pub(super) fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mu
|
|||
}
|
||||
}
|
||||
|
||||
/// A pending tool approval request waiting for user input.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingApproval {
|
||||
pub tool_use_id: String,
|
||||
pub tool_name: String,
|
||||
pub input_summary: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -69,4 +110,39 @@ mod tests {
|
|||
assert_eq!(state.messages[1].0, Role::Assistant);
|
||||
assert_eq!(state.messages[1].1, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drain_tool_approval_sets_pending() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
|
||||
let mut state = AppState::new();
|
||||
tx.send(UIEvent::ToolApprovalRequest {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: "write_file".to_string(),
|
||||
input_summary: "path: foo.txt".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
drain_ui_events(&mut rx, &mut state);
|
||||
assert!(state.pending_approval.is_some());
|
||||
let approval = state.pending_approval.unwrap();
|
||||
assert_eq!(approval.tool_name, "write_file");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drain_tool_result_adds_message() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
|
||||
let mut state = AppState::new();
|
||||
tx.send(UIEvent::ToolResult {
|
||||
tool_name: "read_file".to_string(),
|
||||
output_summary: "file contents...".to_string(),
|
||||
is_error: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
drain_ui_events(&mut rx, &mut state);
|
||||
assert_eq!(state.messages.len(), 1);
|
||||
assert!(state.messages[0].1.contains("read_file result"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ pub(super) enum LoopControl {
|
|||
Quit,
|
||||
/// The user ran `:clear`; wipe the conversation.
|
||||
ClearHistory,
|
||||
/// The user responded to a tool approval prompt.
|
||||
ToolApproval { tool_use_id: String, approved: bool },
|
||||
}
|
||||
|
||||
/// Map a key event to a [`LoopControl`] signal, mutating `state` as a side-effect.
|
||||
|
|
@ -23,6 +25,29 @@ pub(super) fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<
|
|||
let key = key?;
|
||||
// Clear any transient status error on the next keypress.
|
||||
state.status_error = None;
|
||||
|
||||
// If a tool approval is pending, intercept y/n before normal key handling.
|
||||
if let Some(approval) = &state.pending_approval {
|
||||
let tool_use_id = approval.tool_use_id.clone();
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
state.pending_approval = None;
|
||||
return Some(LoopControl::ToolApproval {
|
||||
tool_use_id,
|
||||
approved: true,
|
||||
});
|
||||
}
|
||||
KeyCode::Char('n') | KeyCode::Char('N') => {
|
||||
state.pending_approval = None;
|
||||
return Some(LoopControl::ToolApproval {
|
||||
tool_use_id,
|
||||
approved: false,
|
||||
});
|
||||
}
|
||||
_ => return None, // ignore other keys while approval pending
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+C quits from any mode.
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||
return Some(LoopControl::Quit);
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ pub struct AppState {
|
|||
pub viewport_height: u16,
|
||||
/// Transient error message shown in the status bar, cleared on next keypress.
|
||||
pub status_error: Option<String>,
|
||||
/// A tool approval request waiting for user input (y/n).
|
||||
pub pending_approval: Option<events::PendingApproval>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -85,6 +87,7 @@ impl AppState {
|
|||
pending_keys: Vec::new(),
|
||||
viewport_height: 0,
|
||||
status_error: None,
|
||||
pending_approval: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -185,6 +188,17 @@ pub async fn run(
|
|||
Some(input::LoopControl::ClearHistory) => {
|
||||
let _ = action_tx.send(UserAction::ClearHistory).await;
|
||||
}
|
||||
Some(input::LoopControl::ToolApproval {
|
||||
tool_use_id,
|
||||
approved,
|
||||
}) => {
|
||||
let _ = action_tx
|
||||
.send(UserAction::ToolApprovalResponse {
|
||||
tool_use_id,
|
||||
approved,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,11 +105,37 @@ pub(super) fn render(frame: &mut Frame, state: &AppState) {
|
|||
let output = Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((state.scroll, 0));
|
||||
frame.render_widget(output, chunks[0]);
|
||||
let output_area = chunks[0];
|
||||
frame.render_widget(output, output_area);
|
||||
|
||||
// --- Tool approval overlay ---
|
||||
if let Some(ref approval) = state.pending_approval {
|
||||
let overlay_w = (output_area.width / 2).max(60).min(output_area.width);
|
||||
let overlay_h: u16 = 5;
|
||||
let overlay_x = output_area.x + (output_area.width.saturating_sub(overlay_w)) / 2;
|
||||
let overlay_y = output_area.y + output_area.height.saturating_sub(overlay_h) / 2;
|
||||
let overlay_area = Rect {
|
||||
x: overlay_x,
|
||||
y: overlay_y,
|
||||
width: overlay_w,
|
||||
height: overlay_h.min(output_area.height),
|
||||
};
|
||||
frame.render_widget(Clear, overlay_area);
|
||||
let text = format!(
|
||||
"{}: {}\n\ny = approve, n = deny",
|
||||
approval.tool_name, approval.input_summary
|
||||
);
|
||||
let overlay = Paragraph::new(text).block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(Color::Yellow))
|
||||
.title("Tool Approval"),
|
||||
);
|
||||
frame.render_widget(overlay, overlay_area);
|
||||
}
|
||||
|
||||
// --- Command overlay (floating box centered on output pane) ---
|
||||
if state.mode == Mode::Command {
|
||||
let overlay_area = command_overlay_rect(chunks[0]);
|
||||
let overlay_area = command_overlay_rect(output_area);
|
||||
// Clear the area behind the overlay so it appears floating.
|
||||
frame.render_widget(Clear, overlay_area);
|
||||
let overlay = Paragraph::new(format!(":{}", state.command_buffer)).block(
|
||||
|
|
@ -146,7 +172,7 @@ pub(super) fn render(frame: &mut Frame, state: &AppState) {
|
|||
}
|
||||
Mode::Command => {
|
||||
// Cursor in the floating overlay
|
||||
let overlay = command_overlay_rect(chunks[0]);
|
||||
let overlay = command_overlay_rect(output_area);
|
||||
// border(1) + ":" (1) + buf len
|
||||
let cursor_x = overlay.x + 1 + 1 + state.command_buffer.len() as u16;
|
||||
let cursor_y = overlay.y + 1; // inside the border
|
||||
|
|
@ -386,4 +412,31 @@ mod tests {
|
|||
"expected error in status bar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_approval_overlay_visible() {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = AppState::new();
|
||||
state.pending_approval = Some(super::super::events::PendingApproval {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: "write_file".to_string(),
|
||||
input_summary: "path: foo.txt".to_string(),
|
||||
});
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let all_text: String = buf
|
||||
.content()
|
||||
.iter()
|
||||
.map(|c| c.symbol().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
all_text.contains("Tool Approval"),
|
||||
"expected 'Tool Approval' overlay"
|
||||
);
|
||||
assert!(
|
||||
all_text.contains("write_file"),
|
||||
"expected tool name in overlay"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue