1#![allow(dead_code)]
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::types::Message;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "lowercase")]
16pub enum PromptInputMode {
17 #[default]
18 Prompt,
19 Bash,
20 Print,
21 Continue,
22}
23
24#[derive(Debug, Clone)]
27pub struct ProcessUserInputContext {
28 pub session_id: String,
30 pub cwd: String,
32 pub agent_id: Option<String>,
34 pub query_tracking: Option<QueryTracking>,
36 pub options: ProcessUserInputContextOptions,
38 pub loaded_nested_memory_paths: std::collections::HashSet<String>,
40 pub discovered_skill_names: std::collections::HashSet<String>,
42 pub dynamic_skill_dir_triggers: std::collections::HashSet<String>,
44 pub nested_memory_attachment_triggers: std::collections::HashSet<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct QueryTracking {
52 pub chain_id: String,
53 pub depth: u32,
54}
55
56#[derive(Debug, Clone)]
58pub struct ProcessUserInputContextOptions {
59 pub commands: Vec<Value>,
61 pub debug: bool,
63 pub tools: Vec<crate::types::ToolDefinition>,
65 pub verbose: bool,
67 pub main_loop_model: Option<String>,
69 pub thinking_config: Option<crate::types::api_types::ThinkingConfig>,
71 pub mcp_clients: Vec<Value>,
73 pub mcp_resources: std::collections::HashMap<String, Value>,
75 pub ide_installation_status: Option<Value>,
77 pub is_non_interactive_session: bool,
79 pub custom_system_prompt: Option<String>,
81 pub append_system_prompt: Option<String>,
83 pub agent_definitions: AgentDefinitions,
85 pub theme: Option<String>,
87 pub max_budget_usd: Option<f64>,
89}
90
91impl Default for ProcessUserInputContext {
92 fn default() -> Self {
93 Self {
94 session_id: String::new(),
95 cwd: String::new(),
96 agent_id: None,
97 query_tracking: None,
98 options: ProcessUserInputContextOptions::default(),
99 loaded_nested_memory_paths: std::collections::HashSet::new(),
100 discovered_skill_names: std::collections::HashSet::new(),
101 dynamic_skill_dir_triggers: std::collections::HashSet::new(),
102 nested_memory_attachment_triggers: std::collections::HashSet::new(),
103 }
104 }
105}
106
107impl Default for ProcessUserInputContextOptions {
108 fn default() -> Self {
109 Self {
110 commands: vec![],
111 debug: false,
112 tools: vec![],
113 verbose: false,
114 main_loop_model: None,
115 thinking_config: None,
116 mcp_clients: vec![],
117 mcp_resources: std::collections::HashMap::new(),
118 ide_installation_status: None,
119 is_non_interactive_session: false,
120 custom_system_prompt: None,
121 append_system_prompt: None,
122 agent_definitions: AgentDefinitions::default(),
123 theme: None,
124 max_budget_usd: None,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct AgentDefinitions {
133 pub active_agents: Vec<Value>,
134 pub all_agents: Vec<Value>,
135 pub allowed_agent_types: Option<Vec<String>>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct EffortValue {
142 pub effort: String,
143 pub reason: Option<String>,
144}
145
146#[derive(Debug, Clone)]
148pub struct ProcessUserInputBaseResult {
149 pub messages: Vec<Message>,
151 pub should_query: bool,
153 pub allowed_tools: Option<Vec<String>>,
155 pub model: Option<String>,
157 pub effort: Option<EffortValue>,
159 pub result_text: Option<String>,
161 pub next_input: Option<String>,
163 pub submit_next_input: Option<bool>,
165}
166
167impl Default for ProcessUserInputBaseResult {
168 fn default() -> Self {
169 Self {
170 messages: vec![],
171 should_query: true,
172 allowed_tools: None,
173 model: None,
174 effort: None,
175 result_text: None,
176 next_input: None,
177 submit_next_input: None,
178 }
179 }
180}
181
182pub struct ProcessUserInputOptions {
184 pub input: ProcessUserInput,
186 pub pre_expansion_input: Option<String>,
188 pub mode: PromptInputMode,
190 pub context: ProcessUserInputContext,
192 pub pasted_contents: Option<std::collections::HashMap<u32, PastedContent>>,
194 pub ide_selection: Option<IdeSelection>,
196 pub messages: Option<Vec<Message>>,
198 pub set_user_input_on_processing: Option<Box<dyn Fn(Option<String>) + Send + Sync>>,
200 pub uuid: Option<String>,
202 pub is_already_processing: Option<bool>,
204 pub query_source: Option<QuerySource>,
206 pub can_use_tool: Option<crate::utils::hooks::CanUseToolFnJson>,
208 pub skip_slash_commands: Option<bool>,
210 pub bridge_origin: Option<bool>,
212 pub is_meta: Option<bool>,
214 pub skip_attachments: Option<bool>,
216}
217
218impl Default for ProcessUserInputOptions {
219 fn default() -> Self {
220 Self {
221 input: ProcessUserInput::String(String::new()),
222 pre_expansion_input: None,
223 mode: PromptInputMode::Prompt,
224 context: ProcessUserInputContext::default(),
225 pasted_contents: None,
226 ide_selection: None,
227 messages: None,
228 set_user_input_on_processing: None,
229 uuid: None,
230 is_already_processing: None,
231 query_source: None,
232 can_use_tool: None,
233 skip_slash_commands: None,
234 bridge_origin: None,
235 is_meta: None,
236 skip_attachments: None,
237 }
238 }
239}
240
241#[derive(Clone)]
243pub enum ProcessUserInput {
244 String(String),
245 ContentBlocks(Vec<ContentBlockParam>),
246}
247
248impl std::fmt::Debug for ProcessUserInput {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 ProcessUserInput::String(s) => f.debug_tuple("String").field(s).finish(),
252 ProcessUserInput::ContentBlocks(blocks) => {
253 f.debug_tuple("ContentBlocks").field(blocks).finish()
254 }
255 }
256 }
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub enum ContentBlockParam {
263 Text {
265 text: String,
267 },
268 Image {
270 source: ImageSource,
272 },
273 ToolUse {
275 id: String,
277 name: String,
279 input: Value,
281 },
282 ToolResult {
284 tool_use_id: String,
286 content: Value,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
290 is_error: Option<bool>,
291 },
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
296#[serde(rename_all = "camelCase")]
297pub struct ImageSource {
298 #[serde(rename = "type")]
300 pub source_type: String,
301 pub media_type: String,
303 pub data: String,
305}
306
307#[derive(Debug, Clone)]
309pub struct PastedContent {
310 pub id: u32,
312 pub content: String,
314 pub media_type: Option<String>,
316 pub source_path: Option<String>,
318 pub dimensions: Option<ImageDimensions>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
324#[serde(rename_all = "camelCase")]
325pub struct ImageDimensions {
326 pub width: u32,
327 pub height: u32,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333pub struct IdeSelection {
334 pub file_path: String,
336 pub selected_text: Option<String>,
338 pub cursor_position: Option<CursorPosition>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct CursorPosition {
346 pub line: u32,
347 pub character: u32,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352#[serde(rename_all = "snake_case")]
353pub enum QuerySource {
354 Prompt,
355 Continue,
356 SlashCommand,
357 BashCommand,
358 Attachments,
359 AutoAttach,
360 Resubmit,
361}
362
363pub async fn process_user_input(
371 options: ProcessUserInputOptions,
372) -> Result<ProcessUserInputBaseResult, String> {
373 let input_string = match &options.input {
374 ProcessUserInput::String(s) => Some(s.clone()),
375 ProcessUserInput::ContentBlocks(blocks) => blocks.iter().find_map(|b| {
376 if let ContentBlockParam::Text { text } = b {
377 Some(text.clone())
378 } else {
379 None
380 }
381 }),
382 };
383
384 if options.mode == PromptInputMode::Prompt
386 && input_string.is_some()
387 && options.is_meta != Some(true)
388 {
389 if let Some(ref callback) = options.set_user_input_on_processing {
390 callback(input_string.clone());
391 }
392 }
393
394 let input = options.input;
396 let mode = options.mode;
397 let context = options.context;
398 let pasted_contents = options.pasted_contents;
399 let uuid = options.uuid;
400 let is_meta = options.is_meta;
401 let skip_slash_commands = options.skip_slash_commands;
402 let bridge_origin = options.bridge_origin;
403
404 let result = process_user_input_base(
405 input,
406 mode,
407 context,
408 pasted_contents,
409 uuid,
410 is_meta,
411 skip_slash_commands,
412 bridge_origin,
413 )
414 .await?;
415
416 Ok(result)
420}
421
422async fn process_user_input_base(
424 input: ProcessUserInput,
425 mode: PromptInputMode,
426 context: ProcessUserInputContext,
427 pasted_contents: Option<std::collections::HashMap<u32, PastedContent>>,
428 uuid: Option<String>,
429 is_meta: Option<bool>,
430 skip_slash_commands: Option<bool>,
431 bridge_origin: Option<bool>,
432) -> Result<ProcessUserInputBaseResult, String> {
433 let input_string = match &input {
434 ProcessUserInput::String(s) => Some(s.clone()),
435 ProcessUserInput::ContentBlocks(blocks) => blocks.iter().find_map(|b| {
436 if let ContentBlockParam::Text { text } = b {
437 Some(text.clone())
438 } else {
439 None
440 }
441 }),
442 };
443
444 let mut preceding_input_blocks: Vec<ContentBlockParam> = vec![];
445 let mut normalized_input = input.clone();
446
447 if let ProcessUserInput::ContentBlocks(blocks) = &input {
449 if !blocks.is_empty() {
450 let last_block = blocks.last().unwrap();
451 if let ContentBlockParam::Text { text } = last_block {
452 let text = text.clone();
453 preceding_input_blocks = blocks[..blocks.len() - 1].to_vec();
454 normalized_input = ProcessUserInput::String(text);
455 } else {
456 preceding_input_blocks = blocks.clone();
457 }
458 }
459 }
460
461 if input_string.is_none() && mode != PromptInputMode::Prompt {
463 return Err(format!("Mode: {:?} requires a string input.", mode));
464 }
465
466 let image_content_blocks = process_pasted_images(pasted_contents.as_ref()).await;
468
469 let effective_skip_slash = check_bridge_safe_slash_command(
471 bridge_origin,
472 input_string.as_deref(),
473 skip_slash_commands,
474 );
475
476 if let Some(input) = input_string {
478 if mode == PromptInputMode::Bash {
479 return process_bash_command(input, preceding_input_blocks, vec![], &context).await;
480 }
481
482 if !effective_skip_slash && input.starts_with('/') {
484 return process_slash_command(
485 input,
486 preceding_input_blocks,
487 image_content_blocks,
488 vec![],
489 &context,
490 ).await;
491 }
492 }
493
494 process_text_prompt(
496 normalized_input,
497 image_content_blocks,
498 vec![],
499 uuid,
500 None, is_meta,
502 )
503}
504
505fn check_bridge_safe_slash_command(
507 bridge_origin: Option<bool>,
508 input_string: Option<&str>,
509 skip_slash_commands: Option<bool>,
510) -> bool {
511 if bridge_origin != Some(true) {
512 return skip_slash_commands.unwrap_or(false);
513 }
514
515 let input = match input_string {
516 Some(s) => s,
517 None => return skip_slash_commands.unwrap_or(false),
518 };
519
520 if !input.starts_with('/') {
521 return skip_slash_commands.unwrap_or(false);
522 }
523
524 false
526}
527
528async fn process_pasted_images(
530 pasted_contents: Option<&std::collections::HashMap<u32, PastedContent>>,
531) -> Vec<ContentBlockParam> {
532 if pasted_contents.is_none() {
533 return vec![];
534 }
535
536 let contents = pasted_contents.unwrap();
537 let mut image_blocks = vec![];
538
539 for (_, pasted) in contents.iter() {
540 let media_type = pasted.media_type.as_deref().unwrap_or("image/png");
541 image_blocks.push(ContentBlockParam::Image {
542 source: ImageSource {
543 source_type: "base64".to_string(),
544 media_type: media_type.to_string(),
545 data: pasted.content.clone(),
546 },
547 });
548 }
549
550 image_blocks
551}
552
553fn process_text_prompt(
555 input: ProcessUserInput,
556 _image_content_blocks: Vec<ContentBlockParam>,
557 _attachment_messages: Vec<Message>,
558 uuid: Option<String>,
559 _permission_mode: Option<crate::types::api_types::PermissionMode>,
560 is_meta: Option<bool>,
561) -> Result<ProcessUserInputBaseResult, String> {
562 let content = match input {
563 ProcessUserInput::String(s) => {
564 if s.trim().is_empty() {
565 vec![]
566 } else {
567 vec![Value::String(s)]
568 }
569 }
570 ProcessUserInput::ContentBlocks(blocks) => blocks
571 .iter()
572 .map(|b| serde_json::to_value(b).unwrap_or(Value::Null))
573 .collect(),
574 };
575
576 let message = Message {
577 role: crate::types::MessageRole::User,
578 content: serde_json::json!({ "type": "text", "text": content }).to_string(),
579 attachments: None,
580 tool_call_id: None,
581 tool_calls: None,
582 is_api_error_message: None,
583 error_details: None,
584 is_error: None,
585 is_meta: None,
586 uuid: None,
587 };
588
589 Ok(ProcessUserInputBaseResult {
590 messages: vec![message],
591 should_query: true,
592 ..Default::default()
593 })
594}
595
596fn format_command_input_tags(command_name: &str, args: &str) -> String {
598 let mut parts = vec![
599 format!("<command-message>{}</command-message>", command_name),
600 format!("<command-name>/{}</command-name>", command_name),
601 ];
602 if !args.trim().is_empty() {
603 parts.push(format!("<command-args>{}</command-args>", args));
604 }
605 parts.join("\n")
606}
607
608struct ParsedSlashCommand {
610 command_name: String,
611 args: String,
612 is_mcp: bool,
613}
614
615fn parse_slash_command(input: &str) -> Option<ParsedSlashCommand> {
618 let trimmed = input.trim();
619 if !trimmed.starts_with('/') || trimmed.len() <= 1 {
620 return None;
621 }
622 let without_slash = &trimmed[1..];
623 let words: Vec<&str> = without_slash.split_whitespace().collect();
624 if words.is_empty() {
625 return None;
626 }
627 let mut command_name = words[0].to_string();
628 let mut is_mcp = false;
629 let mut args_start = 1;
630
631 if words.len() > 1 && words[1] == "(MCP)" {
633 command_name = format!("{} (MCP)", command_name);
634 is_mcp = true;
635 args_start = 2;
636 }
637
638 let args = if args_start < words.len() {
640 let skip_len = 1 + words[0].len(); let skip_len = if is_mcp {
643 skip_len + 1 + 5 } else {
645 skip_len + 1 };
647 let skipped = trimmed.chars().skip(skip_len).collect::<String>();
648 skipped.trim_start().to_string()
649 } else {
650 String::new()
651 };
652
653 Some(ParsedSlashCommand {
654 command_name,
655 args,
656 is_mcp,
657 })
658}
659
660fn looks_like_command(command_name: &str) -> bool {
663 command_name.chars().all(|c| {
664 c.is_alphanumeric() || c == ':' || c == '-' || c == '_'
665 })
666}
667
668fn find_command(name: &str, commands: &[serde_json::Value]) -> Option<serde_json::Value> {
670 for cmd in commands {
671 let cmd_name = cmd.get("name").and_then(|n| n.as_str()).unwrap_or("");
672 if cmd_name == name {
673 return Some(cmd.clone());
674 }
675 if let Some(aliases) = cmd.get("aliases").and_then(|a| a.as_array()) {
677 for alias in aliases {
678 if let Some(a) = alias.as_str() {
679 if a == name {
680 return Some(cmd.clone());
681 }
682 }
683 }
684 }
685 }
686 None
687}
688
689fn has_command(name: &str, commands: &[serde_json::Value]) -> bool {
691 find_command(name, commands).is_some()
692}
693
694fn make_user_message(content: String, is_meta: Option<bool>) -> Message {
696 Message {
697 role: crate::types::MessageRole::User,
698 content,
699 uuid: Some(uuid::Uuid::new_v4().to_string()),
700 attachments: None,
701 tool_call_id: None,
702 tool_calls: None,
703 is_error: None,
704 is_meta,
705 is_api_error_message: None,
706 error_details: None,
707 }
708}
709
710fn make_system_message(content: String) -> Message {
712 Message {
713 role: crate::types::MessageRole::System,
714 content,
715 uuid: Some(uuid::Uuid::new_v4().to_string()),
716 attachments: None,
717 tool_call_id: None,
718 tool_calls: None,
719 is_error: None,
720 is_meta: None,
721 is_api_error_message: None,
722 error_details: None,
723 }
724}
725
726fn make_synthetic_caveat() -> Message {
728 Message {
729 role: crate::types::MessageRole::User,
730 content: "The user didn't say anything. Continue working.".to_string(),
731 uuid: Some(uuid::Uuid::new_v4().to_string()),
732 attachments: None,
733 tool_call_id: None,
734 tool_calls: None,
735 is_error: None,
736 is_meta: Some(true),
737 is_api_error_message: None,
738 error_details: None,
739 }
740}
741
742fn make_system_local_command(content: String) -> Message {
744 make_system_message(content)
745}
746
747async fn process_bash_command(
749 input: String,
750 _preceding_input_blocks: Vec<ContentBlockParam>,
751 attachment_messages: Vec<Message>,
752 context: &ProcessUserInputContext,
753) -> Result<ProcessUserInputBaseResult, String> {
754 let user_message = make_user_message(
755 format!("<bash-input>{}</bash-input>", input),
756 None,
757 );
758
759 let use_powershell = crate::tools::config_tools::is_powershell_tool_enabled();
761
762 let tool_result = if use_powershell {
763 let ps_tool = crate::tools::powershell::PowerShellTool::new();
764 ps_tool
765 .execute(
766 serde_json::json!({"command": input}),
767 &crate::types::ToolContext {
768 cwd: context.cwd.clone(),
769 abort_signal: Default::default(),
770 },
771 )
772 .await
773 } else {
774 let bash_tool = crate::tools::bash::BashTool::new();
775 bash_tool
776 .execute(
777 serde_json::json!({"command": input}),
778 &crate::types::ToolContext {
779 cwd: context.cwd.clone(),
780 abort_signal: Default::default(),
781 },
782 )
783 .await
784 };
785
786 let escape_xml = crate::utils::xml::escape_xml;
787
788 match tool_result {
789 Ok(result) => {
790 let stdout = if result.content.is_empty() {
791 "".to_string()
792 } else {
793 result.content.clone()
794 };
795 let stderr = if result.is_error == Some(true) {
796 "Command completed with errors".to_string()
797 } else {
798 String::new()
799 };
800 let output_message = make_user_message(
801 format!(
802 "<bash-stdout>{}</bash-stdout><bash-stderr>{}</bash-stderr>",
803 escape_xml(&stdout),
804 escape_xml(&stderr)
805 ),
806 None,
807 );
808 let mut messages = vec![
809 make_synthetic_caveat(),
810 user_message,
811 ];
812 messages.extend(attachment_messages);
813 messages.push(output_message);
814 Ok(ProcessUserInputBaseResult {
815 messages,
816 should_query: false,
817 ..Default::default()
818 })
819 }
820 Err(e) => {
821 let error_message = make_user_message(
822 format!(
823 "<bash-stderr>Command failed: {}</bash-stderr>",
824 escape_xml(&e.to_string())
825 ),
826 None,
827 );
828 let mut messages = vec![
829 make_synthetic_caveat(),
830 user_message,
831 ];
832 messages.extend(attachment_messages);
833 messages.push(error_message);
834 Ok(ProcessUserInputBaseResult {
835 messages,
836 should_query: false,
837 ..Default::default()
838 })
839 }
840 }
841}
842
843async fn process_slash_command(
845 input: String,
846 preceding_input_blocks: Vec<ContentBlockParam>,
847 _image_content_blocks: Vec<ContentBlockParam>,
848 attachment_messages: Vec<Message>,
849 context: &ProcessUserInputContext,
850) -> Result<ProcessUserInputBaseResult, String> {
851 let parsed = parse_slash_command(&input);
852 let parsed = match parsed {
853 Some(p) => p,
854 None => {
855 let error_msg = "Commands are in the form `/command [args]`".to_string();
856 return Ok(ProcessUserInputBaseResult {
857 messages: vec![
858 make_synthetic_caveat(),
859 ]
860 .into_iter()
861 .chain(attachment_messages.into_iter())
862 .chain(std::iter::once(make_user_message(error_msg.clone(), None)))
863 .collect(),
864 should_query: false,
865 result_text: Some(error_msg),
866 ..Default::default()
867 });
868 }
869 };
870
871 let ParsedSlashCommand {
872 command_name,
873 args,
874 is_mcp: _is_mcp,
875 } = parsed;
876
877 if !has_command(&command_name, &context.options.commands) {
879 let fs = std::path::Path::new(&command_name);
881 let is_file_path = fs.exists();
882
883 if looks_like_command(&command_name) && !is_file_path {
884 let unknown_msg = format!("Unknown skill: {}", command_name);
885 let mut messages = vec![
886 make_synthetic_caveat(),
887 ];
888 messages.extend(attachment_messages);
889 messages.push(make_user_message(unknown_msg.clone(), None));
890 if !args.trim().is_empty() {
891 messages.push(make_system_message(
892 format!("Args from unknown skill: {}", args)
893 ));
894 }
895 return Ok(ProcessUserInputBaseResult {
896 messages,
897 should_query: false,
898 result_text: Some(unknown_msg),
899 ..Default::default()
900 });
901 }
902
903 let content = if preceding_input_blocks.is_empty() {
905 input
906 } else {
907 format!("[{} blocks] {}", preceding_input_blocks.len(), input)
909 };
910 return Ok(ProcessUserInputBaseResult {
911 messages: vec![make_user_message(content, None)]
912 .into_iter()
913 .chain(attachment_messages)
914 .collect(),
915 should_query: true,
916 ..Default::default()
917 });
918 }
919
920 let command = find_command(&command_name, &context.options.commands)
921 .ok_or_else(|| format!("Command '{}' not found", command_name))?;
922
923 let command_type = command.get("type").and_then(|t| t.as_str()).unwrap_or("");
924
925 match command_type {
926 "local" => execute_local_command(command_name, args, command, preceding_input_blocks, attachment_messages, context).await,
927 "prompt" => execute_prompt_command(command_name, args, command, preceding_input_blocks, attachment_messages, context).await,
928 "local-jsx" => {
929 let msg = format!("Command '/{}' requires a UI and is not available in this environment.", command_name);
931 Ok(ProcessUserInputBaseResult {
932 messages: vec![
933 make_synthetic_caveat(),
934 make_user_message(msg.clone(), None),
935 ]
936 .into_iter()
937 .chain(attachment_messages)
938 .collect(),
939 should_query: false,
940 result_text: Some(msg),
941 ..Default::default()
942 })
943 }
944 _ => {
945 let msg = format!("Unknown command type: {}", command_type);
946 Err(msg)
947 }
948 }
949}
950
951async fn execute_local_command(
953 command_name: String,
954 args: String,
955 _command: serde_json::Value,
956 _preceding_input_blocks: Vec<ContentBlockParam>,
957 attachment_messages: Vec<Message>,
958 _context: &ProcessUserInputContext,
959) -> Result<ProcessUserInputBaseResult, String> {
960 let input_display = format_command_input_tags(&command_name, &args);
961 let user_message = make_user_message(input_display, None);
962
963 let result = dispatch_local_command(&command_name, &args).await;
965
966 match result {
967 Ok(call_result) => {
968 use crate::commands::version::CommandCallResult;
969 match call_result.result_type.as_str() {
970 "text" => {
971 let output = if call_result.value.is_empty() {
972 make_system_local_command(
973 "(no output)".to_string()
974 )
975 } else {
976 make_system_local_command(
977 format!("<local-command-stdout>{}</local-command-stdout>", call_result.value)
978 )
979 };
980 let mut messages = vec![
981 make_synthetic_caveat(),
982 user_message,
983 ];
984 messages.extend(attachment_messages);
985 messages.push(output);
986 Ok(ProcessUserInputBaseResult {
987 messages,
988 should_query: false,
989 result_text: Some(call_result.value),
990 ..Default::default()
991 })
992 }
993 "compact" => {
994 let mut messages = vec![
996 make_synthetic_caveat(),
997 user_message,
998 ];
999 messages.extend(attachment_messages);
1000 messages.push(make_system_local_command(
1001 format!("<local-command-stdout>Conversation compacted</local-command-stdout>")
1002 ));
1003 Ok(ProcessUserInputBaseResult {
1004 messages,
1005 should_query: false,
1006 result_text: Some("Conversation compacted".to_string()),
1007 ..Default::default()
1008 })
1009 }
1010 "skip" => Ok(ProcessUserInputBaseResult {
1011 messages: vec![],
1012 should_query: false,
1013 ..Default::default()
1014 }),
1015 _ => Err(format!("Unknown local command result type: {}", call_result.result_type)),
1016 }
1017 }
1018 Err(e) => {
1019 let mut messages = vec![
1020 make_synthetic_caveat(),
1021 user_message,
1022 ];
1023 messages.extend(attachment_messages);
1024 messages.push(make_system_local_command(
1025 format!("<local-command-stderr>{}</local-command-stderr>", e)
1026 ));
1027 Ok(ProcessUserInputBaseResult {
1028 messages,
1029 should_query: false,
1030 ..Default::default()
1031 })
1032 }
1033 }
1034}
1035
1036async fn dispatch_local_command(
1039 name: &str,
1040 args: &str,
1041) -> Result<crate::commands::version::CommandCallResult, String> {
1042 match name {
1043 "clear" => handle_clear_command(args),
1044 "cost" => handle_cost_command(args),
1045 "compact" => handle_compact_command(args),
1046 "version" => handle_version_command(args),
1047 "model" => handle_model_command(args),
1048 _ => {
1049 Ok(crate::commands::version::CommandCallResult::text(
1051 format!("Command '/{}' is registered but not yet implemented in this environment.", name)
1052 ))
1053 }
1054 }
1055}
1056
1057fn handle_clear_command(args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
1059 let target = args.trim().split_whitespace().next().unwrap_or("conversation");
1060 match target {
1061 "cache" => Ok(crate::commands::version::CommandCallResult::text(
1062 "Cache cleared."
1063 )),
1064 "all" => Ok(crate::commands::version::CommandCallResult::text(
1065 "Conversation and cache cleared."
1066 )),
1067 _ => Ok(crate::commands::version::CommandCallResult::text("")),
1068 }
1069}
1070
1071fn handle_cost_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
1073 Ok(crate::commands::version::CommandCallResult::text(
1076 "Cost tracking is available through the session's cost tracker."
1077 ))
1078}
1079
1080fn handle_compact_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
1082 Ok(crate::commands::version::CommandCallResult {
1085 result_type: "compact".to_string(),
1086 value: "Compact requested".to_string(),
1087 })
1088}
1089
1090fn handle_version_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
1092 let version = env!("CARGO_PKG_VERSION");
1093 Ok(crate::commands::version::CommandCallResult::text(version))
1094}
1095
1096fn handle_model_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
1098 Ok(crate::commands::version::CommandCallResult::text(
1099 "Model configuration is managed through session settings."
1100 ))
1101}
1102
1103async fn execute_prompt_command(
1105 command_name: String,
1106 args: String,
1107 command: serde_json::Value,
1108 preceding_input_blocks: Vec<ContentBlockParam>,
1109 attachment_messages: Vec<Message>,
1110 _context: &ProcessUserInputContext,
1111) -> Result<ProcessUserInputBaseResult, String> {
1112 let input_display = format_command_input_tags(&command_name, &args);
1113 let progress_message = command.get("progressMessage").and_then(|p| p.as_str()).unwrap_or("Loading");
1114 let model = command.get("model").and_then(|m| m.as_str()).map(String::from);
1115 let allowed_tools = command
1116 .get("allowedTools")
1117 .and_then(|t| t.as_array())
1118 .map(|t| t.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<String>>());
1119 let effort = command.get("effort").and_then(|e| e.as_str()).map(|e| {
1120 crate::utils::process_user_input::EffortValue {
1121 effort: e.to_string(),
1122 reason: None,
1123 }
1124 });
1125
1126 let content = command.get("content").and_then(|c| c.as_str()).unwrap_or("");
1128 let prompt_text = if !args.trim().is_empty() {
1129 format!("{}\n\nArguments: {}", content, args)
1130 } else {
1131 content.to_string()
1132 };
1133
1134 let full_content = if preceding_input_blocks.is_empty() {
1136 prompt_text
1137 } else {
1138 format!("[{} preceding blocks]\n{}", preceding_input_blocks.len(), prompt_text)
1139 };
1140
1141 let mut messages = vec![
1142 make_system_message(
1143 format!("[{}] {}", progress_message, command_name)
1144 ),
1145 make_user_message(input_display, None),
1146 ];
1147 messages.extend(attachment_messages);
1148 messages.push(make_user_message(full_content, None));
1149
1150 Ok(ProcessUserInputBaseResult {
1151 messages,
1152 should_query: true,
1153 allowed_tools,
1154 model,
1155 effort,
1156 ..Default::default()
1157 })
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162 use super::*;
1163
1164 #[test]
1165 fn test_process_user_input_default() {
1166 let options = ProcessUserInputOptions::default();
1167 assert!(matches!(options.input, ProcessUserInput::String(s) if s.is_empty()));
1168 assert_eq!(options.mode, PromptInputMode::Prompt);
1169 }
1170
1171 #[test]
1172 fn test_process_text_prompt() {
1173 let result = process_text_prompt(
1174 ProcessUserInput::String("Hello".to_string()),
1175 vec![],
1176 vec![],
1177 Some("test-uuid".to_string()),
1178 None,
1179 Some(true),
1180 )
1181 .unwrap();
1182
1183 assert!(result.should_query);
1184 assert_eq!(result.messages.len(), 1);
1185 }
1186
1187 #[test]
1188 fn test_parse_slash_command_basic() {
1189 let parsed = parse_slash_command("/compact").unwrap();
1190 assert_eq!(parsed.command_name, "compact");
1191 assert_eq!(parsed.args, "");
1192 assert!(!parsed.is_mcp);
1193 }
1194
1195 #[test]
1196 fn test_parse_slash_command_with_args() {
1197 let parsed = parse_slash_command("/model opus").unwrap();
1198 assert_eq!(parsed.command_name, "model");
1199 assert_eq!(parsed.args, "opus");
1200 assert!(!parsed.is_mcp);
1201 }
1202
1203 #[test]
1204 fn test_parse_slash_command_mcp() {
1205 let parsed = parse_slash_command("/my-tool (MCP) arg1 arg2").unwrap();
1206 assert_eq!(parsed.command_name, "my-tool (MCP)");
1207 assert_eq!(parsed.args, "arg1 arg2");
1208 assert!(parsed.is_mcp);
1209 }
1210
1211 #[test]
1212 fn test_parse_slash_command_no_slash() {
1213 assert!(parse_slash_command("hello").is_none());
1214 }
1215
1216 #[test]
1217 fn test_parse_slash_command_empty() {
1218 assert!(parse_slash_command("/").is_none());
1219 }
1220
1221 #[test]
1222 fn test_parse_slash_command_spaces_only() {
1223 assert!(parse_slash_command("/ ").is_none());
1224 }
1225
1226 #[test]
1227 fn test_looks_like_command_valid() {
1228 assert!(looks_like_command("compact"));
1229 assert!(looks_like_command("my-command"));
1230 assert!(looks_like_command("my_command"));
1231 assert!(looks_like_command("my:command"));
1232 assert!(looks_like_command("cmd123"));
1233 }
1234
1235 #[test]
1236 fn test_looks_like_command_invalid() {
1237 assert!(!looks_like_command("/var/log"));
1238 assert!(!looks_like_command("file.txt"));
1239 assert!(!looks_like_command("path/to/file"));
1240 }
1241
1242 #[test]
1243 fn test_has_command() {
1244 let commands = vec![
1245 serde_json::json!({"name": "clear", "type": "local"}),
1246 serde_json::json!({"name": "compact", "type": "local", "aliases": ["summarize"]}),
1247 ];
1248 assert!(has_command("clear", &commands));
1249 assert!(has_command("compact", &commands));
1250 assert!(has_command("summarize", &commands));
1251 assert!(!has_command("unknown", &commands));
1252 }
1253
1254 #[test]
1255 fn test_find_command_by_name() {
1256 let commands = vec![
1257 serde_json::json!({"name": "clear", "type": "local"}),
1258 ];
1259 let cmd = find_command("clear", &commands).unwrap();
1260 assert_eq!(cmd["name"], "clear");
1261 }
1262
1263 #[test]
1264 fn test_find_command_by_alias() {
1265 let commands = vec![
1266 serde_json::json!({"name": "compact", "aliases": ["summarize"]}),
1267 ];
1268 let cmd = find_command("summarize", &commands).unwrap();
1269 assert_eq!(cmd["name"], "compact");
1270 }
1271
1272 #[test]
1273 fn test_format_command_input_tags() {
1274 let tags = format_command_input_tags("compact", "");
1275 assert!(tags.contains("<command-message>compact</command-message>"));
1276 assert!(tags.contains("<command-name>/compact</command-name>"));
1277 assert!(!tags.contains("<command-args>"));
1278 }
1279
1280 #[test]
1281 fn test_format_command_input_tags_with_args() {
1282 let tags = format_command_input_tags("model", "opus");
1283 assert!(tags.contains("<command-message>model</command-message>"));
1284 assert!(tags.contains("<command-name>/model</command-name>"));
1285 assert!(tags.contains("<command-args>opus</command-args>"));
1286 }
1287
1288 #[tokio::test]
1289 async fn test_dispatch_clear_command() {
1290 let result = dispatch_local_command("clear", "").await.unwrap();
1291 assert_eq!(result.result_type, "text");
1292 assert_eq!(result.value, "");
1293 }
1294
1295 #[tokio::test]
1296 async fn test_dispatch_clear_cache_command() {
1297 let result = dispatch_local_command("clear", "cache").await.unwrap();
1298 assert_eq!(result.result_type, "text");
1299 assert!(result.value.contains("Cache cleared"));
1300 }
1301
1302 #[tokio::test]
1303 async fn test_dispatch_version_command() {
1304 let result = dispatch_local_command("version", "").await.unwrap();
1305 assert_eq!(result.result_type, "text");
1306 assert!(!result.value.is_empty());
1307 }
1308
1309 #[tokio::test]
1310 async fn test_dispatch_unknown_command() {
1311 let result = dispatch_local_command("unknown-cmd", "").await.unwrap();
1312 assert_eq!(result.result_type, "text");
1313 assert!(result.value.contains("not yet implemented"));
1314 }
1315
1316 #[tokio::test]
1317 async fn test_dispatch_compact_command() {
1318 let result = dispatch_local_command("compact", "").await.unwrap();
1319 assert_eq!(result.result_type, "compact");
1320 }
1321
1322 #[tokio::test]
1323 async fn test_process_slash_command_invalid() {
1324 let context = ProcessUserInputContext::default();
1325 let result = process_slash_command(
1326 "hello".to_string(),
1327 vec![],
1328 vec![],
1329 vec![],
1330 &context,
1331 ).await;
1332 assert!(result.is_ok());
1333 let r = result.unwrap();
1334 assert!(!r.should_query);
1335 assert!(r.result_text.as_ref().unwrap().contains("Commands are in the form"));
1336 }
1337
1338 #[tokio::test]
1339 async fn test_process_slash_command_unknown() {
1340 let context = ProcessUserInputContext::default();
1341 let result = process_slash_command(
1342 "/nonexistent-command".to_string(),
1343 vec![],
1344 vec![],
1345 vec![],
1346 &context,
1347 ).await;
1348 assert!(result.is_ok());
1349 let r = result.unwrap();
1350 assert!(!r.should_query);
1351 assert!(r.result_text.as_ref().unwrap().contains("Unknown skill"));
1352 }
1353
1354 #[tokio::test]
1355 async fn test_process_slash_command_known_local() {
1356 let mut commands = vec![];
1357 commands.push(serde_json::json!({"name": "clear", "type": "local"}));
1358 let context = ProcessUserInputContext {
1359 options: ProcessUserInputContextOptions {
1360 commands,
1361 ..Default::default()
1362 },
1363 ..Default::default()
1364 };
1365 let result = process_slash_command(
1366 "/clear".to_string(),
1367 vec![],
1368 vec![],
1369 vec![],
1370 &context,
1371 ).await;
1372 assert!(result.is_ok());
1373 let r = result.unwrap();
1374 assert!(!r.should_query);
1375 }
1376
1377 #[tokio::test]
1378 async fn test_process_bash_command_echo() {
1379 let context = ProcessUserInputContext::default();
1380 let result = process_bash_command(
1381 "echo hello".to_string(),
1382 vec![],
1383 vec![],
1384 &context,
1385 ).await;
1386 assert!(result.is_ok());
1387 let r = result.unwrap();
1388 assert!(!r.should_query);
1389 assert!(!r.messages.is_empty());
1390 }
1391}