返回 DeepSeek-TUI-2026
tool_routing.rs
根目录 / crates / tui / src / tui / tool_routing.rs
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
1017 lines RUST