1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
//! `AgentEvent::ToolResult` handler extracted from `run_interactive`.
//!
//! Pairs the result with its in-flight tool call (id-match, with a
//! pending-Interrupted fallback for providers that don't emit ids),
//! then paints the result inside the open chamber — or as a single
//! `↳ first_line` trailer if the chamber was closed by a deny path,
//! or as a fresh chamber if a parallel-execution race displaced the
//! original. Edits get the colorized diff path when `show_edit_diff`
//! is enabled; everything else goes through `render_tool_output`.
use crossterm::style::Color;
use crate::ui::colors::c_tool;
use crate::ui::events::sanitize_output;
use crate::ui::run_handlers::RunCtx;
use crate::ui::theme;
use crate::ui::tool_display::{
CollapsedToolResult, chamber_bottom, chamber_row, chamber_row_with_bg, chamber_widths,
close_tool_chamber_passive, fit_banner_header, format_tool_banner_value, lsp_block_start,
render_tool_output, summarize_lsp_tail,
};
pub(crate) async fn handle_tool_result(
ctx: &mut RunCtx<'_>,
id: String,
output: String,
) -> anyhow::Result<()> {
// dirge-5h5: diagnostic for the empty-chambers-on-parallel-reads
// bug. Enable with `RUST_LOG=dirge::ui::chamber=trace dirge …`,
// reproduce (7 parallel reads), then inspect the trace stream:
// each ToolResult logs the id, last_tool_call_id, chamber state,
// and output length so it's clear which results entered the
// dirge-jzj fresh-chamber path, which ones piggybacked on the
// existing chamber, and what their bodies looked like.
tracing::trace!(
target: "dirge::ui::chamber",
event = "tool_result_in",
id = %id,
last_tool_call_id = ?ctx.last_tool_call_id,
tool_chamber_open = *ctx.tool_chamber_open,
chamber_top_start = ?ctx.chamber_top_start,
chamber_top_end = ?ctx.chamber_top_end,
output_len = output.len(),
output_trimmed_len = output.trim().len(),
"ToolResult handler entry"
);
// Phase 3: pair the result with its call.
// Prefer id-match; fall back to the most-
// recent Interrupted (pending) entry for
// providers that don't emit ids.
let target = if !id.is_empty() {
ctx.tool_calls_buf
.iter_mut()
.rev()
.find(|e| e.id == id.as_str())
} else {
ctx.tool_calls_buf
.iter_mut()
.rev()
.find(|e| matches!(e.state, crate::session::ToolCallState::Interrupted))
};
if let Some(entry) = target {
entry.state = crate::session::ToolCallState::Completed {
result: output.to_string(),
};
}
let show_details = ctx.cfg.show_tool_details.unwrap_or(true);
let max_chars = ctx.cfg.resolve_tool_result_max_chars();
let show_diff = ctx.cfg.resolve_show_edit_diff();
// dirge-jzj: if the chamber on screen belongs to a
// DIFFERENT tool call (parallel-execution race
// where ToolResults arrive out of order, or a
// newer ToolCall's TOP displaced this result's
// chamber before the result arrived), paint a
// fresh complete chamber for THIS id below the
// current scroll position. Lets each result land
// in its own correctly-labeled frame regardless
// of completion order. The id-matches case (the
// common sequential path) falls through to the
// existing render paths below.
let jzj_path_active =
!id.is_empty() && ctx.last_tool_call_id.as_deref() != Some(id.as_str()) && show_details;
tracing::trace!(
target: "dirge::ui::chamber",
event = "tool_result_path_decision",
id = %id,
jzj_path = jzj_path_active,
show_details = show_details,
"ToolResult chamber-routing decision"
);
if jzj_path_active {
// Close whatever chamber is on screen first,
// then paint a fresh TOP for this id. We
// don't reuse the ToolCall handler's TOP-
// paint code path because that fires from a
// different event; the body of the new
// chamber will land via path (a) below now
// that tool_chamber_open=true.
if *ctx.tool_chamber_open {
close_tool_chamber_passive(
ctx.renderer,
ctx.last_tool_name,
ctx.tool_chamber_open,
ctx.chamber_top_start,
ctx.chamber_top_end,
)?;
}
let (resolved_name, resolved_args) = ctx
.tool_calls_buf
.iter()
.rev()
.find(|e| e.id == id.as_str())
.map(|e| (e.name.to_string(), e.args.clone()))
.unwrap_or_else(|| (String::new(), serde_json::Value::Null));
if !resolved_name.is_empty() {
let upper = resolved_name.to_ascii_uppercase();
let raw_value = format_tool_banner_value(&resolved_name, &resolved_args);
let raw_value = sanitize_output(&raw_value).into_string();
let (frame_w, _) = chamber_widths(ctx.renderer);
let header = fit_banner_header(&upper, &raw_value, frame_w);
ctx.renderer.write_line("", Color::White)?;
ctx.renderer.write_line_raw(&header, c_tool())?;
*ctx.tool_chamber_open = true;
*ctx.last_tool_name = Some(resolved_name);
*ctx.last_tool_call_id = Some(id.to_string());
}
// If the call wasn't in tool_calls_buf (id
// unknown — shouldn't happen post-ToolCall
// but defensive), fall through to path (b)
// trailer; we have no banner to paint.
}
// on-tool-end is also fired by HookedToolDyn so the
// host doesn't re-dispatch it here.
// Three states at ToolResult time:
//
// (a) chamber OPEN, show_details=true → paint body
// inside the chamber + close with `╰─╯`.
// (b) chamber NOT OPEN (deny path closed it via
// the alert handler), show_details=true →
// emit a single dim ` ↳ {output}` trailer.
// The trailer is the only thing pinned to
// the original tool call now that its
// chamber is gone.
// (c) show_details=false → no body, but if the
// chamber is still open we MUST close it
// (a bare chamber_bottom) or the next
// output paints inside a dead chamber.
//
// Gating on `tool_chamber_open` (not
// `last_tool_name`) is deliberate: the name
// slot has unrelated clear sites and can drain
// while the chamber TOP is still on screen —
// that's the whole reason for the dedicated
// chamber-state bool.
if !*ctx.tool_chamber_open && show_details {
// (b) chamber already closed by deny path.
let trimmed = output.trim();
if !trimmed.is_empty() {
let first_line = trimmed.lines().next().unwrap_or("");
ctx.renderer.write_line(
&format!(" ↳ {}", sanitize_output(first_line)),
theme::dim(),
)?;
}
}
if *ctx.tool_chamber_open && !show_details {
// (c) chamber on-screen but body suppressed
// — show a single dim "(body hidden)" row
// so the chamber doesn't look like an
// empty box with no content. Then close
// with a bare bottom so a stale `╭─`
// doesn't swallow the next paint.
let (frame_w, inner) = chamber_widths(ctx.renderer);
ctx.renderer.write_line(
&chamber_row("(body hidden — show_tool_details=false)", inner),
theme::dim(),
)?;
ctx.renderer
.write_line_raw(&chamber_bottom(frame_w), theme::dim())?;
*ctx.tool_chamber_open = false;
}
if *ctx.tool_chamber_open && show_details {
// Resolve the tool name + banner for the
// collapse store. Prefer the just-stored
// `last_tool_name`; fall back to looking
// up the call by id in `tool_calls_buf`
// (covers paths where `last_tool_name`
// was drained out from under us — same
// shape as the alert-bug fix).
let resolved_name: String = ctx
.last_tool_name
.clone()
.or_else(|| {
ctx.tool_calls_buf
.iter()
.rev()
.find(|e| e.id == id.as_str())
.map(|e| e.name.to_string())
})
.unwrap_or_default();
let resolved_args = ctx
.tool_calls_buf
.iter()
.rev()
.find(|e| e.id == id.as_str())
.map(|e| e.args.clone())
.unwrap_or(serde_json::Value::Null);
let banner_value = format_tool_banner_value(&resolved_name, &resolved_args);
let max_lines = ctx.cfg.resolve_tool_result_max_lines();
// Review #7: gate the colorized diff path
// on `resolved_name`, not `last_tool_name`
// — if the name slot drained we'd lose
// the green/red background coloring and
// fall back to plain `render_tool_output`.
let is_edit = resolved_name == "edit" && show_diff;
// Review #6: empty name fallback would
// paint an unnamed chamber AND collapse
// it. Surface a single dim trailer and
// emit the chamber bottom so the chamber
// doesn't orphan. Skip the rest of branch
// (a).
if resolved_name.is_empty() {
let (frame_w, inner) = chamber_widths(ctx.renderer);
let trimmed = output.trim();
let row_text = if trimmed.is_empty() {
"(unresolved tool, no output)".to_string()
} else {
let first = trimmed.lines().next().unwrap_or("");
format!("(unresolved tool) {}", first)
};
ctx.renderer
.write_line_raw(&chamber_row(&row_text, inner), theme::dim())?;
ctx.renderer
.write_line_raw(&chamber_bottom(frame_w), theme::dim())?;
*ctx.tool_chamber_open = false;
*ctx.chamber_top_start = None;
*ctx.chamber_top_end = None;
*ctx.last_tool_name = None;
// Early-exit equivalent of the inline arm's `continue`:
// the inline `continue` skipped the trailing
// `last_tool_name = None; last_tool_call_id = None;`
// clears below, but `last_tool_name` was already cleared
// here and `last_tool_call_id` is intentionally left
// alone (matches inline behavior).
return Ok(());
}
if is_edit {
// Colorized diff rendering. The edit tool emits
// its diff block starting with "--- a/<path>" —
// match that exact sentinel to avoid false
// positives on stray "--- " prefixes elsewhere
// in the output.
let lines: Vec<&str> = output.lines().collect();
let diff_start = lines.iter().position(|l| l.starts_with("--- a/"));
if let Some(pre) = diff_start {
let (frame_w, inner) = chamber_widths(ctx.renderer);
// The edit tool appends an `LSP errors detected …`
// block after the diff. That block is the agent's
// to act on, but in the chat it's often a wall of
// language-server noise. Render the diff in full
// (meaningful) and collapse the diagnostics tail to
// one summary line; Ctrl+O still expands the full
// output via `last_collapsed`.
let diag_start = lsp_block_start(&lines);
let mut diff_end = diag_start.unwrap_or(lines.len());
// Trim blank lines the `\n\n` separator left between
// the diff and the diagnostics heading.
while diff_end > pre && lines[diff_end - 1].trim().is_empty() {
diff_end -= 1;
}
// Pre-diff prose (the edit tool's
// header line, etc.) renders in
// the chamber's standard tone.
for l in &lines[..pre] {
if !l.is_empty() {
let txt = sanitize_output(l).into_string();
ctx.renderer
.write_line_raw(&chamber_row(&txt, inner), theme::result())?;
}
}
// Colorized diff with opencode-style
// tinted backgrounds: + lines get a
// dim-green bg (palette 22), - lines
// get a dim-red bg (palette 52).
// Header (`--- ` / `+++ ` / `@@`) and
// context lines have no bg.
for l in &lines[pre..diff_end] {
let txt = sanitize_output(l).into_string();
if l.starts_with("--- ") || l.starts_with("+++ ") {
// Filenames in the diff header get
// the same accent as section
// markers elsewhere in chat. Was
// hardcoded `Color::Cyan` which is
// invisible on phosphor (same hue
// as agent text).
ctx.renderer
.write_line_raw(&chamber_row(&txt, inner), theme::accent())?;
} else if l.starts_with("@@") {
// Hunk position markers — use dim
// so they recede behind the +/-
// content lines below.
ctx.renderer
.write_line_raw(&chamber_row(&txt, inner), theme::dim())?;
} else if l.starts_with('+') {
ctx.renderer
.write_line_raw(&chamber_row_with_bg(&txt, inner, 22), Color::Green)?;
} else if l.starts_with('-') {
ctx.renderer
.write_line_raw(&chamber_row_with_bg(&txt, inner, 52), Color::Red)?;
} else {
ctx.renderer
.write_line_raw(&chamber_row(&txt, inner), theme::dim())?;
}
}
// Compact LSP diagnostics summary (one line) in place
// of the appended wall; register the full output so
// Ctrl+O can expand it.
if let Some(ds) = diag_start {
let summary = summarize_lsp_tail(&lines[ds..]);
ctx.renderer
.write_line_raw(&chamber_row(&summary, inner), theme::warn())?;
*ctx.last_collapsed = Some(CollapsedToolResult {
tool_name: resolved_name.clone(),
banner_value: sanitize_output(&banner_value).into_string(),
full_output: output.to_string(),
});
}
ctx.renderer
.write_line_raw(&chamber_bottom(frame_w), theme::dim())?;
*ctx.tool_chamber_open = false;
} else {
// No diff section found, show normally
*ctx.last_collapsed = render_tool_output(
ctx.renderer,
&resolved_name,
&banner_value,
&output,
max_chars,
max_lines,
)?;
*ctx.tool_chamber_open = false;
}
} else {
*ctx.last_collapsed = render_tool_output(
ctx.renderer,
&resolved_name,
&banner_value,
&output,
max_chars,
max_lines,
)?;
*ctx.tool_chamber_open = false;
}
}
// If this output was truncated, make it the freshest Ctrl+O target.
ctx.note_tool_truncation();
// Clear after consuming so a future stray ToolResult
// can't be coloured with a stale tool name.
*ctx.last_tool_name = None;
*ctx.last_tool_call_id = None;
Ok(())
}