diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 8f5cf96..31e84c9 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -1926,6 +1926,131 @@ mod tests { assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal); } + /// A custom command typed into the free-text field, run through the same + /// resolver the daemon/web gate uses (`command_tool`, #199): a path, an + /// env prefix, or a wrapper still resolves to Claude, so the toggle stays + /// offered and the request carries `Structured`; an unrelated program + /// does not. + #[test] + fn structured_view_follows_command_resolution_for_custom_commands() { + let cases: &[(&str, bool)] = &[ + ("claude", true), + ("claude --resume", true), + ("CLAUDE", true), + ("/usr/local/bin/claude -p hi", true), + ("./bin/claude", true), + ("FOO=1 BAR=2 claude", true), + ("npx claude-code", true), + ("sudo claude", true), + ("bash", false), + ("opencode", false), + ("/usr/local/bin/bash -l", false), + ]; + for &(command, supported) in cases { + let mut draft = DraftPane { + picked_cwd: Some(PathBuf::from("/srv/work")), + worktree_enabled: false, + advanced_collapsed: false, + ..DraftPane::default() + }; + draft.selected_preset = draft.presets.len() - 1; + draft.custom_command = crate::text_input::TextInput::with_value(command); + + assert_eq!( + draft.field_order().contains(&DraftField::StructuredToggle), + supported, + "toggle visibility for {command:?}" + ); + draft.toggle_structured_view(); + assert_eq!( + draft.structured_view, supported, + "toggle takes effect for {command:?}" + ); + let view = draft.build_request().unwrap().view; + assert_eq!( + view, + if supported { + SessionView::Structured + } else { + SessionView::Terminal + }, + "request view for {command:?}" + ); + } + } + + /// A configured preset (the spawn-form "alias" for a command) is gated + /// the same way as free text: a preset whose command resolves to Claude + /// through a wrapper still offers structured view. + #[test] + fn structured_view_follows_command_resolution_for_presets() { + let mut draft = DraftPane { + picked_cwd: Some(PathBuf::from("/srv/work")), + worktree_enabled: false, + advanced_collapsed: false, + ..DraftPane::default() + }; + draft.set_presets(vec![ + SpawnPreset { + id: "claude-wrapped".into(), + label: "Claude (npx)".into(), + command: "npx claude-code --resume".into(), + }, + SpawnPreset { + id: "aider".into(), + label: "Aider".into(), + command: "aider".into(), + }, + ]); + + assert_eq!(draft.command(), "npx claude-code --resume"); + assert!(draft.field_order().contains(&DraftField::StructuredToggle)); + draft.toggle_structured_view(); + assert!(draft.structured_view); + assert_eq!(draft.build_request().unwrap().view, SessionView::Structured); + + // Switching to a preset that does not resolve to Claude clears the + // stale selection instead of letting an invalid request through. + draft.cycle_preset(1); + assert_eq!(draft.command(), "aider"); + assert!(!draft.structured_view, "stale selection must clear"); + assert!(!draft.field_order().contains(&DraftField::StructuredToggle)); + assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal); + } + + /// Editing the custom-command text away from Claude clears an armed + /// selection immediately, matching the preset-switch case above — an + /// invalid `Structured` request can never reach `build_request`. + #[test] + fn structured_view_clears_when_custom_command_edited_away_from_claude() { + let mut draft = DraftPane { + picked_cwd: Some(PathBuf::from("/srv/work")), + worktree_enabled: false, + advanced_collapsed: false, + focused_field: DraftField::CustomCommand, + ..DraftPane::default() + }; + draft.selected_preset = draft.presets.len() - 1; + draft.custom_command = crate::text_input::TextInput::with_value("claude"); + draft.toggle_structured_view(); + assert!(draft.structured_view); + + typed(&mut draft, " -p"); + assert_eq!(draft.command(), "claude -p"); + assert!(draft.structured_view, "still Claude, selection survives"); + + for _ in 0.."claude -p".chars().count() { + draft.on_input(&InputKey::Backspace); + } + typed(&mut draft, "bash"); + assert_eq!(draft.command(), "bash"); + assert!( + !draft.structured_view, + "editing the program away from claude clears the stale selection" + ); + assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal); + } + #[test] fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() { let mut draft = DraftPane {