Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions crates/astra-cli/src/cli/turn/turn_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ pub(crate) enum ShellPassthroughDecision {
DenyHighRisk { cmd: String, risks: Vec<String> },
}

/// 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.
Expand All @@ -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<ShellPassthroughDecision> {
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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
));
}
}
49 changes: 43 additions & 6 deletions crates/astra-cli/src/tui/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,18 @@ 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.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).
Expand All @@ -119,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.
Expand All @@ -146,6 +156,29 @@ impl ChatComposer {
expanded
}

/// 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<String> {
let raw = self.textarea.take_text();
let visible = self.expand_pastes(&raw).into_owned();
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).into_owned();
if !suspended.trim().is_empty() && !drafts.contains(&suspended) {
drafts.push(suspended);
}
}
self.history_index = None;
self.pasted_blobs.clear();
drafts
}

pub fn clear_draft(&mut self) {
self.textarea.clear();
self.history_index = None;
Expand Down Expand Up @@ -188,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)]
Expand Down Expand Up @@ -346,6 +379,10 @@ impl ChatComposer {
self.prompt_prefix.width() as u16
}

pub(crate) fn consume_guarded_enter(&mut self) -> bool {
self.textarea.consume_guarded_enter()
}

pub fn flush_paste_burst(&mut self) -> bool {
self.textarea.flush_paste_burst()
}
Expand Down Expand Up @@ -433,7 +470,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Loading
Loading