Skip to main content

mj_transcript/
transcript.rs

1//! Transcript projection and presentation implementation.
2
3use agent_client_protocol::schema::v1::{
4    SessionUpdate, ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdateFields, ToolKind,
5};
6use mj_core::acp::RuntimeEvent;
7pub use mj_core::transcript::*;
8use serde::Deserialize;
9use serde_json::Value;
10use tree_sitter::{Node, Parser};
11const TOOL_SUMMARY_SOURCE_BYTES: usize = 64 * 1024;
12/// Parser-rule version stored with cached tool summaries.
13pub const TOOL_SUMMARY_VERSION: u8 = 4;
14
15/// Reduce a tool call to what a reader still needs, once a verified checkpoint
16/// holds the whole of it.
17///
18/// Tool output is where a projection's bytes are: on one measured session,
19/// 561 MB of 635 MiB. Behind a checkpoint nothing reads it — the checkpoint
20/// archive carries the complete transcript, and restoring it brings the output
21/// back — so what stays here is what the transcript still shows: which tool
22/// ran, on what, with what result, and how many lines each edit changed.
23///
24/// Returns whether anything changed, so a caller can skip the write.
25pub fn compact_tool_call_for_retention(body: &mut TranscriptBody) -> bool {
26    let TranscriptBody::Tool {
27        call,
28        terminal_outputs,
29        terminal_refs,
30        ..
31    } = body
32    else {
33        return false;
34    };
35    let Some(object) = call.as_object_mut() else {
36        return false;
37    };
38    let mut changed = !terminal_outputs.is_empty() || !terminal_refs.is_empty();
39    terminal_outputs.clear();
40    terminal_refs.clear();
41    for field in ["rawInput", "rawOutput", "_meta"] {
42        changed |= object.remove(field).is_some();
43    }
44    let Some(content) = object
45        .get_mut("content")
46        .and_then(|value| value.as_array_mut())
47    else {
48        return changed;
49    };
50    let before = content.len();
51    // Diffs stay, because the transcript still shows their stat. Their patch
52    // text does not, and neither do the two file copies an older record holds
53    // instead of a patch: `diff::drop_patch_text` turns those into the
54    // counts `format_diffstat` reads before dropping them.
55    content.retain(|item| item.get("type").and_then(|kind| kind.as_str()) == Some("diff"));
56    changed |= content.len() != before;
57    for item in content.iter_mut() {
58        changed |= drop_diff_body(item);
59    }
60    changed
61}
62
63fn drop_diff_body(item: &mut serde_json::Value) -> bool {
64    use agent_client_protocol::schema::v1::ToolCallContent;
65
66    // Round-trip through `ToolCallContent`, not `Diff`: the variant tag lives
67    // on the enum, and writing back a bare `Diff` would strip it and make the
68    // whole tool call unreadable.
69    let mut content = match serde_json::from_value::<ToolCallContent>(item.clone()) {
70        Ok(content) => content,
71        // Content this cannot read is content it must not rewrite.
72        Err(error) => {
73            tracing::warn!(%error, "skipping unreadable tool content during retention");
74            return false;
75        }
76    };
77    let ToolCallContent::Diff(diff) = &mut content else {
78        return false;
79    };
80    if !mj_core::diff::drop_patch_text(diff) {
81        return false;
82    }
83    match serde_json::to_value(&content) {
84        Ok(value) => {
85            *item = value;
86            true
87        }
88        Err(error) => {
89            tracing::warn!(%error, "could not rewrite a diff during retention");
90            false
91        }
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96enum ToolSummarySource {
97    Shell(String),
98    Argv {
99        executable: String,
100        arguments: Vec<String>,
101    },
102}
103
104/// Whether a partial update changes inputs used to derive the tool summary.
105pub fn tool_call_update_changes_presentation(
106    call: &ToolCall,
107    fields: &ToolCallUpdateFields,
108) -> bool {
109    fields
110        .title
111        .as_ref()
112        .is_some_and(|title| title != &call.title)
113        || fields.kind.is_some_and(|kind| kind != call.kind)
114        || fields
115            .raw_input
116            .as_ref()
117            .is_some_and(|input| Some(input) != call.raw_input.as_ref())
118        || (call.kind == ToolKind::Execute
119            && fields
120                .raw_output
121                .as_ref()
122                .is_some_and(|output| Some(output) != call.raw_output.as_ref())
123            && command_source(call.raw_input.as_ref()).is_none())
124}
125
126/// Compute the stable presentation metadata for one complete ACP call.
127pub fn tool_call_presentation(call: &ToolCall) -> ToolCallPresentation {
128    let kind = call.kind;
129    if kind == ToolKind::Execute {
130        if let Some(source) = command_source(call.raw_input.as_ref()) {
131            return presentation_from_source(
132                source,
133                ToolSummarySourceKind::RawInput,
134                kind,
135                &call.title,
136            );
137        }
138        if let Some(source) = command_source(call.raw_output.as_ref()) {
139            return presentation_from_source(
140                source,
141                ToolSummarySourceKind::RawOutput,
142                kind,
143                &call.title,
144            );
145        }
146    }
147    presentation_from_title(&call.title, kind)
148}
149
150/// Use cached presentation data when it was produced by current parser rules,
151/// otherwise rebuild it from the complete stored call. This lets parser fixes
152/// repair existing transcripts while current summaries remain cheap to load.
153pub fn materialized_tool_call_presentation(
154    stored: Option<&ToolCallPresentation>,
155    call: &ToolCall,
156) -> ToolCallPresentation {
157    stored
158        .filter(|presentation| presentation.summary_version >= TOOL_SUMMARY_VERSION)
159        .cloned()
160        .unwrap_or_else(|| tool_call_presentation(call))
161}
162
163/// Apply the presentation-relevant portion of a partial ACP update to cached
164/// metadata. ACP updates replace only fields that are present, so a title
165/// update must not erase a summary selected from an earlier raw command.
166pub fn update_tool_call_presentation(
167    previous: Option<&ToolCallPresentation>,
168    title: &str,
169    kind: Option<ToolKind>,
170    raw_input: Option<&Value>,
171    raw_output: Option<&Value>,
172) -> ToolCallPresentation {
173    let next_kind = kind.unwrap_or_else(|| {
174        previous
175            .map(|presentation| presentation.tool_kind)
176            .unwrap_or_default()
177    });
178
179    if next_kind == ToolKind::Execute {
180        if let Some(source) = raw_input.and_then(|value| command_source(Some(value))) {
181            return presentation_from_source(
182                source,
183                ToolSummarySourceKind::RawInput,
184                next_kind,
185                title,
186            );
187        }
188        if let Some(source) = raw_output.and_then(|value| command_source(Some(value)))
189            && (raw_input.is_some()
190                || !previous.is_some_and(|previous| {
191                    previous.source_kind == ToolSummarySourceKind::RawInput
192                }))
193        {
194            return presentation_from_source(
195                source,
196                ToolSummarySourceKind::RawOutput,
197                next_kind,
198                title,
199            );
200        }
201        if let Some(previous) = previous
202            && previous.tool_kind == ToolKind::Execute
203            && ((raw_input.is_none() && previous.source_kind == ToolSummarySourceKind::RawInput)
204                || (raw_input.is_none()
205                    && raw_output.is_none()
206                    && previous.source_kind == ToolSummarySourceKind::RawOutput))
207        {
208            return ToolCallPresentation {
209                tool_kind: next_kind,
210                ..previous.clone()
211            };
212        }
213    }
214
215    presentation_from_title(title, next_kind)
216}
217
218fn command_source(raw: Option<&Value>) -> Option<ToolSummarySource> {
219    let command = raw?.get("command")?;
220    match command {
221        Value::String(command) if !command.trim().is_empty() => {
222            Some(ToolSummarySource::Shell(command.clone()))
223        }
224        Value::Array(argv) => {
225            let argv = argv.iter().map(Value::as_str).collect::<Option<Vec<_>>>()?;
226            let first = argv.first()?.trim();
227            if first.is_empty() {
228                return None;
229            }
230            if is_shell_interpreter(first)
231                && let Some(script) = shell_script_argument(&argv[1..])
232            {
233                return Some(ToolSummarySource::Shell(script.to_owned()));
234            }
235            Some(ToolSummarySource::Argv {
236                executable: first.to_owned(),
237                arguments: argv[1..]
238                    .iter()
239                    .map(|argument| (*argument).to_owned())
240                    .collect(),
241            })
242        }
243        _ => None,
244    }
245}
246
247fn is_shell_interpreter(value: &str) -> bool {
248    let executable = value.rsplit('/').next().unwrap_or(value);
249    matches!(executable, "sh" | "bash" | "dash" | "zsh")
250}
251
252fn shell_script_argument<'a>(arguments: &'a [&'a str]) -> Option<&'a str> {
253    let mut index = 0;
254    while index < arguments.len() {
255        let argument = arguments[index];
256        if argument == "--" {
257            return None;
258        }
259        if argument == "-c" || argument == "--command" {
260            return arguments.get(index + 1).copied();
261        }
262        if argument.starts_with('-') && !argument.starts_with("--") && argument[1..].contains('c') {
263            return arguments.get(index + 1).copied();
264        }
265        index += 1;
266    }
267    None
268}
269
270fn presentation_from_source(
271    source: ToolSummarySource,
272    source_kind: ToolSummarySourceKind,
273    tool_kind: ToolKind,
274    title: &str,
275) -> ToolCallPresentation {
276    let (source, summary) = match source {
277        ToolSummarySource::Shell(source) => {
278            let bounded = bound_summary_source(&source);
279            let summary = summarize_shell(&bounded)
280                .or_else(|| first_meaningful_token(title))
281                .unwrap_or_else(|| "tool".to_owned());
282            (bounded, summary)
283        }
284        ToolSummarySource::Argv {
285            executable,
286            arguments,
287        } => {
288            let source = std::iter::once(executable.as_str())
289                .chain(arguments.iter().map(String::as_str))
290                .collect::<Vec<_>>()
291                .join(" ");
292            let bounded = bound_summary_source(&source);
293            let summary = summarize_invocation(&executable, &arguments)
294                .or_else(|| first_meaningful_token(title))
295                .unwrap_or_else(|| "tool".to_owned());
296            (bounded, summary)
297        }
298    };
299    ToolCallPresentation {
300        summary,
301        source,
302        source_kind,
303        tool_kind,
304        summary_version: TOOL_SUMMARY_VERSION,
305    }
306}
307
308fn presentation_from_title(title: &str, tool_kind: ToolKind) -> ToolCallPresentation {
309    let source = title_source(title);
310    let bounded = bound_summary_source(&source);
311    let summary = if tool_kind == ToolKind::Execute {
312        summarize_shell(&bounded)
313            .or_else(|| first_meaningful_token(&bounded))
314            .unwrap_or_else(|| "tool".to_owned())
315    } else if bounded.trim_end().ends_with('?') {
316        // A question tool (Claude's AskUserQuestion) is titled with the
317        // question; its first word alone ("Which") says nothing.
318        bounded.trim().to_owned()
319    } else {
320        first_meaningful_token(&bounded).unwrap_or_else(|| "tool".to_owned())
321    };
322    ToolCallPresentation {
323        summary,
324        source: bounded,
325        source_kind: ToolSummarySourceKind::Title,
326        tool_kind,
327        summary_version: TOOL_SUMMARY_VERSION,
328    }
329}
330
331fn title_source(title: &str) -> String {
332    let title = title.trim();
333    let title = title
334        .strip_prefix("Running:")
335        .or_else(|| title.strip_prefix("Starting background:"))
336        .map(str::trim)
337        .unwrap_or(title);
338    if let Some(inner) = title
339        .strip_prefix("Execute `")
340        .and_then(|value| value.strip_suffix('`'))
341    {
342        return inner.to_owned();
343    }
344    title.to_owned()
345}
346
347fn first_meaningful_token(value: &str) -> Option<String> {
348    let token = value
349        .split_whitespace()
350        .next()?
351        .trim_matches(|character: char| {
352            !character.is_alphanumeric() && character != '/' && character != '.' && character != '_'
353        });
354    if token.is_empty() {
355        None
356    } else {
357        Some(token.trim_matches(['\'', '"', '`']).to_owned())
358    }
359}
360
361fn bound_summary_source(source: &str) -> String {
362    if source.len() <= TOOL_SUMMARY_SOURCE_BYTES {
363        return source.to_owned();
364    }
365    let mut end = TOOL_SUMMARY_SOURCE_BYTES;
366    while !source.is_char_boundary(end) {
367        end -= 1;
368    }
369    source[..end].to_owned()
370}
371
372fn summarize_shell(source: &str) -> Option<String> {
373    let mut parser = Parser::new();
374    parser
375        .set_language(&tree_sitter_bash::LANGUAGE.into())
376        .ok()?;
377    let tree = parser.parse(source, None)?;
378    let root = tree.root_node();
379    if root.has_error() {
380        return None;
381    }
382
383    let mut commands = Vec::new();
384    let mut operators = Vec::new();
385    let mut subshells = Vec::new();
386    if !collect_shell_tokens(root, source, &mut commands, &mut operators, &mut subshells) {
387        return None;
388    }
389    if commands.is_empty() {
390        return None;
391    }
392    commands.sort_by_key(|command| command.start);
393    operators.sort_by_key(|operator| operator.start);
394    subshells.sort_by_key(|subshell| subshell.start);
395
396    let mut tokens = Vec::new();
397    for (index, command) in commands.iter().enumerate() {
398        if index > 0 {
399            let previous = &commands[index - 1];
400            let separator = shell_separator_between(previous, command, &operators);
401            tokens.push(ShellToken {
402                start: separator.start,
403                text: separator.kind,
404                order: 1,
405            });
406        }
407        tokens.push(ShellToken {
408            start: command.start,
409            text: command.summary.clone(),
410            order: 2,
411        });
412    }
413
414    // Parentheses are meaningful only for subshells that contain a command we
415    // retained. Other punctuation, such as case arms and group delimiters,
416    // is structural and must not leak into the compact summary.
417    for subshell in subshells {
418        if !commands
419            .iter()
420            .any(|command| command.start >= subshell.start && command.end <= subshell.end)
421        {
422            continue;
423        }
424        let close = subshell.end.saturating_sub(1);
425        tokens.push(ShellToken {
426            start: subshell.start,
427            text: "(".to_owned(),
428            order: 0,
429        });
430        tokens.push(ShellToken {
431            start: close,
432            text: ")".to_owned(),
433            order: 3,
434        });
435    }
436
437    tokens.sort_by_key(|token| (token.start, token.order));
438    Some(join_shell_tokens(
439        tokens.into_iter().map(|token| token.text).collect(),
440    ))
441}
442
443#[derive(Debug, Clone)]
444struct ShellCommandToken {
445    start: usize,
446    end: usize,
447    summary: String,
448}
449
450#[derive(Debug, Clone)]
451struct ShellOperatorToken {
452    start: usize,
453    kind: String,
454}
455
456#[derive(Debug, Clone)]
457struct ShellSubshell {
458    start: usize,
459    end: usize,
460}
461
462#[derive(Debug, Clone)]
463struct ShellToken {
464    start: usize,
465    text: String,
466    order: u8,
467}
468
469fn collect_shell_tokens(
470    node: Node<'_>,
471    source: &str,
472    commands: &mut Vec<ShellCommandToken>,
473    operators: &mut Vec<ShellOperatorToken>,
474    subshells: &mut Vec<ShellSubshell>,
475) -> bool {
476    let kind = node.kind();
477    if matches!(kind, "command_substitution" | "process_substitution") {
478        return true;
479    }
480    if kind == "command" {
481        if let Some(summary) = summarize_command_node(node, source) {
482            commands.push(ShellCommandToken {
483                start: node.start_byte(),
484                end: node.end_byte(),
485                summary,
486            });
487            return true;
488        }
489        return false;
490    }
491    if is_shell_operator(node) {
492        operators.push(ShellOperatorToken {
493            start: node.start_byte(),
494            kind: kind.to_owned(),
495        });
496        return true;
497    }
498
499    if kind == "subshell" {
500        subshells.push(ShellSubshell {
501            start: node.start_byte(),
502            end: node.end_byte(),
503        });
504    }
505
506    let mut cursor = node.walk();
507    node.children(&mut cursor)
508        .all(|child| collect_shell_tokens(child, source, commands, operators, subshells))
509}
510
511fn shell_separator_between(
512    previous: &ShellCommandToken,
513    next: &ShellCommandToken,
514    operators: &[ShellOperatorToken],
515) -> ShellOperatorToken {
516    let mut candidates = operators
517        .iter()
518        .filter(|operator| operator.start >= previous.end && operator.start < next.start);
519    let structural = candidates.clone().find(|operator| operator.kind != ";");
520    if let Some(operator) = structural {
521        return operator.clone();
522    }
523    if let Some(operator) = candidates.find(|operator| operator.kind == ";") {
524        return operator.clone();
525    }
526    ShellOperatorToken {
527        start: previous.end,
528        kind: ";".to_owned(),
529    }
530}
531
532/// One argument of a parsed invocation. `value` is its text without matching
533/// quotes; `literal` is false when the text contains an expansion, so `value`
534/// is not what the program receives.
535#[derive(Debug, Clone)]
536struct InvocationArgument {
537    value: String,
538    literal: bool,
539}
540
541fn summarize_command_node(node: Node<'_>, source: &str) -> Option<String> {
542    let name = node.child_by_field_name("name")?;
543    let executable = shell_command_name(name, source)?;
544    let mut cursor = node.walk();
545    let arguments = node
546        .children_by_field_name("argument", &mut cursor)
547        .map(|argument| match shell_argument_value(argument, source) {
548            Some(value) => InvocationArgument {
549                value,
550                literal: true,
551            },
552            None => InvocationArgument {
553                value: strip_matching_quotes(source[argument.byte_range()].trim()).to_owned(),
554                literal: false,
555            },
556        })
557        .collect::<Vec<_>>();
558    summarize_invocation_with_literals(&executable, &arguments)
559}
560
561fn shell_command_name(node: Node<'_>, source: &str) -> Option<String> {
562    if contains_dynamic_shell_node(node) {
563        return None;
564    }
565    normalize_command_name(&source[node.byte_range()])
566}
567
568fn shell_argument_value(node: Node<'_>, source: &str) -> Option<String> {
569    if contains_dynamic_shell_node(node) {
570        return None;
571    }
572    let text = source[node.byte_range()].trim();
573    if text.is_empty() {
574        return None;
575    }
576    Some(strip_matching_quotes(text).to_owned())
577}
578
579fn contains_dynamic_shell_node(node: Node<'_>) -> bool {
580    if matches!(
581        node.kind(),
582        "expansion"
583            | "simple_expansion"
584            | "command_substitution"
585            | "process_substitution"
586            | "arithmetic_expansion"
587    ) {
588        return true;
589    }
590    let mut cursor = node.walk();
591    node.children(&mut cursor).any(contains_dynamic_shell_node)
592}
593
594fn summarize_invocation(executable: &str, arguments: &[String]) -> Option<String> {
595    summarize_invocation_with_literals(
596        executable,
597        &arguments
598            .iter()
599            .map(|value| InvocationArgument {
600                value: strip_matching_quotes(value).to_owned(),
601                literal: true,
602            })
603            .collect::<Vec<_>>(),
604    )
605}
606
607fn summarize_invocation_with_literals(
608    executable: &str,
609    arguments: &[InvocationArgument],
610) -> Option<String> {
611    let executable = normalize_command_name(executable)?;
612    let basename = executable
613        .rsplit(['/', '\\'])
614        .next()
615        .unwrap_or(&executable)
616        .to_owned();
617    if is_shell_interpreter(&basename) {
618        return Some(summarize_shell_invocation(&basename, arguments));
619    }
620    let mut words = vec![executable];
621    if !is_summary_executable(&basename) {
622        return Some(words.remove(0));
623    }
624    let mut index = 0;
625
626    if matches!(basename.as_str(), "cargo" | "mbx")
627        && arguments.first().is_some_and(|argument| {
628            argument.literal && argument.value.starts_with('+') && argument.value.len() > 1
629        })
630    {
631        index += 1;
632    }
633
634    let first_verb = loop {
635        let Some(argument) = arguments.get(index) else {
636            return Some(words.remove(0));
637        };
638        if !argument.literal {
639            return Some(words.remove(0));
640        }
641        if argument.value == "--" || argument.value.starts_with('-') {
642            if let Some(consumed) = known_leading_option_arguments(&basename, arguments, index) {
643                index += consumed;
644                continue;
645            }
646            return Some(words.remove(0));
647        }
648        break argument.value.clone();
649    };
650    words.push(first_verb.clone());
651
652    if allows_second_verb(&basename, &first_verb) {
653        let first = index + 1;
654        if let Some(argument) = arguments.get(first)
655            && argument.literal
656            && !argument.value.starts_with('-')
657            && argument.value != "--"
658        {
659            // `uv run` is followed by a program, which may be given as a path.
660            let verb = if basename == "uv" && first_verb == "run" {
661                argument.value.rsplit('/').next().unwrap_or(&argument.value)
662            } else {
663                &argument.value
664            };
665            words.push(verb.to_owned());
666        }
667    }
668    Some(words.join(" "))
669}
670
671/// A shell is named without its directory, and the script it runs with `-c`
672/// is summarized in its own right, so `/bin/bash -c "python x.py"` reads
673/// `bash -c python`. The script is parsed even when it contains an expansion,
674/// because only its command names matter here.
675fn summarize_shell_invocation(shell: &str, arguments: &[InvocationArgument]) -> String {
676    let values = arguments
677        .iter()
678        .map(|argument| argument.value.as_str())
679        .collect::<Vec<_>>();
680    let Some(script) = shell_script_argument(&values) else {
681        return shell.to_owned();
682    };
683    match summarize_shell(script) {
684        Some(inner) => format!("{shell} -c {inner}"),
685        None => format!("{shell} -c"),
686    }
687}
688
689fn is_summary_executable(basename: &str) -> bool {
690    matches!(
691        basename,
692        "git"
693            | "gh"
694            | "cargo"
695            | "rustup"
696            | "npm"
697            | "pnpm"
698            | "yarn"
699            | "bun"
700            | "uv"
701            | "pip"
702            | "pip3"
703            | "docker"
704            | "podman"
705            | "nice"
706            | "mbx"
707            | "mj"
708            | "tmux"
709    )
710}
711
712fn allows_second_verb(basename: &str, first_verb: &str) -> bool {
713    match basename {
714        // Only commands whose next word is always a subcommand: `git config`
715        // and `git reflog` also take a key or ref there.
716        "git" => matches!(
717            first_verb,
718            "bisect"
719                | "bundle"
720                | "hook"
721                | "lfs"
722                | "maintenance"
723                | "notes"
724                | "remote"
725                | "sparse-checkout"
726                | "stash"
727                | "submodule"
728                | "worktree"
729        ),
730        "gh" => matches!(
731            first_verb,
732            "alias"
733                | "auth"
734                | "cache"
735                | "codespace"
736                | "config"
737                | "extension"
738                | "gist"
739                | "gpg-key"
740                | "issue"
741                | "label"
742                | "org"
743                | "pr"
744                | "project"
745                | "release"
746                | "repo"
747                | "ruleset"
748                | "run"
749                | "search"
750                | "secret"
751                | "ssh-key"
752                | "variable"
753                | "workflow"
754        ),
755        "docker" => matches!(
756            first_verb,
757            "buildx"
758                | "compose"
759                | "config"
760                | "context"
761                | "container"
762                | "image"
763                | "manifest"
764                | "network"
765                | "node"
766                | "plugin"
767                | "secret"
768                | "service"
769                | "stack"
770                | "swarm"
771                | "system"
772                | "trust"
773                | "volume"
774        ),
775        "podman" => matches!(
776            first_verb,
777            "artifact"
778                | "container"
779                | "farm"
780                | "generate"
781                | "image"
782                | "machine"
783                | "manifest"
784                | "network"
785                | "play"
786                | "pod"
787                | "secret"
788                | "system"
789                | "volume"
790        ),
791        "uv" => matches!(first_verb, "cache" | "pip" | "python" | "run" | "tool"),
792        "npm" => matches!(first_verb, "run" | "run-script"),
793        "pnpm" | "yarn" | "bun" => first_verb == "run",
794        "mbx" => matches!(first_verb, "cache" | "explain" | "settings"),
795        "mj" => matches!(
796            first_verb,
797            "daemon" | "import" | "recover" | "review" | "setup" | "workspaces"
798        ),
799        "rustup" => matches!(
800            first_verb,
801            "component" | "override" | "target" | "toolchain"
802        ),
803        _ => false,
804    }
805}
806
807fn known_leading_option_arguments(
808    basename: &str,
809    arguments: &[InvocationArgument],
810    index: usize,
811) -> Option<usize> {
812    let option = arguments.get(index)?.value.as_str();
813    if option == "--" {
814        if basename == "nice" {
815            return Some(1);
816        }
817        return None;
818    }
819    let (option_name, attached_value) = option
820        .split_once('=')
821        .map_or((option, false), |(name, _)| (name, true));
822    if basename == "nice"
823        && option.starts_with('-')
824        && option.len() > 1
825        && option[1..].parse::<i32>().is_ok()
826    {
827        return Some(1);
828    }
829    if basename == "nice"
830        && option
831            .strip_prefix("-n")
832            .is_some_and(|value| !value.is_empty() && value.parse::<i32>().is_ok())
833    {
834        return Some(1);
835    }
836    let attached_short_value = match basename {
837        "git" => option.starts_with("-C") || option.starts_with("-c"),
838        "gh" => option.starts_with("-R"),
839        "docker" | "podman" => option.starts_with("-H"),
840        "mj" => option.starts_with("-i"),
841        "tmux" => ["-L", "-S", "-f", "-c", "-T"]
842            .iter()
843            .any(|prefix| option.starts_with(prefix)),
844        _ => false,
845    } && option.len() > 2;
846    let takes_value = match basename {
847        "git" => matches!(
848            option_name,
849            "-C" | "-c"
850                | "--config-env"
851                | "--exec-path"
852                | "--git-dir"
853                | "--namespace"
854                | "--super-prefix"
855                | "--work-tree"
856        ),
857        "gh" => matches!(
858            option_name,
859            "-R" | "--hostname" | "--repo" | "--jq" | "--template"
860        ),
861        "cargo" => matches!(
862            option_name,
863            "--manifest-path" | "--target-dir" | "--config" | "--color"
864        ),
865        "npm" | "pnpm" | "yarn" | "bun" => {
866            matches!(
867                option_name,
868                "--cwd" | "--dir" | "--prefix" | "--registry" | "--userconfig"
869            )
870        }
871        "uv" => matches!(option_name, "--directory" | "--project" | "--python"),
872        "rustup" => matches!(option_name, "--toolchain"),
873        "mj" => matches!(option_name, "-i" | "--instance" | "--workspace"),
874        "tmux" => matches!(option_name, "-L" | "-S" | "-f" | "-c" | "-T"),
875        "nice" => {
876            matches!(option_name, "-n" | "--adjustment")
877        }
878        "docker" | "podman" => matches!(
879            option_name,
880            "-H" | "--config" | "--connection" | "--context" | "--host" | "--log-level"
881        ),
882        _ => false,
883    };
884    if attached_short_value {
885        return Some(1);
886    }
887    if attached_value {
888        return takes_value.then_some(1);
889    }
890    if takes_value {
891        return arguments
892            .get(index + 1)
893            .filter(|argument| argument.literal)
894            .map(|_| 2);
895    }
896    let known_flag = match basename {
897        "git" => matches!(
898            option_name,
899            "-p" | "--paginate"
900                | "-P"
901                | "--no-pager"
902                | "--bare"
903                | "--literal-pathspecs"
904                | "--glob-pathspecs"
905                | "--noglob-pathspecs"
906                | "--icase-pathspecs"
907                | "--no-optional-locks"
908                | "--no-advice"
909        ),
910        "gh" => false,
911        "cargo" => matches!(
912            option_name,
913            "-q" | "--quiet" | "-v" | "--verbose" | "--locked" | "--offline" | "--frozen"
914        ),
915        "npm" | "pnpm" | "yarn" | "bun" => {
916            matches!(option_name, "-g" | "--global" | "--silent")
917        }
918        "uv" => matches!(
919            option_name,
920            "-q" | "--quiet" | "-v" | "--verbose" | "--offline"
921        ),
922        "rustup" => matches!(option_name, "-q" | "--quiet" | "-v" | "--verbose"),
923        "docker" | "podman" => matches!(option_name, "-D" | "--debug" | "--tls"),
924        "tmux" => matches!(
925            option_name,
926            "-2" | "-C" | "-CC" | "-D" | "-l" | "-N" | "-u" | "-v"
927        ),
928        "nice" => false,
929        _ => false,
930    };
931    known_flag.then_some(1)
932}
933
934fn strip_matching_quotes(value: &str) -> &str {
935    value
936        .strip_prefix('"')
937        .and_then(|value| value.strip_suffix('"'))
938        .or_else(|| {
939            value
940                .strip_prefix('\'')
941                .and_then(|value| value.strip_suffix('\''))
942        })
943        .unwrap_or(value)
944}
945
946fn is_shell_operator(node: Node<'_>) -> bool {
947    match node.kind() {
948        ";" => true,
949        "&&" | "||" => node.parent().is_some_and(|parent| parent.kind() == "list"),
950        "|" | "|&" => node
951            .parent()
952            .is_some_and(|parent| parent.kind() == "pipeline"),
953        "&" => node.parent().is_none_or(|parent| {
954            !matches!(
955                parent.kind(),
956                "binary_expression" | "unary_expression" | "postfix_expression"
957            )
958        }),
959        _ => false,
960    }
961}
962
963fn normalize_command_name(text: &str) -> Option<String> {
964    let text = text.trim();
965    if text.contains('$') || text.contains('`') {
966        return None;
967    }
968    let text = text
969        .strip_prefix('"')
970        .and_then(|value| value.strip_suffix('"'))
971        .or_else(|| {
972            text.strip_prefix('\'')
973                .and_then(|value| value.strip_suffix('\''))
974        })
975        .unwrap_or(text);
976    (!text.is_empty()).then(|| text.to_owned())
977}
978
979fn join_shell_tokens(tokens: Vec<String>) -> String {
980    let mut output = String::new();
981    for token in tokens {
982        match token.as_str() {
983            "(" => {
984                if !output.is_empty() && !output.ends_with(' ') {
985                    output.push(' ');
986                }
987                output.push('(');
988            }
989            ")" => {
990                output = output.trim_end().to_owned();
991                output.push(')');
992            }
993            _ => {
994                if !output.is_empty() && !output.ends_with(' ') && !output.ends_with('(') {
995                    output.push(' ');
996                }
997                output.push_str(&token);
998            }
999        }
1000    }
1001    output
1002}
1003
1004/// Read string arguments as text, including their real line breaks. Other
1005/// values keep their JSON structure; the renderer wraps rather than clips it.
1006pub fn tool_input_text(input: &Value) -> String {
1007    fn value_text(value: &Value) -> String {
1008        match value {
1009            Value::String(text) => text.clone(),
1010            _ => serde_json::to_string_pretty(value)
1011                .expect("tool arguments are serializable JSON values"),
1012        }
1013    }
1014
1015    let text = match input {
1016        Value::Object(fields) if !fields.is_empty() => fields
1017            .iter()
1018            .map(|(name, value)| format!("{name}:\n{}", value_text(value)))
1019            .collect::<Vec<_>>()
1020            .join("\n\n"),
1021        _ => value_text(input),
1022    };
1023    sanitize_terminal_text(&text)
1024}
1025
1026pub fn tool_content_details(
1027    content: &[ToolCallContent],
1028    terminal_outputs: &[TerminalOutputRecord],
1029    raw_output: Option<&serde_json::Value>,
1030) -> Vec<String> {
1031    let mut details = Vec::new();
1032    let mut referenced: Vec<&str> = Vec::new();
1033    for item in content {
1034        let detail = match item {
1035            ToolCallContent::Content(content) => content_block_text(&content.content),
1036            ToolCallContent::Diff(_) => None,
1037            // Kimi-style agents send a terminal reference and no textual copy
1038            // of the output, so the record hel captured is the only thing a
1039            // reader ever sees. Until the terminal is reaped there is none.
1040            ToolCallContent::Terminal(terminal) => {
1041                let terminal_id = terminal.terminal_id.0.as_ref();
1042                referenced.push(terminal_id);
1043                Some(
1044                    terminal_outputs
1045                        .iter()
1046                        .find(|record| record.terminal_id.as_str() == terminal_id)
1047                        .map(terminal_output_detail)
1048                        .or_else(|| raw_output.and_then(raw_output_terminal_detail))
1049                        .unwrap_or_else(|| format!("terminal {}", terminal.terminal_id)),
1050                )
1051            }
1052            _ => None,
1053        };
1054        if let Some(detail) = detail {
1055            details.push(sanitize_terminal_text(&detail));
1056        }
1057    }
1058    // Grok-style agents name the terminal on a mid-flight update and then
1059    // replace `content` wholesale without it, so the output hel captured has
1060    // nothing in the final call pointing at it. Show it rather than lose it.
1061    for record in terminal_outputs {
1062        if referenced.contains(&record.terminal_id.as_str()) {
1063            continue;
1064        }
1065        let output = sanitize_terminal_text(&record.output);
1066        if !output.is_empty() && details.iter().any(|detail| detail == &output) {
1067            // Kimi sends the captured stdout as ordinary tool content and in
1068            // its raw result. Keep the exit summary without printing those
1069            // same bytes a second time in Raw mode.
1070            details.push(terminal_exit_summary(record));
1071        } else {
1072            details.push(sanitize_terminal_text(&terminal_output_detail(record)));
1073        }
1074    }
1075    details
1076}
1077
1078/// The output codex reports for a terminal it ran itself. Codex names its own
1079/// server-side terminal, which hel never opened and has no record for, and
1080/// puts the text in `rawOutput`; reading it here keeps such a call from
1081/// rendering as a bare terminal id.
1082fn raw_output_terminal_detail(raw_output: &serde_json::Value) -> Option<String> {
1083    let output = raw_output.get("formatted_output")?.as_str()?;
1084    let Some(exit_code) = raw_output
1085        .get("exit_code")
1086        .and_then(serde_json::Value::as_i64)
1087    else {
1088        return Some(output.to_owned());
1089    };
1090    let summary = format!("exited {exit_code}");
1091    if output.is_empty() {
1092        return Some(summary);
1093    }
1094    Some(format!("{output}\n{summary}"))
1095}
1096
1097/// One terminal's output followed by how it ended.
1098pub fn terminal_output_detail(record: &TerminalOutputRecord) -> String {
1099    let summary = terminal_exit_summary(record);
1100    if record.output.is_empty() {
1101        return summary;
1102    }
1103    format!("{}\n{summary}", record.output)
1104}
1105
1106/// How a terminal ended, in one line.
1107fn terminal_exit_summary(record: &TerminalOutputRecord) -> String {
1108    let mut summary = match (record.exit_code, &record.signal) {
1109        (_, Some(signal)) => format!("killed by {signal}"),
1110        (Some(code), None) => format!("exited {code}"),
1111        (None, None) => "released before exit".to_owned(),
1112    };
1113    if record.truncated {
1114        summary.push_str(" · output truncated");
1115    }
1116    summary
1117}
1118
1119pub fn tool_diff_paths(content: &[ToolCallContent]) -> Vec<String> {
1120    content
1121        .iter()
1122        .filter_map(|item| match item {
1123            ToolCallContent::Diff(diff) => Some(diff.path.display().to_string()),
1124            _ => None,
1125        })
1126        .collect()
1127}
1128
1129pub fn tool_location_details(locations: &[ToolCallLocation]) -> Vec<String> {
1130    locations
1131        .iter()
1132        .map(|location| match location.line {
1133            Some(line) => format!("{}:{line}", location.path.display()),
1134            None => location.path.display().to_string(),
1135        })
1136        .collect()
1137}
1138
1139/// Append streamed agent or thought text to the transcript, merging it into
1140/// the entry it continues so a message arrives as one entry rather than one
1141/// per chunk.
1142pub(crate) fn push_streamed_entry(
1143    entries: &mut Vec<ChatEntry>,
1144    seq: u64,
1145    recorded_at_ms: Option<i64>,
1146    role: ChatRole,
1147    message_id: Option<String>,
1148    text: &str,
1149) {
1150    let text = sanitize_terminal_text(text);
1151    if let Some(last) = entries.last_mut()
1152        && last.role == role
1153        && (role == ChatRole::Thought || last.message_id == message_id)
1154    {
1155        last.touch(seq);
1156        if role == ChatRole::Thought
1157            && last.message_id != message_id
1158            && !last.text.is_empty()
1159            && !text.is_empty()
1160        {
1161            while last.text.ends_with('\n') {
1162                last.text.pop();
1163            }
1164            last.text.push('\n');
1165            last.text.push_str(text.trim_start_matches('\n'));
1166        } else {
1167            last.text.push_str(&text);
1168        }
1169        return;
1170    }
1171    let mut entry = ChatEntry::plain(seq, role, text).with_recorded_at(recorded_at_ms);
1172    entry.message_id = message_id;
1173    entries.push(entry);
1174}
1175
1176/// Apply the transcript-visible part of one ACP session update. Returns the
1177/// update again when it changes the session surface rather than the
1178/// transcript, so the chat view handles those without decoding twice.
1179pub fn apply_session_update_to_entries(
1180    entries: &mut Vec<ChatEntry>,
1181    seq: u64,
1182    recorded_at_ms: Option<i64>,
1183    update: SessionUpdate,
1184) -> Option<SessionUpdate> {
1185    match update {
1186        SessionUpdate::AgentMessageChunk(chunk) => {
1187            let message_id = chunk.message_id.map(|id| id.to_string());
1188            if let Some(text) = content_block_text(&chunk.content) {
1189                push_streamed_entry(
1190                    entries,
1191                    seq,
1192                    recorded_at_ms,
1193                    ChatRole::Agent,
1194                    message_id,
1195                    &text,
1196                );
1197            }
1198        }
1199        SessionUpdate::AgentThoughtChunk(chunk) => {
1200            let message_id = chunk.message_id.map(|id| id.to_string());
1201            if let Some(text) = content_block_text(&chunk.content) {
1202                push_streamed_entry(
1203                    entries,
1204                    seq,
1205                    recorded_at_ms,
1206                    ChatRole::Thought,
1207                    message_id,
1208                    &text,
1209                );
1210            }
1211        }
1212        // PromptAccepted is the canonical local user-message event. ACP
1213        // user chunks would duplicate it during replay.
1214        SessionUpdate::UserMessageChunk(_) => {}
1215        SessionUpdate::ToolCall(call) => {
1216            let presentation = tool_call_presentation(&call);
1217            let mut entry = ChatEntry::tool(
1218                seq,
1219                call.title,
1220                Some(call.tool_call_id.to_string()),
1221                tool_status(&call.status),
1222            );
1223            entry.tool_summary = Some(presentation.summary.clone());
1224            entry.tool_presentation = Some(presentation);
1225            entry.tool_input = call.raw_input;
1226            entry.tool_content = tool_content_details(&call.content, &[], call.raw_output.as_ref());
1227            entry.tool_diffstats = tool_diff_paths(&call.content);
1228            entry.tool_locations = tool_location_details(&call.locations);
1229            entries.push(entry);
1230        }
1231        SessionUpdate::ToolCallUpdate(update) => {
1232            let tool_call_id = update.tool_call_id.to_string();
1233            let entry = entries.iter_mut().rev().find(|entry| {
1234                entry.role == ChatRole::Tool
1235                    && entry.tool_call_id.as_deref() == Some(tool_call_id.as_str())
1236            })?;
1237            entry.touch(seq);
1238            let kind = update.fields.kind;
1239            let raw_input = update.fields.raw_input.clone();
1240            let raw_output = update.fields.raw_output.clone();
1241            if let Some(input) = &raw_input {
1242                entry.tool_input = Some(input.clone());
1243            }
1244            if let Some(title) = update.fields.title {
1245                entry.text = sanitize_terminal_text(&title);
1246            }
1247            if let Some(status) = update.fields.status {
1248                entry.tool_status = Some(tool_status(&status));
1249            }
1250            if let Some(content) = update.fields.content {
1251                entry.tool_content =
1252                    tool_content_details(&content, &[], update.fields.raw_output.as_ref());
1253                entry.tool_diffstats = tool_diff_paths(&content);
1254            }
1255            if let Some(locations) = update.fields.locations {
1256                entry.tool_locations = tool_location_details(&locations);
1257            }
1258            let presentation = update_tool_call_presentation(
1259                entry.tool_presentation.as_ref(),
1260                &entry.text,
1261                kind,
1262                raw_input.as_ref(),
1263                raw_output.as_ref(),
1264            );
1265            entry.tool_summary = Some(presentation.summary.clone());
1266            entry.tool_presentation = Some(presentation);
1267        }
1268        SessionUpdate::Plan(plan) => {
1269            let lines = plan
1270                .entries
1271                .into_iter()
1272                .map(|entry| PlanLine {
1273                    text: sanitize_terminal_text(&entry.content),
1274                    status: plan_status(&entry.status),
1275                })
1276                .collect();
1277            let latest_user_seq = entries
1278                .iter()
1279                .rev()
1280                .find(|entry| entry.role == ChatRole::User)
1281                .map_or(0, |entry| entry.seq);
1282            if let Some(entry) = entries
1283                .iter_mut()
1284                .rev()
1285                .find(|entry| entry.role == ChatRole::Plan && entry.seq > latest_user_seq)
1286            {
1287                entry.touch(seq);
1288                entry.plan = lines;
1289            } else {
1290                entries.push(ChatEntry::plan(seq, lines));
1291            }
1292        }
1293        other => return Some(other),
1294    }
1295    None
1296}
1297
1298/// Apply the transcript-visible part of one persisted runtime event. Returns
1299/// the event again when it only configures the session surface, which is the
1300/// chat view's business rather than the transcript's.
1301pub fn apply_runtime_event_to_entries(
1302    entries: &mut Vec<ChatEntry>,
1303    seq: u64,
1304    recorded_at_ms: Option<i64>,
1305    runtime: RuntimeEvent,
1306) -> Option<RuntimeEvent> {
1307    match runtime {
1308        RuntimeEvent::SessionUpdate { update } => {
1309            let parsed = match serde_json::from_value::<SessionUpdate>(update.clone()) {
1310                Ok(parsed) => parsed,
1311                Err(error) => {
1312                    tracing::debug!(%error, "ignoring invalid ACP session update");
1313                    return None;
1314                }
1315            };
1316            apply_session_update_to_entries(entries, seq, recorded_at_ms, parsed)
1317                .map(|_| RuntimeEvent::SessionUpdate { update })
1318        }
1319        RuntimeEvent::Warning { message } | RuntimeEvent::SessionFault { message } => {
1320            entries.push(ChatEntry::plain(
1321                seq,
1322                ChatRole::System,
1323                format!("warning: {message}"),
1324            ));
1325            None
1326        }
1327        RuntimeEvent::ConfigApplied { key, value, .. } => {
1328            entries.push(ChatEntry::plain(
1329                seq,
1330                ChatRole::System,
1331                format!("{key} set to {value}"),
1332            ));
1333            None
1334        }
1335        RuntimeEvent::SessionStarted { resumed: false, .. } => {
1336            entries.push(ChatEntry::plain(
1337                seq,
1338                ChatRole::System,
1339                "harness session started",
1340            ));
1341            None
1342        }
1343        RuntimeEvent::SessionStarted { resumed: true, .. } => None,
1344        RuntimeEvent::ElicitationResolved {
1345            reply: Some(reply), ..
1346        } => {
1347            let mut entry =
1348                ChatEntry::plain(seq, ChatRole::User, reply).with_recorded_at(recorded_at_ms);
1349            entry.message_id = Some(format!("{ELICITATION_REPLY_ITEM_PREFIX}{seq}"));
1350            entries.push(entry);
1351            None
1352        }
1353        other => Some(other),
1354    }
1355}
1356
1357/// One transcript item flattened to the text a reader would see.
1358///
1359/// A caller that wants the structure reads the body itself; this is the plain
1360/// reading, built from the same flatteners every other surface uses so that a
1361/// tool call reads as the command it ran rather than as JSON.
1362pub fn transcript_item_text(item: &TranscriptItem) -> String {
1363    match &item.body {
1364        TranscriptBody::User { content } => materialized_content_text(content),
1365        TranscriptBody::Message { event } => format!(
1366            "{}\n\n{}",
1367            event.message_label(),
1368            mj_core::transcript::sanitize_terminal_text(
1369                event.message_text().expect("message entry metadata")
1370            )
1371        ),
1372        TranscriptBody::Agent { chunks, .. } | TranscriptBody::Thought { chunks, .. } => {
1373            materialized_chunks_text(chunks)
1374        }
1375        TranscriptBody::Tool {
1376            call,
1377            terminal_outputs,
1378            presentation,
1379            ..
1380        } => {
1381            let Ok(call) = ToolCall::deserialize(call) else {
1382                return "[invalid tool call]".to_owned();
1383            };
1384            let mut text = materialized_tool_call_presentation(presentation.as_deref(), &call)
1385                .summary
1386                .clone();
1387            if text.trim().is_empty() {
1388                text = call.title.clone();
1389            }
1390            for record in terminal_outputs {
1391                text.push('\n');
1392                text.push_str(&terminal_output_detail(record));
1393            }
1394            sanitize_terminal_text(&text)
1395        }
1396        TranscriptBody::TerminalOutput { record } => {
1397            sanitize_terminal_text(&terminal_output_detail(record))
1398        }
1399        TranscriptBody::Plan { plan } => {
1400            let Ok(plan) = agent_client_protocol::schema::v1::Plan::deserialize(plan) else {
1401                return String::new();
1402            };
1403            plan.entries
1404                .iter()
1405                .map(|entry| {
1406                    let status = match plan_status(&entry.status) {
1407                        PlanStatus::Pending => "pending",
1408                        PlanStatus::Running => "running",
1409                        PlanStatus::Completed => "completed",
1410                    };
1411                    format!("[{status}] {}", sanitize_terminal_text(&entry.content))
1412                })
1413                .collect::<Vec<_>>()
1414                .join("\n")
1415        }
1416        TranscriptBody::PlanProposal { plan, .. } => plan.clone(),
1417        TranscriptBody::System { text } => text.clone(),
1418    }
1419}
1420
1421pub(crate) fn compute_tool_diffstats(content: &[ToolCallContent]) -> Vec<String> {
1422    content
1423        .iter()
1424        .filter_map(|item| match item {
1425            ToolCallContent::Diff(diff) => Some(format_diffstat(diff)),
1426            _ => None,
1427        })
1428        .collect()
1429}
1430
1431pub fn materialized_tool_diffstats(item: &TranscriptItem) -> Option<Vec<String>> {
1432    let TranscriptBody::Tool { call, .. } = &item.body else {
1433        return None;
1434    };
1435    let call = match ToolCall::deserialize(call) {
1436        Ok(call) => call,
1437        Err(error) => {
1438            tracing::warn!(
1439                stable_id = %item.stable_id,
1440                %error,
1441                "could not decode a stored tool call while reading diff summary"
1442            );
1443            return None;
1444        }
1445    };
1446    if !matches!(
1447        tool_status(&call.status),
1448        ToolStatus::Completed | ToolStatus::Failed
1449    ) {
1450        return None;
1451    }
1452    let diffstats = compute_tool_diffstats(&call.content);
1453    (!diffstats.is_empty()).then_some(diffstats)
1454}
1455
1456fn format_diffstat(diff: &agent_client_protocol::schema::v1::Diff) -> String {
1457    // A diff recorded since `diff` landed already carries its counts, so
1458    // this is a lookup. An older record still holds both file copies and is
1459    // diffed here on demand.
1460    let patch = mj_core::diff::patch_of(diff);
1461    format!(
1462        "{}  +{} −{}",
1463        diff.path.display(),
1464        patch.insertions,
1465        patch.deletions
1466    )
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471    use super::*;
1472    use agent_client_protocol::schema::v1::{ToolCall, ToolCallStatus};
1473    use serde_json::json;
1474
1475    /// I1-18: Claude's question tool is titled with the question itself. Its
1476    /// row showed only "Which"; it must show the whole question.
1477    // Hard-won: e570799ccc: a question-tool row displayed only its first word.
1478    #[test]
1479    fn a_question_tool_is_summarized_by_its_whole_question() {
1480        let call = ToolCall::new("ask", "Which file name should I use for the new file?")
1481            .raw_input(json!({"questions": [{"question": "Which file name should I use for the new file?", "header": "File name"}]}));
1482        assert_eq!(
1483            tool_call_presentation(&call).summary,
1484            "Which file name should I use for the new file?"
1485        );
1486        let read = ToolCall::new("read", "Read src/lib.rs");
1487        assert_eq!(tool_call_presentation(&read).summary, "Read");
1488        let stale = ToolCallPresentation {
1489            summary: "Which".into(),
1490            source: call.title.clone(),
1491            source_kind: ToolSummarySourceKind::Title,
1492            tool_kind: ToolKind::Other,
1493            summary_version: 1,
1494        };
1495        assert_eq!(
1496            materialized_tool_call_presentation(Some(&stale), &call).summary,
1497            "Which file name should I use for the new file?",
1498            "stored rows written by the old rules are repaired"
1499        );
1500    }
1501
1502    fn execute_summary(command: serde_json::Value) -> String {
1503        let call = ToolCall::new("argv", "Bash")
1504            .kind(ToolKind::Execute)
1505            .raw_input(command);
1506        tool_call_presentation(&call).summary
1507    }
1508
1509    #[test]
1510    fn unknown_leading_options_make_verb_position_ambiguous() {
1511        assert_eq!(
1512            execute_summary(json!({"command": ["git", "--mystery", "status"]})),
1513            "git"
1514        );
1515        assert_eq!(
1516            execute_summary(json!({"command": ["cargo", "--mystery", "test"]})),
1517            "cargo"
1518        );
1519    }
1520
1521    #[test]
1522    fn git_commands_with_nested_subcommands_show_both_verbs() {
1523        let cases = [
1524            ("git worktree add ../x mj/session", "git worktree add"),
1525            ("git -C repo worktree list", "git worktree list"),
1526            ("git stash push -u -m tag", "git stash push"),
1527            ("git stash", "git stash"),
1528            ("git remote -v", "git remote"),
1529            ("git bisect good abc123", "git bisect good"),
1530            ("git stash -- src", "git stash"),
1531            ("git log origin/master", "git log"),
1532            ("git config user.name", "git config"),
1533        ];
1534        for (command, summary) in cases {
1535            assert_eq!(
1536                execute_summary(json!({"command": command})),
1537                summary,
1538                "{command}"
1539            );
1540        }
1541    }
1542
1543    #[test]
1544    fn project_tools_and_script_runners_show_their_subcommands() {
1545        let cases = [
1546            ("mbx nextest run -p brokk-mj-tui", "mbx nextest"),
1547            ("mbx +1.91 build bifrost", "mbx build"),
1548            ("mbx cache stats --json", "mbx cache stats"),
1549            ("mj daemon restart", "mj daemon restart"),
1550            (
1551                "mj -i test --workspace hel daemon status",
1552                "mj daemon status",
1553            ),
1554            ("mj transcript abc123", "mj transcript"),
1555            ("tmux -L mjv-e send-keys -t 0 q", "tmux send-keys"),
1556            ("tmux -Lmjv capture-pane -p", "tmux capture-pane"),
1557            ("tmux -S /tmp/sock -2 new-session -d", "tmux new-session"),
1558            ("uv run pytest -q", "uv run pytest"),
1559            ("uv run ./scripts/check.py", "uv run check.py"),
1560            ("uv run --with x pytest", "uv run"),
1561            ("npm run build", "npm run build"),
1562            ("npm run-script test:e2e", "npm run-script test:e2e"),
1563            ("pnpm run lint", "pnpm run lint"),
1564            ("npm test", "npm test"),
1565        ];
1566        for (command, summary) in cases {
1567            assert_eq!(
1568                execute_summary(json!({"command": command})),
1569                summary,
1570                "{command}"
1571            );
1572        }
1573    }
1574
1575    #[test]
1576    fn a_nested_shell_is_named_without_its_path_and_shows_its_script() {
1577        let cases = [
1578            ("bash -c 'python x.py'", "bash -c python"),
1579            ("/bin/bash -c \"python x.py\"", "bash -c python"),
1580            (
1581                "/usr/bin/zsh -lc 'git status && cargo test'",
1582                "zsh -c git status && cargo test",
1583            ),
1584            ("bash -c \"cd $dir && make\"", "bash -c cd && make"),
1585            ("bash -c \"$script\"", "bash -c"),
1586            ("/bin/bash script.sh", "bash"),
1587            (
1588                "bash -c 'bash -c \"uv run pytest\"'",
1589                "bash -c bash -c uv run pytest",
1590            ),
1591        ];
1592        for (command, summary) in cases {
1593            assert_eq!(
1594                execute_summary(json!({"command": command})),
1595                summary,
1596                "{command}"
1597            );
1598        }
1599        // An argv shell call already stands for its script.
1600        assert_eq!(
1601            execute_summary(json!({"command": ["/bin/bash", "-lc", "python x.py"]})),
1602            "python"
1603        );
1604        assert_eq!(
1605            execute_summary(json!({"command": ["/bin/zsh", "script.sh"]})),
1606            "zsh"
1607        );
1608    }
1609
1610    #[test]
1611    fn quoted_and_dynamic_shell_verbs_are_distinguished() {
1612        let quoted = ToolCall::new("quoted", "Bash")
1613            .kind(ToolKind::Execute)
1614            .raw_input(json!({"command": "git \"status\""}));
1615        assert_eq!(tool_call_presentation(&quoted).summary, "git status");
1616
1617        let dynamic = ToolCall::new("dynamic", "Bash")
1618            .kind(ToolKind::Execute)
1619            .raw_input(json!({"command": "git \"$verb\""}));
1620        assert_eq!(tool_call_presentation(&dynamic).summary, "git");
1621
1622        let dynamic_name = ToolCall::new("dynamic-name", "Bash")
1623            .kind(ToolKind::Execute)
1624            .raw_input(json!({"command": "$command status"}));
1625        assert_eq!(tool_call_presentation(&dynamic_name).summary, "Bash");
1626    }
1627
1628    #[test]
1629    fn shell_summary_skips_assignments_arguments_and_nested_substitutions() {
1630        let call = ToolCall::new("call", "Bash")
1631            .kind(ToolKind::Execute)
1632            .raw_input(json!({
1633                "command": "FOO=bar env -i bash -c \"echo $(printf hi)\""
1634            }));
1635        assert_eq!(tool_call_presentation(&call).summary, "env");
1636
1637        let subshell = ToolCall::new("subshell", "Bash")
1638            .kind(ToolKind::Execute)
1639            .raw_input(json!({
1640                "command": "(cd dir && python x.py) | cat"
1641            }));
1642        assert_eq!(
1643            tool_call_presentation(&subshell).summary,
1644            "(cd && python) | cat"
1645        );
1646    }
1647
1648    #[test]
1649    fn shell_summary_keeps_list_pipeline_and_background_operators() {
1650        let simple = ToolCall::new("call-1", "Bash")
1651            .kind(ToolKind::Execute)
1652            .raw_input(json!({
1653                "command": "cd dir && python x.py | cat | wc ; print ok"
1654            }));
1655        let presentation = tool_call_presentation(&simple);
1656        assert_eq!(presentation.summary, "cd && python | cat | wc ; print");
1657        assert_eq!(presentation.source_kind, ToolSummarySourceKind::RawInput);
1658
1659        let call = ToolCall::new("operators", "Bash")
1660            .kind(ToolKind::Execute)
1661            .raw_input(json!({
1662                "command": "'printf' '%s' hi >out |& sed s/hi/bye/ || echo failed & wait; cat <in"
1663            }));
1664
1665        assert_eq!(
1666            tool_call_presentation(&call).summary,
1667            "printf |& sed || echo & wait ; cat"
1668        );
1669    }
1670
1671    #[test]
1672    fn shell_summary_removes_structural_loop_and_group_separators() {
1673        let call = ToolCall::new("compound", "Bash")
1674            .kind(ToolKind::Execute)
1675            .raw_input(json!({
1676                "command": "for file in a b; do rm \"$file\"; done; mkdir -p out; nice -n 10 python3 script.py"
1677            }));
1678
1679        assert_eq!(
1680            tool_call_presentation(&call).summary,
1681            "rm ; mkdir ; nice python3"
1682        );
1683
1684        let conditional = ToolCall::new("conditional", "Bash")
1685            .kind(ToolKind::Execute)
1686            .raw_input(json!({
1687                "command": "if test -f foo; then rm foo; fi; { mkdir bar; echo done; }"
1688            }));
1689        assert_eq!(
1690            tool_call_presentation(&conditional).summary,
1691            "test ; rm ; mkdir ; echo"
1692        );
1693
1694        let case_statement = ToolCall::new("case", "Bash")
1695            .kind(ToolKind::Execute)
1696            .raw_input(json!({
1697                "command": "echo start; case x in a|b) echo branch;; esac; echo done"
1698            }));
1699        assert_eq!(
1700            tool_call_presentation(&case_statement).summary,
1701            "echo ; echo ; echo"
1702        );
1703    }
1704
1705    #[test]
1706    fn shell_summary_handles_a_loop_with_a_leading_pipeline_and_nice() {
1707        let call = ToolCall::new("live-loop-shape", "Bash")
1708            .kind(ToolKind::Execute)
1709            .raw_input(json!({
1710                "command": "cd /tmp && for spec in a b; do set -- $spec; rm \"$spec\"; mkdir -p \"$spec\"; nice -n 10 ./bin/bifrost \"$spec\"; echo \"$spec\"; done; python3 script.py"
1711            }));
1712
1713        assert_eq!(
1714            tool_call_presentation(&call).summary,
1715            "cd && set ; rm ; mkdir ; nice ./bin/bifrost ; echo ; python3"
1716        );
1717    }
1718
1719    #[test]
1720    fn shell_summary_inserts_a_separator_after_a_heredoc() {
1721        let call = ToolCall::new("heredoc", "Bash")
1722            .kind(ToolKind::Execute)
1723            .raw_input(json!({
1724                "command": "python3 <<'PYEOF'\nprint(\"x\")\nPYEOF\ngrep -n x file | head"
1725            }));
1726
1727        assert_eq!(
1728            tool_call_presentation(&call).summary,
1729            "python3 ; grep | head"
1730        );
1731    }
1732
1733    #[test]
1734    fn materialized_summary_from_an_older_parser_is_repaired() {
1735        let source = "python3 <<'PYEOF'\nprint(\"x\")\nPYEOF\ngrep -n x file | head";
1736        let call = ToolCall::new("heredoc", "Bash")
1737            .kind(ToolKind::Execute)
1738            .raw_input(json!({ "command": source }));
1739        let stale = ToolCallPresentation {
1740            summary: "python3 grep | head".into(),
1741            source: source.into(),
1742            source_kind: ToolSummarySourceKind::RawInput,
1743            tool_kind: ToolKind::Execute,
1744            summary_version: 0,
1745        };
1746
1747        let repaired = materialized_tool_call_presentation(Some(&stale), &call);
1748        assert_eq!(repaired.summary, "python3 ; grep | head");
1749        assert_eq!(repaired.summary_version, TOOL_SUMMARY_VERSION);
1750
1751        let mut current = repaired;
1752        current.summary = "stored current summary".into();
1753        assert_eq!(
1754            materialized_tool_call_presentation(Some(&current), &call).summary,
1755            "stored current summary"
1756        );
1757    }
1758
1759    #[test]
1760    fn execute_summary_bounds_the_retained_source_before_parsing() {
1761        let command = format!("echo {}", "argument".repeat(TOOL_SUMMARY_SOURCE_BYTES));
1762        let call = ToolCall::new("bounded", "Bash")
1763            .kind(ToolKind::Execute)
1764            .raw_input(json!({ "command": command }));
1765
1766        let presentation = tool_call_presentation(&call);
1767        assert_eq!(presentation.source.len(), TOOL_SUMMARY_SOURCE_BYTES);
1768        assert_eq!(presentation.summary, "echo");
1769    }
1770
1771    #[test]
1772    fn title_wrappers_and_malformed_shell_fall_back_safely() {
1773        let wrapped = ToolCall::new("wrapped", "Running: ls -la").kind(ToolKind::Execute);
1774        assert_eq!(tool_call_presentation(&wrapped).summary, "ls");
1775
1776        let malformed = ToolCall::new("bad", "Bash")
1777            .kind(ToolKind::Execute)
1778            .raw_input(json!({"command": "cd && ("}));
1779        assert_eq!(tool_call_presentation(&malformed).summary, "Bash");
1780    }
1781
1782    #[test]
1783    fn explicit_empty_raw_input_drops_a_stale_raw_summary() {
1784        let initial = ToolCall::new("call", "Bash")
1785            .kind(ToolKind::Execute)
1786            .raw_input(json!({"command": "python script.py"}));
1787        let previous = tool_call_presentation(&initial);
1788        let empty_input = json!({"command": null});
1789        let updated = update_tool_call_presentation(
1790            Some(&previous),
1791            "Running: ls -la",
1792            None,
1793            Some(&empty_input),
1794            None,
1795        );
1796        assert_eq!(updated.summary, "ls");
1797        assert_eq!(updated.source_kind, ToolSummarySourceKind::Title);
1798
1799        let output = json!({"command": "cat result.txt"});
1800        let updated = update_tool_call_presentation(
1801            Some(&previous),
1802            "Running: ls -la",
1803            None,
1804            Some(&empty_input),
1805            Some(&output),
1806        );
1807        assert_eq!(updated.summary, "cat");
1808        assert_eq!(updated.source_kind, ToolSummarySourceKind::RawOutput);
1809
1810        let output_initial = ToolCall::new("output", "Bash")
1811            .kind(ToolKind::Execute)
1812            .raw_output(json!({"command": "python result.py"}));
1813        let output_previous = tool_call_presentation(&output_initial);
1814        let empty_output = json!({"command": null});
1815        let updated = update_tool_call_presentation(
1816            Some(&output_previous),
1817            "Running: ls -la",
1818            None,
1819            None,
1820            Some(&empty_output),
1821        );
1822        assert_eq!(updated.summary, "ls");
1823        assert_eq!(updated.source_kind, ToolSummarySourceKind::Title);
1824    }
1825
1826    fn rendered_tool_row(call: &ToolCall) -> String {
1827        let presentation = tool_call_presentation(call);
1828        let item = TranscriptItem {
1829            stable_id: "tool:golden".into(),
1830            position: 1,
1831            latest_content_event_ordinal: None,
1832            created_at_ms: 1,
1833            last_changed_at_ms: 1,
1834            body: TranscriptBody::Tool {
1835                call: serde_json::to_value(call).expect("serialize tool call"),
1836                terminal_outputs: Vec::new(),
1837                terminal_refs: Vec::new(),
1838                presentation: Some(Box::new(presentation)),
1839            },
1840        };
1841        transcript_item_text(&item)
1842    }
1843
1844    fn append_tool_row(output: &mut String, label: &str, call: &ToolCall) {
1845        use std::fmt::Write as _;
1846        if !output.is_empty() {
1847            output.push('\n');
1848        }
1849        writeln!(output, "=== {label} (transcript row) ===").unwrap();
1850        output.push_str(&rendered_tool_row(call));
1851        output.push('\n');
1852    }
1853
1854    #[test]
1855    fn golden_transcript_tool_row_summary() {
1856        use std::fmt::Write as _;
1857
1858        let mut output = String::new();
1859        let diff = agent_client_protocol::schema::v1::Diff::new("/workspace/new.txt", "one\ntwo\n");
1860        writeln!(output, "=== new file diff detail (transcript row) ===").unwrap();
1861        writeln!(output, "{}", format_diffstat(&diff)).unwrap();
1862
1863        let terminal = |exit_code, signal: Option<&str>, truncated, text: &str| {
1864            let record = TerminalOutputRecord {
1865                terminal_id: "term-1".into(),
1866                output: text.into(),
1867                truncated,
1868                exit_code,
1869                signal: signal.map(str::to_owned),
1870            };
1871            let item = TranscriptItem {
1872                stable_id: "terminal:golden".into(),
1873                position: 1,
1874                latest_content_event_ordinal: None,
1875                created_at_ms: 1,
1876                last_changed_at_ms: 1,
1877                body: TranscriptBody::TerminalOutput { record },
1878            };
1879            transcript_item_text(&item)
1880        };
1881        for (label, row) in [
1882            ("successful terminal", terminal(Some(0), None, false, "out")),
1883            ("truncated terminal", terminal(Some(1), None, true, "out")),
1884            (
1885                "signal termination",
1886                terminal(None, Some("SIGKILL"), false, "out"),
1887            ),
1888            ("released terminal", terminal(None, None, false, "out")),
1889            (
1890                "silent signal termination",
1891                terminal(None, Some("SIGTERM"), false, ""),
1892            ),
1893        ] {
1894            writeln!(output, "\n=== {label} (transcript row) ===\n{row}").unwrap();
1895        }
1896
1897        append_tool_row(
1898            &mut output,
1899            "shell command",
1900            &ToolCall::new("shell", "Terminal")
1901                .kind(ToolKind::Execute)
1902                .raw_input(json!({
1903                    "command": ["bash", "-lc", "cd dir && python x.py | cat"]
1904                })),
1905        );
1906        append_tool_row(
1907            &mut output,
1908            "ordinary argv command",
1909            &ToolCall::new("argv", "Execute")
1910                .kind(ToolKind::Execute)
1911                .raw_input(json!({"command": ["python", "-c", "print(1)"]})),
1912        );
1913
1914        let update_call = ToolCall::new("shell", "Bash")
1915            .kind(ToolKind::Execute)
1916            .raw_input(json!({"command": "cargo test"}));
1917        let original = tool_call_presentation(&update_call);
1918        let mut output_update = ToolCallUpdateFields::default();
1919        output_update.raw_output = Some(json!({"output": "x".repeat(128 * 1024)}));
1920        writeln!(output, "\n=== output-only update (transcript row) ===").unwrap();
1921        writeln!(
1922            output,
1923            "presentation changed: {}",
1924            tool_call_update_changes_presentation(&update_call, &output_update)
1925        )
1926        .unwrap();
1927        writeln!(output, "{}", original.summary).unwrap();
1928        let mut status_update = ToolCallUpdateFields::default();
1929        status_update.status = Some(ToolCallStatus::Completed);
1930        writeln!(
1931            output,
1932            "status-only update changes presentation: {}",
1933            tool_call_update_changes_presentation(&update_call, &status_update)
1934        )
1935        .unwrap();
1936        let output_call = ToolCall::new("output", "Bash").kind(ToolKind::Execute);
1937        writeln!(
1938            output,
1939            "output-only call changes presentation: {}",
1940            tool_call_update_changes_presentation(&output_call, &output_update)
1941        )
1942        .unwrap();
1943        let changed_input = json!({"command": "cargo check"});
1944        let mut input_update = ToolCallUpdateFields::default();
1945        input_update.raw_input = Some(changed_input.clone());
1946        writeln!(
1947            output,
1948            "changed command presentation: {}",
1949            tool_call_update_changes_presentation(&update_call, &input_update)
1950        )
1951        .unwrap();
1952        writeln!(
1953            output,
1954            "updated row: {}",
1955            update_tool_call_presentation(
1956                Some(&original),
1957                "Bash",
1958                Some(ToolKind::Execute),
1959                Some(&changed_input),
1960                None,
1961            )
1962            .summary
1963        )
1964        .unwrap();
1965
1966        for (label, command) in [
1967            (
1968                "git registered verb",
1969                json!({"command": ["git", "--no-pager", "status", "--short"]}),
1970            ),
1971            (
1972                "cargo registered verb",
1973                json!({"command": ["cargo", "+nightly", "test", "--package", "hel"]}),
1974            ),
1975            (
1976                "gh registered namespace",
1977                json!({"command": ["gh", "--hostname", "github.example", "pr", "list"]}),
1978            ),
1979            (
1980                "docker registered namespace",
1981                json!({"command": ["docker", "--context", "work", "compose", "up"]}),
1982            ),
1983            (
1984                "podman registered namespace",
1985                json!({"command": ["podman", "machine", "list"]}),
1986            ),
1987            (
1988                "uv registered namespace",
1989                json!({"command": ["uv", "--project", "app", "pip", "install", "ruff"]}),
1990            ),
1991            (
1992                "rustup registered namespace",
1993                json!({"command": ["rustup", "toolchain", "list"]}),
1994            ),
1995            (
1996                "npm registered namespace",
1997                json!({"command": ["npm", "--prefix", "web", "run", "build"]}),
1998            ),
1999        ] {
2000            append_tool_row(
2001                &mut output,
2002                label,
2003                &ToolCall::new("argv", "Bash")
2004                    .kind(ToolKind::Execute)
2005                    .raw_input(command),
2006            );
2007        }
2008
2009        let shell = ToolCall::new("string", "Bash")
2010            .kind(ToolKind::Execute)
2011            .raw_input(json!({"command": "git --no-pager status --short"}));
2012        let argv = ToolCall::new("argv", "Bash")
2013            .kind(ToolKind::Execute)
2014            .raw_input(json!({"command": ["git", "--no-pager", "status", "--short"]}));
2015        writeln!(
2016            output,
2017            "\n=== shell and argv invocation depth (transcript rows) ==="
2018        )
2019        .unwrap();
2020        writeln!(output, "shell: {}", rendered_tool_row(&shell)).unwrap();
2021        writeln!(output, "argv: {}", rendered_tool_row(&argv)).unwrap();
2022
2023        for (label, command) in [
2024            ("sudo wrapper", "sudo -n git status"),
2025            ("env wrapper", "env FOO=bar git status"),
2026            ("command wrapper", "command git status"),
2027        ] {
2028            append_tool_row(
2029                &mut output,
2030                label,
2031                &ToolCall::new("wrapper", "Bash")
2032                    .kind(ToolKind::Execute)
2033                    .raw_input(json!({"command": command})),
2034            );
2035        }
2036
2037        for (label, command) in [
2038            (
2039                "unlisted build tool",
2040                json!({"command": ["mytool", "build", "src"]}),
2041            ),
2042            (
2043                "unlisted script",
2044                json!({"command": ["mytool", "./script.sh"]}),
2045            ),
2046            ("python script", json!({"command": ["python", "script.py"]})),
2047            (
2048                "gh API namespace",
2049                json!({"command": ["gh", "api", "graphql"]}),
2050            ),
2051            (
2052                "docker run namespace",
2053                json!({"command": ["docker", "run", "ubuntu"]}),
2054            ),
2055            (
2056                "registered future git verb",
2057                json!({"command": ["git", "future-verb"]}),
2058            ),
2059            (
2060                "absolute git with attached directory",
2061                json!({"command": ["/usr/bin/git", "-Crepo", "status"]}),
2062            ),
2063            (
2064                "git short global option",
2065                json!({"command": ["git", "-c", "core.pager=cat", "status"]}),
2066            ),
2067            (
2068                "gh attached repository option",
2069                json!({"command": ["gh", "-Rorg/repo", "pr", "list"]}),
2070            ),
2071            (
2072                "cargo color option",
2073                json!({"command": ["cargo", "--color=always", "test"]}),
2074            ),
2075            (
2076                "cargo config option",
2077                json!({"command": ["cargo", "--config", "build.jobs=2", "test"]}),
2078            ),
2079        ] {
2080            append_tool_row(
2081                &mut output,
2082                label,
2083                &ToolCall::new("argv", "Bash")
2084                    .kind(ToolKind::Execute)
2085                    .raw_input(command),
2086            );
2087        }
2088
2089        for command in [
2090            vec!["nice", "-n", "10", "python3", "script.py"],
2091            vec!["nice", "--adjustment", "10", "python3", "script.py"],
2092            vec!["nice", "--adjustment=10", "python3", "script.py"],
2093            vec!["nice", "-10", "python3", "script.py"],
2094            vec!["nice", "-n10", "python3", "script.py"],
2095            vec!["nice", "--", "python3", "script.py"],
2096        ] {
2097            append_tool_row(
2098                &mut output,
2099                "nice command options",
2100                &ToolCall::new("nice", "Bash")
2101                    .kind(ToolKind::Execute)
2102                    .raw_input(json!({"command": command})),
2103            );
2104        }
2105        append_tool_row(
2106            &mut output,
2107            "nice shell command",
2108            &ToolCall::new("nice-shell", "Bash")
2109                .kind(ToolKind::Execute)
2110                .raw_input(json!({"command": "nice -n 10 python3 script.py"})),
2111        );
2112        append_tool_row(
2113            &mut output,
2114            "non-execute tool title",
2115            &ToolCall::new("read", "Read src/lib.rs").kind(ToolKind::Read),
2116        );
2117
2118        mj_core::golden::assert_golden(
2119            env!("CARGO_MANIFEST_DIR"),
2120            "transcript-tool-row-summary",
2121            &output,
2122        );
2123    }
2124}