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:
Drew 2026-03-02 03:00:13 +00:00 committed by Drew
parent 6b85ff3cb8
commit 797d7564b7
20 changed files with 1822 additions and 129 deletions

View file

@ -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"));
}
}

View file

@ -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);

View file

@ -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 => {}
}
}

View file

@ -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"
);
}
}