Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,11 @@ pub async fn run_agent_loop(
let mut is_error = false;
let mut was_interrupted = false;

// Thinking chain tracking: accumulate thinking delta and track
// whether we've shown the header, so we only print new content.
let mut thinking_content = String::new();
let mut thinking_header_shown = false;

// Spinner state: animate while waiting for the first content chunk
let mut spinner_idx: usize = 0;
let mut waiting_for_first_chunk = true;
Expand Down Expand Up @@ -450,6 +455,32 @@ pub async fn run_agent_loop(
is_error = true;
}

// Display thinking/reasoning chain if enabled and present
if let Some(ref thinking) = msg.message.thinking
&& ctx.show_thinking
&& !thinking.is_empty()
{
// Clear spinner before first output (thinking or content)
if waiting_for_first_chunk && has_shown_spinner {
write!(stdout, "\r{CLEAR_LINE}")?;
stdout.flush()?;
waiting_for_first_chunk = false;
} else {
waiting_for_first_chunk = false;
}

// Show [thinking] header once, before the first delta
if !thinking_header_shown {
write!(stdout, "{DIM}{THINK_COLOR}[thinking] ")?;
thinking_header_shown = true;
}

// Each chunk's `thinking` is a delta — print only the new part
write!(stdout, "{thinking}")?;
thinking_content.push_str(thinking);
stdout.flush()?;
}

if !msg.message.content.is_empty() {
// Clear spinner before first content
if waiting_for_first_chunk && has_shown_spinner {
Expand All @@ -461,6 +492,12 @@ pub async fn run_agent_loop(
waiting_for_first_chunk = false;
}

// Transition from thinking to content: close styling
if thinking_header_shown {
writeln!(stdout, "{RESET}")?;
thinking_header_shown = false;
}

response_content.push_str(&msg.message.content);
stdout.write_all(msg.message.content.as_bytes())?;
stdout.flush()?;
Expand Down Expand Up @@ -505,6 +542,12 @@ pub async fn run_agent_loop(
stdout.flush()?;
}

// Close thinking styling if stream ended while still in thinking mode
if thinking_header_shown {
writeln!(stdout, "{RESET}")?;
stdout.flush()?;
}

// Handle user interrupt (Ctrl+C during generation)
if was_interrupted {
interrupted.store(false, Ordering::SeqCst);
Expand Down
132 changes: 100 additions & 32 deletions src/agent/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ use crate::ui::confirm::Confirmation;

use super::safety::is_safe_command;

/// Result from executing a generic tool call.
struct GenericToolResult {
/// Formatted content for batching into the conversation message.
content: String,
/// If this was an auditable tool (run/write/edit), the tool name.
audit_tool_name: Option<String>,
/// For auditable tools: the primary argument (command for "run", path for "write"/"edit").
audit_detail: Option<String>,
/// Duration of the tool execution in milliseconds.
duration_ms: u64,
/// Whether the tool returned an error.
is_error: bool,
}

/// Handle tool calls from the assistant response.
///
/// Returns `Ok(true)` if tool results were added to messages (the caller should
Expand Down Expand Up @@ -53,6 +67,9 @@ pub async fn handle_tool_calls<W: Write>(
});
session.append_message(messages.last().expect("just pushed a message"));

// Collect generic tool results to batch them into a single message
let mut generic_tool_results: Vec<GenericToolResult> = Vec::new();

for call in tool_calls {
// Check for interrupt between tool calls
if interrupted.load(std::sync::atomic::Ordering::SeqCst) {
Expand Down Expand Up @@ -156,8 +173,51 @@ pub async fn handle_tool_calls<W: Write>(
continue;
}

// Generic tool execution
execute_generic_tool(call, tool_manager, messages, session, stdout, auto_accepted).await;
// Generic tool execution — collect result for batching
let result = execute_generic_tool(call, tool_manager, stdout, auto_accepted).await;

// Log to audit if this was an auditable tool (run/write/edit)
if let Some(ref tool_name) = result.audit_tool_name {
let exit_code = if result.is_error { -1 } else { 0 };
let session_id = session.id().to_string();
crate::commands::audit::log_command(
&session_id,
tool_name,
result.audit_detail.as_deref().unwrap_or(""),
exit_code,
auto_accepted,
result.duration_ms,
);
}

generic_tool_results.push(result);
}

// Batch all generic tool results into a single message
if !generic_tool_results.is_empty() {
let batched_content = if generic_tool_results.len() == 1 {
generic_tool_results.into_iter().next().unwrap().content
} else {
format!(
"Multiple tool results ({} total):\n\n{}",
generic_tool_results.len(),
generic_tool_results
.into_iter()
.map(|r| r.content)
.collect::<Vec<_>>()
.join("\n\n---\n\n")
)
};

messages.push(Message {
role: Role::Tool,
content: format!(
"Tool results:\n{}\n\nUse these results to continue helping the user.",
batched_content
),
tool_calls: vec![],
});
session.append_message(messages.last().expect("just pushed a message"));
}

Ok(true)
Expand Down Expand Up @@ -244,11 +304,9 @@ fn format_duration(ms: u64) -> String {
async fn execute_generic_tool<W: Write>(
call: &ToolCall,
tool_manager: &ToolManager,
messages: &mut Vec<Message>,
session: &mut Session,
stdout: &mut W,
auto_accepted: bool,
) {
) -> GenericToolResult {
// Show the "Executing..." header line
if auto_accepted {
if call.function.name == "run" {
Expand Down Expand Up @@ -334,25 +392,6 @@ async fn execute_generic_tool<W: Write>(

let duration_ms = start_time.elapsed().as_millis() as u64;

// Log to audit if this is a "run" command
if call.function.name == "run"
&& let Some(cmd) = call
.function
.arguments
.get("command")
.and_then(|v| v.as_str())
{
let exit_code = if result.starts_with("Error:") { -1 } else { 0 };
let session_id = session.id().to_string();
crate::commands::audit::log_command(
&session_id,
cmd,
exit_code,
auto_accepted,
duration_ms,
);
}

// For tools that return potentially large listings, show only a summary
match call.function.name.as_str() {
"read" => {
Expand Down Expand Up @@ -441,15 +480,44 @@ async fn execute_generic_tool<W: Write>(
}
writeln!(stdout, "{RESET}").unwrap();
stdout.flush().unwrap();
messages.push(Message {
role: Role::Tool,
content: format!(
"Tool '{}' result:\n{}\n\nUse this result to continue helping the user.",
call.function.name, result

// Capture audit-relevant info before returning
let (audit_tool_name, audit_detail) = match call.function.name.as_str() {
"run" => (
Some("run".to_string()),
call.function
.arguments
.get("command")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
),
tool_calls: vec![],
});
session.append_message(messages.last().expect("just pushed a message"));
"write" => (
Some("write".to_string()),
call.function
.arguments
.get("path")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
),
"edit" => (
Some("edit".to_string()),
call.function
.arguments
.get("path")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
),
_ => (None, None),
};
let is_error = result.starts_with("Error:");

GenericToolResult {
content: format!("### {} Tool Result\n\n{}", call.function.name, result),
audit_tool_name,
audit_detail,
duration_ms,
is_error,
}
}

/// Handle the switch_mode signal: update context and system prompt.
Expand Down
55 changes: 38 additions & 17 deletions src/commands/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ pub struct AuditEntry {
pub timestamp: u64,
/// Session ID where the command was run.
pub session_id: String,
/// The command that was executed.
/// The tool that was executed (e.g. "run", "write", "edit").
#[serde(default = "default_tool_name")]
pub tool_name: String,
/// The primary argument: shell command for "run", file path for "write"/"edit".
pub command: String,
/// Exit code of the command (0 = success).
pub exit_code: i32,
Expand All @@ -28,6 +31,10 @@ pub struct AuditEntry {
pub duration_ms: u64,
}

fn default_tool_name() -> String {
"run".to_string()
}

// ── Audit log path ──────────────────────────────────────────────────────────

/// Get the default audit log path: ~/.local/share/tinyharness/audit.jsonl
Expand All @@ -50,6 +57,7 @@ pub fn ensure_audit_dir() -> std::io::Result<()> {
/// Append a command execution to the audit log.
pub fn log_command(
session_id: &str,
tool_name: &str,
command: &str,
exit_code: i32,
auto_accepted: bool,
Expand All @@ -60,6 +68,7 @@ pub fn log_command(
let entry = AuditEntry {
timestamp: now_timestamp(),
session_id: session_id.to_string(),
tool_name: tool_name.to_string(),
command: command.to_string(),
exit_code,
auto_accepted,
Expand Down Expand Up @@ -150,14 +159,15 @@ pub fn show_last(n: usize) {

// Header
println!(
" {}{:20} {:30} {:6} {:8} {:10}{}",
BOLD, "Timestamp", "Command", "Exit", "Auto?", "Duration", RESET
" {}{:20} {:6} {:26} {:6} {:8} {:10}{}",
BOLD, "Timestamp", "Tool", "Command", "Exit", "Auto?", "Duration", RESET
);
println!(
" {}{:20} {:30} {:6} {:8} {:10}{}",
" {}{:20} {:6} {:26} {:6} {:8} {:10}{}",
GRAY,
"────────────────────",
"──────────────────────────────",
"──────",
"──────────────────────────",
"──────",
"────────",
"──────────",
Expand All @@ -166,8 +176,13 @@ pub fn show_last(n: usize) {

for entry in &entries {
let ts_str = format_timestamp(entry.timestamp);
let cmd_display = if entry.command.len() > 30 {
format!("{}...", &entry.command[..27])
let tool_display = if entry.tool_name.len() > 6 {
format!("{}...", &entry.tool_name[..3])
} else {
entry.tool_name.clone()
};
let cmd_display = if entry.command.len() > 26 {
format!("{}...", &entry.command[..23])
} else {
entry.command.clone()
};
Expand Down Expand Up @@ -196,8 +211,8 @@ pub fn show_last(n: usize) {
};

println!(
" {}{} {} {} {} {}",
GRAY, ts_str, cmd_display, exit_str, auto_str, duration_str
" {}{} {}{}{} {} {} {} {}",
GRAY, ts_str, CYAN, tool_display, RESET, cmd_display, exit_str, auto_str, duration_str
);
}

Expand All @@ -220,14 +235,15 @@ pub fn show_session(session_id: &str) {

// Header
println!(
" {}{:20} {:30} {:6} {:8} {:10}{}",
BOLD, "Timestamp", "Command", "Exit", "Auto?", "Duration", RESET
" {}{:20} {:6} {:26} {:6} {:8} {:10}{}",
BOLD, "Timestamp", "Tool", "Command", "Exit", "Auto?", "Duration", RESET
);
println!(
" {}{:20} {:30} {:6} {:8} {:10}{}",
" {}{:20} {:6} {:26} {:6} {:8} {:10}{}",
GRAY,
"────────────────────",
"──────────────────────────────",
"──────",
"──────────────────────────",
"──────",
"────────",
"──────────",
Expand All @@ -236,8 +252,13 @@ pub fn show_session(session_id: &str) {

for entry in &entries {
let ts_str = format_timestamp(entry.timestamp);
let cmd_display = if entry.command.len() > 30 {
format!("{}...", &entry.command[..27])
let tool_display = if entry.tool_name.len() > 6 {
format!("{}...", &entry.tool_name[..3])
} else {
entry.tool_name.clone()
};
let cmd_display = if entry.command.len() > 26 {
format!("{}...", &entry.command[..23])
} else {
entry.command.clone()
};
Expand Down Expand Up @@ -266,8 +287,8 @@ pub fn show_session(session_id: &str) {
};

println!(
" {}{} {} {} {} {}",
GRAY, ts_str, cmd_display, exit_str, auto_str, duration_str
" {}{} {}{}{} {} {} {} {}",
GRAY, ts_str, CYAN, tool_display, RESET, cmd_display, exit_str, auto_str, duration_str
);
}

Expand Down
Loading
Loading