Allow displaying diffs in the ui. (#8)

Reviewed-on: #8
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
Drew 2026-03-11 16:37:42 +00:00 committed by Drew
parent af080710cc
commit 5a49cba1e6
9 changed files with 481 additions and 114 deletions

View file

@ -4,8 +4,8 @@ use tracing::{debug, warn};
use crate::core::history::ConversationHistory;
use crate::core::types::{
ContentBlock, ConversationMessage, Role, StampedEvent, StreamEvent, ToolDefinition, UIEvent,
UserAction,
ContentBlock, ConversationMessage, Role, StampedEvent, StreamEvent, ToolDefinition,
ToolDisplay, UIEvent, UserAction,
};
use crate::provider::ModelProvider;
use crate::sandbox::Sandbox;
@ -303,6 +303,86 @@ impl<P: ModelProvider> Orchestrator<P> {
.await;
}
/// Build a [`ToolDisplay`] from the tool name and its JSON input.
///
/// Matches on known tool names to extract structured fields; falls back to
/// `Generic` with a JSON summary for anything else.
fn build_tool_display(&self, tool_name: &str, input: &serde_json::Value) -> ToolDisplay {
match tool_name {
"write_file" => {
let path = input["path"].as_str().unwrap_or("<unknown>").to_string();
let new_content = input["content"].as_str().unwrap_or("").to_string();
// Try to read existing content for diffing.
let old_content = self.sandbox.read_file(&path).ok();
ToolDisplay::WriteFile {
path,
old_content,
new_content,
}
}
"shell_exec" => {
let command = input["command"].as_str().unwrap_or("").to_string();
ToolDisplay::ShellExec { command }
}
"list_directory" => {
let path = input["path"].as_str().unwrap_or(".").to_string();
ToolDisplay::ListDirectory { path }
}
"read_file" => {
let path = input["path"].as_str().unwrap_or("<unknown>").to_string();
ToolDisplay::ReadFile { path }
}
_ => ToolDisplay::Generic {
summary: serde_json::to_string(input).unwrap_or_default(),
},
}
}
/// Build a [`ToolDisplay`] for a tool result, incorporating the output content.
fn build_result_display(
&self,
tool_name: &str,
input: &serde_json::Value,
output: &str,
) -> ToolDisplay {
match tool_name {
"write_file" => {
let path = input["path"].as_str().unwrap_or("<unknown>").to_string();
let new_content = input["content"].as_str().unwrap_or("").to_string();
// For results, old_content isn't available post-write. We already
// showed the diff at approval/executing time; the result just
// confirms the byte count via the display formatter.
ToolDisplay::WriteFile {
path,
old_content: None,
new_content,
}
}
"shell_exec" => {
let command = input["command"].as_str().unwrap_or("").to_string();
ToolDisplay::ShellExec {
command: format!("{command}\n{output}"),
}
}
"list_directory" => {
let path = input["path"].as_str().unwrap_or(".").to_string();
ToolDisplay::ListDirectory {
path: format!("{path}\n{output}"),
}
}
"read_file" => {
let path = input["path"].as_str().unwrap_or("<unknown>").to_string();
let line_count = output.lines().count();
ToolDisplay::ReadFile {
path: format!("{path} ({line_count} lines)"),
}
}
_ => ToolDisplay::Generic {
summary: truncate(output, 200),
},
}
}
/// Execute a single tool, handling approval if needed.
///
/// For auto-approve tools, executes immediately. For tools requiring
@ -325,14 +405,15 @@ impl<P: ModelProvider> Orchestrator<P> {
}
};
let input_summary = serde_json::to_string(input).unwrap_or_default();
let display = self.build_tool_display(tool_name, input);
// Check approval.
let approved = match risk {
RiskLevel::AutoApprove => {
self.send(UIEvent::ToolExecuting {
tool_use_id: tool_use_id.to_string(),
tool_name: tool_name.to_string(),
input_summary: input_summary.clone(),
display,
})
.await;
true
@ -341,7 +422,7 @@ impl<P: ModelProvider> Orchestrator<P> {
self.send(UIEvent::ToolApprovalRequest {
tool_use_id: tool_use_id.to_string(),
tool_name: tool_name.to_string(),
input_summary: input_summary.clone(),
display,
})
.await;
@ -361,9 +442,11 @@ impl<P: ModelProvider> Orchestrator<P> {
let tool = self.tool_registry.get(tool_name).unwrap();
match tool.execute(input, &self.sandbox).await {
Ok(output) => {
let result_display = self.build_result_display(tool_name, input, &output.content);
self.send(UIEvent::ToolResult {
tool_use_id: tool_use_id.to_string(),
tool_name: tool_name.to_string(),
output_summary: truncate(&output.content, 200),
display: result_display,
is_error: output.is_error,
})
.await;
@ -372,8 +455,11 @@ impl<P: ModelProvider> Orchestrator<P> {
Err(e) => {
let msg = e.to_string();
self.send(UIEvent::ToolResult {
tool_use_id: tool_use_id.to_string(),
tool_name: tool_name.to_string(),
output_summary: msg.clone(),
display: ToolDisplay::Generic {
summary: msg.clone(),
},
is_error: true,
})
.await;