Skip to main content

claude_code_cli_acp/acp/
updates.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4};
5
6use agent_client_protocol::schema::{
7    AvailableCommandsUpdate, ClientCapabilities, ContentBlock, ContentChunk, Diff,
8    EmbeddedResourceResource, ImageContent, PermissionOption, PermissionOptionKind, Plan,
9    PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptRequest, RequestPermissionOutcome,
10    RequestPermissionRequest, SessionId, SessionUpdate, Terminal, TextContent, ToolCall,
11    ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate,
12    ToolCallUpdateFields, ToolKind,
13};
14use serde_json::{Value, json};
15
16use crate::{
17    config::commands,
18    session::manager::PendingPermission,
19    terminal::recognizers::{PermissionDecision, PermissionDialog},
20    transcript::events::{TranscriptEvent, TranscriptEventKind},
21};
22
23pub const ALLOW_ONCE_OPTION_ID: &str = "allow_once";
24pub const ALLOW_ALWAYS_OPTION_ID: &str = "allow_always";
25pub const REJECT_OPTION_ID: &str = "reject";
26
27pub fn prompt_text(request: &PromptRequest) -> String {
28    request
29        .prompt
30        .iter()
31        .filter_map(content_block_text)
32        .collect::<Vec<_>>()
33        .join("\n\n")
34}
35
36fn content_block_text(block: &ContentBlock) -> Option<String> {
37    match block {
38        ContentBlock::Text(text) => Some(format_text_prompt(&text.text)),
39        ContentBlock::Image(image) => Some(format!(
40            "[image attachment: data:{};base64,{}]",
41            image.mime_type, image.data
42        )),
43        ContentBlock::ResourceLink(link) => Some(format_resource_link(&link.name, &link.uri)),
44        ContentBlock::Resource(resource) => match &resource.resource {
45            EmbeddedResourceResource::TextResourceContents(text) => Some(format!(
46                "{}\n\n<context ref=\"{}\">\n{}\n</context>",
47                format_resource_link("", &text.uri),
48                text.uri,
49                text.text
50            )),
51            EmbeddedResourceResource::BlobResourceContents(blob) => Some(format!(
52                "[resource attachment: {};base64,{}]",
53                blob.mime_type
54                    .as_deref()
55                    .unwrap_or("application/octet-stream"),
56                blob.blob
57            )),
58            _ => None,
59        },
60        ContentBlock::Audio(_) => None,
61        _ => None,
62    }
63}
64
65fn format_resource_link(name: &str, uri: &str) -> String {
66    let display_name = if name.is_empty() {
67        uri.rsplit('/')
68            .next()
69            .filter(|part| !part.is_empty())
70            .unwrap_or(uri)
71    } else {
72        name
73    };
74    if display_name.is_empty() {
75        uri.to_string()
76    } else {
77        format!("[@{display_name}]({uri})")
78    }
79}
80
81fn format_text_prompt(text: &str) -> String {
82    let Some(rest) = text.strip_prefix("/mcp:") else {
83        return text.to_string();
84    };
85    let Some((server, rest)) = rest.split_once(':') else {
86        return text.to_string();
87    };
88    let (command, args) = rest
89        .split_once(char::is_whitespace)
90        .map_or((rest, ""), |(command, args)| (command, args.trim_start()));
91    if server.is_empty() || command.is_empty() {
92        return text.to_string();
93    }
94    if args.is_empty() {
95        format!("/{server}:{command} (MCP)")
96    } else {
97        format!("/{server}:{command} (MCP) {args}")
98    }
99}
100
101pub fn user_message_chunk(text: impl Into<String>) -> SessionUpdate {
102    SessionUpdate::UserMessageChunk(ContentChunk::new(text.into().into()))
103}
104
105pub fn agent_message_chunk(text: impl Into<String>) -> SessionUpdate {
106    SessionUpdate::AgentMessageChunk(ContentChunk::new(text.into().into()))
107}
108
109pub fn agent_thought_chunk(text: impl Into<String>) -> SessionUpdate {
110    SessionUpdate::AgentThoughtChunk(ContentChunk::new(text.into().into()))
111}
112
113pub fn transcript_event_update(event: &TranscriptEvent) -> Option<SessionUpdate> {
114    TranscriptUpdateMapper::new(None, false)
115        .updates_for_event(event)
116        .into_iter()
117        .next()
118}
119
120#[derive(Debug, Clone)]
121struct ToolUseSnapshot {
122    name: String,
123}
124
125#[derive(Debug, Clone, Default)]
126pub struct TranscriptUpdateMapper {
127    cwd: Option<PathBuf>,
128    supports_terminal_output: bool,
129    tool_uses: HashMap<String, ToolUseSnapshot>,
130}
131
132impl TranscriptUpdateMapper {
133    pub fn new(cwd: Option<PathBuf>, supports_terminal_output: bool) -> Self {
134        Self {
135            cwd,
136            supports_terminal_output,
137            tool_uses: HashMap::new(),
138        }
139    }
140
141    pub fn from_client(cwd: &Path, client_capabilities: &ClientCapabilities) -> Self {
142        Self::new(
143            Some(cwd.to_path_buf()),
144            client_supports_terminal_output(client_capabilities),
145        )
146    }
147
148    pub fn updates_for_event(&mut self, event: &TranscriptEvent) -> Vec<SessionUpdate> {
149        match event.kind {
150            TranscriptEventKind::AssistantMessage => event
151                .text
152                .clone()
153                .map(agent_message_chunk)
154                .into_iter()
155                .collect(),
156            TranscriptEventKind::AssistantThought => event
157                .text
158                .clone()
159                .map(agent_thought_chunk)
160                .into_iter()
161                .collect(),
162            TranscriptEventKind::UserMessage => event
163                .text
164                .clone()
165                .map(user_message_chunk)
166                .into_iter()
167                .collect(),
168            TranscriptEventKind::ToolUse => self.tool_use_updates(event),
169            TranscriptEventKind::ToolResult => self.tool_result_updates(event),
170            TranscriptEventKind::System | TranscriptEventKind::Diagnostic => Vec::new(),
171        }
172    }
173
174    fn tool_use_updates(&mut self, event: &TranscriptEvent) -> Vec<SessionUpdate> {
175        let id = tool_event_id(event);
176        let name = event
177            .name
178            .as_deref()
179            .or(event.text.as_deref())
180            .unwrap_or("Unknown Tool")
181            .to_string();
182        let input = event.raw_input.clone();
183        self.tool_uses
184            .insert(id.clone(), ToolUseSnapshot { name: name.clone() });
185
186        if name == "TodoWrite" {
187            return todo_plan_update(input.as_ref()).into_iter().collect();
188        }
189
190        let mut info = tool_info(
191            &name,
192            input.as_ref(),
193            self.supports_terminal_output,
194            self.cwd.as_deref(),
195        );
196        let mut meta = claude_tool_meta(&name);
197        if name == "Bash" && self.supports_terminal_output {
198            meta.insert(
199                "terminal_info".to_string(),
200                json!({ "terminal_id": id.clone() }),
201            );
202            info.content = vec![ToolCallContent::Terminal(Terminal::new(id.clone()))];
203        }
204        let mut tool = ToolCall::new(ToolCallId::new(id.clone()), info.title)
205            .kind(info.kind)
206            .status(ToolCallStatus::Pending)
207            .content(info.content)
208            .locations(info.locations)
209            .meta(meta);
210        if let Some(input) = input {
211            tool = tool.raw_input(input);
212        }
213        vec![SessionUpdate::ToolCall(tool)]
214    }
215
216    fn tool_result_updates(&mut self, event: &TranscriptEvent) -> Vec<SessionUpdate> {
217        let id = tool_event_id(event);
218        let tool = self.tool_uses.get(&id).cloned();
219        if tool.as_ref().is_some_and(|tool| tool.name == "TodoWrite") {
220            return Vec::new();
221        }
222        let name = tool
223            .as_ref()
224            .map(|tool| tool.name.as_str())
225            .unwrap_or("Unknown Tool");
226        let raw_output = event.raw_output.clone();
227        let status = if event.is_error {
228            ToolCallStatus::Failed
229        } else {
230            ToolCallStatus::Completed
231        };
232
233        if event.is_error {
234            return vec![SessionUpdate::ToolCallUpdate(tool_result_update(
235                ToolResultUpdateParts {
236                    id,
237                    name,
238                    status,
239                    raw_output,
240                    content: text_content_vec(error_text(
241                        event.text.as_deref().unwrap_or_default(),
242                    )),
243                    locations: None,
244                    title: None,
245                    extra_meta: None,
246                },
247            ))];
248        }
249
250        if name == "Bash" && self.supports_terminal_output {
251            let output = bash_output(raw_output.as_ref(), event.text.as_deref());
252            let exit_code = bash_exit_code(raw_output.as_ref(), false);
253            let output_update =
254                ToolCallUpdate::new(ToolCallId::new(id.clone()), ToolCallUpdateFields::new()).meta(
255                    meta_from_pairs([(
256                        "terminal_output",
257                        json!({ "terminal_id": id.clone(), "data": output }),
258                    )]),
259                );
260            let terminal_content = vec![ToolCallContent::Terminal(Terminal::new(id.clone()))];
261            let exit_update = tool_result_update(ToolResultUpdateParts {
262                id: id.clone(),
263                name,
264                status,
265                raw_output,
266                content: terminal_content,
267                locations: None,
268                title: None,
269                extra_meta: Some(meta_from_pairs([(
270                    "terminal_exit",
271                    json!({ "terminal_id": id, "exit_code": exit_code, "signal": Value::Null }),
272                )])),
273            });
274            return vec![
275                SessionUpdate::ToolCallUpdate(output_update),
276                SessionUpdate::ToolCallUpdate(exit_update),
277            ];
278        }
279
280        let (title, content, locations) = match name {
281            "Bash" => {
282                let output = bash_output(raw_output.as_ref(), event.text.as_deref());
283                if output.trim().is_empty() {
284                    (None, Vec::new(), None)
285                } else {
286                    (
287                        None,
288                        text_content_vec(format!("```console\n{}\n```", output.trim_end())),
289                        None,
290                    )
291                }
292            }
293            "Read" => (
294                None,
295                raw_output
296                    .as_ref()
297                    .map(|output| acp_content_update(output, false, true))
298                    .unwrap_or_default(),
299                None,
300            ),
301            "Edit" | "Write" => (None, Vec::new(), None),
302            "ExitPlanMode" => (Some("Exited Plan Mode".to_string()), Vec::new(), None),
303            _ => (
304                None,
305                raw_output
306                    .as_ref()
307                    .map(|output| acp_content_update(output, false, false))
308                    .unwrap_or_else(|| {
309                        event.text.clone().map(text_content_vec).unwrap_or_default()
310                    }),
311                None,
312            ),
313        };
314
315        vec![SessionUpdate::ToolCallUpdate(tool_result_update(
316            ToolResultUpdateParts {
317                id,
318                name,
319                status,
320                raw_output,
321                content,
322                locations,
323                title,
324                extra_meta: None,
325            },
326        ))]
327    }
328}
329
330pub fn client_supports_terminal_output(capabilities: &ClientCapabilities) -> bool {
331    capabilities.terminal
332        || capabilities
333            .meta
334            .as_ref()
335            .and_then(|meta| meta.get("terminal_output"))
336            .and_then(Value::as_bool)
337            .unwrap_or(false)
338}
339
340struct ToolInfo {
341    title: String,
342    kind: ToolKind,
343    content: Vec<ToolCallContent>,
344    locations: Vec<ToolCallLocation>,
345}
346
347fn tool_info(
348    name: &str,
349    input: Option<&Value>,
350    supports_terminal_output: bool,
351    cwd: Option<&Path>,
352) -> ToolInfo {
353    match name {
354        "Agent" | "Task" => ToolInfo {
355            title: input_string(input, "description").unwrap_or_else(|| "Task".to_string()),
356            kind: ToolKind::Think,
357            content: input_string(input, "prompt")
358                .map(text_content_vec)
359                .unwrap_or_default(),
360            locations: Vec::new(),
361        },
362        "Bash" => ToolInfo {
363            title: input_string(input, "command").unwrap_or_else(|| "Terminal".to_string()),
364            kind: ToolKind::Execute,
365            content: if supports_terminal_output {
366                Vec::new()
367            } else {
368                input_string(input, "description")
369                    .map(text_content_vec)
370                    .unwrap_or_default()
371            },
372            locations: Vec::new(),
373        },
374        "Read" => {
375            let file_path = input_string(input, "file_path");
376            let offset = input_i64(input, "offset").unwrap_or(1);
377            let limit = input_i64(input, "limit");
378            let suffix = match limit {
379                Some(limit) if limit > 0 => {
380                    format!(" ({offset} - {})", offset + limit - 1)
381                }
382                _ if input_i64(input, "offset").is_some() => format!(" (from line {offset})"),
383                _ => String::new(),
384            };
385            let display = file_path
386                .as_deref()
387                .map(|path| display_path(path, cwd))
388                .unwrap_or_else(|| "File".to_string());
389            ToolInfo {
390                title: format!("Read {display}{suffix}"),
391                kind: ToolKind::Read,
392                content: Vec::new(),
393                locations: file_path
394                    .map(|path| vec![ToolCallLocation::new(path).line(offset.max(1) as u32)])
395                    .unwrap_or_default(),
396            }
397        }
398        "Write" => {
399            let file_path = input_string(input, "file_path");
400            let content_text = input_string(input, "content");
401            let display = file_path.as_deref().map(|path| display_path(path, cwd));
402            let content = match (file_path.as_deref(), content_text.as_deref()) {
403                (Some(path), Some(text)) => {
404                    vec![ToolCallContent::Diff(Diff::new(path, text.to_string()))]
405                }
406                (None, Some(text)) => text_content_vec(text.to_string()),
407                _ => Vec::new(),
408            };
409            ToolInfo {
410                title: display
411                    .map(|path| format!("Write {path}"))
412                    .unwrap_or_else(|| "Write".to_string()),
413                kind: ToolKind::Edit,
414                content,
415                locations: file_path
416                    .map(|path| vec![ToolCallLocation::new(path)])
417                    .unwrap_or_default(),
418            }
419        }
420        "Edit" => {
421            let file_path = input_string(input, "file_path");
422            let old_text = input_string(input, "old_string");
423            let new_text = input_string(input, "new_string");
424            let display = file_path.as_deref().map(|path| display_path(path, cwd));
425            let content =
426                if let (Some(path), Some(new_text)) = (file_path.as_deref(), new_text.as_deref()) {
427                    vec![ToolCallContent::Diff(
428                        Diff::new(path, new_text.to_string()).old_text(old_text),
429                    )]
430                } else {
431                    Vec::new()
432                };
433            ToolInfo {
434                title: display
435                    .map(|path| format!("Edit {path}"))
436                    .unwrap_or_else(|| "Edit".to_string()),
437                kind: ToolKind::Edit,
438                content,
439                locations: file_path
440                    .map(|path| vec![ToolCallLocation::new(path)])
441                    .unwrap_or_default(),
442            }
443        }
444        "Glob" => {
445            let mut title = "Find".to_string();
446            if let Some(path) = input_string(input, "path") {
447                title.push_str(&format!(" `{path}`"));
448            }
449            if let Some(pattern) = input_string(input, "pattern") {
450                title.push_str(&format!(" `{pattern}`"));
451            }
452            ToolInfo {
453                title,
454                kind: ToolKind::Search,
455                content: Vec::new(),
456                locations: input_string(input, "path")
457                    .map(|path| vec![ToolCallLocation::new(path)])
458                    .unwrap_or_default(),
459            }
460        }
461        "Grep" => ToolInfo {
462            title: grep_title(input),
463            kind: ToolKind::Search,
464            content: Vec::new(),
465            locations: Vec::new(),
466        },
467        "WebFetch" => ToolInfo {
468            title: input_string(input, "url")
469                .map(|url| format!("Fetch {url}"))
470                .unwrap_or_else(|| "Fetch".to_string()),
471            kind: ToolKind::Fetch,
472            content: input_string(input, "prompt")
473                .map(text_content_vec)
474                .unwrap_or_default(),
475            locations: Vec::new(),
476        },
477        "WebSearch" => ToolInfo {
478            title: web_search_title(input),
479            kind: ToolKind::Fetch,
480            content: Vec::new(),
481            locations: Vec::new(),
482        },
483        "ExitPlanMode" => ToolInfo {
484            title: "Ready to code?".to_string(),
485            kind: ToolKind::SwitchMode,
486            content: input_string(input, "plan")
487                .map(text_content_vec)
488                .unwrap_or_default(),
489            locations: Vec::new(),
490        },
491        "Other" => ToolInfo {
492            title: name.to_string(),
493            kind: ToolKind::Other,
494            content: input
495                .map(|input| {
496                    text_content_vec(format!(
497                        "```json\n{}\n```",
498                        serde_json::to_string_pretty(input).unwrap_or_else(|_| "{}".to_string())
499                    ))
500                })
501                .unwrap_or_default(),
502            locations: Vec::new(),
503        },
504        _ => ToolInfo {
505            title: if name.is_empty() {
506                "Unknown Tool".to_string()
507            } else {
508                name.to_string()
509            },
510            kind: ToolKind::Other,
511            content: Vec::new(),
512            locations: Vec::new(),
513        },
514    }
515}
516
517struct ToolResultUpdateParts<'a> {
518    id: String,
519    name: &'a str,
520    status: ToolCallStatus,
521    raw_output: Option<Value>,
522    content: Vec<ToolCallContent>,
523    locations: Option<Vec<ToolCallLocation>>,
524    title: Option<String>,
525    extra_meta: Option<serde_json::Map<String, Value>>,
526}
527
528fn tool_result_update(parts: ToolResultUpdateParts<'_>) -> ToolCallUpdate {
529    let ToolResultUpdateParts {
530        id,
531        name,
532        status,
533        raw_output,
534        content,
535        locations,
536        title,
537        extra_meta,
538    } = parts;
539    let mut fields = ToolCallUpdateFields::new().status(status);
540    if let Some(raw_output) = raw_output {
541        fields = fields.raw_output(raw_output);
542    }
543    if !content.is_empty() {
544        fields = fields.content(content);
545    }
546    if let Some(locations) = locations.filter(|locations| !locations.is_empty()) {
547        fields = fields.locations(locations);
548    }
549    if let Some(title) = title {
550        fields = fields.title(title);
551    }
552    let mut meta = claude_tool_meta(name);
553    if let Some(extra_meta) = extra_meta {
554        meta.extend(extra_meta);
555    }
556    ToolCallUpdate::new(ToolCallId::new(id), fields).meta(meta)
557}
558
559fn todo_plan_update(input: Option<&Value>) -> Option<SessionUpdate> {
560    let todos = input?.get("todos")?.as_array()?;
561    let entries = todos
562        .iter()
563        .map(|todo| {
564            let content = todo
565                .get("content")
566                .and_then(Value::as_str)
567                .unwrap_or_default();
568            let status = match todo.get("status").and_then(Value::as_str) {
569                Some("completed") => PlanEntryStatus::Completed,
570                Some("in_progress") => PlanEntryStatus::InProgress,
571                _ => PlanEntryStatus::Pending,
572            };
573            PlanEntry::new(content, PlanEntryPriority::Medium, status)
574        })
575        .collect();
576    Some(SessionUpdate::Plan(Plan::new(entries)))
577}
578
579fn acp_content_update(
580    value: &Value,
581    is_error: bool,
582    markdown_escape_text: bool,
583) -> Vec<ToolCallContent> {
584    match value {
585        Value::Array(items) => items
586            .iter()
587            .filter_map(|item| acp_content_block(item, is_error, markdown_escape_text))
588            .collect(),
589        Value::Object(_) => acp_content_block(value, is_error, markdown_escape_text)
590            .into_iter()
591            .collect(),
592        Value::String(text) if !text.is_empty() => {
593            let text = if is_error {
594                error_text(text)
595            } else if markdown_escape_text {
596                markdown_escape(text)
597            } else {
598                text.clone()
599            };
600            text_content_vec(text)
601        }
602        _ => Vec::new(),
603    }
604}
605
606fn acp_content_block(
607    value: &Value,
608    is_error: bool,
609    markdown_escape_text: bool,
610) -> Option<ToolCallContent> {
611    let kind = value.get("type").and_then(Value::as_str)?;
612    match kind {
613        "text" => {
614            let text = value
615                .get("text")
616                .and_then(Value::as_str)
617                .unwrap_or_default();
618            let text = if is_error {
619                error_text(text)
620            } else if markdown_escape_text {
621                markdown_escape(text)
622            } else {
623                text.to_string()
624            };
625            Some(ToolCallContent::Content(agent_text_content(text)))
626        }
627        "image" => {
628            let source = value.get("source")?;
629            if source.get("type").and_then(Value::as_str) == Some("base64") {
630                Some(ToolCallContent::Content(
631                    agent_client_protocol::schema::Content::new(ContentBlock::Image(
632                        ImageContent::new(
633                            source
634                                .get("data")
635                                .and_then(Value::as_str)
636                                .unwrap_or_default(),
637                            source
638                                .get("media_type")
639                                .and_then(Value::as_str)
640                                .unwrap_or("application/octet-stream"),
641                        ),
642                    )),
643                ))
644            } else {
645                Some(ToolCallContent::Content(agent_text_content(
646                    source
647                        .get("url")
648                        .and_then(Value::as_str)
649                        .map(|url| format!("[image: {url}]"))
650                        .unwrap_or_else(|| "[image: file reference]".to_string()),
651                )))
652            }
653        }
654        "bash_code_execution_result" => Some(ToolCallContent::Content(agent_text_content(
655            format!("Output: {}", bash_output(Some(value), None)),
656        ))),
657        "web_search_result" => Some(ToolCallContent::Content(agent_text_content(format!(
658            "{} ({})",
659            value
660                .get("title")
661                .and_then(Value::as_str)
662                .unwrap_or("Result"),
663            value.get("url").and_then(Value::as_str).unwrap_or_default()
664        )))),
665        "web_fetch_result" => Some(ToolCallContent::Content(agent_text_content(format!(
666            "Fetched: {}",
667            value.get("url").and_then(Value::as_str).unwrap_or_default()
668        )))),
669        _ => Some(ToolCallContent::Content(agent_text_content(
670            value.to_string(),
671        ))),
672    }
673}
674
675fn text_content_vec(text: impl Into<String>) -> Vec<ToolCallContent> {
676    vec![ToolCallContent::Content(agent_text_content(text.into()))]
677}
678
679fn bash_output(raw_output: Option<&Value>, fallback_text: Option<&str>) -> String {
680    match raw_output {
681        Some(Value::Object(object))
682            if object.get("type").and_then(Value::as_str) == Some("bash_code_execution_result") =>
683        {
684            [object.get("stdout"), object.get("stderr")]
685                .into_iter()
686                .flatten()
687                .filter_map(Value::as_str)
688                .filter(|text| !text.is_empty())
689                .collect::<Vec<_>>()
690                .join("\n")
691        }
692        Some(Value::String(text)) => text.clone(),
693        Some(Value::Array(items)) => items
694            .iter()
695            .filter_map(|item| {
696                item.as_str()
697                    .or_else(|| item.get("text").and_then(Value::as_str))
698            })
699            .collect::<Vec<_>>()
700            .join("\n"),
701        Some(value) => value.to_string(),
702        None => fallback_text.unwrap_or_default().to_string(),
703    }
704}
705
706fn bash_exit_code(raw_output: Option<&Value>, is_error: bool) -> i64 {
707    raw_output
708        .and_then(|value| value.get("return_code"))
709        .and_then(Value::as_i64)
710        .unwrap_or(i64::from(is_error))
711}
712
713fn markdown_escape(text: &str) -> String {
714    let mut fence = "```".to_string();
715    for line in text.lines() {
716        let tick_count = line.chars().take_while(|ch| *ch == '`').count();
717        while tick_count >= fence.len() {
718            fence.push('`');
719        }
720    }
721    format!(
722        "{fence}\n{}{}{fence}",
723        text,
724        if text.ends_with('\n') { "" } else { "\n" }
725    )
726}
727
728fn error_text(text: &str) -> String {
729    format!("```\n{text}\n```")
730}
731
732fn grep_title(input: Option<&Value>) -> String {
733    let mut label = "grep".to_string();
734    if input_bool(input, "-i") {
735        label.push_str(" -i");
736    }
737    if input_bool(input, "-n") {
738        label.push_str(" -n");
739    }
740    for flag in ["-A", "-B", "-C"] {
741        if let Some(value) = input_i64(input, flag) {
742            label.push_str(&format!(" {flag} {value}"));
743        }
744    }
745    match input_string(input, "output_mode").as_deref() {
746        Some("files_with_matches") => label.push_str(" -l"),
747        Some("count") => label.push_str(" -c"),
748        _ => {}
749    }
750    if let Some(limit) = input_i64(input, "head_limit") {
751        label.push_str(&format!(" | head -{limit}"));
752    }
753    if let Some(glob) = input_string(input, "glob") {
754        label.push_str(&format!(" --include=\"{glob}\""));
755    }
756    if let Some(file_type) = input_string(input, "type") {
757        label.push_str(&format!(" --type={file_type}"));
758    }
759    if input_bool(input, "multiline") {
760        label.push_str(" -P");
761    }
762    if let Some(pattern) = input_string(input, "pattern") {
763        label.push_str(&format!(" \"{pattern}\""));
764    }
765    if let Some(path) = input_string(input, "path") {
766        label.push_str(&format!(" {path}"));
767    }
768    label
769}
770
771fn web_search_title(input: Option<&Value>) -> String {
772    let mut label = input_string(input, "query")
773        .map(|query| format!("\"{query}\""))
774        .unwrap_or_else(|| "Web search".to_string());
775    if let Some(domains) = input_string_array(input, "allowed_domains")
776        && !domains.is_empty()
777    {
778        label.push_str(&format!(" (allowed: {})", domains.join(", ")));
779    }
780    if let Some(domains) = input_string_array(input, "blocked_domains")
781        && !domains.is_empty()
782    {
783        label.push_str(&format!(" (blocked: {})", domains.join(", ")));
784    }
785    label
786}
787
788fn input_string(input: Option<&Value>, key: &str) -> Option<String> {
789    input?
790        .get(key)
791        .and_then(Value::as_str)
792        .map(ToString::to_string)
793}
794
795fn input_i64(input: Option<&Value>, key: &str) -> Option<i64> {
796    input?.get(key).and_then(Value::as_i64)
797}
798
799fn input_bool(input: Option<&Value>, key: &str) -> bool {
800    input
801        .and_then(|input| input.get(key))
802        .and_then(Value::as_bool)
803        .unwrap_or(false)
804}
805
806fn input_string_array(input: Option<&Value>, key: &str) -> Option<Vec<String>> {
807    Some(
808        input?
809            .get(key)?
810            .as_array()?
811            .iter()
812            .filter_map(Value::as_str)
813            .map(ToString::to_string)
814            .collect(),
815    )
816}
817
818fn display_path(file_path: &str, cwd: Option<&Path>) -> String {
819    let Some(cwd) = cwd else {
820        return file_path.to_string();
821    };
822    let path = Path::new(file_path);
823    path.strip_prefix(cwd)
824        .ok()
825        .and_then(|relative| {
826            let text = relative.to_string_lossy().to_string();
827            (!text.is_empty()).then_some(text)
828        })
829        .unwrap_or_else(|| file_path.to_string())
830}
831
832fn tool_event_id(event: &TranscriptEvent) -> String {
833    event.id.clone().unwrap_or_else(|| event.uuid.clone())
834}
835
836fn claude_tool_meta(name: &str) -> serde_json::Map<String, Value> {
837    meta_from_pairs([("claudeCode", json!({ "toolName": name }))])
838}
839
840fn meta_from_pairs<const N: usize>(pairs: [(&str, Value); N]) -> serde_json::Map<String, Value> {
841    pairs
842        .into_iter()
843        .map(|(key, value)| (key.to_string(), value))
844        .collect()
845}
846
847fn agent_text_content(text: String) -> agent_client_protocol::schema::Content {
848    agent_client_protocol::schema::Content::new(ContentBlock::Text(TextContent::new(text)))
849}
850
851pub fn pending_permission_update(id: impl Into<String>, title: impl Into<String>) -> SessionUpdate {
852    SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
853        ToolCallId::new(id.into()),
854        ToolCallUpdateFields::new()
855            .status(ToolCallStatus::Pending)
856            .title(title.into()),
857    ))
858}
859
860pub fn permission_request(
861    session_id: SessionId,
862    permission: &PendingPermission,
863) -> RequestPermissionRequest {
864    let tool_call = ToolCallUpdate::new(
865        permission_tool_call_id(&permission.dialog),
866        ToolCallUpdateFields::new()
867            .status(ToolCallStatus::Pending)
868            .title(permission.dialog.title.clone()),
869    );
870    RequestPermissionRequest::new(
871        session_id,
872        tool_call,
873        permission_options(&permission.dialog),
874    )
875}
876
877pub fn permission_decision(outcome: &RequestPermissionOutcome) -> Option<PermissionDecision> {
878    match outcome {
879        RequestPermissionOutcome::Cancelled => Some(PermissionDecision::Reject),
880        RequestPermissionOutcome::Selected(selected) => match selected.option_id.0.as_ref() {
881            ALLOW_ONCE_OPTION_ID => Some(PermissionDecision::AllowOnce),
882            ALLOW_ALWAYS_OPTION_ID => Some(PermissionDecision::AllowAlways),
883            REJECT_OPTION_ID => Some(PermissionDecision::Reject),
884            _ => None,
885        },
886        _ => None,
887    }
888}
889
890fn permission_options(dialog: &PermissionDialog) -> Vec<PermissionOption> {
891    let mut options = Vec::new();
892    if dialog
893        .options
894        .iter()
895        .any(|option| option.decision == PermissionDecision::AllowOnce)
896    {
897        options.push(PermissionOption::new(
898            ALLOW_ONCE_OPTION_ID,
899            "Allow once",
900            PermissionOptionKind::AllowOnce,
901        ));
902    }
903    if dialog
904        .options
905        .iter()
906        .any(|option| option.decision == PermissionDecision::AllowAlways)
907    {
908        options.push(PermissionOption::new(
909            ALLOW_ALWAYS_OPTION_ID,
910            "Allow for session",
911            PermissionOptionKind::AllowAlways,
912        ));
913    }
914    if dialog
915        .options
916        .iter()
917        .any(|option| option.decision == PermissionDecision::Reject)
918    {
919        options.push(PermissionOption::new(
920            REJECT_OPTION_ID,
921            "Reject",
922            PermissionOptionKind::RejectOnce,
923        ));
924    }
925    options
926}
927
928fn permission_tool_call_id(dialog: &PermissionDialog) -> ToolCallId {
929    let mut hash = 0xcbf29ce484222325_u64;
930    for byte in dialog.title.as_bytes() {
931        hash ^= u64::from(*byte);
932        hash = hash.wrapping_mul(0x100000001b3);
933    }
934    ToolCallId::new(format!("claude-permission-{hash:x}"))
935}
936
937pub fn available_commands(cwd: &Path) -> SessionUpdate {
938    SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(
939        commands::available_commands(cwd),
940    ))
941}