| 1 | //! Active tool-card routing helpers for the TUI loop. |
| 2 | |
| 3 | use std::path::PathBuf; |
| 4 | use std::time::Instant; |
| 5 | |
| 6 | use crate::hooks::HookEvent; |
| 7 | use crate::tools::ReviewOutput; |
| 8 | use crate::tools::spec::{ToolError, ToolResult}; |
| 9 | use crate::tui::active_cell::ActiveCell; |
| 10 | use crate::tui::app::{App, ToolDetailRecord}; |
| 11 | use crate::tui::history::{ |
| 12 | DiffPreviewCell, ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell, |
| 13 | McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus, |
| 14 | ViewImageCell, WebSearchCell, summarize_mcp_output, summarize_tool_args, summarize_tool_output, |
| 15 | }; |
| 16 | |
| 17 | #[allow(clippy::too_many_lines)] |
| 18 | pub(super) fn handle_tool_call_started( |
| 19 | app: &mut App, |
| 20 | id: &str, |
| 21 | name: &str, |
| 22 | input: &serde_json::Value, |
| 23 | ) { |
| 24 | // #455 (observer-only): fire `tool_call_before` hooks here, before |
| 25 | // any UI bookkeeping. Hooks are read-only observers in this slice |
| 26 | // — they can log, notify, or audit, but cannot mutate the args. |
| 27 | // Fast-path skip when no hooks are configured so per-tool |
| 28 | // dispatch doesn't pay for context construction in the common |
| 29 | // case (most users have no hooks). |
| 30 | if app.hooks.has_hooks_for_event(HookEvent::ToolCallBefore) { |
| 31 | let context = app |
| 32 | .base_hook_context() |
| 33 | .with_tool_name(name) |
| 34 | .with_tool_args(input); |
| 35 | let _ = app.execute_hooks(HookEvent::ToolCallBefore, &context); |
| 36 | } |
| 37 | |
| 38 | let id = id.to_string(); |
| 39 | |
| 40 | // All in-flight tool work for the current turn lives in `app.active_cell` |
| 41 | // until the turn completes. This mirrors Codex's contract: ONE active cell |
| 42 | // mutates in place; finalized history isn't touched until flush. This |
| 43 | // keeps the transcript stable while parallel completions arrive in any |
| 44 | // order. |
| 45 | if app.active_cell.is_none() { |
| 46 | app.active_cell = Some(ActiveCell::new()); |
| 47 | } |
| 48 | |
| 49 | if is_exploring_tool(name) { |
| 50 | let label = exploring_label(name, input); |
| 51 | // ensure_exploring + append_to_exploring keeps all parallel exploring |
| 52 | // starts in a single ExploringCell entry. |
| 53 | let active = app.active_cell.as_mut().expect("active_cell just ensured"); |
| 54 | let entry_idx = active.ensure_exploring(); |
| 55 | let inner = active |
| 56 | .append_to_exploring( |
| 57 | id.clone(), |
| 58 | ExploringEntry { |
| 59 | label, |
| 60 | status: ToolStatus::Running, |
| 61 | }, |
| 62 | ) |
| 63 | .map_or(0, |(_, inner)| inner); |
| 64 | app.exploring_cell = Some(entry_idx); |
| 65 | let virtual_index = app.history.len() + entry_idx; |
| 66 | app.exploring_entries |
| 67 | .insert(id.clone(), (virtual_index, inner)); |
| 68 | register_tool_cell(app, &id, name, input, virtual_index); |
| 69 | app.mark_history_updated(); |
| 70 | return; |
| 71 | } |
| 72 | |
| 73 | // Non-exploring tool: each is its own entry inside the active cell. We |
| 74 | // intentionally do NOT clear `exploring_cell` here — the active cell can |
| 75 | // hold both an exploring aggregate AND independent tool entries |
| 76 | // simultaneously, which is exactly the case CX#7 fixes. |
| 77 | |
| 78 | if is_exec_tool(name) { |
| 79 | let command = exec_command_from_input(input).unwrap_or_else(|| "<command>".to_string()); |
| 80 | let source = exec_source_from_input(input); |
| 81 | let interaction = exec_interaction_summary(name, input); |
| 82 | let mut is_wait = false; |
| 83 | |
| 84 | if let Some((summary, wait)) = interaction.as_ref() { |
| 85 | is_wait = *wait; |
| 86 | if is_wait |
| 87 | && app |
| 88 | .last_exec_wait_command |
| 89 | .as_ref() |
| 90 | .is_some_and(|last| last == &command) |
| 91 | { |
| 92 | app.ignored_tool_calls.insert(id); |
| 93 | return; |
| 94 | } |
| 95 | if is_wait { |
| 96 | app.last_exec_wait_command = Some(command.clone()); |
| 97 | } |
| 98 | |
| 99 | push_active_tool_cell( |
| 100 | app, |
| 101 | &id, |
| 102 | name, |
| 103 | input, |
| 104 | HistoryCell::Tool(ToolCell::Exec(ExecCell { |
| 105 | command, |
| 106 | status: ToolStatus::Running, |
| 107 | output: None, |
| 108 | started_at: Some(Instant::now()), |
| 109 | duration_ms: None, |
| 110 | source, |
| 111 | interaction: Some(summary.clone()), |
| 112 | })), |
| 113 | ); |
| 114 | return; |
| 115 | } |
| 116 | |
| 117 | if exec_is_background(input) |
| 118 | && app |
| 119 | .last_exec_wait_command |
| 120 | .as_ref() |
| 121 | .is_some_and(|last| last == &command) |
| 122 | { |
| 123 | app.ignored_tool_calls.insert(id); |
| 124 | return; |
| 125 | } |
| 126 | if exec_is_background(input) && !is_wait { |
| 127 | app.last_exec_wait_command = Some(command.clone()); |
| 128 | } |
| 129 | |
| 130 | push_active_tool_cell( |
| 131 | app, |
| 132 | &id, |
| 133 | name, |
| 134 | input, |
| 135 | HistoryCell::Tool(ToolCell::Exec(ExecCell { |
| 136 | command, |
| 137 | status: ToolStatus::Running, |
| 138 | output: None, |
| 139 | started_at: Some(Instant::now()), |
| 140 | duration_ms: None, |
| 141 | source, |
| 142 | interaction: None, |
| 143 | })), |
| 144 | ); |
| 145 | return; |
| 146 | } |
| 147 | |
| 148 | if name == "update_plan" { |
| 149 | let (explanation, steps) = parse_plan_input(input); |
| 150 | push_active_tool_cell( |
| 151 | app, |
| 152 | &id, |
| 153 | name, |
| 154 | input, |
| 155 | HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell { |
| 156 | explanation, |
| 157 | steps, |
| 158 | status: ToolStatus::Running, |
| 159 | })), |
| 160 | ); |
| 161 | return; |
| 162 | } |
| 163 | |
| 164 | if name == "apply_patch" { |
| 165 | let (path, summary) = parse_patch_summary(input); |
| 166 | push_active_tool_cell( |
| 167 | app, |
| 168 | &id, |
| 169 | name, |
| 170 | input, |
| 171 | HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell { |
| 172 | path, |
| 173 | summary, |
| 174 | status: ToolStatus::Running, |
| 175 | error: None, |
| 176 | })), |
| 177 | ); |
| 178 | return; |
| 179 | } |
| 180 | |
| 181 | if name == "review" { |
| 182 | let target = review_target_label(input); |
| 183 | push_active_tool_cell( |
| 184 | app, |
| 185 | &id, |
| 186 | name, |
| 187 | input, |
| 188 | HistoryCell::Tool(ToolCell::Review(ReviewCell { |
| 189 | target, |
| 190 | status: ToolStatus::Running, |
| 191 | output: None, |
| 192 | error: None, |
| 193 | })), |
| 194 | ); |
| 195 | return; |
| 196 | } |
| 197 | |
| 198 | if is_mcp_tool(name) { |
| 199 | push_active_tool_cell( |
| 200 | app, |
| 201 | &id, |
| 202 | name, |
| 203 | input, |
| 204 | HistoryCell::Tool(ToolCell::Mcp(McpToolCell { |
| 205 | tool: name.to_string(), |
| 206 | status: ToolStatus::Running, |
| 207 | content: None, |
| 208 | is_image: false, |
| 209 | })), |
| 210 | ); |
| 211 | return; |
| 212 | } |
| 213 | |
| 214 | if is_view_image_tool(name) { |
| 215 | if let Some(path) = input.get("path").and_then(|v| v.as_str()) { |
| 216 | let raw_path = PathBuf::from(path); |
| 217 | let display_path = raw_path |
| 218 | .strip_prefix(&app.workspace) |
| 219 | .unwrap_or(&raw_path) |
| 220 | .to_path_buf(); |
| 221 | push_active_tool_cell( |
| 222 | app, |
| 223 | &id, |
| 224 | name, |
| 225 | input, |
| 226 | HistoryCell::Tool(ToolCell::ViewImage(ViewImageCell { path: display_path })), |
| 227 | ); |
| 228 | } |
| 229 | return; |
| 230 | } |
| 231 | |
| 232 | if is_web_search_tool(name) { |
| 233 | let query = web_search_query(input); |
| 234 | push_active_tool_cell( |
| 235 | app, |
| 236 | &id, |
| 237 | name, |
| 238 | input, |
| 239 | HistoryCell::Tool(ToolCell::WebSearch(WebSearchCell { |
| 240 | query, |
| 241 | status: ToolStatus::Running, |
| 242 | summary: None, |
| 243 | })), |
| 244 | ); |
| 245 | return; |
| 246 | } |
| 247 | |
| 248 | let input_summary = summarize_tool_args(input); |
| 249 | push_active_tool_cell( |
| 250 | app, |
| 251 | &id, |
| 252 | name, |
| 253 | input, |
| 254 | HistoryCell::Tool(ToolCell::Generic(GenericToolCell { |
| 255 | name: name.to_string(), |
| 256 | status: ToolStatus::Running, |
| 257 | input_summary, |
| 258 | output: None, |
| 259 | prompts: None, |
| 260 | spillover_path: None, |
| 261 | })), |
| 262 | ); |
| 263 | } |
| 264 | |
| 265 | /// Push a tool cell as a new entry in `active_cell`, register the tool id, |
| 266 | /// and write a stub detail record so the pager / Ctrl+O can find it. |
| 267 | fn push_active_tool_cell( |
| 268 | app: &mut App, |
| 269 | tool_id: &str, |
| 270 | tool_name: &str, |
| 271 | input: &serde_json::Value, |
| 272 | cell: HistoryCell, |
| 273 | ) { |
| 274 | if app.active_cell.is_none() { |
| 275 | app.active_cell = Some(ActiveCell::new()); |
| 276 | } |
| 277 | let active = app.active_cell.as_mut().expect("active_cell just ensured"); |
| 278 | let entry_idx = active.push_tool(tool_id.to_string(), cell); |
| 279 | let virtual_index = app.history.len() + entry_idx; |
| 280 | register_tool_cell(app, tool_id, tool_name, input, virtual_index); |
| 281 | app.mark_history_updated(); |
| 282 | } |
| 283 | |
| 284 | fn register_tool_cell( |
| 285 | app: &mut App, |
| 286 | tool_id: &str, |
| 287 | tool_name: &str, |
| 288 | input: &serde_json::Value, |
| 289 | cell_index: usize, |
| 290 | ) { |
| 291 | app.tool_cells.insert(tool_id.to_string(), cell_index); |
| 292 | let record = ToolDetailRecord { |
| 293 | tool_id: tool_id.to_string(), |
| 294 | tool_name: tool_name.to_string(), |
| 295 | input: input.clone(), |
| 296 | output: None, |
| 297 | }; |
| 298 | if cell_index < app.history.len() { |
| 299 | app.tool_details_by_cell.insert(cell_index, record); |
| 300 | } else { |
| 301 | // Active-cell entry: keep the detail record in `active_tool_details` |
| 302 | // until the active cell flushes. `flush_active_cell` migrates these |
| 303 | // records into `tool_details_by_cell` keyed by the eventual real |
| 304 | // cell index. |
| 305 | app.active_tool_details.insert(tool_id.to_string(), record); |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | fn store_tool_detail_output( |
| 310 | app: &mut App, |
| 311 | tool_id: &str, |
| 312 | cell_index: usize, |
| 313 | result: &Result<ToolResult, ToolError>, |
| 314 | ) { |
| 315 | let payload = Some(match result { |
| 316 | Ok(tool_result) => tool_result.content.clone(), |
| 317 | Err(err) => err.to_string(), |
| 318 | }); |
| 319 | if cell_index < app.history.len() |
| 320 | && let Some(detail) = app.tool_details_by_cell.get_mut(&cell_index) |
| 321 | { |
| 322 | detail.output = payload.clone(); |
| 323 | } |
| 324 | // Also write to the active table while the entry might still live there; |
| 325 | // some callsites pre-rewrite cell_index but the active_tool_details map is |
| 326 | // the canonical source for in-flight outputs. |
| 327 | if let Some(detail) = app.active_tool_details.get_mut(tool_id) { |
| 328 | detail.output = payload; |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | #[allow(clippy::too_many_lines)] |
| 333 | /// Inspect a tool's success metadata for the `child_*` token-usage |
| 334 | /// fields that tools spawning their own LLM calls populate (e.g. |
| 335 | /// `rlm`). Roll any reported child-token cost into the session's |
| 336 | /// running sub-agent cost counter so the footer total reflects all |
| 337 | /// tokens the user is actually billed for, not just the parent turn's |
| 338 | /// tokens. |
| 339 | /// |
| 340 | /// Without this hook, an RLM-heavy session shows a fraction of the |
| 341 | /// real spend because the parent turn's `Usage` only counts the |
| 342 | /// orchestrator's tokens, not the dozens of `deepseek-v4-flash` child |
| 343 | /// rounds RLM fans out under the hood (#524). |
| 344 | fn accrue_child_token_cost_if_any(app: &mut App, result: &Result<ToolResult, ToolError>) { |
| 345 | let Ok(tool_result) = result else { return }; |
| 346 | let Some(metadata) = tool_result.metadata.as_ref() else { |
| 347 | return; |
| 348 | }; |
| 349 | let Some(model) = metadata |
| 350 | .get("child_model") |
| 351 | .and_then(serde_json::Value::as_str) |
| 352 | else { |
| 353 | return; |
| 354 | }; |
| 355 | let input_tokens = metadata |
| 356 | .get("child_input_tokens") |
| 357 | .and_then(serde_json::Value::as_u64) |
| 358 | .unwrap_or(0); |
| 359 | let output_tokens = metadata |
| 360 | .get("child_output_tokens") |
| 361 | .and_then(serde_json::Value::as_u64) |
| 362 | .unwrap_or(0); |
| 363 | if input_tokens == 0 && output_tokens == 0 { |
| 364 | return; |
| 365 | } |
| 366 | let prompt_cache_hit_tokens = metadata |
| 367 | .get("child_prompt_cache_hit_tokens") |
| 368 | .and_then(serde_json::Value::as_u64) |
| 369 | .map(|v| u32::try_from(v).unwrap_or(u32::MAX)); |
| 370 | let prompt_cache_miss_tokens = metadata |
| 371 | .get("child_prompt_cache_miss_tokens") |
| 372 | .and_then(serde_json::Value::as_u64) |
| 373 | .map(|v| u32::try_from(v).unwrap_or(u32::MAX)); |
| 374 | let usage = crate::models::Usage { |
| 375 | input_tokens: u32::try_from(input_tokens).unwrap_or(u32::MAX), |
| 376 | output_tokens: u32::try_from(output_tokens).unwrap_or(u32::MAX), |
| 377 | prompt_cache_hit_tokens, |
| 378 | prompt_cache_miss_tokens, |
| 379 | reasoning_tokens: None, |
| 380 | reasoning_replay_tokens: None, |
| 381 | server_tool_use: None, |
| 382 | }; |
| 383 | if let Some(cost) = crate::pricing::calculate_turn_cost_estimate_from_usage(model, &usage) { |
| 384 | app.accrue_subagent_cost_estimate(cost); |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | pub(super) fn handle_tool_call_complete( |
| 389 | app: &mut App, |
| 390 | id: &str, |
| 391 | name: &str, |
| 392 | result: &Result<ToolResult, ToolError>, |
| 393 | ) { |
| 394 | if app.ignored_tool_calls.remove(id) { |
| 395 | return; |
| 396 | } |
| 397 | // Roll any child-LLM token usage the tool reports into the |
| 398 | // session-cost counter. Runs unconditionally so future tools that |
| 399 | // spawn their own LLM calls (RLM, summarizers, retrieval helpers) |
| 400 | // get accrued without needing a per-tool hook (#524). |
| 401 | accrue_child_token_cost_if_any(app, result); |
| 402 | |
| 403 | // Exploring entries land in the per-tool map regardless of whether they |
| 404 | // live in the active cell or in finalized history; the path is the same. |
| 405 | if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) { |
| 406 | app.tool_cells.remove(id); |
| 407 | store_tool_detail_output(app, id, cell_index, result); |
| 408 | if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) = |
| 409 | app.cell_at_virtual_index_mut(cell_index) |
| 410 | && let Some(entry) = cell.entries.get_mut(entry_index) |
| 411 | { |
| 412 | entry.status = match result.as_ref() { |
| 413 | Ok(tool_result) if tool_result.success => ToolStatus::Success, |
| 414 | Ok(_) | Err(_) => ToolStatus::Failed, |
| 415 | }; |
| 416 | app.mark_history_updated(); |
| 417 | // Mutating the in-flight exploring cell needs an active-cell |
| 418 | // revision bump so the transcript cache invalidates the synthetic |
| 419 | // tail row. |
| 420 | if cell_index >= app.history.len() { |
| 421 | app.active_cell_revision = app.active_cell_revision.wrapping_add(1); |
| 422 | if let Some(active) = app.active_cell.as_mut() { |
| 423 | active.bump_revision(); |
| 424 | } |
| 425 | } |
| 426 | } |
| 427 | return; |
| 428 | } |
| 429 | |
| 430 | // Look up the cell by tool id. If the id isn't registered, that's an |
| 431 | // orphan completion (race condition where the started event was lost or |
| 432 | // a tool result arrived after the active cell was already flushed). Build |
| 433 | // a finalized standalone cell from the result so the user can still see |
| 434 | // the output, but DO NOT touch the active cell. |
| 435 | let Some(cell_index) = app.tool_cells.remove(id) else { |
| 436 | push_orphan_tool_completion(app, id, name, result); |
| 437 | return; |
| 438 | }; |
| 439 | |
| 440 | store_tool_detail_output(app, id, cell_index, result); |
| 441 | let in_active = cell_index >= app.history.len(); |
| 442 | |
| 443 | let status = match result.as_ref() { |
| 444 | Ok(tool_result) => match tool_result.metadata.as_ref() { |
| 445 | Some(meta) |
| 446 | if meta |
| 447 | .get("status") |
| 448 | .and_then(|v| v.as_str()) |
| 449 | .is_some_and(|s| s == "Running") => |
| 450 | { |
| 451 | ToolStatus::Running |
| 452 | } |
| 453 | _ => { |
| 454 | if tool_result.success { |
| 455 | ToolStatus::Success |
| 456 | } else { |
| 457 | ToolStatus::Failed |
| 458 | } |
| 459 | } |
| 460 | }, |
| 461 | Err(_) => ToolStatus::Failed, |
| 462 | }; |
| 463 | |
| 464 | if let Some(cell) = app.cell_at_virtual_index_mut(cell_index) { |
| 465 | match cell { |
| 466 | HistoryCell::Tool(ToolCell::Exec(exec)) => { |
| 467 | exec.status = status; |
| 468 | if let Ok(tool_result) = result.as_ref() { |
| 469 | exec.duration_ms = tool_result |
| 470 | .metadata |
| 471 | .as_ref() |
| 472 | .and_then(|m| m.get("duration_ms")) |
| 473 | .and_then(serde_json::Value::as_u64); |
| 474 | if status != ToolStatus::Running && exec.interaction.is_none() { |
| 475 | exec.output = Some(tool_result.content.clone()); |
| 476 | } |
| 477 | } else if let Err(err) = result.as_ref() |
| 478 | && exec.interaction.is_none() |
| 479 | { |
| 480 | exec.output = Some(err.to_string()); |
| 481 | } |
| 482 | app.mark_history_updated(); |
| 483 | } |
| 484 | HistoryCell::Tool(ToolCell::PlanUpdate(plan)) => { |
| 485 | plan.status = status; |
| 486 | app.mark_history_updated(); |
| 487 | } |
| 488 | HistoryCell::Tool(ToolCell::PatchSummary(patch)) => { |
| 489 | patch.status = status; |
| 490 | match result.as_ref() { |
| 491 | Ok(tool_result) => { |
| 492 | if let Ok(json) = |
| 493 | serde_json::from_str::<serde_json::Value>(&tool_result.content) |
| 494 | && let Some(message) = json.get("message").and_then(|v| v.as_str()) |
| 495 | { |
| 496 | patch.summary = message.to_string(); |
| 497 | } |
| 498 | } |
| 499 | Err(err) => { |
| 500 | patch.error = Some(err.to_string()); |
| 501 | } |
| 502 | } |
| 503 | app.mark_history_updated(); |
| 504 | } |
| 505 | HistoryCell::Tool(ToolCell::Review(review)) => { |
| 506 | review.status = status; |
| 507 | match result.as_ref() { |
| 508 | Ok(tool_result) => { |
| 509 | if tool_result.success { |
| 510 | review.output = Some(ReviewOutput::from_str(&tool_result.content)); |
| 511 | } else { |
| 512 | review.error = Some(tool_result.content.clone()); |
| 513 | } |
| 514 | } |
| 515 | Err(err) => { |
| 516 | review.error = Some(err.to_string()); |
| 517 | } |
| 518 | } |
| 519 | app.mark_history_updated(); |
| 520 | } |
| 521 | HistoryCell::Tool(ToolCell::Mcp(mcp)) => { |
| 522 | match result.as_ref() { |
| 523 | Ok(tool_result) => { |
| 524 | let summary = summarize_mcp_output(&tool_result.content); |
| 525 | if summary.is_error == Some(true) { |
| 526 | mcp.status = ToolStatus::Failed; |
| 527 | } else { |
| 528 | mcp.status = status; |
| 529 | } |
| 530 | mcp.is_image = summary.is_image; |
| 531 | mcp.content = summary.content; |
| 532 | } |
| 533 | Err(err) => { |
| 534 | mcp.status = status; |
| 535 | mcp.content = Some(err.to_string()); |
| 536 | } |
| 537 | } |
| 538 | app.mark_history_updated(); |
| 539 | } |
| 540 | HistoryCell::Tool(ToolCell::WebSearch(search)) => { |
| 541 | search.status = status; |
| 542 | match result.as_ref() { |
| 543 | Ok(tool_result) => { |
| 544 | search.summary = Some(summarize_tool_output(&tool_result.content)); |
| 545 | } |
| 546 | Err(err) => { |
| 547 | search.summary = Some(err.to_string()); |
| 548 | } |
| 549 | } |
| 550 | app.mark_history_updated(); |
| 551 | } |
| 552 | HistoryCell::Tool(ToolCell::Generic(generic)) => { |
| 553 | generic.status = status; |
| 554 | match result.as_ref() { |
| 555 | Ok(tool_result) => { |
| 556 | generic.output = Some(summarize_tool_output(&tool_result.content)); |
| 557 | } |
| 558 | Err(err) => { |
| 559 | generic.output = Some(err.to_string()); |
| 560 | } |
| 561 | } |
| 562 | app.mark_history_updated(); |
| 563 | } |
| 564 | _ => {} |
| 565 | } |
| 566 | } |
| 567 | |
| 568 | // If the mutated cell lived inside the active group, bump the active-cell |
| 569 | // revision so the transcript cache re-renders the synthetic tail row. |
| 570 | if in_active { |
| 571 | app.active_cell_revision = app.active_cell_revision.wrapping_add(1); |
| 572 | if let Some(active) = app.active_cell.as_mut() { |
| 573 | active.bump_revision(); |
| 574 | } |
| 575 | } |
| 576 | |
| 577 | // #455 (observer-only): fire `tool_call_after` hooks once the |
| 578 | // result has settled. Hooks see tool_name + the result content |
| 579 | // (or error message) + success flag. Read-only — they cannot |
| 580 | // mutate the result that goes back to the model. Mutation |
| 581 | // remains a v0.8.9 follow-up. Fast-path skip avoids the |
| 582 | // result.content.clone() and HookContext allocation when no |
| 583 | // hooks are configured. |
| 584 | if app.hooks.has_hooks_for_event(HookEvent::ToolCallAfter) { |
| 585 | let (result_text, success): (String, bool) = match result.as_ref() { |
| 586 | Ok(tool_result) => (tool_result.content.clone(), tool_result.success), |
| 587 | Err(err) => (err.to_string(), false), |
| 588 | }; |
| 589 | let context = app |
| 590 | .base_hook_context() |
| 591 | .with_tool_name(name) |
| 592 | .with_tool_result(&result_text, success, None); |
| 593 | let _ = app.execute_hooks(HookEvent::ToolCallAfter, &context); |
| 594 | } |
| 595 | } |
| 596 | |
| 597 | /// Build a finalized standalone history cell for a tool completion whose |
| 598 | /// start was never registered (orphan). This preserves the contract that |
| 599 | /// every tool result is visible somewhere; the alternative (silently |
| 600 | /// dropping it) hides errors and breaks debuggability. |
| 601 | /// |
| 602 | /// Choice of cell type: we use `GenericToolCell` because we have no input |
| 603 | /// payload to reconstruct a more specific cell. The pager remains usable — |
| 604 | /// `tool_details_by_cell` is populated with the result text. |
| 605 | /// |
| 606 | /// ## Index drift |
| 607 | /// |
| 608 | /// If an active cell is in flight when the orphan arrives, pushing the |
| 609 | /// orphan into `app.history` shifts every active-cell virtual index forward |
| 610 | /// by 1. We must rewrite `tool_cells` / `exploring_entries` accordingly so |
| 611 | /// later completion lookups still find the right entries. |
| 612 | fn push_orphan_tool_completion( |
| 613 | app: &mut App, |
| 614 | tool_id: &str, |
| 615 | name: &str, |
| 616 | result: &Result<ToolResult, ToolError>, |
| 617 | ) { |
| 618 | let status = match result.as_ref() { |
| 619 | Ok(tool_result) => { |
| 620 | if tool_result.success { |
| 621 | ToolStatus::Success |
| 622 | } else { |
| 623 | ToolStatus::Failed |
| 624 | } |
| 625 | } |
| 626 | Err(_) => ToolStatus::Failed, |
| 627 | }; |
| 628 | let output = match result.as_ref() { |
| 629 | Ok(tool_result) => Some(summarize_tool_output(&tool_result.content)), |
| 630 | Err(err) => Some(err.to_string()), |
| 631 | }; |
| 632 | let history_threshold_before_push = app.history.len(); |
| 633 | let active_in_flight = app.active_cell.is_some(); |
| 634 | let spillover_path = result |
| 635 | .as_ref() |
| 636 | .ok() |
| 637 | .and_then(|r| r.metadata.as_ref()) |
| 638 | .and_then(|m| m.get("spillover_path")) |
| 639 | .and_then(serde_json::Value::as_str) |
| 640 | .map(std::path::PathBuf::from); |
| 641 | app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { |
| 642 | name: name.to_string(), |
| 643 | status, |
| 644 | input_summary: None, |
| 645 | output, |
| 646 | prompts: None, |
| 647 | spillover_path, |
| 648 | }))); |
| 649 | let cell_index = app.history.len().saturating_sub(1); |
| 650 | app.tool_details_by_cell.insert( |
| 651 | cell_index, |
| 652 | ToolDetailRecord { |
| 653 | tool_id: tool_id.to_string(), |
| 654 | tool_name: name.to_string(), |
| 655 | input: serde_json::Value::Null, |
| 656 | output: match result.as_ref() { |
| 657 | Ok(tool_result) => Some(tool_result.content.clone()), |
| 658 | Err(err) => Some(err.to_string()), |
| 659 | }, |
| 660 | }, |
| 661 | ); |
| 662 | |
| 663 | // Shift active-cell virtual indices forward by 1 to absorb the new |
| 664 | // history cell. Without this, the next completion would address the |
| 665 | // wrong entry. |
| 666 | if active_in_flight { |
| 667 | let threshold = history_threshold_before_push; |
| 668 | for idx in app.tool_cells.values_mut() { |
| 669 | if *idx >= threshold { |
| 670 | *idx = idx.wrapping_add(1); |
| 671 | } |
| 672 | } |
| 673 | for (cell_idx, _) in app.exploring_entries.values_mut() { |
| 674 | if *cell_idx >= threshold { |
| 675 | *cell_idx = cell_idx.wrapping_add(1); |
| 676 | } |
| 677 | } |
| 678 | if let Some(idx) = app.exploring_cell.as_mut() |
| 679 | && *idx >= threshold |
| 680 | { |
| 681 | *idx = idx.wrapping_add(1); |
| 682 | } |
| 683 | } |
| 684 | } |
| 685 | |
| 686 | fn is_exploring_tool(name: &str) -> bool { |
| 687 | matches!(name, "read_file" | "list_dir" | "grep_files" | "list_files") |
| 688 | } |
| 689 | |
| 690 | fn is_exec_tool(name: &str) -> bool { |
| 691 | matches!( |
| 692 | name, |
| 693 | "exec_shell" | "exec_shell_wait" | "exec_shell_interact" | "exec_wait" | "exec_interact" |
| 694 | ) |
| 695 | } |
| 696 | |
| 697 | pub(super) fn exploring_label(name: &str, input: &serde_json::Value) -> String { |
| 698 | let fallback = format!("{name} tool"); |
| 699 | let obj = input.as_object(); |
| 700 | match name { |
| 701 | "read_file" => obj |
| 702 | .and_then(|o| o.get("path")) |
| 703 | .and_then(|v| v.as_str()) |
| 704 | .map_or(fallback, |path| format!("Reading {path}")), |
| 705 | "list_dir" => obj |
| 706 | .and_then(|o| o.get("path")) |
| 707 | .and_then(|v| v.as_str()) |
| 708 | .map_or("Listing directory".to_string(), |path| { |
| 709 | format!("Listing {path}") |
| 710 | }), |
| 711 | "grep_files" => { |
| 712 | let pattern = obj |
| 713 | .and_then(|o| o.get("pattern")) |
| 714 | .and_then(|v| v.as_str()) |
| 715 | .unwrap_or("pattern"); |
| 716 | format!("Searching for `{pattern}`") |
| 717 | } |
| 718 | "list_files" => "Listing files".to_string(), |
| 719 | _ => fallback, |
| 720 | } |
| 721 | } |
| 722 | |
| 723 | fn is_mcp_tool(name: &str) -> bool { |
| 724 | name.starts_with("mcp_") |
| 725 | } |
| 726 | |
| 727 | fn is_view_image_tool(name: &str) -> bool { |
| 728 | matches!(name, "view_image" | "view_image_file" | "view_image_tool") |
| 729 | } |
| 730 | |
| 731 | fn is_web_search_tool(name: &str) -> bool { |
| 732 | matches!(name, "web_search" | "search_web" | "search" | "web.run") |
| 733 | || name.ends_with("_web_search") |
| 734 | } |
| 735 | |
| 736 | fn web_search_query(input: &serde_json::Value) -> String { |
| 737 | if let Some(searches) = input.get("search_query").and_then(|v| v.as_array()) |
| 738 | && let Some(first) = searches.first() |
| 739 | && let Some(q) = first.get("q").and_then(|v| v.as_str()) |
| 740 | { |
| 741 | return q.to_string(); |
| 742 | } |
| 743 | |
| 744 | input |
| 745 | .get("query") |
| 746 | .or_else(|| input.get("q")) |
| 747 | .or_else(|| input.get("search")) |
| 748 | .and_then(|v| v.as_str()) |
| 749 | .unwrap_or("Web search") |
| 750 | .to_string() |
| 751 | } |
| 752 | |
| 753 | fn review_target_label(input: &serde_json::Value) -> String { |
| 754 | let target = input |
| 755 | .get("target") |
| 756 | .and_then(|v| v.as_str()) |
| 757 | .unwrap_or("review") |
| 758 | .trim(); |
| 759 | let kind = input |
| 760 | .get("kind") |
| 761 | .and_then(|v| v.as_str()) |
| 762 | .unwrap_or("") |
| 763 | .trim() |
| 764 | .to_ascii_lowercase(); |
| 765 | let staged = input |
| 766 | .get("staged") |
| 767 | .and_then(|v| v.as_bool()) |
| 768 | .unwrap_or(false); |
| 769 | let target_lower = target.to_ascii_lowercase(); |
| 770 | |
| 771 | if kind == "diff" |
| 772 | || target_lower == "diff" |
| 773 | || target_lower == "git diff" |
| 774 | || target_lower == "staged" |
| 775 | || target_lower == "cached" |
| 776 | { |
| 777 | if staged || target_lower == "staged" || target_lower == "cached" { |
| 778 | return "git diff --cached".to_string(); |
| 779 | } |
| 780 | return "git diff".to_string(); |
| 781 | } |
| 782 | |
| 783 | target.to_string() |
| 784 | } |
| 785 | |
| 786 | fn parse_plan_input(input: &serde_json::Value) -> (Option<String>, Vec<PlanStep>) { |
| 787 | let explanation = input |
| 788 | .get("explanation") |
| 789 | .and_then(|v| v.as_str()) |
| 790 | .map(std::string::ToString::to_string); |
| 791 | let mut steps = Vec::new(); |
| 792 | if let Some(items) = input.get("plan").and_then(|v| v.as_array()) { |
| 793 | for item in items { |
| 794 | let step = item.get("step").and_then(|v| v.as_str()).unwrap_or(""); |
| 795 | let status = item |
| 796 | .get("status") |
| 797 | .and_then(|v| v.as_str()) |
| 798 | .unwrap_or("pending"); |
| 799 | if !step.is_empty() { |
| 800 | steps.push(PlanStep { |
| 801 | step: step.to_string(), |
| 802 | status: status.to_string(), |
| 803 | }); |
| 804 | } |
| 805 | } |
| 806 | } |
| 807 | (explanation, steps) |
| 808 | } |
| 809 | |
| 810 | fn parse_patch_summary(input: &serde_json::Value) -> (String, String) { |
| 811 | if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) { |
| 812 | let count = changes.len(); |
| 813 | let path = changes |
| 814 | .first() |
| 815 | .and_then(|c| c.get("path")) |
| 816 | .and_then(|v| v.as_str()) |
| 817 | .map(str::to_string) |
| 818 | .unwrap_or_else(|| "<file>".to_string()); |
| 819 | let label = if count <= 1 { |
| 820 | path |
| 821 | } else { |
| 822 | format!("{count} files") |
| 823 | }; |
| 824 | let summary = format!("Changes: {count} file(s)"); |
| 825 | return (label, summary); |
| 826 | } |
| 827 | |
| 828 | let patch_text = input.get("patch").and_then(|v| v.as_str()).unwrap_or(""); |
| 829 | let paths = extract_patch_paths(patch_text); |
| 830 | let path = input |
| 831 | .get("path") |
| 832 | .and_then(|v| v.as_str()) |
| 833 | .map(str::to_string) |
| 834 | .or_else(|| { |
| 835 | if paths.len() == 1 { |
| 836 | paths.first().cloned() |
| 837 | } else if paths.is_empty() { |
| 838 | None |
| 839 | } else { |
| 840 | Some(format!("{} files", paths.len())) |
| 841 | } |
| 842 | }) |
| 843 | .unwrap_or_else(|| "<file>".to_string()); |
| 844 | |
| 845 | let (adds, removes) = count_patch_changes(patch_text); |
| 846 | let summary = if adds == 0 && removes == 0 { |
| 847 | "Patch applied".to_string() |
| 848 | } else { |
| 849 | format!("Changes: +{adds} / -{removes}") |
| 850 | }; |
| 851 | (path, summary) |
| 852 | } |
| 853 | |
| 854 | fn extract_patch_paths(patch: &str) -> Vec<String> { |
| 855 | let mut paths = Vec::new(); |
| 856 | for line in patch.lines() { |
| 857 | if let Some(rest) = line.strip_prefix("+++ ") { |
| 858 | let raw = rest.trim(); |
| 859 | if raw == "/dev/null" || raw == "dev/null" { |
| 860 | continue; |
| 861 | } |
| 862 | let raw = raw.strip_prefix("b/").unwrap_or(raw); |
| 863 | if !paths.contains(&raw.to_string()) { |
| 864 | paths.push(raw.to_string()); |
| 865 | } |
| 866 | } else if let Some(rest) = line.strip_prefix("diff --git ") { |
| 867 | let parts: Vec<&str> = rest.split_whitespace().collect(); |
| 868 | if let Some(path) = parts.get(1).or_else(|| parts.first()) { |
| 869 | let raw = path.trim(); |
| 870 | let raw = raw |
| 871 | .strip_prefix("b/") |
| 872 | .or_else(|| raw.strip_prefix("a/")) |
| 873 | .unwrap_or(raw); |
| 874 | if !paths.contains(&raw.to_string()) { |
| 875 | paths.push(raw.to_string()); |
| 876 | } |
| 877 | } |
| 878 | } |
| 879 | } |
| 880 | paths |
| 881 | } |
| 882 | |
| 883 | pub(super) fn maybe_add_patch_preview(app: &mut App, input: &serde_json::Value) { |
| 884 | if let Some(patch) = input.get("patch").and_then(|v| v.as_str()) { |
| 885 | app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell { |
| 886 | title: "Patch Preview".to_string(), |
| 887 | diff: patch.to_string(), |
| 888 | }))); |
| 889 | app.mark_history_updated(); |
| 890 | return; |
| 891 | } |
| 892 | |
| 893 | if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) { |
| 894 | let preview = format_changes_preview(changes); |
| 895 | if !preview.trim().is_empty() { |
| 896 | app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell { |
| 897 | title: "Changes Preview".to_string(), |
| 898 | diff: preview, |
| 899 | }))); |
| 900 | app.mark_history_updated(); |
| 901 | } |
| 902 | } |
| 903 | } |
| 904 | |
| 905 | fn format_changes_preview(changes: &[serde_json::Value]) -> String { |
| 906 | let mut out = String::new(); |
| 907 | for change in changes { |
| 908 | let path = change |
| 909 | .get("path") |
| 910 | .and_then(|v| v.as_str()) |
| 911 | .unwrap_or("<file>"); |
| 912 | let content = change.get("content").and_then(|v| v.as_str()).unwrap_or(""); |
| 913 | |
| 914 | out.push_str(&format!("diff --git a/{path} b/{path}\n")); |
| 915 | out.push_str(&format!("--- a/{path}\n+++ b/{path}\n")); |
| 916 | out.push_str("@@ -0,0 +1,1 @@\n"); |
| 917 | |
| 918 | let mut count = 0usize; |
| 919 | for line in content.lines() { |
| 920 | out.push('+'); |
| 921 | out.push_str(line); |
| 922 | out.push('\n'); |
| 923 | count += 1; |
| 924 | if count >= 20 { |
| 925 | out.push_str("+... (truncated)\n"); |
| 926 | break; |
| 927 | } |
| 928 | } |
| 929 | if content.is_empty() { |
| 930 | out.push_str("+\n"); |
| 931 | } |
| 932 | } |
| 933 | out |
| 934 | } |
| 935 | |
| 936 | fn count_patch_changes(patch: &str) -> (usize, usize) { |
| 937 | let mut adds = 0; |
| 938 | let mut removes = 0; |
| 939 | for line in patch.lines() { |
| 940 | if line.starts_with("+++") || line.starts_with("---") { |
| 941 | continue; |
| 942 | } |
| 943 | if line.starts_with('+') { |
| 944 | adds += 1; |
| 945 | } else if line.starts_with('-') { |
| 946 | removes += 1; |
| 947 | } |
| 948 | } |
| 949 | (adds, removes) |
| 950 | } |
| 951 | |
| 952 | fn exec_command_from_input(input: &serde_json::Value) -> Option<String> { |
| 953 | input |
| 954 | .get("command") |
| 955 | .and_then(|v| v.as_str()) |
| 956 | .map(std::string::ToString::to_string) |
| 957 | } |
| 958 | |
| 959 | fn exec_source_from_input(input: &serde_json::Value) -> ExecSource { |
| 960 | match input.get("source").and_then(|v| v.as_str()) { |
| 961 | Some(source) if source.eq_ignore_ascii_case("user") => ExecSource::User, |
| 962 | _ => ExecSource::Assistant, |
| 963 | } |
| 964 | } |
| 965 | |
| 966 | fn exec_interaction_summary(name: &str, input: &serde_json::Value) -> Option<(String, bool)> { |
| 967 | let command = exec_command_from_input(input).unwrap_or_else(|| "<command>".to_string()); |
| 968 | let command_display = format!("\"{command}\""); |
| 969 | let interaction_input = input |
| 970 | .get("input") |
| 971 | .or_else(|| input.get("stdin")) |
| 972 | .or_else(|| input.get("data")) |
| 973 | .and_then(|v| v.as_str()); |
| 974 | |
| 975 | let is_wait_tool = matches!(name, "exec_shell_wait" | "exec_wait"); |
| 976 | let is_interact_tool = matches!(name, "exec_shell_interact" | "exec_interact"); |
| 977 | |
| 978 | if is_interact_tool || interaction_input.is_some() { |
| 979 | let preview = interaction_input.map(summarize_interaction_input); |
| 980 | let summary = if let Some(preview) = preview { |
| 981 | format!("Interacted with {command_display}, sent {preview}") |
| 982 | } else { |
| 983 | format!("Interacted with {command_display}") |
| 984 | }; |
| 985 | return Some((summary, false)); |
| 986 | } |
| 987 | |
| 988 | if is_wait_tool || input.get("wait").and_then(serde_json::Value::as_bool) == Some(true) { |
| 989 | return Some((format!("Waited for {command_display}"), true)); |
| 990 | } |
| 991 | |
| 992 | None |
| 993 | } |
| 994 | |
| 995 | fn summarize_interaction_input(input: &str) -> String { |
| 996 | let mut single_line = input.replace('\r', ""); |
| 997 | single_line = single_line.replace('\n', "\\n"); |
| 998 | single_line = single_line.replace('\"', "'"); |
| 999 | let max_len = 80; |
| 1000 | if single_line.chars().count() <= max_len { |
| 1001 | return format!("\"{single_line}\""); |
| 1002 | } |
| 1003 | let mut out = String::new(); |
| 1004 | for ch in single_line.chars().take(max_len.saturating_sub(3)) { |
| 1005 | out.push(ch); |
| 1006 | } |
| 1007 | out.push_str("..."); |
| 1008 | format!("\"{out}\"") |
| 1009 | } |
| 1010 | |
| 1011 | fn exec_is_background(input: &serde_json::Value) -> bool { |
| 1012 | input |
| 1013 | .get("background") |
| 1014 | .and_then(serde_json::Value::as_bool) |
| 1015 | .unwrap_or(false) |
| 1016 | } |
| 1017 |