From a5c9dac435ed0ce01fa1bcb87d72ba7add900df0 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Fri, 23 Oct 2026 11:09:31 +0800 Subject: [PATCH 1/5] fix(runtime): streamline recovery and preserve literal TUI input --- crates/astra-cli/src/tui/bottom_pane/mod.rs | 51 +++- .../bottom_pane/slash_integration_tests.rs | 71 +++-- crates/astra-cli/src/tui/event_loop.rs | 242 ++++++++++++++++-- crates/astra-cli/src/tui/slash_dispatch.rs | 59 ++++- crates/astra-cli/src/tui/slash_menu/menu.rs | 5 +- crates/astra-cli/src/tui/ui_adapter.rs | 6 +- crates/runtime/src/server/server_loop_host.rs | 48 +++- crates/services/src/inference_execution.rs | 154 ++++++++--- .../tests/inference_execution_db_it.rs | 72 ++++++ docs/design/context-and-prompt.md | 3 + docs/design/data-and-storage.md | 11 + docs/reference/cli-commands.md | 7 + 12 files changed, 622 insertions(+), 107 deletions(-) diff --git a/crates/astra-cli/src/tui/bottom_pane/mod.rs b/crates/astra-cli/src/tui/bottom_pane/mod.rs index c3e9c43188..5ee9d6831b 100644 --- a/crates/astra-cli/src/tui/bottom_pane/mod.rs +++ b/crates/astra-cli/src/tui/bottom_pane/mod.rs @@ -642,7 +642,9 @@ impl BottomPane { }; self.pending_user_intents.remove(index); self.applied_user_intent_ids.insert(intent_id.to_string()); - self.restore_into_composer(runtime_content); + self.restore_into_composer(&super::slash_dispatch::conversation_for_composer( + runtime_content, + )); true } @@ -650,15 +652,46 @@ impl BottomPane { /// preserving any draft the user is currently editing by appending /// beneath it. Never silently drop user input. pub fn restore_into_composer(&mut self, text: &str) { - if text.trim().is_empty() { - return; + self.restore_inputs_into_composer(std::iter::once(text)); + } + + /// Merge raw composer fragments before encoding the leading command + /// boundary. Joining escaped fragments first would leave interior escapes + /// in the model's message. Typed continuations use their captured target. + pub(crate) fn restore_inputs_into_composer( + &mut self, + inputs: impl IntoIterator>, + ) { + use super::slash_dispatch::{conversation_for_composer, parse_submission}; + let existing = self.composer.text(); + let parsed = parse_submission(&existing); + let mut literal = parsed.is_literal(); + let mut merged = parsed.text().to_owned(); + let mut changed = false; + let mut has_content = !merged.trim().is_empty(); + for input in inputs { + let text = input.as_ref(); + if text.trim().is_empty() { + continue; + } + let parsed = parse_submission(text); + if !has_content { + literal = parsed.is_literal(); + has_content = true; + } + if !merged.is_empty() { + merged.truncate(merged.trim_end().len()); + merged.push_str("\n\n"); + } + merged.push_str(parsed.text()); + changed = true; } - if self.composer.is_empty() { - self.composer.set_text(text); - } else { - let existing = self.composer.text(); - self.composer - .set_text(&format!("{}\n\n{}", existing.trim_end(), text)); + if changed { + if literal { + self.composer.set_text(&conversation_for_composer(&merged)); + } else { + self.composer.set_text(&merged); + } } } diff --git a/crates/astra-cli/src/tui/bottom_pane/slash_integration_tests.rs b/crates/astra-cli/src/tui/bottom_pane/slash_integration_tests.rs index 61572894d0..848cf4a7e1 100644 --- a/crates/astra-cli/src/tui/bottom_pane/slash_integration_tests.rs +++ b/crates/astra-cli/src/tui/bottom_pane/slash_integration_tests.rs @@ -281,8 +281,12 @@ fn enter_submits_selected_command() { } #[test] -fn command_arguments_are_submitted_unchanged() { +fn command_arguments_and_literal_slashes_are_submitted_unchanged() { for text in [ + "//", + "//stop", + "//help", + "//tmp/624/path", "/resume 01234567-89ab-4cde-8123-456789abcdef", "/model 5.2", "/unknown 123", @@ -292,34 +296,45 @@ fn command_arguments_are_submitted_unchanged() { "/mcp inspect github:list_prs 123 \"quoted argument\"", ] { for paste in [false, true] { - let mut bp = fresh(); - bp.set_slash_items(vec![ - SlashItem { - name: "/mcp".into(), - subcommands: &[("inspect", "Inspect server")], - extra_subcommands: vec![("inspect github:list_prs".into(), "List PRs".into())], - ..Default::default() - }, - SlashItem::simple("/resume", "Resume"), - SlashItem::simple("/model", "Model"), - SlashItem::simple("/help", "Help"), - ]); - if paste { - bp.handle_paste(text); - } else { - for c in text.chars() { - let event = if c == '\n' { - KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT) - } else { - key(c) - }; - let _ = bp.handle_key(event); + for followup in [false, true] { + let mut bp = fresh(); + bp.set_slash_items(vec![ + SlashItem { + name: "/mcp".into(), + subcommands: &[("inspect", "Inspect server")], + extra_subcommands: vec![( + "inspect github:list_prs".into(), + "List PRs".into(), + )], + ..Default::default() + }, + SlashItem::simple("/resume", "Resume"), + SlashItem::simple("/model", "Model"), + SlashItem::simple("/help", "Help"), + ]); + if paste { + bp.handle_paste(text); + } else { + for c in text.chars() { + let event = if c == '\n' { + KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT) + } else { + key(c) + }; + let _ = bp.handle_key(event); + } + } + assert!(!bp.slash_menu_is_open(), "{text:?}, paste={paste}"); + let submit = if followup { + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL) + } else { + special(KeyCode::Enter) + }; + match (bp.handle_key(submit), followup) { + (BottomPaneAction::SubmitInput(actual), false) + | (BottomPaneAction::QueueFollowUp(actual), true) => assert_eq!(actual, text), + other => panic!("expected unchanged submission, got {other:?}"), } - } - assert!(!bp.slash_menu_is_open(), "{text:?}, paste={paste}"); - match bp.handle_key(special(KeyCode::Enter)) { - BottomPaneAction::SubmitInput(actual) => assert_eq!(actual, text), - other => panic!("expected unchanged submission, got {other:?}"), } } } diff --git a/crates/astra-cli/src/tui/event_loop.rs b/crates/astra-cli/src/tui/event_loop.rs index 06bb15b648..cc37c3badb 100644 --- a/crates/astra-cli/src/tui/event_loop.rs +++ b/crates/astra-cli/src/tui/event_loop.rs @@ -1028,7 +1028,8 @@ fn should_queue_work_start_submission( // Commands that launch a turn are conversational despite a slash prefix. // Other slash commands are local controls and should stay responsive so // the user can inspect or explicitly switch Sessions while Work starts. - !trimmed.starts_with('/') || submission_starts_conversation(trimmed) + !slash_dispatch::parse_submission(trimmed).is_command() + || submission_starts_conversation(trimmed) } /// Returns whether a pristine Work start still owns the sessionless identity. @@ -1068,8 +1069,7 @@ fn release_work_start_submissions( } } - let restored = pending.drain(..).collect::>().join("\n\n"); - bottom_pane.restore_into_composer(&restored); + bottom_pane.restore_inputs_into_composer(pending.drain(..)); false } @@ -1091,7 +1091,7 @@ fn restore_work_start_submissions_after_scope_change( if restored.is_empty() { return 0; } - bottom_pane.restore_into_composer(&restored.join("\n\n")); + bottom_pane.restore_inputs_into_composer(&restored); restored.len() } @@ -2771,7 +2771,7 @@ fn restore_queued_input(entries: VecDeque, bottom_pane: &mut } } if !interactive.is_empty() { - bottom_pane.restore_into_composer(&interactive.join("\n\n")); + bottom_pane.restore_inputs_into_composer(interactive); } } @@ -2791,7 +2791,7 @@ fn active_submission_belongs_to_next_turn( output_has_settled: bool, foreground_lifecycle_transferred: bool, ) -> bool { - text.trim_start().starts_with('/') + slash_dispatch::parse_submission(text).is_command() || submission_belongs_to_next_turn(output_has_settled, foreground_lifecycle_transferred) } @@ -2896,7 +2896,7 @@ fn should_start_queued_followups( fn model_facing_followup_backlog(queued: &VecDeque) -> bool { queued.iter().any(|entry| { matches!(entry, NextTurnSubmission::Conversation { .. }) - || !entry.text().trim_start().starts_with('/') + || !slash_dispatch::parse_submission(entry.text()).is_command() }) } @@ -3070,7 +3070,9 @@ fn admit_idle_followup( fn submission_starts_conversation(text: &str) -> bool { let trimmed = text.trim_start(); slash_plan_goal(trimmed).is_some() - || (!trimmed.is_empty() && !trimmed.starts_with('/') && !trimmed.starts_with('!')) + || (!trimmed.is_empty() + && !slash_dispatch::parse_submission(trimmed).is_command() + && !trimmed.starts_with('!')) } /// Handle the explicit composer action before guidance admission. Local @@ -7145,12 +7147,15 @@ enum SubmissionProjection<'a> { } fn submission_projection(text: &str) -> SubmissionProjection<'_> { - if let Some(goal) = slash_plan_goal(text) { - SubmissionProjection::Conversation(goal) - } else if text.trim_start().starts_with('/') { - SubmissionProjection::LocalAction(text) - } else { - SubmissionProjection::Conversation(text) + match slash_dispatch::parse_submission(text) { + slash_dispatch::SubmissionInput::Command(text) => match slash_plan_goal(text) { + Some(goal) => SubmissionProjection::Conversation(goal), + None => SubmissionProjection::LocalAction(text), + }, + slash_dispatch::SubmissionInput::Conversation(text) + | slash_dispatch::SubmissionInput::Literal(text) => { + SubmissionProjection::Conversation(text) + } } } @@ -8400,7 +8405,7 @@ pub(crate) async fn run_tui_session( } let auto_submit = work_start_succeeded && work_is_current && stale.is_empty(); if !stale.is_empty() { - bottom_pane.restore_into_composer(&stale.join("\n\n")); + bottom_pane.restore_inputs_into_composer(stale); chat_widget.commit_system(history_cell::system::SystemCell::info( "A message queued for the previous Work Session was kept in the composer after the Session changed. Review it before sending.", )); @@ -8412,8 +8417,7 @@ pub(crate) async fn run_tui_session( &mut queued_followup_submissions, ) } else { - let restored = pending.drain(..).collect::>().join("\n\n"); - bottom_pane.restore_into_composer(&restored); + bottom_pane.restore_inputs_into_composer(pending.drain(..)); false }; if scheduled { @@ -8454,9 +8458,7 @@ pub(crate) async fn run_tui_session( .map(|submission| submission.text) .collect::>(); if !pending.is_empty() { - bottom_pane.restore_into_composer( - &pending.into_iter().collect::>().join("\n\n"), - ); + bottom_pane.restore_inputs_into_composer(pending); } chat_widget.commit_system(history_cell::system::SystemCell::error(format!( "Work start stopped before the Server replied: {error}. Your message is kept in the composer; retry `/work start ` when ready.", @@ -9009,7 +9011,7 @@ pub(crate) async fn run_tui_session( flush_chat_widget(&mut guard, &mut chat_widget, w); } - if continuation.is_none() && text.trim_start().starts_with('/') + if continuation.is_none() && slash_dispatch::parse_submission(&text).is_command() && inline_chat_submit.is_none() { // Snapshot the session identity before a @@ -9286,9 +9288,17 @@ pub(crate) async fn run_tui_session( } frame_requester.schedule_frame(); } else { + let parsed = slash_dispatch::parse_submission(&text); let literal_submission = continuation.is_some() - || inline_chat_submit.is_some(); - let submit_text = inline_chat_submit.unwrap_or(text); + || inline_chat_submit.is_some() + || parsed.is_literal(); + let submit_text = if let Some(goal) = inline_chat_submit { + goal + } else if continuation.is_some() { + text + } else { + parsed.text().to_owned() + }; if !runtime_notification_submission && crate::cli::plan::plan_lifecycle::looks_like_pending_local_plan_entry( &state, @@ -10127,7 +10137,7 @@ pub(crate) async fn run_tui_session( intent_id.clone(), astra_turn_types::UserIntentDelivery::GuideCurrentRun, astra_turn_types::UserIntentStatus::AcceptedLocal, - queued_text.clone(), + slash_dispatch::parse_submission(&queued_text).text().to_owned(), ) { // Empty input is not a guidance intent. It can be // produced by an internal wake-up submission and must @@ -10177,7 +10187,7 @@ pub(crate) async fn run_tui_session( remote_run_id: &submission_run_id, run_control: &submission_run_control, intent_id: &submission_intent_id, - text: &submission_text, + text: slash_dispatch::parse_submission(&submission_text).text(), background_work_snapshot: Some(&active_work_snapshot), work_unit_observations: &active_work_observations, }, @@ -11188,7 +11198,7 @@ pub(crate) async fn run_tui_session( &mut queued_followup_submissions, locally_owned_user_intents .iter() - .map(|intent| intent.text.clone()), + .map(|intent| slash_dispatch::conversation_for_composer(&intent.text).into_owned()), &mut post_output_submissions, should_start_followups, ) { @@ -22845,6 +22855,186 @@ mod tests { )); } + #[tokio::test] + async fn escaped_slash_auth_rejection_restores_a_message_not_a_command() { + for raw in ["//stop", "//plan inspect", "///tmp/path"] { + let effective = slash_dispatch::parse_submission(raw).text(); + let queue: RestoreInputQueue = Arc::new(std::sync::Mutex::new(VecDeque::new())); + let (tx, _rx) = tokio::sync::mpsc::channel(8); + let mut ui = ui_adapter::TuiUiAdapter::new(tx, queue.clone(), "escaped-submit", None); + let api = astra_thin_client::ThinClient::new("http://127.0.0.1:9", None).unwrap(); + let mut state = crate::cli::session::session_state::SessionState::default(); + let outcome = crate::cli::turn::turn_entry::handle_chat_input_with_ui( + crate::cli::turn::turn_entry::ChatInput::Conversation(effective.to_string()), + None, + crate::cli::session::session_runtime::AccessMiss::NotLoggedIn, + &mut state, + crate::cli::turn::turn_entry::TurnContext { + api: &api, + profile: None, + post_commit_tx: None, + admission: None, + explain_analyze_terminal_degraded: None, + }, + &mut ui, + ) + .await + .unwrap(); + assert!(matches!( + outcome, + crate::cli::turn::turn_entry::InteractiveTurnOutcome::NotStarted + )); + let request = take_restore_input_request(&queue).expect("rejected input retained"); + for (draft, prefix) in [ + ("", ""), + ("continue", "continue\n\n"), + ("//existing", "/existing\n\n"), + ] { + let mut pane = explicit_followup_pane(); + pane.composer.set_text(draft); + assert!(apply_restore_input_request( + &request, + None, + Some("escaped-submit"), + &mut pane, + &mut std::collections::HashSet::new(), + )); + let expected = format!("{prefix}{effective}"); + assert_eq!( + submission_projection(&pane.composer.text()), + SubmissionProjection::Conversation(&expected) + ); + } + assert_eq!(state.turn, 0); + } + } + + #[test] + fn escaped_slash_submissions_remain_literal_across_projection_and_queueing() { + for (raw, effective) in [ + ("//tmp/project inspect this", "/tmp/project inspect this"), + ("//plan inspect this", "/plan inspect this"), + ("//stop", "/stop"), + ("//", "/"), + ("//login", "/login"), + ("///tmp/project", "//tmp/project"), + (" //tmp/path", "/tmp/path"), + ] { + assert_eq!( + submission_projection(raw), + SubmissionProjection::Conversation(effective), + "{raw}" + ); + assert!(submission_starts_conversation(raw), "{raw}"); + assert!(should_flush_submission_immediately(raw), "{raw}"); + assert!(slash_dispatch::immediate_control(raw).is_none(), "{raw}"); + assert!(!slash_dispatch::is_model_picker_request(raw), "{raw}"); + assert!(!active_submission_belongs_to_next_turn(raw, false, false)); + assert!(should_queue_work_start_submission( + raw, + false, + true, + None, + Some(7), + 7 + )); + let guidance = LocalRunControl::guidance_input( + slash_dispatch::parse_submission(raw).text(), + None, + &[], + ) + .unwrap(); + assert_eq!(guidance["content"], effective); + let mut pane = explicit_followup_pane(); + pane.try_accept_user_intent( + "escaped-guidance", + astra_turn_types::UserIntentDelivery::GuideCurrentRun, + astra_turn_types::UserIntentStatus::AcceptedLocal, + effective, + ) + .unwrap(); + let local = pane.take_client_recoverable_user_intents(); + let mut followups = VecDeque::new(); + let mut post = VecDeque::new(); + settle_followup_submissions( + &mut followups, + local.iter().map(|intent| { + slash_dispatch::conversation_for_composer(&intent.text).into_owned() + }), + &mut post, + true, + ); + assert_eq!( + submission_projection(followups[0].text()), + SubmissionProjection::Conversation(effective) + ); + for (index, (draft, prefix)) in [ + ("", ""), + ("continue", "continue\n\n"), + ("//existing", "/existing\n\n"), + ] + .into_iter() + .enumerate() + { + pane.composer.set_text(draft); + let intent_id = format!("returned-guidance-{index}"); + pane.try_accept_user_intent( + &intent_id, + astra_turn_types::UserIntentDelivery::GuideCurrentRun, + astra_turn_types::UserIntentStatus::AcceptedRemote, + effective, + ) + .unwrap(); + assert!(pane.return_user_intent( + &intent_id, + astra_turn_types::UserIntentStatus::Returned, + effective + )); + let expected = format!("{prefix}{effective}"); + assert_eq!( + submission_projection(&pane.composer.text()), + SubmissionProjection::Conversation(&expected) + ); + } + let queued = VecDeque::from([NextTurnSubmission::Interactive(raw.to_string())]); + assert!(model_facing_followup_backlog(&queued)); + assert_eq!(queued.front().unwrap().text(), raw, "queue keeps raw input"); + let mut widget = chat_widget::ChatWidget::new(String::new()); + commit_submission_projection(&mut widget, raw); + let event = widget.history()[0].to_persist().unwrap(); + assert!(matches!(event, + crate::tui::turn_event::TurnEvent::User { text, .. } if text == effective + )); + } + for command in ["/stop", "/login", "/hlep", "/he"] { + assert!(matches!( + submission_projection(command), + SubmissionProjection::LocalAction(_) + )); + } + } + + #[test] + fn restored_fragments_decode_once_before_encoding_the_merged_boundary() { + for (draft, expected) in [ + ("", "/stop\n\n//tmp/624"), + ("continue", "continue\n\n/stop\n\n//tmp/624"), + ("//existing", "/existing\n\n/stop\n\n//tmp/624"), + (" ", "/stop\n\n//tmp/624"), + ] { + let mut pane = explicit_followup_pane(); + pane.composer.set_text(draft); + restore_queued_input( + interactive_queue(["//stop".into(), "///tmp/624".into()]), + &mut pane, + ); + assert_eq!( + submission_projection(&pane.composer.text()), + SubmissionProjection::Conversation(expected) + ); + } + } + #[test] fn submission_projection_separates_local_actions_from_user_turns() { let mut widget = chat_widget::ChatWidget::new(String::new()); diff --git a/crates/astra-cli/src/tui/slash_dispatch.rs b/crates/astra-cli/src/tui/slash_dispatch.rs index db8d81e175..ecd22d0b40 100644 --- a/crates/astra-cli/src/tui/slash_dispatch.rs +++ b/crates/astra-cli/src/tui/slash_dispatch.rs @@ -61,7 +61,58 @@ pub(crate) enum ImmediateControl { StopCurrentRun, } +/// Classify raw composer text before command dispatch. Queues retain the raw +/// spelling; literal escapes are removed only when projecting or delivering it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SubmissionInput<'a> { + Conversation(&'a str), + Literal(&'a str), + Command(&'a str), +} + +impl<'a> SubmissionInput<'a> { + pub(crate) fn text(self) -> &'a str { + match self { + Self::Conversation(text) | Self::Literal(text) | Self::Command(text) => text, + } + } + + pub(crate) fn is_command(self) -> bool { + matches!(self, Self::Command(_)) + } + + pub(crate) fn is_literal(self) -> bool { + matches!(self, Self::Literal(_)) + } +} + +pub(crate) fn parse_submission(text: &str) -> SubmissionInput<'_> { + let trimmed = text.trim_start(); + if trimmed.starts_with("//") { + SubmissionInput::Literal(&trimmed[1..]) + } else if trimmed.starts_with('/') { + SubmissionInput::Command(text) + } else { + SubmissionInput::Conversation(text) + } +} + +/// Return semantic conversation text to the command-aware composer. Custody +/// returns carry already decoded text, so escape it again before an Enter can +/// interpret it as a local control. +pub(crate) fn conversation_for_composer(text: &str) -> std::borrow::Cow<'_, str> { + let trimmed = text.trim_start(); + if trimmed.starts_with('/') { + std::borrow::Cow::Owned(format!("/{trimmed}")) + } else { + std::borrow::Cow::Borrowed(text) + } +} + pub(crate) fn immediate_control(text: &str) -> Option { + let SubmissionInput::Command(text) = parse_submission(text) else { + return None; + }; let (command, _args) = parse_slash(text); match command_registry::resolve_command(command).ok()? { "/exit" => Some(ImmediateControl::Exit), @@ -332,11 +383,11 @@ pub(crate) async fn dispatch(text: &str, ctx: &mut DispatchContext<'_>) -> Slash let hints: Vec<&'static str> = scored.into_iter().take(3).map(|(_, n)| n).collect(); if hints.is_empty() { ctx.show_error(format!( - "Unknown command: {cmd} · type / to browse all commands" + "Unknown command: {cmd} · type / to browse all commands\nFor literal slash text, use // (for example //tmp/path)." )); } else { ctx.show_error(format!( - "Unknown command: {cmd} · did you mean: {}?", + "Unknown command: {cmd} · did you mean: {}?\nFor literal slash text, use // (for example //tmp/path).", hints.join(", ") )); } @@ -2903,6 +2954,7 @@ fn keyboard_shortcut_pairs() -> Vec<(&'static str, String)> { "Ctrl+Q", "queue conversational input for the next turn while working; send normally when idle".into(), ), + ("//", "send literal slash text: //tmp/path sends /tmp/path; //stop stays a message".into()), ("Shift+Enter", "insert a newline in the composer".into()), ( "Ctrl+C", @@ -2975,6 +3027,9 @@ pub(crate) const MODEL_THINKING_PICKER_FOOTER_HINT: &str = /// from direct model selection so the event loop can fetch the catalog without /// borrowing its UI state across a network wait. pub(crate) fn is_model_picker_request(text: &str) -> bool { + let SubmissionInput::Command(text) = parse_submission(text) else { + return false; + }; let (command, args) = parse_slash(text); matches!(command_registry::resolve_command(command), Ok("/model")) && matches!(args.trim(), "" | "list") diff --git a/crates/astra-cli/src/tui/slash_menu/menu.rs b/crates/astra-cli/src/tui/slash_menu/menu.rs index 5c43e050ba..be3d43f118 100644 --- a/crates/astra-cli/src/tui/slash_menu/menu.rs +++ b/crates/astra-cli/src/tui/slash_menu/menu.rs @@ -73,7 +73,10 @@ impl SlashItem { /// Should the menu be open for the given composer buffer? pub(crate) fn is_open_for(buffer: &str, items: &[SlashItem]) -> bool { - if !buffer.starts_with('/') || buffer.contains(['\n', '\r']) { + if !buffer.starts_with('/') + || !crate::tui::slash_dispatch::parse_submission(buffer).is_command() + || buffer.contains(['\n', '\r']) + { return false; } let Some((command, suffix)) = buffer.split_once(char::is_whitespace) else { diff --git a/crates/astra-cli/src/tui/ui_adapter.rs b/crates/astra-cli/src/tui/ui_adapter.rs index 22fe9463b8..fb1c0251a9 100644 --- a/crates/astra-cli/src/tui/ui_adapter.rs +++ b/crates/astra-cli/src/tui/ui_adapter.rs @@ -60,7 +60,11 @@ impl ReplUiAdapter for TuiUiAdapter { fn restore_input(&mut self, text: &str, session_id: Option<&str>) -> bool { let request = RestoreInputRequest { - text: text.to_string(), + text: if self.continuation.is_some() { + text.to_string() + } else { + super::slash_dispatch::conversation_for_composer(text).into_owned() + }, continuation: self.continuation.clone(), session_id: session_id.map(str::to_string), submission_id: self.submission_id.clone(), diff --git a/crates/runtime/src/server/server_loop_host.rs b/crates/runtime/src/server/server_loop_host.rs index ce0546cdd7..6640dffafb 100644 --- a/crates/runtime/src/server/server_loop_host.rs +++ b/crates/runtime/src/server/server_loop_host.rs @@ -22921,6 +22921,17 @@ impl AgenticLoopHost for ServerAgenticLoopHost { ); return None; } + let spill_count = pre_turn_summary_spill_count(&state.messages); + if spill_count == 0 { + state.compaction_effectiveness.record_futile(); + tracing::debug!( + target: "astra::pre_turn_compaction", + pressure, + session_id = %self.session_id, + "pre-turn compaction skipped because no completed history prefix can be summarized" + ); + return None; + } let config = match self.resolve_llm_config_for_state(state).await { Ok(config) => config, Err(error) => { @@ -22984,17 +22995,6 @@ impl AgenticLoopHost for ServerAgenticLoopHost { }; let client = self.pre_turn_summary_client(&config, state, summary_tools, cache_capability)?; - let spill_count = pre_turn_summary_spill_count(&state.messages); - if spill_count == 0 { - state.compaction_effectiveness.record_futile(); - tracing::debug!( - target: "astra::pre_turn_compaction", - pressure, - session_id = %self.session_id, - "pre-turn compaction skipped because active canonical authority protects the entire history" - ); - return None; - } if let Some(summary_text) = astra_turn_core::cloud_summary::generate_inline_summary( &system_messages, &state.messages[..spill_count], @@ -52969,6 +52969,32 @@ mod tests { server.abort(); } + #[tokio::test] + #[serial_test::serial(auxiliary_llm_capacity_policy_env)] + async fn single_task_pre_turn_compaction_rejects_before_model_resolution() { + let _aux_policy = EnvVarGuard::set(AUX_LLM_POLICY_ENV, "always"); + let mut host = test_host_builder("user-no-prefix", "session-no-prefix").build(); + let mut state = create_test_state(); + state.messages = vec![json!({"role":"user","content":"Continue the long task"})]; + for i in 0..16 { + state + .messages + .push(json!({"role":"assistant","content":format!("Observation {i}")})); + } + let original = state.messages.clone(); + let event = ::maybe_pre_turn_compact( + &mut host, &mut state, 0.95, + ).await; + assert!(event.is_none()); + assert_eq!(state.messages, original); + assert!(host.resolved_llm_config.is_none()); + assert_eq!( + state.compaction_effectiveness.consecutive_futile_attempts, + 1 + ); + assert_eq!(state.compact_tier_applied, CompactionTier::Normal); + } + #[tokio::test] #[serial_test::serial(auxiliary_llm_capacity_policy_env)] async fn maybe_pre_turn_compact_requires_a_durable_inference_ledger() { diff --git a/crates/services/src/inference_execution.rs b/crates/services/src/inference_execution.rs index a12d859ca7..459eeaf484 100644 --- a/crates/services/src/inference_execution.rs +++ b/crates/services/src/inference_execution.rs @@ -3874,26 +3874,103 @@ pub async fn load_inference_canonical_transitions_for_session( )?; validate_identity(user_id, "user_id", 128)?; validate_identity(session_id, "session_id", 64)?; - if let Some(previous_turn) = first_turn.checked_sub(1) - && let Err(error) = retire_inference_canonical_transitions_through_turn( + // One statement snapshot replaces the separate retirement/reconciliation + // preflights for the common empty boundary. It grants no admission authority: + // nonempty recovery and subsequent provider admission retain their fences. + let mut snapshot_connection = CancellationSafePoolConnection::acquire(pool.get()) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "acquire canonical recovery preflight connection", + error, + ) + })?; + let snapshot: Option<(i64, i64, i64)> = sqlx::query_as( + "SELECT + CASE WHEN EXISTS (SELECT 1 FROM inference_canonical_transition_wal + WHERE user_id = ? AND session_id = ? AND turn_index < ?) + OR EXISTS (SELECT 1 FROM inference_canonical_transition_heads + WHERE user_id = ? AND session_id = ? AND turn_index < ?) + THEN 1 ELSE 0 END, + CASE WHEN EXISTS (SELECT 1 FROM inference_canonical_transition_wal + WHERE user_id = ? AND session_id = ? AND turn_index = ?) + OR EXISTS (SELECT 1 FROM inference_canonical_transition_heads + WHERE user_id = ? AND session_id = ? AND turn_index = ?) + THEN 1 ELSE 0 END, + CASE WHEN EXISTS (SELECT 1 FROM inference_invocations + WHERE user_id = ? AND session_id = ? AND turn_index >= ? + AND scope_kind = 'run' AND purpose = 'primary_agent' + AND status = 'admitted') + THEN 1 ELSE 0 END + FROM agent_sessions session_root + LEFT JOIN agent_session_lifecycle_fences fence + ON fence.user_id = session_root.user_id AND fence.session_id = session_root.session_id + WHERE session_root.user_id = ? AND session_root.session_id = ? + AND session_root.status <> 'deleting' + AND fence.delete_requested_at IS NULL AND fence.database_deleted_at IS NULL", + ) + .bind(user_id) + .bind(session_id) + .bind(i64::from(first_turn)) + .bind(user_id) + .bind(session_id) + .bind(i64::from(first_turn)) + .bind(user_id) + .bind(session_id) + .bind(i64::from(first_turn)) + .bind(user_id) + .bind(session_id) + .bind(i64::from(first_turn)) + .bind(user_id) + .bind(session_id) + .bind(i64::from(first_turn)) + .bind(user_id) + .bind(session_id) + .fetch_optional(snapshot_connection.connection_mut()) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "snapshot canonical transition recovery work", + error, + ) + })?; + snapshot_connection.release(); + let (absorbed, current, admitted) = snapshot.ok_or_else(|| { + ServiceError::not_found("canonical transition WAL session is unavailable") + })?; + if absorbed == 0 && current == 0 && admitted == 0 { + return Ok(Vec::new()); + } + if absorbed != 0 + && let Some(previous_turn) = first_turn.checked_sub(1) + && let Err(error) = retire_inference_canonical_transitions_through_turn_inner( pool, user_id, session_id, previous_turn, + false, ) .await { tracing::warn!( target: "astra_services::inference_execution", - user_id, - session_id, - previous_turn, - %error, + user_id, session_id, previous_turn, %error, "failed to retire already-absorbed provider canonical WAL; recovery continues" ); } - reconcile_provider_canonical_transition_boundary(pool.get(), user_id, session_id, first_turn) + // The snapshot checkout is released before recovery acquires connections. + // A racing admission is still rejected by the locked recheck below. + if admitted != 0 { + reconcile_provider_canonical_transition_boundary( + pool.get(), + user_id, + session_id, + first_turn, + ) .await?; + } // Reconciliation intentionally runs before acquiring the shared session // child-write fence because expired-owner recovery needs that same lock. // Once fenced, recheck admission inside this transaction: a provider @@ -4497,6 +4574,23 @@ pub async fn retire_inference_canonical_transitions_through_turn( user_id: &str, session_id: &str, through_turn: u32, +) -> ServiceResult { + retire_inference_canonical_transitions_through_turn_inner( + pool, + user_id, + session_id, + through_turn, + true, + ) + .await +} + +async fn retire_inference_canonical_transitions_through_turn_inner( + pool: &SharedPool, + user_id: &str, + session_id: &str, + through_turn: u32, + check_empty: bool, ) -> ServiceResult { validate_identity(user_id, "user_id", 128)?; validate_identity(session_id, "session_id", 64)?; @@ -4512,8 +4606,9 @@ pub async fn retire_inference_canonical_transitions_through_turn( // Most session boundaries have nothing left to retire. An empty, // writable snapshot needs no child-write transaction; nonempty or // unavailable snapshots still use the canonical locked boundary below. - let pending: Option = sqlx::query_scalar( - "SELECT CASE WHEN + if check_empty { + let pending: Option = sqlx::query_scalar( + "SELECT CASE WHEN EXISTS (SELECT 1 FROM inference_canonical_transition_wal WHERE user_id = ? AND session_id = ? AND turn_index <= ?) OR EXISTS (SELECT 1 FROM inference_canonical_transition_heads @@ -4525,27 +4620,28 @@ pub async fn retire_inference_canonical_transitions_through_turn( WHERE session_root.user_id = ? AND session_root.session_id = ? AND session_root.status <> 'deleting' AND fence.delete_requested_at IS NULL AND fence.database_deleted_at IS NULL", - ) - .bind(user_id) - .bind(session_id) - .bind(i64::from(through_turn)) - .bind(user_id) - .bind(session_id) - .bind(i64::from(through_turn)) - .bind(user_id) - .bind(session_id) - .fetch_optional(connection.connection_mut()) - .await - .map_err(|error| { - ServiceError::with_source( - ServiceErrorKind::Persistence, - "check absorbed inference canonical transition retirement work", - error, ) - })?; - if pending == Some(0) { - connection.release(); - return Ok(0); + .bind(user_id) + .bind(session_id) + .bind(i64::from(through_turn)) + .bind(user_id) + .bind(session_id) + .bind(i64::from(through_turn)) + .bind(user_id) + .bind(session_id) + .fetch_optional(connection.connection_mut()) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "check absorbed inference canonical transition retirement work", + error, + ) + })?; + if pending == Some(0) { + connection.release(); + return Ok(0); + } } let mut tx = connection.begin().await.map_err(|error| { ServiceError::with_source( diff --git a/crates/services/tests/inference_execution_db_it.rs b/crates/services/tests/inference_execution_db_it.rs index 607de051c3..714369922c 100644 --- a/crates/services/tests/inference_execution_db_it.rs +++ b/crates/services/tests/inference_execution_db_it.rs @@ -567,6 +567,78 @@ async fn empty_canonical_wal_recovery_reuses_its_physical_connection() { connection_id_before, connection_id_after, "successful empty WAL recovery must return its healthy connection to the pool" ); + let transition = astra_turn_types::ProviderCanonicalTransitionV2::new_from_durable_base( + None, + astra_turn_types::ProviderCanonicalWalBaseV2::from_messages(&[]).unwrap(), + &[serde_json::json!({"role":"user","content":"one-slot recovery"})], + vec![canonical_authority("one-slot")], + ) + .unwrap(); + commit_canonical_transition( + &shared_pool, + &user_id, + &session_id, + &run_id, + 0, + "one-slot", + &transition, + ) + .await; + let recovered = tokio::time::timeout( + std::time::Duration::from_secs(5), + load_inference_canonical_transitions_for_session(&shared_pool, &user_id, &session_id, 1), + ) + .await + .expect("nonempty recovery must release the snapshot checkout") + .unwrap(); + assert_eq!(recovered.len(), 1); + let retired = tokio::time::timeout( + std::time::Duration::from_secs(5), + load_inference_canonical_transitions_for_session(&shared_pool, &user_id, &session_id, 2), + ) + .await + .expect("retirement must acquire the one available connection") + .unwrap(); + assert!(retired.is_empty()); + let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM inference_canonical_transition_wal WHERE user_id = ? AND session_id = ?") + .bind(&user_id).bind(&session_id).fetch_one(pool).await.unwrap(); + assert_eq!( + rows, 0, + "absorbed WAL must be retired, not hidden by the empty path" + ); + + let foreign = load_inference_canonical_transitions_for_session( + &shared_pool, + "another-owner", + &session_id, + 0, + ) + .await + .expect_err("an empty snapshot must not hide owner mismatch"); + assert_eq!(foreign.kind, ServiceErrorKind::NotFound); + let missing = load_inference_canonical_transitions_for_session( + &shared_pool, + &user_id, + &format!("missing-{suffix}"), + 0, + ) + .await + .expect_err("an absent session is not empty recovery"); + assert_eq!(missing.kind, ServiceErrorKind::NotFound); + + // A committed deletion intent still wins when there is no WAL at all. + let fenced = sqlx::query("UPDATE agent_session_lifecycle_fences SET delete_requested_at = NOW(6) WHERE user_id = ? AND session_id = ?") + .bind(&user_id).bind(&session_id).execute(pool).await.unwrap(); + assert_eq!( + fenced.rows_affected(), + 1, + "fixture must install a real deletion fence" + ); + let deleted = + load_inference_canonical_transitions_for_session(&shared_pool, &user_id, &session_id, 0) + .await + .expect_err("empty recovery cannot bypass a lifecycle fence"); + assert_eq!(deleted.kind, ServiceErrorKind::NotFound); cleanup(pool, &user_id, &session_id, &run_id).await; } diff --git a/docs/design/context-and-prompt.md b/docs/design/context-and-prompt.md index 692f6fb5ce..544b4ff0c3 100644 --- a/docs/design/context-and-prompt.md +++ b/docs/design/context-and-prompt.md @@ -165,6 +165,9 @@ Compaction should preserve: Compaction should not turn transient tool output into permanent truth without attribution. Generate a compaction summary only after history actually changes. An elevated pressure tier alone does not authorize an LLM call for intact or protected history. +Pre-turn summarization rejects a zero spill prefix before resolving the model, +revalidating its Offering or assembling a summary request. This optimization +does not widen the prefix or preserve arbitrary single-task intermediate facts. ## Memory injection diff --git a/docs/design/data-and-storage.md b/docs/design/data-and-storage.md index c938f3e4a4..0fda9dd38a 100644 --- a/docs/design/data-and-storage.md +++ b/docs/design/data-and-storage.md @@ -288,3 +288,14 @@ transaction, and session deletion retains bounded owner/session cleanup. The durable run retry counter is retired from the fresh schema and Rust record. Verification retries retain their `retry_of` and `retry_scope` lineage; recovery generations and provider-attempt retry accounting remain separate authorities. + +## Empty provider canonical recovery + +The canonical transition loader classifies owner-scoped recovery work in one +statement snapshot. An existing writable Session with no admitted primary +invocation, no current WAL/head, and no absorbed WAL/head can return an empty +chain without a reconstruction transaction. Missing/deleting Sessions and +committed lifecycle fences fail closed. Nonempty state retains the existing +retirement, expired-owner recovery and fenced reconstruction boundaries. +The classification grants no inference authority; later provider admission +still validates its owner, generation, lease, control and canonical frontier. diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index fdb2afeba0..07dfef75de 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -305,3 +305,10 @@ level; verify the deployment's supported values before declaring its protocol. - Once a chat stream has started, `astra chat --json` and `--print --output-format json` also return a structured result on hard failure (exit code 3 for API failures). It retains the validated root run identity, partial output and observed usage, with `success: false` and partial evidence coverage. A request rejected before Run admission has no run identity; an error does not imply successful or resumable execution. - An unsupported local workspace coordination host fails before admission with exit code `1`, `error_kind: "tool_unavailable"`, `error_code: "workspace_host_unsupported"`, non-retryable metadata, and `server_admission_not_dispatched: true`. No model or tool ran. Interactive mode restores the draft and preserves recovery state. Human diagnostics remain on stderr in text, JSON, and stream-json modes. Repair the host prerequisite described in the diagnostic; changing workspace grants does not repair it. - Interactive mode launches the TUI (requires a TTY; use `astra chat -m` or `--print` for non-interactive invocations). There is no separate console slash frontend. + +In the interactive composer, a leading `/` selects a slash action. To send literal +slash text, escape it with another slash: `//tmp/project inspect this` sends +`/tmp/project inspect this`, and `//stop` sends `/stop` as conversation text. +The escape also applies to queued messages and guidance for an active run. +Literal text stays literal when rejected input returns to an existing draft. +Unknown or ambiguous single-slash actions still report a command error. From 8250f8ad6376edc880f3a6cb32af8e80b4511b91 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Fri, 23 Oct 2026 11:09:39 +0800 Subject: [PATCH 2/5] fix(cli): preserve literal slash text when editing the last message --- crates/astra-cli/src/tui/event_loop.rs | 164 ++++++++++++++++-- .../tests/inference_execution_db_it.rs | 26 +++ docs/reference/cli-commands.md | 3 + 3 files changed, 176 insertions(+), 17 deletions(-) diff --git a/crates/astra-cli/src/tui/event_loop.rs b/crates/astra-cli/src/tui/event_loop.rs index cc37c3badb..d762c73e71 100644 --- a/crates/astra-cli/src/tui/event_loop.rs +++ b/crates/astra-cli/src/tui/event_loop.rs @@ -4365,6 +4365,33 @@ fn handle_task_surface_refresh_shortcut( true } +/// Edit the latest semantic user message as a fresh turn. Keep the prior +/// transcript intact and leave overlays and in-flight drafts in control. +fn handle_edit_last_shortcut( + key: &crossterm::event::KeyEvent, + chat_widget: &chat_widget::ChatWidget, + bottom_pane: &mut BottomPane, + frame_requester: &FrameRequester, +) -> bool { + if key.code != crossterm::event::KeyCode::Char('r') + || !key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + || bottom_pane.has_active_view() + || !bottom_pane.composer.is_empty() + { + return false; + } + let Some(prev) = chat_widget.last_user_text() else { + return false; + }; + bottom_pane + .composer + .set_text(&slash_dispatch::conversation_for_composer(&prev)); + frame_requester.schedule_frame(); + true +} + fn handle_agent_monitor_shortcut( key: &crossterm::event::KeyEvent, chat_widget: &mut chat_widget::ChatWidget, @@ -8682,23 +8709,12 @@ pub(crate) async fn run_tui_session( ) { continue; } - // Ctrl+R: edit last — pull the most recent user - // message back into the composer so the user can - // re-word and resubmit without retyping. Works only - // when idle (no overlay, composer empty) so it - // doesn't clobber in-flight drafts. The prior - // scrollback stays visible: the retry runs as a - // fresh turn below, and the model sees the earlier - // attempt + its reply as context (which is the point - // — "try again, differently"). - if key.code == crossterm::event::KeyCode::Char('r') - && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) - && !bottom_pane.has_active_view() - && bottom_pane.composer.is_empty() - && let Some(prev) = chat_widget.last_user_text() - { - bottom_pane.composer.set_text(&prev); - frame_requester.schedule_frame(); + if handle_edit_last_shortcut( + &key, + &chat_widget, + &mut bottom_pane, + &frame_requester, + ) { continue; } if handle_agent_monitor_shortcut( @@ -22855,6 +22871,120 @@ mod tests { )); } + #[test] + fn edit_last_shortcut_resubmits_semantic_slash_text_without_local_actions() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + for (raw, semantic) in [ + ("//exit", "/exit"), + ("//stop", "/stop"), + ("//tmp/project inspect this", "/tmp/project inspect this"), + ("///tmp/project", "//tmp/project"), + ("//", "/"), + ("ordinary message", "ordinary message"), + ] { + let mut pane = BottomPane::new(); + pane.set_slash_items( + crate::cli::command_registry::tui_commands() + .filter(|meta| !meta.name.contains(' ')) + .map(|meta| slash_menu::SlashItem::simple(meta.name, meta.description)) + .collect(), + ); + let mut widget = chat_widget::ChatWidget::new("edit-session"); + for ch in raw.chars() { + pane.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + let BottomPaneAction::SubmitInput(text) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("initial message must submit: {raw}"); + }; + commit_submission_projection(&mut widget, &text); + assert_eq!(widget.last_user_text().as_deref(), Some(semantic)); + widget.handle_event(chat_widget::AppEvent::wire( + chat_widget::WireEvent::AnswerDelta { + model_item_id: None, + text: "Completed reply".into(), + }, + )); + widget.handle_event(chat_widget::AppEvent::wire( + chat_widget::WireEvent::TurnComplete(Box::default()), + )); + + // Repeated editing must not consume another slash or change the + // canonical transcript into composer syntax. + for _ in 0..2 { + assert!(handle_edit_last_shortcut( + &KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), + &widget, + &mut pane, + &FrameRequester::test_dummy(), + )); + let BottomPaneAction::SubmitInput(retry) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("edited conversation must submit: {raw}"); + }; + assert_eq!( + submission_projection(&retry), + SubmissionProjection::Conversation(semantic), + "edit last must preserve conversation meaning: {raw}", + ); + assert!(slash_dispatch::immediate_control(&retry).is_none()); + assert!(!slash_dispatch::parse_submission(&retry).is_command()); + commit_submission_projection(&mut widget, &retry); + assert_eq!(widget.last_user_text().as_deref(), Some(semantic)); + } + let user_texts: Vec<_> = widget + .history() + .iter() + .filter_map(|cell| match cell.to_persist() { + Some(crate::tui::turn_event::TurnEvent::User { text, .. }) => Some(text), + _ => None, + }) + .collect(); + assert_eq!(user_texts, vec![semantic; 3]); + } + } + + #[test] + fn edit_last_shortcut_preserves_drafts_overlays_and_empty_session() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let edit = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL); + let frame = FrameRequester::test_dummy(); + let mut widget = chat_widget::ChatWidget::new("edit-session"); + commit_submission_projection(&mut widget, "//exit"); + let mut pane = BottomPane::new(); + pane.composer.set_text("existing draft"); + assert!(!handle_edit_last_shortcut( + &edit, &widget, &mut pane, &frame + )); + assert_eq!(pane.composer.text(), "existing draft"); + pane.composer.set_text(""); + pane.push_view(Box::new(bottom_pane::info_view::InfoView::from_plain( + "Details", + vec!["Keep this view".into()], + ))); + assert!(!handle_edit_last_shortcut( + &edit, &widget, &mut pane, &frame + )); + assert!(pane.has_active_view()); + assert!(pane.composer.is_empty()); + + let mut pane = BottomPane::new(); + let other = chat_widget::ChatWidget::new("empty-other-session"); + assert!(!handle_edit_last_shortcut(&edit, &other, &mut pane, &frame)); + assert!(pane.composer.is_empty()); + assert!(!handle_edit_last_shortcut( + &KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), + &widget, + &mut pane, + &frame, + )); + assert!(pane.composer.is_empty()); + } + #[tokio::test] async fn escaped_slash_auth_rejection_restores_a_message_not_a_command() { for raw in ["//stop", "//plan inspect", "///tmp/path"] { diff --git a/crates/services/tests/inference_execution_db_it.rs b/crates/services/tests/inference_execution_db_it.rs index 714369922c..01efc5db05 100644 --- a/crates/services/tests/inference_execution_db_it.rs +++ b/crates/services/tests/inference_execution_db_it.rs @@ -543,6 +543,26 @@ async fn empty_canonical_wal_recovery_reuses_its_physical_connection() { let run_id = format!("empty-wal-run-{suffix}"); seed_run(pool, &user_id, &session_id, &run_id).await; + // Cancel while the new snapshot preflight waits for the only pool slot. + // Releasing that slot must still permit an independent query and recovery. + let held = pool.acquire().await.unwrap(); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(100), + load_inference_canonical_transitions_for_session( + &shared_pool, + &user_id, + &session_id, + 0, + ), + ) + .await + .is_err(), + "recovery must wait for the occupied snapshot checkout" + ); + drop(held); + assert_independent_query_can_replace_cancelled_checkout(pool).await; + let connection_id_before: u64 = sqlx::query_scalar("SELECT CONNECTION_ID()") .fetch_one(pool) .await @@ -640,6 +660,12 @@ async fn empty_canonical_wal_recovery_reuses_its_physical_connection() { .expect_err("empty recovery cannot bypass a lifecycle fence"); assert_eq!(deleted.kind, ServiceErrorKind::NotFound); cleanup(pool, &user_id, &session_id, &run_id).await; + pool.close().await; + let unavailable = + load_inference_canonical_transitions_for_session(&shared_pool, &user_id, &session_id, 0) + .await + .expect_err("preflight acquisition failure must not look like empty recovery"); + assert_eq!(unavailable.kind, ServiceErrorKind::Persistence); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 07dfef75de..85a9dadb77 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -311,4 +311,7 @@ slash text, escape it with another slash: `//tmp/project inspect this` sends `/tmp/project inspect this`, and `//stop` sends `/stop` as conversation text. The escape also applies to queued messages and guidance for an active run. Literal text stays literal when rejected input returns to an existing draft. +Ctrl+R also restores the latest conversation with its slash escape, so editing +and resending `/exit` text keeps it a message. The transcript shows the original +conversation text, without the composer escape. Unknown or ambiguous single-slash actions still report a command error. From 59b8889c07e7161c96e192b76290868d6640f014 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Fri, 23 Oct 2026 13:42:23 +0800 Subject: [PATCH 3/5] fix(cli): preserve unsent drafts across input recovery --- crates/astra-cli/src/cli/turn/turn_entry.rs | 15 +- .../src/tui/bottom_pane/chat_composer.rs | 20 +- .../bottom_pane/mention_integration_tests.rs | 16 ++ crates/astra-cli/src/tui/bottom_pane/mod.rs | 153 ++++++++++----- .../src/tui/bottom_pane/paste_burst.rs | 41 +++- .../tui/bottom_pane/queue_preview_tests.rs | 175 ++++++++++++++++++ .../astra-cli/src/tui/bottom_pane/textarea.rs | 27 ++- .../tui/bottom_pane/update_notice_tests.rs | 2 +- crates/astra-cli/src/tui/event_loop.rs | 96 +++++++++- crates/astra-cli/src/tui/slash_dispatch.rs | 5 +- .../src/tui/terminal_startup_pty_tests.rs | 80 ++++++++ docs/reference/cli-commands.md | 6 +- 12 files changed, 557 insertions(+), 79 deletions(-) diff --git a/crates/astra-cli/src/cli/turn/turn_entry.rs b/crates/astra-cli/src/cli/turn/turn_entry.rs index 7cf5f6fad8..c67d64a04a 100644 --- a/crates/astra-cli/src/cli/turn/turn_entry.rs +++ b/crates/astra-cli/src/cli/turn/turn_entry.rs @@ -34,6 +34,11 @@ pub(crate) enum ShellPassthroughDecision { DenyHighRisk { cmd: String, risks: Vec }, } +/// Syntactic shell boundary shared by dispatch and composer restoration. +pub(crate) fn shell_input_body(line: &str) -> Option<&str> { + line.trim_start().strip_prefix('!') +} + /// Classify a chat-input line that started with `!` (or `!!`). /// /// This is the policy hot path for the user-typed shell pass-through. @@ -50,12 +55,10 @@ pub(crate) enum ShellPassthroughDecision { /// - `!!` always allows the command after risk reporting — it's the /// "I see the warning, run it anyway" override. pub(crate) fn classify_shell_passthrough(line: &str) -> Option { - let trimmed = line.trim_start(); - let (override_high_risk, body) = if let Some(rest) = trimmed.strip_prefix("!!") { - (true, rest) - } else { - let rest = trimmed.strip_prefix('!')?; - (false, rest) + let body = shell_input_body(line)?; + let (override_high_risk, body) = match body.strip_prefix('!') { + Some(rest) => (true, rest), + None => (false, body), }; let cmd = body.trim(); if cmd.is_empty() { diff --git a/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs b/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs index 8cfd64a556..5ed6567358 100644 --- a/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs +++ b/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs @@ -107,7 +107,11 @@ impl ChatComposer { } pub fn is_empty(&self) -> bool { - self.textarea.is_empty() + self.textarea.is_empty() && !self.paste_burst_active() + } + + pub(crate) fn paste_burst_active(&self) -> bool { + self.textarea.paste_burst_active() } /// True when the user is browsing history (Up/Down navigation). @@ -146,6 +150,16 @@ impl ChatComposer { expanded } + /// Move editable content without creating history or submission feedback. + pub(crate) fn take_draft(&mut self) -> String { + let raw = self.textarea.take_text(); + let text = self.expand_pastes(&raw); + self.history_index = None; + self.draft = None; + self.pasted_blobs.clear(); + text + } + pub fn clear_draft(&mut self) { self.textarea.clear(); self.history_index = None; @@ -346,6 +360,10 @@ impl ChatComposer { self.prompt_prefix.width() as u16 } + pub(crate) fn insert_newline_if_pasting(&mut self) -> bool { + self.textarea.insert_newline_if_pasting() + } + pub fn flush_paste_burst(&mut self) -> bool { self.textarea.flush_paste_burst() } diff --git a/crates/astra-cli/src/tui/bottom_pane/mention_integration_tests.rs b/crates/astra-cli/src/tui/bottom_pane/mention_integration_tests.rs index 16332f36df..e7f6fa3a73 100644 --- a/crates/astra-cli/src/tui/bottom_pane/mention_integration_tests.rs +++ b/crates/astra-cli/src/tui/bottom_pane/mention_integration_tests.rs @@ -210,3 +210,19 @@ fn slash_and_mention_are_mutually_exclusive() { assert!(bp.slash_menu_is_open()); assert!(!bp.mention_menu_is_open()); } + +#[test] +fn restored_conversation_refreshes_mention_range_without_losing_content() { + let mut bp = fresh(); + type_string(&mut bp, "inspect @src"); + assert!(bp.mention_menu_is_open()); + bp.restore_into_composer("//returned"); + assert!( + !bp.mention_menu_is_open(), + "cursor moved beyond the old mention" + ); + let BottomPaneAction::SubmitInput(text) = bp.handle_key(special(KeyCode::Enter)) else { + panic!("recovered conversation must submit in full"); + }; + assert_eq!(text, "inspect @src\n\n/returned"); +} diff --git a/crates/astra-cli/src/tui/bottom_pane/mod.rs b/crates/astra-cli/src/tui/bottom_pane/mod.rs index 5ee9d6831b..8c182db7c9 100644 --- a/crates/astra-cli/src/tui/bottom_pane/mod.rs +++ b/crates/astra-cli/src/tui/bottom_pane/mod.rs @@ -122,6 +122,8 @@ pub(crate) struct BottomPane { /// current turn has committed its canonical boundary. These are next-turn /// submissions, not guidance for the previous run. queued_next_turn_submissions: std::collections::VecDeque, + /// Recovery is editable input, not authority for automatic next-turn replay. + restored_drafts: std::collections::VecDeque, applied_user_intent_ids: std::collections::HashSet, /// Typed actions emitted while a retained projection refreshes. These are /// not user-input events: for example, a live agent transcript upgrades @@ -297,6 +299,7 @@ impl BottomPane { approval_queue: ApprovalQueue::new(), pending_user_intents: std::collections::VecDeque::new(), queued_next_turn_submissions: std::collections::VecDeque::new(), + restored_drafts: std::collections::VecDeque::new(), applied_user_intent_ids: std::collections::HashSet::new(), projection_actions: std::collections::VecDeque::new(), model_catalog: None, @@ -648,51 +651,61 @@ impl BottomPane { true } - /// Restore queued-but-unapplied input into the composer at run end, - /// preserving any draft the user is currently editing by appending - /// beneath it. Never silently drop user input. + /// Return unapplied input for editing, without authorizing a new submission. pub fn restore_into_composer(&mut self, text: &str) { self.restore_inputs_into_composer(std::iter::once(text)); } - /// Merge raw composer fragments before encoding the leading command - /// boundary. Joining escaped fragments first would leave interior escapes - /// in the model's message. Typed continuations use their captured target. + /// Merge only adjacent conversation fragments. Commands and shell drafts + /// retain their own submission boundary and require a fresh gesture each. pub(crate) fn restore_inputs_into_composer( &mut self, inputs: impl IntoIterator>, ) { - use super::slash_dispatch::{conversation_for_composer, parse_submission}; - let existing = self.composer.text(); - let parsed = parse_submission(&existing); - let mut literal = parsed.is_literal(); - let mut merged = parsed.text().to_owned(); - let mut changed = false; - let mut has_content = !merged.trim().is_empty(); for input in inputs { let text = input.as_ref(); - if text.trim().is_empty() { - continue; - } - let parsed = parse_submission(text); - if !has_content { - literal = parsed.is_literal(); - has_content = true; + if !text.trim().is_empty() { + self.restored_drafts.push_back(text.to_owned()); } - if !merged.is_empty() { + } + self.restore_next_draft(); + } + + fn restore_next_draft(&mut self) -> bool { + use super::slash_dispatch::{conversation_for_composer, parse_submission}; + let conversation = |text: &str| { + !parse_submission(text).is_command() + && crate::cli::turn::turn_entry::shell_input_body(text).is_none() + }; + if self.restored_drafts.is_empty() || self.composer.paste_burst_active() { + return false; + } + let mut raw = self.composer.text(); + let mut changed = false; + if raw.trim().is_empty() { + raw = self.restored_drafts.pop_front().expect("nonempty drafts"); + changed = true; + } + if conversation(&raw) { + let mut merged = parse_submission(&raw).text().to_owned(); + while self + .restored_drafts + .front() + .is_some_and(|text| conversation(text)) + { + let next = self.restored_drafts.pop_front().expect("checked front"); merged.truncate(merged.trim_end().len()); merged.push_str("\n\n"); + merged.push_str(parse_submission(&next).text()); + changed = true; } - merged.push_str(parsed.text()); - changed = true; - } - if changed { - if literal { - self.composer.set_text(&conversation_for_composer(&merged)); - } else { - self.composer.set_text(&merged); + if changed { + self.replace_composer_text(&conversation_for_composer(&merged)); } + } else if changed { + self.replace_composer_text(&raw); } + changed } /// Drain active-run intents when the run settles. @@ -1463,7 +1476,11 @@ impl BottomPane { } } - if self.view_stack.is_empty() && text.starts_with('$') && !self.skill_items.is_empty() { + if self.view_stack.is_empty() + && text.starts_with('$') + && !text.contains(['\n', '\r']) + && !self.skill_items.is_empty() + { self.slash_menu = None; self.close_mention(); let popup = self @@ -1793,12 +1810,7 @@ impl BottomPane { // One quiet row above the composer is reserved for the context rail. // Keeping it stable avoids shifting the input when the first usage // measurement arrives; before that it simply acts as breathing room. - content_h - + approval_h - + queue_h - + popup_h - + 2 - + self.update_notice_lines(width).len() as u16 + content_h + approval_h + queue_h + popup_h + 2 + self.notice_lines(width).len() as u16 } /// Top-level key routing. Dispatches to named phase handlers so @@ -1811,6 +1823,17 @@ impl BottomPane { /// 5. Popup navigation (slash / skill / mention menus) /// 6. Composer (text input) pub fn handle_key(&mut self, key: KeyEvent) -> BottomPaneAction { + if self.composer.is_empty() { + self.restore_next_draft(); + } + let action = self.handle_key_inner(key); + if self.composer.is_empty() { + self.restore_next_draft(); + } + action + } + + fn handle_key_inner(&mut self, key: KeyEvent) -> BottomPaneAction { if let Some(a) = self.handle_ctrl_keys(key) { return a; } @@ -1826,13 +1849,21 @@ impl BottomPane { .is_some_and(|view| view.slash_reclaims_focus()) { self.pop_active_view(); - self.composer.set_text("/"); - self.sync_popups(); + self.start_composer_draft("/"); return BottomPaneAction::Consumed; } if let Some(a) = self.handle_active_view_key(key) { return a; } + // A trailing pasted newline belongs to editing, before a popup or + // approval can interpret Enter. The textarea consumes it atomically. + if key.code == KeyCode::Enter + && key.modifiers.is_empty() + && self.composer.insert_newline_if_pasting() + { + self.sync_popups(); + return BottomPaneAction::Consumed; + } if let Some(a) = self.handle_approval_keys(key) { return a; } @@ -2242,6 +2273,7 @@ impl BottomPane { // Flush paste burst buffer when idle timeout expires. if self.composer.flush_paste_burst() { self.sync_popups(); + self.restore_next_draft(); changed = true; } if self @@ -2254,6 +2286,15 @@ impl BottomPane { changed } + /// A command/view starts another draft without discarding ongoing editing. + pub(crate) fn start_composer_draft(&mut self, text: &str) { + let existing = self.composer.take_draft(); + if !existing.trim().is_empty() { + self.restored_drafts.push_front(existing); + } + self.replace_composer_text(text); + } + pub fn replace_composer_text(&mut self, text: &str) { self.composer.set_text(text); self.sync_popups(); @@ -2307,7 +2348,7 @@ impl BottomPane { // Keep installation notices below the composer/status strip, outside // conversation history. Modal views retain their existing layout. - let notice_lines = self.update_notice_lines(area.width); + let notice_lines = self.notice_lines(area.width); let notice_h = (notice_lines.len() as u16).min(area.height.saturating_sub(3)); let content_area = Rect::new(area.x, area.y, area.width, area.height - notice_h); ratatui::widgets::Paragraph::new(notice_lines).render( @@ -2368,17 +2409,27 @@ impl BottomPane { } } - fn update_notice_lines(&self, width: u16) -> Vec> { - let Some(notice) = &self.update_notice else { - return Vec::new(); - }; - super::wrapping::word_wrap_lines( - [Line::styled( - notice.clone(), - Style::default().fg(super::theme::current().warn), - )], - width.max(1) as usize, - ) + fn notice_lines(&self, width: u16) -> Vec> { + let style = Style::default().fg(super::theme::current().warn); + let mut lines = Vec::new(); + if let Some(notice) = &self.update_notice { + lines.push(Line::styled(notice.clone(), style)); + } + if let Some(next) = self.restored_drafts.front() { + let preview: String = next + .chars() + .take(60) + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .collect(); + lines.push(Line::styled( + format!( + "Unsent drafts ({}) · send or clear this draft to edit next: {preview}", + self.restored_drafts.len(), + ), + style, + )); + } + super::wrapping::word_wrap_lines(lines, width.max(1) as usize) } fn render_focused_approval(&self, area: Rect, buf: &mut Buffer) { @@ -2578,7 +2629,7 @@ impl BottomPane { } let notice_h = - (self.update_notice_lines(area.width).len() as u16).min(area.height.saturating_sub(3)); + (self.notice_lines(area.width).len() as u16).min(area.height.saturating_sub(3)); let area = Rect::new(area.x, area.y, area.width, area.height - notice_h); let approval_h = self.focused_approval_height(area.width); diff --git a/crates/astra-cli/src/tui/bottom_pane/paste_burst.rs b/crates/astra-cli/src/tui/bottom_pane/paste_burst.rs index 7c940598d5..fefccdcea9 100644 --- a/crates/astra-cli/src/tui/bottom_pane/paste_burst.rs +++ b/crates/astra-cli/src/tui/bottom_pane/paste_burst.rs @@ -91,26 +91,36 @@ impl PasteBurstDetector { .unwrap_or(u64::MAX); if idle > FLUSH_IDLE_MS { - let text = std::mem::take(&mut self.buffer); - self.active = false; - self.consecutive_fast_chars = 0; - self.last_flush_time = Some(now); - if text.is_empty() { None } else { Some(text) } + self.flush(now) } else { None } } + pub fn flush(&mut self, now: Instant) -> Option { + if !self.active { + return None; + } + let text = std::mem::take(&mut self.buffer); + self.active = false; + self.consecutive_fast_chars = 0; + self.last_flush_time = Some(now); + (!text.is_empty()).then_some(text) + } + pub fn is_active(&self) -> bool { self.active } - pub fn reset(&mut self) { + /// Replacing or clearing text must still reject a trailing pasted newline. + pub fn reset_preserving_enter_guard(&mut self) { + if self.active { + self.last_flush_time = Some(Instant::now()); + } self.last_char_time = None; self.consecutive_fast_chars = 0; self.buffer.clear(); self.active = false; - self.last_flush_time = None; } #[cfg(test)] @@ -132,6 +142,18 @@ mod tests { use super::{BurstDecision, PasteBurstDetector}; use std::time::{Duration, Instant}; + #[test] + fn text_replacement_preserves_the_flush_enter_guard() { + let now = Instant::now(); + let mut detector = PasteBurstDetector::new(); + detector.force_due_buffer_for_test("pasted", now); + assert_eq!(detector.flush_if_due(now).as_deref(), Some("pasted")); + detector.reset_preserving_enter_guard(); + assert!(!detector.is_active()); + assert!(detector.enter_should_insert_newline(now + Duration::from_millis(100))); + assert!(!detector.enter_should_insert_newline(now + Duration::from_millis(150))); + } + #[test] fn slow_typing_is_not_burst() { let mut d = PasteBurstDetector::new(); @@ -205,7 +227,7 @@ mod tests { } #[test] - fn reset_drops_active_buffer_and_timing_state() { + fn reset_drops_active_buffer_but_keeps_newline_protection() { let mut d = PasteBurstDetector::new(); let t0 = Instant::now(); d.on_char('a', t0); @@ -213,7 +235,8 @@ mod tests { d.on_char('c', t0 + Duration::from_millis(4)); assert!(d.is_active()); - d.reset(); + d.reset_preserving_enter_guard(); + assert!(d.enter_should_insert_newline(Instant::now())); assert!(!d.is_active()); assert!(d.flush_if_due(t0 + Duration::from_millis(20)).is_none()); diff --git a/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs b/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs index 19a31a8009..7a227e6f4e 100644 --- a/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs +++ b/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs @@ -603,3 +603,178 @@ fn snapshot_active_bottom_surface_narrow_42() { snapshot_text(&pane, Rect::new(0, 0, 42, 5)) ); } + +#[test] +fn restored_drafts_survive_menu_edits_and_explicit_clearing() { + use super::{BottomPaneAction, chat_composer::ChatComposer}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + for edit in [KeyCode::Tab, KeyCode::Char('1'), KeyCode::Esc] { + let mut pane = BottomPane::new(); + pane.composer = ChatComposer::new_ephemeral(); + pane.set_slash_items(vec![crate::tui::slash_menu::SlashItem::simple( + "/help", "help", + )]); + pane.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + pane.restore_into_composer("//returned"); + assert!( + pane.notice_lines(100) + .iter() + .any(|line| line.to_string().contains("Unsent drafts (1)")) + ); + pane.handle_key(KeyEvent::new(edit, KeyModifiers::NONE)); + assert_eq!(pane.restored_drafts.len(), 1); + assert!( + pane.slash_menu.is_none(), + "dismissed menus must stay dismissed" + ); + pane.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert_eq!(pane.composer.text(), "//returned"); + assert!(pane.restored_drafts.is_empty()); + let BottomPaneAction::SubmitInput(raw) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("restored input must still need Enter"); + }; + assert_eq!(raw, "//returned"); + } +} + +#[test] +fn restoring_multiline_conversation_closes_skill_completion() { + use super::{BottomPaneAction, chat_composer::ChatComposer, skill_popup::SkillItem}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut pane = BottomPane::new(); + pane.composer = ChatComposer::new_ephemeral(); + pane.set_skill_items(vec![SkillItem { + name: "inspect".into(), + description: "inspect".into(), + source: "test".into(), + }]); + for ch in "$inspect".chars() { + pane.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert!(pane.skill_popup.is_some()); + pane.restore_into_composer("//returned"); + assert!(pane.skill_popup.is_none()); + let BottomPaneAction::SubmitInput(raw) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("completion must not swallow multiline input"); + }; + assert_eq!(raw, "$inspect\n\n/returned"); +} + +#[test] +fn restoration_waits_for_buffered_paste_without_losing_its_bytes() { + use super::{BottomPaneAction, chat_composer::ChatComposer}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut pane = BottomPane::new(); + pane.composer = ChatComposer::new_ephemeral(); + pane.composer.force_pending_paste_burst_for_test( + "draft being pasted", + Instant::now() - std::time::Duration::from_secs(1), + ); + pane.restore_into_composer("//returned"); + assert!( + !pane.composer.is_empty(), + "buffered paste prevents replay and empty-input quit" + ); + assert!( + pane.composer.text().is_empty(), + "restoration must not reset a pending burst" + ); + pane.pre_draw_tick(Instant::now()); + assert!( + matches!( + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + BottomPaneAction::Consumed + ), + "a late pasted newline must not submit recovery" + ); + assert!(pane.composer.text().ends_with('\n')); + std::thread::sleep(std::time::Duration::from_millis(150)); + let BottomPaneAction::SubmitInput(raw) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("a later deliberate Enter must submit"); + }; + assert_eq!(raw.trim_end(), "draft being pasted\n\n/returned"); +} + +#[test] +fn starting_another_draft_preserves_paste_payloads_and_pending_bytes() { + use super::{BottomPaneAction, chat_composer::ChatComposer}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + for placeholder in [false, true] { + let mut pane = BottomPane::new(); + pane.composer = ChatComposer::new_ephemeral(); + let original = if placeholder { + "large pasted line\n".repeat(100) + } else { + "pending paste".into() + }; + if placeholder { + pane.handle_paste(&original); + assert!( + !pane.composer.text().contains(&original), + "fixture uses a compact placeholder" + ); + } else { + pane.composer + .force_pending_paste_burst_for_test(&original, Instant::now()); + } + pane.start_composer_draft("/help"); + assert_eq!(pane.composer.text(), "/help"); + pane.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert_eq!(pane.composer.text(), original); + if !placeholder { + std::thread::sleep(std::time::Duration::from_millis(150)); + } + let BottomPaneAction::SubmitInput(raw) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("cleared command must reveal an editable draft"); + }; + assert_eq!(raw, original); + } +} + +#[test] +fn clearing_a_paste_keeps_a_trailing_newline_from_submitting_recovery() { + use super::{BottomPaneAction, chat_composer::ChatComposer}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + for returned in ["//returned", "/help"] { + for clear in [ + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ] { + let mut pane = BottomPane::new(); + pane.composer = ChatComposer::new_ephemeral(); + pane.set_slash_items(vec![crate::tui::slash_menu::SlashItem::simple( + "/help", "help", + )]); + pane.composer + .force_pending_paste_burst_for_test("unfinished paste", Instant::now()); + pane.restore_into_composer(returned); + pane.handle_key(clear); + assert_eq!(pane.composer.text(), returned); + assert_eq!(pane.slash_menu_is_open(), returned == "/help"); + assert!( + matches!( + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + BottomPaneAction::Consumed + ), + "cancelled paste still needs protection against its trailing newline" + ); + assert_eq!(pane.composer.text(), format!("{returned}\n")); + assert!(!pane.slash_menu_is_open()); + std::thread::sleep(std::time::Duration::from_millis(150)); + let BottomPaneAction::SubmitInput(raw) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("a later fresh gesture must submit recovery"); + }; + assert_eq!(raw.trim_end(), returned); + } + } +} diff --git a/crates/astra-cli/src/tui/bottom_pane/textarea.rs b/crates/astra-cli/src/tui/bottom_pane/textarea.rs index 9afef254e5..b507d26062 100644 --- a/crates/astra-cli/src/tui/bottom_pane/textarea.rs +++ b/crates/astra-cli/src/tui/bottom_pane/textarea.rs @@ -55,7 +55,7 @@ impl TextArea { self.text.clear(); self.cursor_pos = 0; self.preferred_col = None; - self.paste_burst.reset(); + self.paste_burst.reset_preserving_enter_guard(); self.invalidate_wrap(); } @@ -63,10 +63,19 @@ impl TextArea { self.text = text.to_string(); self.cursor_pos = self.text.len(); self.preferred_col = None; - self.paste_burst.reset(); + self.paste_burst.reset_preserving_enter_guard(); self.invalidate_wrap(); } + pub fn take_text(&mut self) -> String { + if let Some(pending) = self.paste_burst.flush(Instant::now()) { + self.insert_str(&pending); + } + let text = std::mem::take(&mut self.text); + self.set_text(""); + text + } + /// Current cursor byte offset within `text()`. pub fn cursor_byte(&self) -> usize { self.cursor_pos @@ -105,6 +114,16 @@ impl TextArea { self.paste_burst.force_due_buffer_for_test(text, now); } + /// Consume a guarded Enter before a popup can treat it as submission. + pub fn insert_newline_if_pasting(&mut self) -> bool { + if self.paste_burst.enter_should_insert_newline(Instant::now()) { + self.insert_char('\n'); + true + } else { + false + } + } + pub fn handle_key(&mut self, key: KeyEvent) -> TextAreaAction { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let shift = key.modifiers.contains(KeyModifiers::SHIFT); @@ -116,9 +135,7 @@ impl TextArea { TextAreaAction::Changed } KeyCode::Enter => { - let now = Instant::now(); - if self.paste_burst.enter_should_insert_newline(now) { - self.insert_char('\n'); + if self.insert_newline_if_pasting() { TextAreaAction::Changed } else { TextAreaAction::Submit diff --git a/crates/astra-cli/src/tui/bottom_pane/update_notice_tests.rs b/crates/astra-cli/src/tui/bottom_pane/update_notice_tests.rs index b2731dad4b..66a32abe3d 100644 --- a/crates/astra-cli/src/tui/bottom_pane/update_notice_tests.rs +++ b/crates/astra-cli/src/tui/bottom_pane/update_notice_tests.rs @@ -34,7 +34,7 @@ fn update_notice_stays_below_composer_and_status_without_consuming_draft() { assert!(text.find("test-model").unwrap() < text.find("Update available").unwrap()); assert_eq!(pane.composer.text(), "my unsent draft"); let (_, cursor_y) = pane.cursor_position(area).unwrap(); - assert!(cursor_y < height - pane.update_notice_lines(width).len() as u16); + assert!(cursor_y < height - pane.notice_lines(width).len() as u16); pane.update_notice = None; assert_eq!(pane.desired_height(width), before); diff --git a/crates/astra-cli/src/tui/event_loop.rs b/crates/astra-cli/src/tui/event_loop.rs index d762c73e71..4fa3beaa08 100644 --- a/crates/astra-cli/src/tui/event_loop.rs +++ b/crates/astra-cli/src/tui/event_loop.rs @@ -7263,7 +7263,7 @@ enum LocalShellSubmission { } fn classify_local_shell_submission(text: &str) -> LocalShellSubmission { - let Some(command) = text.trim_start().strip_prefix('!') else { + let Some(command) = crate::cli::turn::turn_entry::shell_input_body(text) else { return LocalShellSubmission::NotShell; }; let command = command.trim(); @@ -11828,7 +11828,7 @@ pub(crate) async fn run_tui_session( // Keep the continuation explicit and editable. The // actual Work turn is submitted only after the user // writes a message and presses Enter. - bottom_pane.composer.set_text(&format!( + bottom_pane.start_composer_draft(&format!( "/work continue {} ", work_id )); @@ -23165,6 +23165,98 @@ mod tests { } } + #[test] + fn returned_literal_waits_for_a_fresh_gesture_after_command_drafts() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + for draft in [ + "/help", + "/model chosen", + "/he", + "/unknown", + "/plan inspect", + "!echo held", + ] { + for followup in [false, true] { + let key = if followup { + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL) + } else { + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE) + }; + let mut pane = explicit_followup_pane(); + pane.set_slash_items(vec![slash_menu::SlashItem::simple("/help", "help")]); + pane.try_accept_user_intent( + "returned-literal", + astra_turn_types::UserIntentDelivery::GuideCurrentRun, + astra_turn_types::UserIntentStatus::AcceptedRemote, + "/tmp/project inspect this", + ) + .unwrap(); + for ch in draft.chars() { + pane.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert!(pane.return_user_intent( + "returned-literal", + astra_turn_types::UserIntentStatus::Returned, + "/tmp/project inspect this", + )); + assert!(!pane.return_user_intent( + "returned-literal", + astra_turn_types::UserIntentStatus::Returned, + "/tmp/project inspect this", + )); + assert_eq!(pane.composer.text(), draft); + let first = pane.handle_key(key); + let submitted = match first { + BottomPaneAction::SubmitInput(text) if !followup => text, + BottomPaneAction::QueueFollowUp(text) if followup => text, + other => panic!("expected draft submission, got {other:?}"), + }; + assert_eq!(submitted, if draft == "/he" { "/help" } else { draft }); + assert_eq!(pane.queued_next_turn_submission_count(), 0); + assert_eq!(pane.composer.text(), "//tmp/project inspect this"); + assert!(!pane.slash_menu_is_open()); + let second = pane.handle_key(key); + let restored = match second { + BottomPaneAction::SubmitInput(text) if !followup => text, + BottomPaneAction::QueueFollowUp(text) if followup => text, + other => panic!("expected separately submitted return, got {other:?}"), + }; + assert_eq!( + submission_projection(&restored), + SubmissionProjection::Conversation("/tmp/project inspect this") + ); + assert!(pane.composer.is_empty()); + } + } + } + + #[test] + fn restored_mixed_inputs_preserve_order_and_dispatch_boundaries() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut pane = explicit_followup_pane(); + pane.composer.set_text("existing"); + restore_queued_input( + interactive_queue([ + "//one".into(), + "/help".into(), + "///two".into(), + "!echo held".into(), + ]), + &mut pane, + ); + for expected in ["existing\n\n/one", "/help", "//two", "!echo held"] { + let BottomPaneAction::SubmitInput(raw) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("each restored draft requires its own Enter"); + }; + assert_eq!(slash_dispatch::parse_submission(&raw).text(), expected); + } + assert!(pane.composer.is_empty()); + assert_eq!(pane.queued_next_turn_submission_count(), 0); + } + #[test] fn submission_projection_separates_local_actions_from_user_turns() { let mut widget = chat_widget::ChatWidget::new(String::new()); diff --git a/crates/astra-cli/src/tui/slash_dispatch.rs b/crates/astra-cli/src/tui/slash_dispatch.rs index ecd22d0b40..d35d574c88 100644 --- a/crates/astra-cli/src/tui/slash_dispatch.rs +++ b/crates/astra-cli/src/tui/slash_dispatch.rs @@ -739,8 +739,7 @@ pub(crate) async fn dispatch(text: &str, ctx: &mut DispatchContext<'_>) -> Slash .iter() .filter(|manifest| manifest.user_invocable) .count(); - ctx.bottom_pane.composer.set_text("$"); - ctx.bottom_pane.sync_popups(); + ctx.bottom_pane.start_composer_draft("$"); ctx.show_response(format!( "Opened skills · {skill_count} available · type to filter" )); @@ -1647,7 +1646,7 @@ pub(crate) fn handle_display_view_result(result: ViewResult, bottom_pane: &mut B )); } ViewResult::InsertCommand(command) => { - bottom_pane.composer.set_text(&format!("{command} ")); + bottom_pane.start_composer_draft(&format!("{command} ")); } _ => {} } diff --git a/crates/astra-cli/src/tui/terminal_startup_pty_tests.rs b/crates/astra-cli/src/tui/terminal_startup_pty_tests.rs index 11d56b2977..38edc0d99f 100644 --- a/crates/astra-cli/src/tui/terminal_startup_pty_tests.rs +++ b/crates/astra-cli/src/tui/terminal_startup_pty_tests.rs @@ -175,6 +175,79 @@ fn probe_child() { } } startup.handoff(); + if case == "draft_recovery" { + use crate::tui::{ + bottom_pane::{BottomPane, BottomPaneAction}, + slash_dispatch, + }; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + for draft_command in ["/skill", "/stats"] { + let mut pane = BottomPane::new(); + pane.composer = crate::tui::bottom_pane::chat_composer::ChatComposer::new_ephemeral(); + pane.set_slash_items(vec![crate::tui::slash_menu::SlashItem::simple( + "/skill", "skills", + )]); + for ch in draft_command.chars() { + pane.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + pane.restore_into_composer("//returned"); + let BottomPaneAction::SubmitInput(command) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("expected slash command"); + }; + assert_eq!(pane.composer.text(), "//returned"); + let mut state = crate::cli::session::session_state::SessionState::default(); + let mut widget = crate::tui::chat_widget::ChatWidget::new(""); + let api = astra_thin_client::ThinClient::new("http://127.0.0.1:9", None).unwrap(); + let mut retries = std::collections::BTreeMap::new(); + runtime.block_on(async { + let result = slash_dispatch::dispatch( + &command, + &mut slash_dispatch::DispatchContext { + api: &api, + profile: None, + state: &mut state, + guard: &mut guard, + bottom_pane: &mut pane, + chat_widget: &mut widget, + width: 120, + client_id: "draft-recovery", + pending_work_retries: &mut retries, + }, + ) + .await; + assert!(matches!(result, slash_dispatch::SlashResult::Handled)); + }); + if draft_command == "/stats" { + assert!(pane.has_active_view()); + pane.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + assert!(!pane.has_active_view()); + assert_eq!(pane.composer.text(), "/"); + } else { + assert_eq!(pane.composer.text(), "$"); + } + pane.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert_eq!(pane.composer.text(), "//returned"); + let BottomPaneAction::SubmitInput(returned) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("recovery needs a new submission gesture"); + }; + assert_eq!(returned, "//returned"); + assert_eq!(pane.queued_next_turn_submission_count(), 0); + } + drop(guard); + drop(startup); + let after = nix::sys::termios::tcgetattr(std::io::stdin()).unwrap(); + println!( + "{RESULT}{}", + serde_json::json!({ + "restored": terminal_modes_restored(&before, &after), "draft_recovered": true, + }) + ); + return; + } if case == "late_da1" { assert!(sixel_before.is_none()); let image = tempfile::NamedTempFile::new().unwrap(); @@ -699,6 +772,7 @@ fn run_case(case: &str) -> (Value, Vec) { | "early_palette" | "early_theme" | "keyboard_enhancement_panic" + | "draft_recovery" ) { return (value, output); } @@ -901,3 +975,9 @@ fn pty_resize_cursor_query_preserves_input_and_handles_missing_reply() { run_case(case); } } + +#[test] +fn pty_command_and_picker_navigation_preserve_recovered_drafts() { + let (value, _) = run_case("draft_recovery"); + assert_eq!(value["draft_recovered"], true); +} diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 85a9dadb77..5bb64fcf48 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -310,7 +310,11 @@ In the interactive composer, a leading `/` selects a slash action. To send liter slash text, escape it with another slash: `//tmp/project inspect this` sends `/tmp/project inspect this`, and `//stop` sends `/stop` as conversation text. The escape also applies to queued messages and guidance for an active run. -Literal text stays literal when rejected input returns to an existing draft. +Returned conversation can merge with an existing conversation draft. Commands +and shell drafts stay separate: “Unsent drafts” shows retained input, which +moves into the composer after the current draft is sent or cleared. Each draft +requires a new submission gesture; restoration never sends it automatically. +These editable drafts remain local to the running client. Ctrl+R also restores the latest conversation with its slash escape, so editing and resending `/exit` text keeps it a message. The transcript shows the original conversation text, without the composer escape. From 433185f24f2fef1714a30c8195496944822163e7 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Fri, 23 Oct 2026 15:11:01 +0800 Subject: [PATCH 4/5] fix(cli): retain FIFO custody and complete draft state --- .../src/tui/bottom_pane/chat_composer.rs | 25 +- crates/astra-cli/src/tui/bottom_pane/mod.rs | 5 +- .../tui/bottom_pane/queue_preview_tests.rs | 93 +++ crates/astra-cli/src/tui/event.rs | 8 +- crates/astra-cli/src/tui/event_loop.rs | 573 +++++++++++++----- docs/design/client-surfaces-and-deployment.md | 3 + docs/reference/cli-commands.md | 3 + 7 files changed, 533 insertions(+), 177 deletions(-) diff --git a/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs b/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs index 5ed6567358..2584638952 100644 --- a/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs +++ b/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs @@ -150,14 +150,27 @@ impl ChatComposer { expanded } - /// Move editable content without creating history or submission feedback. - pub(crate) fn take_draft(&mut self) -> String { + /// Transfer all unsent editing content before clearing its history/paste + /// state. An unchanged recalled entry remains available in history. + pub(crate) fn take_recoverable_drafts(&mut self) -> Vec { let raw = self.textarea.take_text(); - let text = self.expand_pastes(&raw); + let visible = self.expand_pastes(&raw); + let mut drafts = Vec::with_capacity(2); + let unchanged_recall = self + .history_index + .is_some_and(|i| self.history[i] == visible); + if !unchanged_recall && !visible.trim().is_empty() { + drafts.push(visible); + } + if let Some(raw) = self.draft.take() { + let suspended = self.expand_pastes(&raw); + if !suspended.trim().is_empty() && !drafts.contains(&suspended) { + drafts.push(suspended); + } + } self.history_index = None; - self.draft = None; self.pasted_blobs.clear(); - text + drafts } pub fn clear_draft(&mut self) { @@ -451,7 +464,7 @@ impl ChatComposer { } match self.history_index { None => { - self.draft = Some(self.textarea.text().to_string()); + self.draft = Some(self.textarea.take_text()); self.history_index = Some(self.history.len() - 1); } Some(0) => return, diff --git a/crates/astra-cli/src/tui/bottom_pane/mod.rs b/crates/astra-cli/src/tui/bottom_pane/mod.rs index 8c182db7c9..35d44114a8 100644 --- a/crates/astra-cli/src/tui/bottom_pane/mod.rs +++ b/crates/astra-cli/src/tui/bottom_pane/mod.rs @@ -2288,9 +2288,8 @@ impl BottomPane { /// A command/view starts another draft without discarding ongoing editing. pub(crate) fn start_composer_draft(&mut self, text: &str) { - let existing = self.composer.take_draft(); - if !existing.trim().is_empty() { - self.restored_drafts.push_front(existing); + for draft in self.composer.take_recoverable_drafts().into_iter().rev() { + self.restored_drafts.push_front(draft); } self.replace_composer_text(text); } diff --git a/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs b/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs index 7a227e6f4e..a77b48efc9 100644 --- a/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs +++ b/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs @@ -778,3 +778,96 @@ fn clearing_a_paste_keeps_a_trailing_newline_from_submitting_recovery() { } } } + +#[test] +fn picker_navigation_transfers_suspended_history_drafts_and_pastes() { + use super::{BottomPaneAction, chat_composer::ChatComposer, info_view::InfoView}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + for (original, recalled_edit) in [ + ("original draft".to_owned(), None), + ( + "original draft".to_owned(), + Some("edited history".to_owned()), + ), + ("same".to_owned(), Some("same".to_owned())), + (String::new(), None), + ( + "original paste\n".repeat(100), + Some("edited paste\n".repeat(100)), + ), + ] { + let mut pane = BottomPane::new(); + pane.composer = ChatComposer::new_ephemeral(); + pane.handle_paste("previous message"); + assert!(matches!( + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + BottomPaneAction::SubmitInput(_) + )); + pane.handle_paste(&original); + pane.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + if let Some(edit) = &recalled_edit { + pane.composer.set_text(""); + pane.handle_paste(edit); + } + pane.push_view(Box::new(InfoView::from_plain( + "Memory Stats", + vec!["ready".into()], + ))); + pane.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + assert!(!pane.has_active_view()); + assert_eq!(pane.composer.text(), "/"); + let mut expected = Vec::new(); + if let Some(edit) = recalled_edit { + expected.push(edit); + } + if !original.is_empty() && !expected.contains(&original) { + expected.push(original); + } + assert_eq!( + pane.restored_drafts.iter().cloned().collect::>(), + expected + ); + pane.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + if expected.is_empty() { + assert!(pane.composer.is_empty()); + } else { + let BottomPaneAction::SubmitInput(raw) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("fresh gesture submits retained editing"); + }; + assert_eq!( + raw, + expected + .iter() + .map(|s| s.trim_end()) + .collect::>() + .join("\n\n") + + if expected.last().is_some_and(|s| s.ends_with('\n')) { + "\n" + } else { + "" + } + ); + } + } +} + +#[test] +fn history_suspension_transfers_pending_paste_bytes_before_up() { + use super::chat_composer::ChatComposer; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut pane = BottomPane::new(); + pane.composer = ChatComposer::new_ephemeral(); + pane.composer.set_text("previous"); + pane.composer.clear_and_submit(); + pane.composer.set_text("original "); + pane.composer + .force_pending_paste_burst_for_test("pending bytes", Instant::now()); + pane.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + pane.start_composer_draft("/help"); + assert_eq!( + pane.restored_drafts.iter().cloned().collect::>(), + vec!["original pending bytes"] + ); +} diff --git a/crates/astra-cli/src/tui/event.rs b/crates/astra-cli/src/tui/event.rs index d228444dae..19a504eef6 100644 --- a/crates/astra-cli/src/tui/event.rs +++ b/crates/astra-cli/src/tui/event.rs @@ -28,11 +28,9 @@ pub(crate) enum TuiEvent { /// into the composer would turn presentation bytes into a control /// protocol and could collide with real user input. RuntimeNotificationTurn, - /// Literal, owner-bound next-turn input; never a composer key event. - SessionContinuation { - target: crate::tui::event_loop::SessionContinuationTarget, - content: String, - }, + /// A wake for the existing FIFO. The input stays owned by that queue + /// until the captured attachment and input gates are checked again. + QueuedSubmission(crate::tui::event_loop::QueuedSubmissionContext), } pub(crate) struct TuiEventStream { diff --git a/crates/astra-cli/src/tui/event_loop.rs b/crates/astra-cli/src/tui/event_loop.rs index 4fa3beaa08..0574c25320 100644 --- a/crates/astra-cli/src/tui/event_loop.rs +++ b/crates/astra-cli/src/tui/event_loop.rs @@ -1048,10 +1048,10 @@ fn work_start_identity_pending( } /// Move messages held behind a pristine Work start back into the ordinary -/// submit lane. The first message is scheduled immediately once the Work -/// Session is attached; the rest retain FIFO ordering in the existing -/// follow-up queue. If the user has a draft, append the held messages instead -/// of overwriting it or submitting under an unexpected Session. +/// follow-up FIFO. Its common typed wake handles the first message once the +/// Work Session is attached, retaining the same input gates and ordering. +/// If the user has a draft, return the held messages for editing instead of +/// overwriting it or submitting under an unexpected Session. fn release_work_start_submissions( pending: &mut VecDeque, bottom_pane: &mut BottomPane, @@ -1061,12 +1061,8 @@ fn release_work_start_submissions( return false; } if bottom_pane.composer.is_empty() { - if let Some(first) = pending.pop_front() { - bottom_pane.composer.set_text(&first); - queued_followup_submissions - .extend(pending.drain(..).map(NextTurnSubmission::Interactive)); - return true; - } + queued_followup_submissions.extend(pending.drain(..).map(NextTurnSubmission::Interactive)); + return true; } bottom_pane.restore_inputs_into_composer(pending.drain(..)); @@ -2918,10 +2914,6 @@ fn local_slash_releases_followups( #[derive(Debug, PartialEq, Eq)] enum QueuedFollowupRelease { SubmitNext, - Conversation { - target: SessionContinuationTarget, - content: String, - }, HeldWithDraft, Idle, } @@ -2938,16 +2930,8 @@ fn release_next_queued_followup( )); return QueuedFollowupRelease::HeldWithDraft; } - if let Some(next_turn_submission) = queued_followup_submissions.pop_front() { - return match next_turn_submission { - NextTurnSubmission::Interactive(text) => { - bottom_pane.composer.set_text(&text); - QueuedFollowupRelease::SubmitNext - } - NextTurnSubmission::Conversation { target, content } => { - QueuedFollowupRelease::Conversation { target, content } - } - }; + if !queued_followup_submissions.is_empty() { + return QueuedFollowupRelease::SubmitNext; } QueuedFollowupRelease::Idle } @@ -2961,16 +2945,13 @@ fn release_next_queued_followup( struct FollowupReleaseGate { model_catalog_loading: bool, background_reads_in_flight: bool, + foreground_busy: bool, session_rebound: bool, } #[derive(Debug, PartialEq, Eq)] enum QueuedFollowupHandoff { SubmitNext, - Conversation { - target: SessionContinuationTarget, - content: String, - }, HeldWithDraft, RestoredAfterSessionChange, Held, @@ -2996,24 +2977,77 @@ fn handoff_queued_followups( if bottom_pane.has_active_view() || gate.model_catalog_loading || gate.background_reads_in_flight + || gate.foreground_busy { return QueuedFollowupHandoff::Held; } match release_next_queued_followup(queued_followup_submissions, bottom_pane, chat_widget) { QueuedFollowupRelease::SubmitNext => QueuedFollowupHandoff::SubmitNext, - QueuedFollowupRelease::Conversation { target, content } => { - QueuedFollowupHandoff::Conversation { target, content } - } QueuedFollowupRelease::HeldWithDraft => QueuedFollowupHandoff::HeldWithDraft, QueuedFollowupRelease::Idle => QueuedFollowupHandoff::Idle, } } +/// The queued wake captures attachment authority, never another copy of input. +#[derive(Debug)] +pub(crate) struct QueuedSubmissionContext { + session_id: Option, + attachment_epoch: u64, + owner: crate::cli::cli_config::cli_utils::CliOwnerAuthSnapshot, +} + +impl QueuedSubmissionContext { + fn capture(state: &crate::cli::session::session_state::SessionState) -> Self { + Self { + session_id: state.session_id.clone(), + attachment_epoch: state.session_attachment_epoch, + owner: crate::cli::cli_config::cli_utils::cli_owner_auth_snapshot(), + } + } + + fn is_attached(&self, session_id: Option<&str>, attachment_epoch: u64) -> bool { + self.session_id.as_deref() == session_id + && self.attachment_epoch == attachment_epoch + && self.owner.is_current() + } +} + +fn consume_queued_handoff( + context: QueuedSubmissionContext, + current_attachment: (Option<&str>, u64), + queue: &mut VecDeque, + bottom_pane: &mut BottomPane, + chat_widget: &mut chat_widget::ChatWidget, + armed: &mut bool, + mut gate: FollowupReleaseGate, +) -> Option { + if !std::mem::take(armed) { + return None; + } + gate.session_rebound |= !context.is_attached(current_attachment.0, current_attachment.1); + if handoff_queued_followups(queue, bottom_pane, chat_widget, gate) + != QueuedFollowupHandoff::SubmitNext + { + return None; + } + let next = queue.pop_front()?; + if let NextTurnSubmission::Conversation { target, .. } = &next + && !target + .0 + .is_attached(current_attachment.0, current_attachment.1) + { + restore_queued_input(VecDeque::from([next]), bottom_pane); + return None; + } + Some(next) +} + fn resume_queued_followups_after_modal( queued_followup_submissions: &mut VecDeque, bottom_pane: &mut BottomPane, chat_widget: &mut chat_widget::ChatWidget, event_stream: &mut TuiEventStream, + state: &crate::cli::session::session_state::SessionState, followup_replay_armed: &mut bool, gate: FollowupReleaseGate, ) -> QueuedFollowupHandoff { @@ -3021,6 +3055,7 @@ fn resume_queued_followups_after_modal( handoff_queued_followups(queued_followup_submissions, bottom_pane, chat_widget, gate); schedule_queued_handoff( &handoff, + state, |event| event_stream.push_front(event), followup_replay_armed, ); @@ -3029,35 +3064,29 @@ fn resume_queued_followups_after_modal( fn schedule_queued_handoff( handoff: &QueuedFollowupHandoff, + state: &crate::cli::session::session_state::SessionState, deliver: impl FnOnce(TuiEvent), followup_replay_armed: &mut bool, ) { - let event = match handoff { - QueuedFollowupHandoff::SubmitNext => TuiEvent::Key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Enter, - crossterm::event::KeyModifiers::NONE, - )), - QueuedFollowupHandoff::Conversation { target, content } => TuiEvent::SessionContinuation { - target: target.clone(), - content: content.clone(), - }, - _ => return, - }; + if *followup_replay_armed || *handoff != QueuedFollowupHandoff::SubmitNext { + return; + } *followup_replay_armed = true; - deliver(event); + deliver(TuiEvent::QueuedSubmission( + QueuedSubmissionContext::capture(state), + )); } /// A model-facing message typed while an older backlog is still held must -/// join that FIFO. Local controls and the one injected replay may proceed. +/// join that FIFO. Local controls and the typed FIFO consumption may proceed. fn admit_idle_followup( text: &str, - followup_replay_armed: &mut bool, + queued_replay: bool, queued_followup_submissions: &mut VecDeque, bottom_pane: &mut BottomPane, ) -> bool { queued_followup_submissions.extend(bottom_pane.take_queued_next_turn_submissions()); - if *followup_replay_armed { - *followup_replay_armed = false; + if queued_replay { return false; } if !submission_starts_conversation(text) || queued_followup_submissions.is_empty() { @@ -3106,6 +3135,7 @@ fn followup_release_gate( FollowupReleaseGate { model_catalog_loading, background_reads_in_flight: background_reads_in_flight > 0, + foreground_busy: false, session_rebound: false, } } @@ -3116,21 +3146,13 @@ fn turn_settlement_followup_handoff( chat_widget: &mut chat_widget::ChatWidget, model_catalog_loading: bool, background_reads_in_flight: usize, - followup_replay_armed: &mut bool, ) -> QueuedFollowupHandoff { - let handoff = handoff_queued_followups( + handoff_queued_followups( queued_followup_submissions, bottom_pane, chat_widget, followup_release_gate(model_catalog_loading, background_reads_in_flight), - ); - if matches!( - handoff, - QueuedFollowupHandoff::SubmitNext | QueuedFollowupHandoff::Conversation { .. } - ) { - *followup_replay_armed = true; - } - handoff + ) } fn followup_gate_after_auth( @@ -8293,6 +8315,7 @@ pub(crate) async fn run_tui_session( &mut bottom_pane, &mut chat_widget, &mut event_stream, + &state, &mut followup_replay_armed, followup_release_gate(model_catalog_loading, slash_background_read_count), ); @@ -8450,6 +8473,7 @@ pub(crate) async fn run_tui_session( if scheduled { schedule_queued_handoff( &QueuedFollowupHandoff::SubmitNext, + &state, |event| event_stream.push_front(event), &mut followup_replay_armed, ); @@ -8513,6 +8537,7 @@ pub(crate) async fn run_tui_session( &mut bottom_pane, &mut chat_widget, &mut event_stream, + &state, &mut followup_replay_armed, followup_release_gate(model_catalog_loading, slash_background_read_count), ); @@ -8530,6 +8555,7 @@ pub(crate) async fn run_tui_session( &mut bottom_pane, &mut chat_widget, &mut event_stream, + &state, &mut followup_replay_armed, followup_release_gate(model_catalog_loading, slash_background_read_count), ); @@ -8577,22 +8603,27 @@ pub(crate) async fn run_tui_session( event => event, }; match ev { - event @ (TuiEvent::Key(_) | TuiEvent::SessionContinuation { .. }) => { + event @ (TuiEvent::Key(_) | TuiEvent::QueuedSubmission(_)) => { let runtime_notification_submission = runtime_notification_event && runtime_notification_turn_pending; + let queued_replay = matches!(event, TuiEvent::QueuedSubmission(_)); let (continuation, bottom_pane_action) = match event { - TuiEvent::SessionContinuation { target, content } => { - followup_replay_armed = false; - if !target.0.is_attached(state.session_id.as_deref(), state.session_attachment_epoch) - || bottom_pane.has_active_view() - || !bottom_pane.composer.is_empty() - || !login_tasks.is_empty() - { - restore_queued_input(VecDeque::from([NextTurnSubmission::Conversation { target, content }]), &mut bottom_pane); + TuiEvent::QueuedSubmission(context) => { + let mut gate = followup_release_gate(model_catalog_loading, slash_background_read_count); + gate.foreground_busy = !login_tasks.is_empty(); + let next = consume_queued_handoff( + context, (state.session_id.as_deref(), state.session_attachment_epoch), + &mut queued_followup_submissions, &mut bottom_pane, &mut chat_widget, + &mut followup_replay_armed, gate, + ); + let Some(next) = next else { frame_requester.schedule_frame(); continue; + }; + match next { + NextTurnSubmission::Interactive(text) => (None, BottomPaneAction::SubmitInput(text)), + NextTurnSubmission::Conversation { target, content } => (Some(target), BottomPaneAction::SubmitInput(content)), } - (Some(target), BottomPaneAction::SubmitInput(content)) } TuiEvent::Key(key) => { if !login_tasks.is_empty() { @@ -8892,7 +8923,7 @@ pub(crate) async fn run_tui_session( if continuation.is_none() && admit_idle_followup( &text, - &mut followup_replay_armed, + queued_replay, &mut queued_followup_submissions, &mut bottom_pane, ) { @@ -9291,6 +9322,7 @@ pub(crate) async fn run_tui_session( &mut bottom_pane, &mut chat_widget, &mut event_stream, + &state, &mut followup_replay_armed, followup_gate, ); @@ -10417,9 +10449,14 @@ pub(crate) async fn run_tui_session( let _ = do_draw(&mut guard, frame.active, frame.multi_agent, frame.explain_analyze, &mut bottom_pane, Some((&*task_board, board_expanded)), frame.task_board); } } - TuiEvent::SessionContinuation { target, content } => { - followup_replay_armed = false; - bottom_pane.queue_session_continuation(target, content); + TuiEvent::QueuedSubmission(context) => { + let mut gate = followup_release_gate(model_catalog_loading, slash_background_read_count); + gate.foreground_busy = true; + let _ = consume_queued_handoff( + context, (turn_session_id.as_deref(), turn_session_attachment_epoch), + &mut queued_followup_submissions, &mut bottom_pane, &mut chat_widget, + &mut followup_replay_armed, gate, + ); } TuiEvent::RuntimeNotificationTurn => { // Runtime-notification wakes are only scheduled by @@ -11406,9 +11443,8 @@ pub(crate) async fn run_tui_session( &mut chat_widget, model_catalog_loading, slash_background_read_count, - &mut followup_replay_armed, ); - schedule_queued_handoff(&handoff, |event| event_stream.push_front(event), &mut followup_replay_armed); + schedule_queued_handoff(&handoff, &state, |event| event_stream.push_front(event), &mut followup_replay_armed); if matches!( handoff, QueuedFollowupHandoff::HeldWithDraft @@ -11512,6 +11548,7 @@ pub(crate) async fn run_tui_session( &mut bottom_pane, &mut chat_widget, &mut event_stream, + &state, &mut followup_replay_armed, followup_release_gate( model_catalog_loading, @@ -11562,6 +11599,7 @@ pub(crate) async fn run_tui_session( &mut bottom_pane, &mut chat_widget, &mut event_stream, + &state, &mut followup_replay_armed, followup_gate_after_auth( auth_session_id.as_deref(), @@ -11616,6 +11654,7 @@ pub(crate) async fn run_tui_session( &mut bottom_pane, &mut chat_widget, &mut event_stream, + &state, &mut followup_replay_armed, followup_gate_after_auth( auth_session_id.as_deref(), @@ -11661,6 +11700,7 @@ pub(crate) async fn run_tui_session( &mut bottom_pane, &mut chat_widget, &mut event_stream, + &state, &mut followup_replay_armed, followup_release_gate( model_catalog_loading, @@ -11683,7 +11723,7 @@ pub(crate) async fn run_tui_session( flush_chat_widget(&mut guard, &mut chat_widget, w); resume_queued_followups_after_modal( &mut queued_followup_submissions, &mut bottom_pane, - &mut chat_widget, &mut event_stream, &mut followup_replay_armed, + &mut chat_widget, &mut event_stream, &state, &mut followup_replay_armed, followup_release_gate(model_catalog_loading, slash_background_read_count), ); } @@ -12008,10 +12048,12 @@ pub(crate) async fn run_tui_session( &mut bottom_pane, &mut chat_widget, &mut event_stream, + &state, &mut followup_replay_armed, FollowupReleaseGate { model_catalog_loading, background_reads_in_flight: slash_background_read_count > 0, + foreground_busy: false, session_rebound: followup_session_rebound, }, ); @@ -12205,6 +12247,7 @@ pub(crate) async fn run_tui_session( &mut bottom_pane, &mut chat_widget, &mut event_stream, + &state, &mut followup_replay_armed, followup_release_gate(model_catalog_loading, slash_background_read_count), ); @@ -13141,10 +13184,7 @@ mod tests { let mut pane = BottomPane::new(); pane.queue_session_continuation(target.clone(), "instruction A".into()); let mut queue = VecDeque::new(); - let mut armed = false; - assert!(admit_idle_followup( - later, &mut armed, &mut queue, &mut pane - )); + assert!(admit_idle_followup(later, false, &mut queue, &mut pane)); assert_eq!( queue, VecDeque::from([ @@ -13159,9 +13199,7 @@ mod tests { assert!(pane.composer.is_empty()); let before = queue.clone(); for control in ["/help", "/agent", "/agent list"] { - assert!(!admit_idle_followup( - control, &mut armed, &mut queue, &mut pane - )); + assert!(!admit_idle_followup(control, false, &mut queue, &mut pane)); assert_eq!(queue, before); } } @@ -13180,18 +13218,34 @@ mod tests { pane.queue_session_continuation(target.clone(), content.into()); pane.queue_next_turn_submission("last".into()); let mut queue = pane.take_queued_next_turn_submissions(); + let mut armed = true; assert_eq!( - release_next_queued_followup(&mut queue, &mut pane, &mut widget), - QueuedFollowupRelease::SubmitNext + consume_queued_handoff( + QueuedSubmissionContext::capture(&state), + (state.session_id.as_deref(), state.session_attachment_epoch), + &mut queue, + &mut pane, + &mut widget, + &mut armed, + followup_release_gate(false, 0) + ), + Some(NextTurnSubmission::Interactive("first".into())), ); - assert_eq!(pane.composer.text(), "first"); - pane.composer.set_text(""); + armed = true; assert_eq!( - release_next_queued_followup(&mut queue, &mut pane, &mut widget), - QueuedFollowupRelease::Conversation { + consume_queued_handoff( + QueuedSubmissionContext::capture(&state), + (state.session_id.as_deref(), state.session_attachment_epoch), + &mut queue, + &mut pane, + &mut widget, + &mut armed, + followup_release_gate(false, 0) + ), + Some(NextTurnSubmission::Conversation { target, content: content.into() - } + }), ); assert!(pane.composer.is_empty()); assert_eq!(queue, interactive_queue(["last".into()])); @@ -13226,6 +13280,7 @@ mod tests { FollowupReleaseGate { model_catalog_loading: false, background_reads_in_flight: false, + foreground_busy: false, session_rebound: true } ), @@ -14869,27 +14924,33 @@ mod tests { &mut pane, &mut followups, )); - assert_eq!(pane.composer.text(), "first instruction"); + assert!(pane.composer.is_empty()); + assert_eq!(followups.front().unwrap().text(), "first instruction"); assert!(pending.is_empty()); let mut scheduled = None; let mut armed = false; schedule_queued_handoff( &QueuedFollowupHandoff::SubmitNext, + &crate::cli::session::session_state::SessionState::default(), |event| scheduled = Some(event), &mut armed, ); assert!(armed); - assert!( - matches!(scheduled, Some(TuiEvent::Key(key)) if key.code == crossterm::event::KeyCode::Enter) - ); + assert!(matches!(scheduled, Some(TuiEvent::QueuedSubmission(_)))); assert!(!admit_idle_followup( "first instruction", - &mut armed, + true, &mut followups, &mut pane )); - assert!(!armed); - assert_eq!(interactive_texts(followups), vec!["second instruction"]); + assert!( + armed, + "physical admission does not consume the pending wake" + ); + assert_eq!( + interactive_texts(followups), + vec!["first instruction", "second instruction"] + ); let mut switched_pane = BottomPane::new(); switched_pane.composer.set_text("new Session draft"); @@ -15924,6 +15985,212 @@ mod tests { assert_eq!(pane.queued_next_turn_submission_count(), 3); } + #[test] + #[serial_test::serial] + fn queued_paste_replay_preserves_real_enter_guard_and_fifo_order() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut pane = explicit_followup_pane(); + let mut chat = chat_widget::ChatWidget::new(""); + pane.composer.force_pending_paste_burst_for_test( + "pasted request", + std::time::Instant::now() - Duration::from_secs(1), + ); + let action = pane.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL)); + assert!(queue_explicit_active_followup( + &action, &mut pane, &mut chat + )); + let mut queue = pane.take_queued_next_turn_submissions(); + queue.push_back(NextTurnSubmission::Interactive("second".into())); + // A physical trailing newline stays in editing even while the FIFO is ready. + assert!(matches!( + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + BottomPaneAction::Consumed + )); + assert_eq!(pane.composer.text(), "\n"); + pane.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert_eq!( + replay_one_for_test(&mut queue, &mut pane, &mut chat), + Some(NextTurnSubmission::Interactive("pasted request".into())) + ); + assert!(pane.composer.is_empty()); + assert_eq!(queue, interactive_queue(["second".into()])); + assert_eq!( + replay_one_for_test(&mut queue, &mut pane, &mut chat), + Some(NextTurnSubmission::Interactive("second".into())) + ); + assert!(replay_one_for_test(&mut queue, &mut pane, &mut chat).is_none()); + } + + #[test] + #[serial_test::serial] + fn queued_wake_rechecks_attachment_input_and_loading_before_consumption() { + use bottom_pane::info_view::InfoView; + for case in [ + "ready", "modal", "login", "active", "catalog", "read", "draft", "paste", "session", + "epoch", "cancel", + ] { + let mut state = crate::cli::session::session_state::SessionState::default(); + state.set_session_id("source"); + let mut pane = explicit_followup_pane(); + let mut chat = chat_widget::ChatWidget::new("source"); + let mut queue = interactive_queue(["accepted".into()]); + let mut events = Vec::new(); + let mut armed = false; + for _ in 0..3 { + let handoff = handoff_queued_followups( + &mut queue, + &mut pane, + &mut chat, + followup_release_gate(false, 0), + ); + schedule_queued_handoff(&handoff, &state, |event| events.push(event), &mut armed); + } + assert_eq!(events.len(), 1, "one wake owns no input: {case}"); + assert_eq!(queue.len(), 1); + let mut gate = followup_release_gate(false, 0); + match case { + "modal" => { + pane.push_view(Box::new(InfoView::from_plain("Info", vec!["ready".into()]))) + } + "login" | "active" => gate.foreground_busy = true, + "catalog" => gate.model_catalog_loading = true, + "read" => gate.background_reads_in_flight = true, + "draft" => pane.handle_paste("new draft"), + "paste" => pane + .composer + .force_pending_paste_burst_for_test("new paste", std::time::Instant::now()), + "session" => state.set_session_id("different"), + "epoch" => state.session_attachment_epoch += 1, + "cancel" => { + recover_queued_followups_after_cancelled_read(&mut queue, &mut pane, &mut chat); + } + _ => (), + } + let TuiEvent::QueuedSubmission(context) = events.pop().unwrap() else { + panic!("typed wake"); + }; + let result = consume_queued_handoff( + context, + (state.session_id.as_deref(), state.session_attachment_epoch), + &mut queue, + &mut pane, + &mut chat, + &mut armed, + gate, + ); + assert!(!armed, "every consumption clears the wake latch: {case}"); + if case == "ready" { + assert_eq!( + result, + Some(NextTurnSubmission::Interactive("accepted".into())) + ); + assert!(queue.is_empty()); + } else { + assert!(result.is_none(), "must not dispatch: {case}"); + if matches!(case, "modal" | "login" | "active" | "catalog" | "read") { + assert_eq!(queue.len(), 1, "held queue keeps its head: {case}"); + } else { + assert!(queue.is_empty(), "returned manual custody: {case}"); + if case == "paste" { + pane.pre_draw_tick(std::time::Instant::now() + Duration::from_secs(1)); + // The still-active detector is not forced to flush by this synthetic tick. + assert!(!pane.composer.is_empty()); + } else { + assert!(pane.composer.text().contains("accepted")); + } + } + } + } + } + + #[test] + #[serial_test::serial] + fn armed_wake_does_not_grant_a_physical_submission_fifo_exemption() { + let state = crate::cli::session::session_state::SessionState::default(); + let mut queue = interactive_queue(["older".into()]); + let mut pane = explicit_followup_pane(); + let mut chat = chat_widget::ChatWidget::new(""); + let mut armed = false; + let mut wake = None; + schedule_queued_handoff( + &QueuedFollowupHandoff::SubmitNext, + &state, + |event| wake = Some(event), + &mut armed, + ); + assert!(admit_idle_followup( + "new physical submission", + false, + &mut queue, + &mut pane + )); + assert!(armed); + let Some(TuiEvent::QueuedSubmission(context)) = wake else { + panic!("wake"); + }; + let next = consume_queued_handoff( + context, + (None, 0), + &mut queue, + &mut pane, + &mut chat, + &mut armed, + followup_release_gate(false, 0), + ); + assert_eq!(next, Some(NextTurnSubmission::Interactive("older".into()))); + assert_eq!(queue, interactive_queue(["new physical submission".into()])); + } + + #[test] + #[serial_test::serial] + fn queued_interactive_dispatch_preserves_raw_purpose_and_duplicate_entries() { + let texts = [ + "//stop", + "/plan inspect", + "/help", + "!echo ok", + "same", + "same", + ]; + let mut queue = interactive_queue(texts.map(str::to_owned)); + let mut pane = explicit_followup_pane(); + let mut chat = chat_widget::ChatWidget::new(""); + for raw in texts { + assert_eq!( + replay_one_for_test(&mut queue, &mut pane, &mut chat), + Some(NextTurnSubmission::Interactive(raw.into())) + ); + } + assert!(queue.is_empty()); + assert!(pane.composer.is_empty()); + } + + fn replay_one_for_test( + queue: &mut VecDeque, + pane: &mut BottomPane, + chat: &mut chat_widget::ChatWidget, + ) -> Option { + let state = crate::cli::session::session_state::SessionState::default(); + let mut armed = false; + let handoff = handoff_queued_followups(queue, pane, chat, followup_release_gate(false, 0)); + let mut wake = None; + schedule_queued_handoff(&handoff, &state, |event| wake = Some(event), &mut armed); + let TuiEvent::QueuedSubmission(context) = wake? else { + panic!("typed wake required"); + }; + let result = consume_queued_handoff( + context, + (None, 0), + queue, + pane, + chat, + &mut armed, + followup_release_gate(false, 0), + ); + assert!(!armed); + result + } + #[test] fn explicit_followup_uses_existing_fifo_and_one_at_a_time_handoff() { let mut pane = explicit_followup_pane(); @@ -15946,42 +16213,26 @@ mod tests { ) .is_none() ); - let mut replay_armed = false; assert_eq!( - turn_settlement_followup_handoff( - &mut followups, - &mut pane, - &mut chat, - false, - 0, - &mut replay_armed, - ), + turn_settlement_followup_handoff(&mut followups, &mut pane, &mut chat, false, 0,), QueuedFollowupHandoff::SubmitNext, ); - assert!(replay_armed); - let replay = pane.handle_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Enter, - crossterm::event::KeyModifiers::NONE, - )); - let BottomPaneAction::SubmitInput(text) = replay else { - panic!("handoff must use the ordinary submit action"); + let Some(NextTurnSubmission::Interactive(text)) = + replay_one_for_test(&mut followups, &mut pane, &mut chat) + else { + panic!("ready head must dispatch"); }; assert_eq!(text, "older fallback"); - assert!(!admit_idle_followup( - &text, - &mut replay_armed, - &mut followups, - &mut pane - )); + assert!(!admit_idle_followup(&text, true, &mut followups, &mut pane)); assert!(!admit_idle_followup( "/status", - &mut replay_armed, + false, &mut followups, &mut pane )); assert!(admit_idle_followup( "later input", - &mut replay_armed, + false, &mut followups, &mut pane )); @@ -16043,6 +16294,7 @@ mod tests { let gate = FollowupReleaseGate { model_catalog_loading: false, background_reads_in_flight: false, + foreground_busy: false, session_rebound: false, }; pane.push_view(Box::new(slash_dispatch::build_permission_mode_picker( @@ -16216,7 +16468,8 @@ mod tests { release_next_queued_followup(&mut replayed, &mut replay_pane, &mut replay_chat), QueuedFollowupRelease::SubmitNext )); - assert_eq!(replay_pane.composer.text().trim(), "用 Python 写"); + assert!(replay_pane.composer.is_empty()); + assert_eq!(replayed.pop_front().unwrap().text(), "用 Python 写"); replay_pane.composer.set_text(""); let replay_latch = model_facing_followup_backlog(&replayed); assert!(replay_latch); @@ -16256,13 +16509,15 @@ mod tests { release_next_queued_followup(&mut slash_queue, &mut slash_pane, &mut slash_chat), QueuedFollowupRelease::SubmitNext )); - assert_eq!(slash_pane.composer.text().trim(), "/model gpt"); + assert!(slash_pane.composer.is_empty()); + assert_eq!(slash_queue.pop_front().unwrap().text(), "/model gpt"); slash_pane.composer.set_text(""); assert!(matches!( release_next_queued_followup(&mut slash_queue, &mut slash_pane, &mut slash_chat), QueuedFollowupRelease::SubmitNext )); - assert_eq!(slash_pane.composer.text().trim(), "plain A"); + assert!(slash_pane.composer.is_empty()); + assert_eq!(slash_queue.pop_front().unwrap().text(), "plain A"); assert!(slash_queue.is_empty()); let mut auth_queue = interactive_queue(["queued during refresh".to_string()]); @@ -16300,7 +16555,8 @@ mod tests { release_next_queued_followup(&mut closed_view, &mut closed_pane, &mut closed_chat), QueuedFollowupRelease::SubmitNext )); - assert_eq!(closed_pane.composer.text().trim(), "plain A"); + assert!(closed_pane.composer.is_empty()); + assert_eq!(closed_view.front().unwrap().text(), "plain A"); } #[test] @@ -16315,6 +16571,7 @@ mod tests { let loading = FollowupReleaseGate { model_catalog_loading: true, background_reads_in_flight: false, + foreground_busy: false, session_rebound: false, }; assert_eq!( @@ -16340,7 +16597,8 @@ mod tests { handoff_queued_followups(&mut queued, &mut pane, &mut chat, ready), QueuedFollowupHandoff::SubmitNext ); - assert_eq!(pane.composer.text().trim(), "plain A"); + assert!(pane.composer.is_empty()); + assert_eq!(queued.front().unwrap().text(), "plain A"); } #[test] @@ -16361,6 +16619,7 @@ mod tests { FollowupReleaseGate { model_catalog_loading: false, background_reads_in_flight: false, + foreground_busy: false, session_rebound: false, }, ), @@ -16381,12 +16640,14 @@ mod tests { FollowupReleaseGate { model_catalog_loading: false, background_reads_in_flight: false, + foreground_busy: false, session_rebound: false, }, ), QueuedFollowupHandoff::SubmitNext ); - assert_eq!(pane.composer.text().trim(), "plain A"); + assert!(pane.composer.is_empty()); + assert_eq!(queued.front().unwrap().text(), "plain A"); } #[test] @@ -16397,6 +16658,7 @@ mod tests { let in_flight = FollowupReleaseGate { model_catalog_loading: false, background_reads_in_flight: true, + foreground_busy: false, session_rebound: false, }; assert_eq!( @@ -16416,7 +16678,8 @@ mod tests { ), QueuedFollowupHandoff::SubmitNext ); - assert_eq!(pane.composer.text().trim(), "plain A"); + assert!(pane.composer.is_empty()); + assert_eq!(queued.front().unwrap().text(), "plain A"); } #[test] @@ -16432,13 +16695,14 @@ mod tests { FollowupReleaseGate { model_catalog_loading: false, background_reads_in_flight: false, + foreground_busy: false, session_rebound: true, }, ), QueuedFollowupHandoff::RestoredAfterSessionChange ); assert!(queued.is_empty()); - assert_eq!(pane.composer.text().trim(), "plain A"); + assert_eq!(pane.composer.text(), "plain A"); } #[test] @@ -16450,19 +16714,10 @@ mod tests { let mut chat = chat_widget::ChatWidget::new(""); pane.push_view(Box::new(BackgroundTaskView::new(Vec::new()))); assert!(pane.has_active_view()); - let mut replay_armed = false; assert_eq!( - turn_settlement_followup_handoff( - &mut queued, - &mut pane, - &mut chat, - false, - 0, - &mut replay_armed, - ), + turn_settlement_followup_handoff(&mut queued, &mut pane, &mut chat, false, 0,), QueuedFollowupHandoff::Held ); - assert!(!replay_armed); assert_eq!(queued, interactive_queue(["plain A".to_string()])); assert!(pane.composer.is_empty()); } @@ -16472,27 +16727,22 @@ mod tests { let mut queued = interactive_queue(["plain A".to_string(), "plain B".to_string()]); let mut pane = BottomPane::new(); let mut chat = chat_widget::ChatWidget::new(""); - let mut replay_armed = false; assert_eq!( - turn_settlement_followup_handoff( - &mut queued, - &mut pane, - &mut chat, - false, - 0, - &mut replay_armed, - ), + turn_settlement_followup_handoff(&mut queued, &mut pane, &mut chat, false, 0,), QueuedFollowupHandoff::SubmitNext ); - let replayed = pane.composer.text(); - assert_eq!(replayed.trim(), "plain A"); + let Some(NextTurnSubmission::Interactive(replayed)) = + replay_one_for_test(&mut queued, &mut pane, &mut chat) + else { + panic!("ready head must dispatch"); + }; + assert_eq!(replayed, "plain A"); assert!(!admit_idle_followup( &replayed, - &mut replay_armed, + true, &mut queued, - &mut BottomPane::new(), + &mut pane )); - assert!(!replay_armed); assert_eq!(queued, interactive_queue(["plain B".to_string()])); assert!(slash_result_releases_followups( &slash_dispatch::SlashResult::OpenWorkTasks @@ -16517,7 +16767,7 @@ mod tests { &mut chat, )); assert!(queued.is_empty()); - assert_eq!(pane.composer.text().trim(), "plain A"); + assert_eq!(pane.composer.text(), "plain A"); } } @@ -16532,7 +16782,7 @@ mod tests { &mut chat )); assert!(queued.is_empty()); - assert_eq!(pane.composer.text().trim(), "plain A"); + assert_eq!(pane.composer.text(), "plain A"); assert!(!recover_queued_followups_after_cancelled_read( &mut queued, &mut pane, @@ -16552,7 +16802,7 @@ mod tests { QueuedFollowupHandoff::RestoredAfterSessionChange ); assert!(queued.is_empty()); - assert_eq!(pane.composer.text().trim(), "plain A"); + assert_eq!(pane.composer.text(), "plain A"); let same = followup_gate_after_auth(Some("same"), 4, Some("same"), 4, false, 0); assert!(!same.session_rebound); @@ -16562,16 +16812,16 @@ mod tests { handoff_queued_followups(&mut queued, &mut pane, &mut chat, same), QueuedFollowupHandoff::SubmitNext ); - assert_eq!(pane.composer.text().trim(), "plain B"); + assert!(pane.composer.is_empty()); + assert_eq!(queued.front().unwrap().text(), "plain B"); } #[test] fn idle_submission_waits_behind_a_held_backlog_unless_it_is_the_replay() { let mut queued = interactive_queue(["plain A".to_string()]); - let mut replay_armed = false; assert!(admit_idle_followup( "manual B", - &mut replay_armed, + false, &mut queued, &mut BottomPane::new(), )); @@ -16581,21 +16831,18 @@ mod tests { ); let mut queued = interactive_queue(["plain A".to_string()]); - let mut replay_armed = true; assert!(!admit_idle_followup( - "/model gpt", - &mut replay_armed, + "queued replay", + true, &mut queued, &mut BottomPane::new(), )); - assert!(!replay_armed); assert_eq!(queued, interactive_queue(["plain A".to_string()])); let mut queued = interactive_queue(["plain A".to_string()]); - let mut replay_armed = false; assert!(!admit_idle_followup( "/help", - &mut replay_armed, + false, &mut queued, &mut BottomPane::new(), )); diff --git a/docs/design/client-surfaces-and-deployment.md b/docs/design/client-surfaces-and-deployment.md index 3dffb971f1..0d446e40dd 100644 --- a/docs/design/client-surfaces-and-deployment.md +++ b/docs/design/client-surfaces-and-deployment.md @@ -88,6 +88,9 @@ FIFO and never becomes current-run guidance. Enqueueing alone does not close guidance admission or arm the startup ordering latch. The ordinary lifecycle handoff, modal/draft gates, session-change restoration, and guidance custody rules remain authoritative. Local controls retain their dispatcher semantics. +A typed client wake rechecks owner/Session attachment and input gates before +consuming one FIFO entry. Input remains in the FIFO until consumption; terminal +paste protection and popup handling apply to user gestures independently. This queue has process-local custody, not durable Server acceptance or recovery across client restart; it does not add a runtime delivery class or scheduler. diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 5bb64fcf48..05b5696b65 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -314,6 +314,9 @@ Returned conversation can merge with an existing conversation draft. Commands and shell drafts stay separate: “Unsent drafts” shows retained input, which moves into the composer after the current draft is sent or cleared. Each draft requires a new submission gesture; restoration never sends it automatically. +Navigation also preserves the draft suspended while browsing history, including +pasted content. An unchanged recalled message remains in history; edited recall +and distinct original drafts return for editing through the same unsent list. These editable drafts remain local to the running client. Ctrl+R also restores the latest conversation with its slash escape, so editing and resending `/exit` text keeps it a message. The transcript shows the original From d47ffb528f5b106e40b6380998737dd10d82c156 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Fri, 23 Oct 2026 15:54:39 +0800 Subject: [PATCH 5/5] fix(cli): preserve paste boundaries during input recovery --- .../bottom_pane/approval_integration_tests.rs | 27 ++++ .../src/tui/bottom_pane/chat_composer.rs | 22 ++- crates/astra-cli/src/tui/bottom_pane/mod.rs | 31 ++-- .../tui/bottom_pane/queue_preview_tests.rs | 141 ++++++++++++++++-- .../astra-cli/src/tui/bottom_pane/textarea.rs | 55 ++++++- crates/astra-cli/src/tui/event_loop.rs | 43 +++++- docs/design/client-surfaces-and-deployment.md | 2 + docs/reference/cli-commands.md | 3 +- 8 files changed, 278 insertions(+), 46 deletions(-) diff --git a/crates/astra-cli/src/tui/bottom_pane/approval_integration_tests.rs b/crates/astra-cli/src/tui/bottom_pane/approval_integration_tests.rs index 0ef78b5c14..77df61c1aa 100644 --- a/crates/astra-cli/src/tui/bottom_pane/approval_integration_tests.rs +++ b/crates/astra-cli/src/tui/bottom_pane/approval_integration_tests.rs @@ -803,3 +803,30 @@ fn shift_tab_selects_permission_mode_without_changing_pending_approval_focus() { assert!(matches!(action, BottomPaneAction::CyclePermissionMode)); assert_eq!(bp.focused_approval_index(), Some(1)); } + +#[test] +fn accepted_paste_tail_does_not_approve_or_create_a_new_draft() { + let mut bp = BottomPane::new(); + bp.composer = super::chat_composer::ChatComposer::new_ephemeral(); + let mut rx = enqueue(&mut bp, "bash"); + bp.composer.force_pending_paste_burst_for_test( + "accepted request", + std::time::Instant::now() - std::time::Duration::from_secs(1), + ); + assert!(matches!( + bp.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL)), + BottomPaneAction::QueueFollowUp(ref text) if text == "accepted request" + )); + for _ in 0..2 { + assert!(matches!( + bp.handle_key(special(KeyCode::Enter)), + BottomPaneAction::Consumed + )); + assert!(bp.composer.is_empty()); + assert!(bp.has_pending_approvals()); + assert!(matches!( + rx.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + } +} diff --git a/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs b/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs index 2584638952..eef6ba3658 100644 --- a/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs +++ b/crates/astra-cli/src/tui/bottom_pane/chat_composer.rs @@ -106,6 +106,12 @@ impl ChatComposer { self.textarea.text().to_string() } + /// Command-aware input with presentation-only paste placeholders expanded. + /// Reading it never consumes pending paste or editing/history state. + pub(crate) fn expanded_raw_text(&self) -> std::borrow::Cow<'_, str> { + self.expand_pastes(self.textarea.text()) + } + pub fn is_empty(&self) -> bool { self.textarea.is_empty() && !self.paste_burst_active() } @@ -123,7 +129,7 @@ impl ChatComposer { pub fn clear_and_submit(&mut self) -> String { let raw = self.textarea.text().to_string(); - let expanded = self.expand_pastes(&raw); + let expanded = self.expand_pastes(&raw).into_owned(); // Submission is a semantic boundary, not just a key event. Keep // whitespace-only drafts out of the event loop and preserve them so // an accidental Enter cannot erase what the user typed. @@ -154,7 +160,7 @@ impl ChatComposer { /// state. An unchanged recalled entry remains available in history. pub(crate) fn take_recoverable_drafts(&mut self) -> Vec { let raw = self.textarea.take_text(); - let visible = self.expand_pastes(&raw); + let visible = self.expand_pastes(&raw).into_owned(); let mut drafts = Vec::with_capacity(2); let unchanged_recall = self .history_index @@ -163,7 +169,7 @@ impl ChatComposer { drafts.push(visible); } if let Some(raw) = self.draft.take() { - let suspended = self.expand_pastes(&raw); + let suspended = self.expand_pastes(&raw).into_owned(); if !suspended.trim().is_empty() && !drafts.contains(&suspended) { drafts.push(suspended); } @@ -215,15 +221,15 @@ impl ChatComposer { } /// Replace every placeholder in `text` with its stored blob. - fn expand_pastes(&self, text: &str) -> String { + fn expand_pastes<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> { if self.pasted_blobs.is_empty() { - return text.to_string(); + return std::borrow::Cow::Borrowed(text); } let mut out = text.to_string(); for (placeholder, blob) in &self.pasted_blobs { out = out.replace(placeholder, blob); } - out + std::borrow::Cow::Owned(out) } #[cfg(test)] @@ -373,8 +379,8 @@ impl ChatComposer { self.prompt_prefix.width() as u16 } - pub(crate) fn insert_newline_if_pasting(&mut self) -> bool { - self.textarea.insert_newline_if_pasting() + pub(crate) fn consume_guarded_enter(&mut self) -> bool { + self.textarea.consume_guarded_enter() } pub fn flush_paste_burst(&mut self) -> bool { diff --git a/crates/astra-cli/src/tui/bottom_pane/mod.rs b/crates/astra-cli/src/tui/bottom_pane/mod.rs index 35d44114a8..90beae4072 100644 --- a/crates/astra-cli/src/tui/bottom_pane/mod.rs +++ b/crates/astra-cli/src/tui/bottom_pane/mod.rs @@ -672,9 +672,10 @@ impl BottomPane { } fn restore_next_draft(&mut self) -> bool { - use super::slash_dispatch::{conversation_for_composer, parse_submission}; + use super::slash_dispatch::parse_submission; let conversation = |text: &str| { - !parse_submission(text).is_command() + !text.trim().is_empty() + && !parse_submission(text).is_command() && crate::cli::turn::turn_entry::shell_input_body(text).is_none() }; if self.restored_drafts.is_empty() || self.composer.paste_burst_active() { @@ -682,27 +683,29 @@ impl BottomPane { } let mut raw = self.composer.text(); let mut changed = false; - if raw.trim().is_empty() { + let compatible = if raw.is_empty() { raw = self.restored_drafts.pop_front().expect("nonempty drafts"); changed = true; - } - if conversation(&raw) { - let mut merged = parse_submission(&raw).text().to_owned(); + conversation(&raw) + } else { + conversation(&self.composer.expanded_raw_text()) + }; + if compatible { + // Keep the first fragment's composer encoding and paste placeholders. + // Only later fragments lose their leading literal escape; they no + // longer occupy a command boundary in the combined conversation. while self .restored_drafts .front() .is_some_and(|text| conversation(text)) { let next = self.restored_drafts.pop_front().expect("checked front"); - merged.truncate(merged.trim_end().len()); - merged.push_str("\n\n"); - merged.push_str(parse_submission(&next).text()); + raw.push_str("\n\n"); + raw.push_str(parse_submission(&next).text()); changed = true; } - if changed { - self.replace_composer_text(&conversation_for_composer(&merged)); - } - } else if changed { + } + if changed { self.replace_composer_text(&raw); } changed @@ -1859,7 +1862,7 @@ impl BottomPane { // approval can interpret Enter. The textarea consumes it atomically. if key.code == KeyCode::Enter && key.modifiers.is_empty() - && self.composer.insert_newline_if_pasting() + && self.composer.consume_guarded_enter() { self.sync_popups(); return BottomPaneAction::Consumed; diff --git a/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs b/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs index a77b48efc9..11a8801cce 100644 --- a/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs +++ b/crates/astra-cli/src/tui/bottom_pane/queue_preview_tests.rs @@ -836,19 +836,7 @@ fn picker_navigation_transfers_suspended_history_drafts_and_pastes() { else { panic!("fresh gesture submits retained editing"); }; - assert_eq!( - raw, - expected - .iter() - .map(|s| s.trim_end()) - .collect::>() - .join("\n\n") - + if expected.last().is_some_and(|s| s.ends_with('\n')) { - "\n" - } else { - "" - } - ); + assert_eq!(raw, expected.join("\n\n")); } } } @@ -871,3 +859,130 @@ fn history_suspension_transfers_pending_paste_bytes_before_up() { vec!["original pending bytes"] ); } + +#[test] +fn pasted_drafts_keep_dispatch_boundaries_and_exact_payloads_on_restoration() { + use super::{BottomPaneAction, chat_composer::ChatComposer}; + use crate::tui::slash_dispatch::parse_submission; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + for (prefix, payload, separate) in [ + ("", "!printf 'one'\nprintf 'two'\nprintf 'three'\r\n ", true), + (" \t", "!!printf 'one'\nprintf 'two'\nprintf 'three'", true), + ("", "/unknown\nargument\nlast\t", true), + ("", "/plan inspect\nargument\nlast\r\n", true), + ("", "!\n\n", true), + ("", "//stop\nconversation\nlast\r\n ", false), + ("", "///tmp/path\nconversation\nlast\t", false), + ("/", "/stop\nconversation\nlast\r\n", false), + ( + "explain ", + "!printf 'one'\nprintf 'two'\nprintf 'three'", + false, + ), + ] { + for followup in [false, true] { + let mut pane = BottomPane::new(); + pane.composer = ChatComposer::new_ephemeral(); + pane.composer.set_text(prefix); + pane.handle_paste(payload); + let display = pane.composer.text(); + assert!(display.contains("[Pasted #")); + let original = format!("{prefix}{payload}"); + assert_eq!(pane.composer.expanded_raw_text(), original); + let blobs = pane.composer.pasted_blob_count(); + pane.restore_into_composer("//returned"); + assert_eq!(pane.composer.pasted_blob_count(), blobs); + if separate { + assert_eq!(pane.composer.text(), display); + assert_eq!(pane.restored_drafts.len(), 1); + } + let key = if followup { + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL) + } else { + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE) + }; + let action = pane.handle_key(key); + let raw = match (action, followup) { + (BottomPaneAction::SubmitInput(raw), false) + | (BottomPaneAction::QueueFollowUp(raw), true) => raw, + other => panic!("expected deliberate submission: {other:?}"), + }; + if separate { + assert_eq!(raw, original); + assert_eq!(pane.composer.text(), "//returned"); + let second = pane.handle_key(key); + assert!(matches!(second, + BottomPaneAction::SubmitInput(ref raw) | BottomPaneAction::QueueFollowUp(ref raw) + if parse_submission(raw).text() == "/returned" + )); + } else { + assert_eq!(raw, format!("{original}\n\n/returned")); + assert_eq!( + parse_submission(&raw).text(), + format!("{}\n\n/returned", parse_submission(&original).text()) + ); + } + assert!(pane.composer.is_empty()); + assert!(pane.restored_drafts.is_empty()); + } + } +} + +#[test] +fn restoration_preserves_multiple_paste_blobs_and_suspended_history() { + use super::{BottomPaneAction, chat_composer::ChatComposer}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut pane = BottomPane::new(); + pane.composer = ChatComposer::new_ephemeral(); + pane.handle_paste("previous message"); + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let suspended = "suspended paste\n".repeat(100); + pane.handle_paste(&suspended); + pane.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + pane.composer.set_text(""); + let first = "//first\r\nsecond\r\nlast \t"; + let second = "another\nlarge\npaste\r\n"; + pane.handle_paste(first); + pane.handle_paste(second); + let expected = format!("{first}{second}"); + let display = pane.composer.text(); + let cursor = pane.composer.cursor_byte(); + for _ in 0..2 { + assert_eq!(pane.composer.expanded_raw_text(), expected); + } + assert_eq!(pane.composer.text(), display); + assert_eq!(pane.composer.cursor_byte(), cursor); + assert!(pane.composer.is_browsing_history()); + assert_eq!(pane.composer.pasted_blob_count(), 3); + pane.restore_into_composer("//returned"); + assert_eq!(pane.composer.pasted_blob_count(), 3); + pane.start_composer_draft("/help"); + assert_eq!( + pane.restored_drafts.iter().cloned().collect::>(), + vec![format!("{expected}\n\n/returned"), suspended.clone()] + ); + pane.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + let BottomPaneAction::SubmitInput(raw) = + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("retained input still needs a new gesture"); + }; + assert_eq!(raw, format!("{expected}\n\n/returned\n\n{suspended}")); +} + +#[test] +fn recovery_waits_behind_intentional_whitespace_drafts() { + use super::chat_composer::ChatComposer; + for draft in [" ", "\n", "\r\n \t"] { + let mut pane = BottomPane::new(); + pane.composer = ChatComposer::new_ephemeral(); + pane.composer.set_text(draft); + pane.restore_into_composer("//returned"); + assert_eq!(pane.composer.text(), draft); + assert_eq!( + pane.restored_drafts.front().map(String::as_str), + Some("//returned") + ); + assert!(!pane.composer.is_empty()); + } +} diff --git a/crates/astra-cli/src/tui/bottom_pane/textarea.rs b/crates/astra-cli/src/tui/bottom_pane/textarea.rs index b507d26062..155e01fa52 100644 --- a/crates/astra-cli/src/tui/bottom_pane/textarea.rs +++ b/crates/astra-cli/src/tui/bottom_pane/textarea.rs @@ -114,10 +114,14 @@ impl TextArea { self.paste_burst.force_due_buffer_for_test(text, now); } - /// Consume a guarded Enter before a popup can treat it as submission. - pub fn insert_newline_if_pasting(&mut self) -> bool { + /// Stop a stale paste Enter before submission, popup or approval routing. + pub fn consume_guarded_enter(&mut self) -> bool { if self.paste_burst.enter_should_insert_newline(Instant::now()) { - self.insert_char('\n'); + // A submitted/cancelled paste has no remaining draft. Its tail must + // not manufacture one and block an already accepted FIFO handoff. + if !self.is_empty() || self.paste_burst.is_active() { + self.insert_char('\n'); + } true } else { false @@ -135,7 +139,7 @@ impl TextArea { TextAreaAction::Changed } KeyCode::Enter => { - if self.insert_newline_if_pasting() { + if self.consume_guarded_enter() { TextAreaAction::Changed } else { TextAreaAction::Submit @@ -712,6 +716,49 @@ mod tests { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::time::Duration; + #[test] + fn guarded_enter_consumes_empty_tails_and_preserves_real_drafts() { + for (draft, active, expected) in [ + ("", false, ""), + (" ", false, " \n"), + ("\n", false, "\n\n"), + ("recovered", false, "recovered\n"), + ("", true, "\n"), + ] { + let mut textarea = TextArea::new(); + textarea.set_text(draft); + textarea.force_pending_paste_burst_for_test("pending", Instant::now()); + if !active { + textarea.paste_burst.reset_preserving_enter_guard(); + } + assert!(textarea.consume_guarded_enter()); + assert_eq!(textarea.text(), expected); + assert_eq!(textarea.paste_burst_active(), active); + } + let mut textarea = TextArea::new(); + textarea.force_pending_paste_burst_for_test("accepted", Instant::now()); + textarea.clear(); + assert!(textarea.consume_guarded_enter()); + assert_eq!(textarea.text(), ""); + textarea.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + assert_eq!( + textarea.text(), + "\n", + "explicit newline remains a real draft" + ); + + // Expiration resumes the original Enter path, without sleeps. + let expired = Instant::now() - Duration::from_secs(1); + textarea.force_pending_paste_burst_for_test("past", expired); + textarea.paste_burst.flush(expired); + textarea.set_text("fresh draft"); + assert_eq!( + textarea.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + TextAreaAction::Submit + ); + assert_eq!(textarea.text(), "fresh draft"); + } + #[test] fn clear_resets_pending_paste_burst_state() { let mut textarea = TextArea::new(); diff --git a/crates/astra-cli/src/tui/event_loop.rs b/crates/astra-cli/src/tui/event_loop.rs index 0574c25320..57b23e0d5f 100644 --- a/crates/astra-cli/src/tui/event_loop.rs +++ b/crates/astra-cli/src/tui/event_loop.rs @@ -16001,13 +16001,17 @@ mod tests { )); let mut queue = pane.take_queued_next_turn_submissions(); queue.push_back(NextTurnSubmission::Interactive("second".into())); - // A physical trailing newline stays in editing even while the FIFO is ready. + // A stale paste tail is consumed without creating a new draft. assert!(matches!( pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), BottomPaneAction::Consumed )); - assert_eq!(pane.composer.text(), "\n"); - pane.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(pane.composer.is_empty()); + assert!(matches!( + pane.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + BottomPaneAction::Consumed + )); + assert!(pane.composer.is_empty()); assert_eq!( replay_one_for_test(&mut queue, &mut pane, &mut chat), Some(NextTurnSubmission::Interactive("pasted request".into())) @@ -16026,8 +16030,19 @@ mod tests { fn queued_wake_rechecks_attachment_input_and_loading_before_consumption() { use bottom_pane::info_view::InfoView; for case in [ - "ready", "modal", "login", "active", "catalog", "read", "draft", "paste", "session", - "epoch", "cancel", + "ready", + "modal", + "login", + "active", + "catalog", + "read", + "draft", + "paste", + "session", + "epoch", + "cancel", + "newline", + "whitespace", ] { let mut state = crate::cli::session::session_state::SessionState::default(); state.set_session_id("source"); @@ -16056,6 +16071,13 @@ mod tests { "catalog" => gate.model_catalog_loading = true, "read" => gate.background_reads_in_flight = true, "draft" => pane.handle_paste("new draft"), + "newline" => { + pane.handle_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::SHIFT, + )); + } + "whitespace" => pane.handle_paste(" "), "paste" => pane .composer .force_pending_paste_burst_for_test("new paste", std::time::Instant::now()), @@ -16096,6 +16118,16 @@ mod tests { // The still-active detector is not forced to flush by this synthetic tick. assert!(!pane.composer.is_empty()); } else { + if matches!(case, "newline" | "whitespace") { + assert_eq!( + pane.composer.text(), + if case == "newline" { "\n" } else { " " } + ); + pane.handle_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('c'), + crossterm::event::KeyModifiers::CONTROL, + )); + } assert!(pane.composer.text().contains("accepted")); } } @@ -23397,7 +23429,6 @@ mod tests { ("", "/stop\n\n//tmp/624"), ("continue", "continue\n\n/stop\n\n//tmp/624"), ("//existing", "/existing\n\n/stop\n\n//tmp/624"), - (" ", "/stop\n\n//tmp/624"), ] { let mut pane = explicit_followup_pane(); pane.composer.set_text(draft); diff --git a/docs/design/client-surfaces-and-deployment.md b/docs/design/client-surfaces-and-deployment.md index 0d446e40dd..ee28b4ba59 100644 --- a/docs/design/client-surfaces-and-deployment.md +++ b/docs/design/client-surfaces-and-deployment.md @@ -91,6 +91,8 @@ rules remain authoritative. Local controls retain their dispatcher semantics. A typed client wake rechecks owner/Session attachment and input gates before consuming one FIFO entry. Input remains in the FIFO until consumption; terminal paste protection and popup handling apply to user gestures independently. +A guarded paste-tail Enter on an empty composer is consumed without creating a +new draft; explicit newlines and nonempty drafts retain their handoff gates. This queue has process-local custody, not durable Server acceptance or recovery across client restart; it does not add a runtime delivery class or scheduler. diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 05b5696b65..9746ca2a19 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -311,7 +311,8 @@ slash text, escape it with another slash: `//tmp/project inspect this` sends `/tmp/project inspect this`, and `//stop` sends `/stop` as conversation text. The escape also applies to queued messages and guidance for an active run. Returned conversation can merge with an existing conversation draft. Commands -and shell drafts stay separate: “Unsent drafts” shows retained input, which +and shell drafts, including multiline pasted content, stay separate. Intentional +whitespace-only drafts also remain editable. “Unsent drafts” shows retained input, which moves into the composer after the current draft is sent or cleared. Each draft requires a new submission gesture; restoration never sends it automatically. Navigation also preserves the draft suspended while browsing history, including