From c8e90af91c326867b679055fb307834f82c5dd6c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 6 Sep 2026 18:43:09 +0100 Subject: [PATCH] feat(maestro): support Codex structured TUI --- crates/maestro-tui/src/app.rs | 4 +- crates/maestro-tui/src/draft.rs | 76 ++++++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 0ab87db..09ac938 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -3053,17 +3053,19 @@ mod tests { let request = |view| CreateSessionRequest { view, cwd: std::path::PathBuf::from("/srv/work"), - command: "claude".into(), + command: "codex".into(), env: Default::default(), metadata: Default::default(), worktree: None, }; let structured_id = Uuid::from_u128(188); + app.state.mode = Mode::Insert; app.submit_spawn(request(SessionView::Structured)).await; assert!(app.catalog.snapshot().sessions.is_empty()); assert!(app.structured.contains_key(&structured_id)); assert!(app.emulators.get(structured_id).is_none()); + assert_eq!(app.state.mode, Mode::Normal); let terminal_id = Uuid::from_u128(187); app.submit_spawn(request(SessionView::Terminal)).await; diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 5691731..b78b275 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -91,7 +91,7 @@ pub enum DraftField { /// Disclosure collapsing the optional view and worktree controls below. /// Closed by default; Enter / Space toggles. Advanced, - /// ACP-backed transcript instead of a terminal. Claude Code only. + /// ACP-backed transcript instead of a terminal. Claude Code or Codex. StructuredToggle, /// Worktree on/off for this session (#65). Enter / Space toggles. WorktreeToggle, @@ -256,7 +256,7 @@ pub struct DraftPane { /// take the default target. pub advanced_collapsed: bool, /// ACP-backed structured transcript. Off by default and supported only - /// when the resolved command is Claude Code. + /// when the resolved command is Claude Code or Codex. pub structured_view: bool, /// Enclosing repository of the chosen target — from the project scan for /// picked rows, `/api/gitroot` for raw paths. `None` until known. @@ -673,7 +673,7 @@ impl DraftPane { } fn supports_structured_view(&self) -> bool { - command_tool(self.command()) == Some("claude") + matches!(command_tool(self.command()), Some("claude" | "codex")) } fn clear_unsupported_structured_view(&mut self) { @@ -1888,11 +1888,28 @@ mod tests { } #[test] - fn structured_view_is_default_off_and_only_visible_for_claude_code() { + fn structured_view_is_default_off_and_visible_for_supported_agents() { let mut draft = DraftPane { advanced_collapsed: false, ..DraftPane::default() }; + draft.set_presets(vec![ + SpawnPreset { + id: "claude".into(), + label: "Claude".into(), + command: "claude".into(), + }, + SpawnPreset { + id: "codex".into(), + label: "Codex".into(), + command: "codex".into(), + }, + SpawnPreset { + id: "opencode".into(), + label: "OpenCode".into(), + command: "opencode".into(), + }, + ]); assert!(!draft.structured_view); assert!(draft.field_order().contains(&DraftField::StructuredToggle)); let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); @@ -1901,6 +1918,12 @@ mod tests { draft.toggle_structured_view(); assert!(draft.structured_view); draft.cycle_preset(1); + assert_eq!(draft.command(), "codex"); + assert!( + draft.structured_view, + "Codex keeps the structured selection" + ); + draft.cycle_preset(1); assert_eq!(draft.command(), "opencode"); assert!( !draft.structured_view, @@ -1928,9 +1951,9 @@ mod tests { /// 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. + /// env prefix, or a wrapper still resolves to Claude Code or Codex, 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)] = &[ @@ -1952,6 +1975,16 @@ mod tests { ("yarn --silent dlx claude-code", true), ("bun x claude-code", true), ("\"/opt/my agent/claude\" --resume", true), + ("codex", true), + ("codex --full-auto", true), + ("CODEX=1 codex", true), + ("/usr/local/bin/codex exec", true), + ("npx codex", true), + ("sudo -u root codex", true), + ("env -i codex", true), + ("command codex", true), + ("pnpm --silent dlx codex", true), + ("\"/opt/my agent/codex\" exec", true), ("bash", false), ("opencode", false), ("/usr/local/bin/bash -l", false), @@ -1999,7 +2032,7 @@ mod tests { /// 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. + /// Code or Codex through a wrapper still offers structured view. #[test] fn structured_view_follows_command_resolution_for_presets() { let mut draft = DraftPane { @@ -2014,6 +2047,11 @@ mod tests { label: "Claude (npx)".into(), command: "npx claude-code --resume".into(), }, + SpawnPreset { + id: "codex-wrapped".into(), + label: "Codex (npx)".into(), + command: "npx codex exec".into(), + }, SpawnPreset { id: "aider".into(), label: "Aider".into(), @@ -2027,7 +2065,13 @@ mod tests { 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 + // Switching between supported presets preserves the selection. + draft.cycle_preset(1); + assert_eq!(draft.command(), "npx codex exec"); + assert!(draft.structured_view); + assert_eq!(draft.build_request().unwrap().view, SessionView::Structured); + + // Switching to a preset that does not resolve to an ACP agent clears the // stale selection instead of letting an invalid request through. draft.cycle_preset(1); assert_eq!(draft.command(), "aider"); @@ -2036,11 +2080,11 @@ mod tests { assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal); } - /// Editing the custom-command text away from Claude clears an armed + /// Editing the custom-command text away from a supported agent 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() { + fn structured_view_clears_when_custom_command_edited_away_from_supported_agent() { let mut draft = DraftPane { picked_cwd: Some(PathBuf::from("/srv/work")), worktree_enabled: false, @@ -2049,22 +2093,22 @@ mod tests { ..DraftPane::default() }; draft.selected_preset = draft.presets.len() - 1; - draft.custom_command = crate::text_input::TextInput::with_value("claude"); + draft.custom_command = crate::text_input::TextInput::with_value("codex"); 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"); + assert_eq!(draft.command(), "codex -p"); + assert!(draft.structured_view, "still Codex, selection survives"); - for _ in 0.."claude -p".chars().count() { + for _ in 0.."codex -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" + "editing the program away from Codex clears the stale selection" ); assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal); }