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}