diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 9f98eebf..db6e8c4a 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -2958,11 +2958,15 @@ mod tests { } #[test] - fn parses_top_level_prompt_flag_for_canonical_one_shot() { + fn parses_top_level_prompt_flag_for_interactive_startup_prompt() { let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]); assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK.")); assert!(cli.prompt.is_empty()); + assert_eq!( + root_tui_passthrough(&cli).unwrap(), + vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()] + ); } #[test] @@ -2976,7 +2980,7 @@ mod tests { } #[test] - fn top_level_continue_rejects_one_shot_prompt() { + fn top_level_continue_rejects_startup_prompt() { let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]); let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected"); @@ -2992,6 +2996,10 @@ mod tests { assert_eq!(cli.prompt, vec!["hello", "world"]); assert!(cli.command.is_none()); + assert_eq!( + root_tui_passthrough(&cli).unwrap(), + vec!["--prompt".to_string(), "hello world".to_string()] + ); } #[test] @@ -3000,6 +3008,10 @@ mod tests { assert_eq!(cli.prompt_flag.as_deref(), Some("hello")); assert_eq!(cli.prompt, vec!["world"]); + assert_eq!( + root_tui_passthrough(&cli).unwrap(), + vec!["--prompt".to_string(), "hello world".to_string()] + ); } #[test] diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 4e5e35ae..476124ce 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -121,7 +121,7 @@ struct Cli { #[command(flatten)] feature_toggles: FeatureToggles, - /// Send a one-shot prompt (non-interactive) + /// Initial prompt to submit in the interactive TUI. Use `exec` for non-interactive runs. #[arg(short, long, value_name = "PROMPT", num_args = 1..)] prompt: Vec, @@ -427,6 +427,10 @@ fn join_prompt_parts(parts: &[String]) -> String { parts.join(" ") } +fn top_level_prompt_initial_input(parts: &[String]) -> Option { + (!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts))) +} + fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result> { if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) { return Ok(Some(id.clone())); @@ -975,12 +979,12 @@ async fn main() -> Result<()> { }; } - // One-shot prompt mode + // Top-level prompt mode: submit the initial prompt, then keep the TUI alive + // for follow-up messages. Use `codewhale exec` for explicit non-interactive + // one-shot behavior (#2370). let config = load_config_from_cli(&cli)?; - if !cli.prompt.is_empty() { - let prompt = join_prompt_parts(&cli.prompt); - let model = config.default_model(); - return run_one_shot(&config, &model, &prompt).await; + if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) { + return run_interactive(&cli, &config, None, Some(initial_input)).await; } // Handle session resume. Plain `codewhale` starts fresh: interrupted @@ -3783,7 +3787,13 @@ async fn run_pr( } else { cli.resume.clone() }; - run_interactive(cli, config, resume_session_id, Some(prompt)).await + run_interactive( + cli, + config, + resume_session_id, + Some(tui::InitialInput::Prefill(prompt)), + ) + .await } /// Return true if `name` resolves to an executable on the current `PATH`. @@ -4795,7 +4805,7 @@ async fn run_interactive( cli: &Cli, config: &Config, resume_session_id: Option, - initial_input: Option, + initial_input: Option, ) -> Result<()> { let workspace = cli .workspace @@ -5838,6 +5848,16 @@ mod terminal_mode_tests { assert_eq!(cli.prompt, vec!["hello", "world"]); } + #[test] + fn prompt_flag_starts_interactive_submit_input() { + let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]); + + assert_eq!( + top_level_prompt_initial_input(&cli.prompt), + Some(tui::InitialInput::Submit("read the project".to_string())) + ); + } + #[test] fn companion_binary_reports_its_own_name() { assert_eq!(Cli::command().get_name(), "codewhale-tui"); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 0bf82425..94a5c1f5 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -814,7 +814,19 @@ pub struct TuiOptions { /// Used by `deepseek pr ` (#451) to drop the model into a /// session with the PR context already typed — the user can edit /// before sending or hit Enter to fire as-is. - pub initial_input: Option, + pub initial_input: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InitialInput { + /// Pre-populate the composer and wait for the user to press Enter. + /// + /// Used by `codewhale pr ` (#451) to drop the model into a session + /// with the PR context already typed so the user can edit before sending. + Prefill(String), + /// Pre-populate the composer, submit it once startup is ready, then keep + /// the interactive session open for follow-up messages (#2370). + Submit(String), } #[derive(Debug, Clone, Copy)] @@ -1444,6 +1456,8 @@ pub struct App { /// Most recent user prompt accepted for an active engine turn. Ctrl+C can /// restore this into an empty composer after cancelling that turn. pub last_submitted_prompt: Option, + /// Startup prompt should be submitted automatically after the engine is ready. + pub auto_submit_initial_input: bool, /// Two-tap quit confirmation. When set, a prior Ctrl+C in idle state has /// armed the quit shortcut; a second Ctrl+C before this `Instant` exits /// the app, while expiry silently re-arms the prompt for next time. @@ -1803,17 +1817,22 @@ impl App { let cached_skills = Self::discover_cached_skills(&workspace, &skills_dir); let input_history = crate::composer_history::load_history(); - let (initial_input_text, initial_input_cursor) = match initial_input { - // #451: pre-populate the composer when invoked via - // `deepseek pr ` (or any future caller that wants to - // drop the model into a session with context already - // typed). Cursor lands at the end so Enter sends as-is. - Some(text) if !text.is_empty() => { - let cursor = text.len(); - (text, cursor) - } - _ => (String::new(), 0), - }; + let (initial_input_text, initial_input_cursor, auto_submit_initial_input) = + match initial_input { + // #451: pre-populate the composer when invoked via + // `deepseek pr ` (or any future caller that wants to + // drop the model into a session with context already + // typed). Cursor lands at the end so Enter sends as-is. + Some(InitialInput::Prefill(text)) if !text.is_empty() => { + let cursor = text.len(); + (text, cursor, false) + } + Some(InitialInput::Submit(text)) if !text.is_empty() => { + let cursor = text.len(); + (text, cursor, true) + } + _ => (String::new(), 0, false), + }; Self { mode: initial_mode, composer: ComposerState { @@ -2004,6 +2023,7 @@ impl App { coherence_state: CoherenceState::default(), last_send_at: None, last_submitted_prompt: None, + auto_submit_initial_input, quit_armed_until: None, cycle_count: 0, cycle_briefings: Vec::new(), @@ -4855,6 +4875,35 @@ mod tests { } } + #[test] + fn initial_input_prefill_waits_for_manual_submit() { + let mut options = test_options(false); + options.initial_input = Some(InitialInput::Prefill("review this PR".to_string())); + + let app = App::new(options, &Config::default()); + + assert_eq!(app.input, "review this PR"); + assert_eq!(app.cursor_position, "review this PR".chars().count()); + assert!(!app.auto_submit_initial_input); + } + + #[test] + fn initial_input_submit_marks_startup_dispatch() { + let mut options = test_options(false); + options.initial_input = Some(InitialInput::Submit( + "Read the project and wait for instructions".to_string(), + )); + + let app = App::new(options, &Config::default()); + + assert_eq!(app.input, "Read the project and wait for instructions"); + assert_eq!( + app.cursor_position, + "Read the project and wait for instructions".chars().count() + ); + assert!(app.auto_submit_initial_input); + } + #[test] fn composer_arrows_scroll_default_is_true_without_mouse_capture() { assert!(default_composer_arrows_scroll_for_platform(false, false)); diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index bfb2e477..3b20455f 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -76,5 +76,5 @@ pub mod workspace_context; // === Re-exports === -pub use app::TuiOptions; +pub use app::{InitialInput, TuiOptions}; pub use ui::run_tui; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index efbae714..e1a040a5 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -530,6 +530,19 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { persistence_actor::init_actor(handle); } + if app.auto_submit_initial_input { + app.auto_submit_initial_input = false; + if app.onboarding == OnboardingState::None { + if let Some(input) = app.submit_input() { + let queued = build_queued_message(&mut app, input); + dispatch_user_message(&mut app, config, &engine_handle, queued).await?; + } + } else if app.status_message.is_none() && !app.input.trim().is_empty() { + app.status_message = + Some("Initial prompt ready; complete setup, then press Enter".to_string()); + } + } + let result = run_event_loop( &mut terminal, &mut app,