Merge feat/ctrl-bs-delete-line into main: add Ctrl+Backspace line delete, Ctrl+Z undo in TUI composer; treat benign finalize commits as success; keep v0.2.88 release metadata

This commit is contained in:
James Peter
2025-09-08 11:51:11 +10:00
3 changed files with 80 additions and 11 deletions
+13 -4
View File
@@ -564,11 +564,20 @@ impl App<'_> {
kind: KeyEventKind::Press,
..
} => {
#[cfg(unix)]
{
self.suspend(terminal)?;
// Prefer in-app undo in Chat (composer) over shell suspend.
match &mut self.app_state {
AppState::Chat { widget } => {
widget.handle_key_event(key_event);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppState::Onboarding { .. } => {
#[cfg(unix)]
{
self.suspend(terminal)?;
}
// No-op on non-Unix platforms.
}
}
// No-op on non-Unix platforms.
}
KeyEvent {
code: KeyCode::Char('r') | KeyCode::Char('t'),
+54
View File
@@ -20,6 +20,9 @@ pub(crate) struct TextArea {
cursor_pos: usize,
wrap_cache: RefCell<Option<WrapCache>>,
preferred_col: Option<usize>,
// Simple undo stack capturing full snapshots of text and cursor before edits.
// This is intentionally simple to reliably undo paste and bulk edits across terminals.
undo_stack: Vec<UndoSnapshot>,
}
#[derive(Debug, Clone)]
@@ -28,6 +31,12 @@ struct WrapCache {
lines: Vec<Range<usize>>,
}
#[derive(Debug, Clone)]
struct UndoSnapshot {
text: String,
cursor_pos: usize,
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct TextAreaState {
/// Index into wrapped lines of the first visible line.
@@ -41,6 +50,7 @@ impl TextArea {
cursor_pos: 0,
wrap_cache: RefCell::new(None),
preferred_col: None,
undo_stack: Vec::new(),
}
}
@@ -60,6 +70,7 @@ impl TextArea {
}
pub fn insert_str_at(&mut self, pos: usize, text: &str) {
self.push_undo_snapshot();
let pos = self.clamp_pos_for_insertion(pos);
self.text.insert_str(pos, text);
self.wrap_cache.replace(None);
@@ -77,6 +88,7 @@ impl TextArea {
fn replace_range_raw(&mut self, range: std::ops::Range<usize>, text: &str) {
assert!(range.start <= range.end);
self.push_undo_snapshot();
let start = range.start.clamp(0, self.text.len());
let end = range.end.clamp(0, self.text.len());
let removed_len = end - start;
@@ -253,6 +265,16 @@ impl TextArea {
} if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
self.delete_backward_word()
},
KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::CONTROL,
..
} => {
// Map Ctrl+Backspace to delete the entire current line contents.
// This provides a portable shortcut similar to macOS Cmd+Backspace
// for terminals that do not forward the Command (SUPER) modifier.
self.delete_entire_line();
}
KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::ALT,
@@ -285,6 +307,14 @@ impl TextArea {
} => {
self.delete_backward_word();
}
// Undo: Ctrl+Z reverts the last edit snapshot (paste, insert, delete, etc.).
KeyEvent {
code: KeyCode::Char('z'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.undo();
}
// macOS-like shortcuts (when terminals report the Command key as SUPER):
// Cmd+Left -> move to beginning of line
// Cmd+Right -> move to end of line
@@ -507,6 +537,16 @@ impl TextArea {
self.set_cursor(bol);
}
/// Revert the most recent edit (if any).
pub fn undo(&mut self) {
if let Some(prev) = self.undo_stack.pop() {
self.text = prev.text;
self.cursor_pos = prev.cursor_pos.min(self.text.len());
self.wrap_cache.replace(None);
self.preferred_col = None;
}
}
/// Move the cursor left by a single grapheme cluster.
pub fn move_cursor_left(&mut self) {
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos);
@@ -719,6 +759,20 @@ impl TextArea {
}
}
fn push_undo_snapshot(&mut self) {
// Capture current state before mutation.
self.undo_stack.push(UndoSnapshot {
text: self.text.clone(),
cursor_pos: self.cursor_pos,
});
// Keep a bounded history to avoid unbounded memory growth.
const MAX_UNDO_SNAPSHOTS: usize = 128;
if self.undo_stack.len() > MAX_UNDO_SNAPSHOTS {
let excess = self.undo_stack.len() - MAX_UNDO_SNAPSHOTS;
self.undo_stack.drain(0..excess);
}
}
pub(crate) fn beginning_of_previous_word(&self) -> usize {
if let Some(first_non_ws) = self.text[..self.cursor_pos].rfind(|c: char| !c.is_whitespace())
{
+13 -7
View File
@@ -6303,15 +6303,21 @@ impl ChatWidget<'_> {
.output()
.await;
if let Ok(o) = &commit_out {
if !o.status.success() {
// Ignore "nothing to commit" (exit 1); surface others
let msg = String::from_utf8_lossy(&o.stderr);
if !msg.contains("nothing to commit") {
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
let _ = tx.send(AppEvent::CodexEvent(Event { id: uuid::Uuid::new_v4().to_string(), event_seq: 0, msg: EventMsg::BackgroundEvent(BackgroundEventEvent { message: format!("`/branch finalize` — commit failed: {}", msg.trim()) }), order: None }));
}
if !o.status.success() {
// Git prints "nothing to commit, working tree clean" on stdout and exits 1.
let stderr_s = String::from_utf8_lossy(&o.stderr);
let stdout_s = String::from_utf8_lossy(&o.stdout);
let benign = stdout_s.contains("nothing to commit")
|| stdout_s.contains("working tree clean")
|| stderr_s.contains("nothing to commit")
|| stderr_s.contains("working tree clean");
if !benign {
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
let detail = if !stderr_s.trim().is_empty() { stderr_s.trim().to_string() } else { stdout_s.trim().to_string() };
let _ = tx.send(AppEvent::CodexEvent(Event { id: uuid::Uuid::new_v4().to_string(), event_seq: 0, msg: EventMsg::BackgroundEvent(BackgroundEventEvent { message: format!("`/branch finalize` — commit failed: {}", detail) }), order: None }));
}
}
}
// Determine default branch in main repo
let default_branch = match codex_core::git_worktree::detect_default_branch(&git_root).await {