feat: keep startup prompts interactive

This commit is contained in:
Nightt
2026-05-30 13:45:07 +08:00
committed by Hunter Bown
parent 43f098965e
commit fde5959e3d
5 changed files with 117 additions and 23 deletions
+14 -2
View File
@@ -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]
+28 -8
View File
@@ -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<String>,
@@ -427,6 +427,10 @@ fn join_prompt_parts(parts: &[String]) -> String {
parts.join(" ")
}
fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
(!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
}
fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
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<String>,
initial_input: Option<String>,
initial_input: Option<tui::InitialInput>,
) -> 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");
+61 -12
View File
@@ -814,7 +814,19 @@ pub struct TuiOptions {
/// Used by `deepseek pr <N>` (#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<String>,
pub initial_input: Option<InitialInput>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InitialInput {
/// Pre-populate the composer and wait for the user to press Enter.
///
/// Used by `codewhale pr <N>` (#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<String>,
/// 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 <N>` (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 <N>` (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));
+1 -1
View File
@@ -76,5 +76,5 @@ pub mod workspace_context;
// === Re-exports ===
pub use app::TuiOptions;
pub use app::{InitialInput, TuiOptions};
pub use ui::run_tui;
+13
View File
@@ -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,