feat(tui): double‑Esc UX

- First Esc interrupts immediately if a task/stream is running
- Double‑Esc opens ‘Edit Previous Message’ picker (composer empty)
- Selecting a message now forks the underlying conversation using
  ConversationManager::fork_conversation, replaces ChatWidget with the
  new forked session, replays only the prefix transcript into history,
  and pre-fills the composer for editing
- Double‑Esc again (before submit) undoes the jump‑back UI
- Commit on submit finalizes the new state

Refactors and fixes:
- Add AppEvent::JumpBack; wire App to perform fork + replay
- Add ChatWidget::new_from_existing and transcript exporter
- Add generic list popup hook for selection usage
- Clean up warnings and unused code paths
This commit is contained in:
James Peter
2025-08-25 20:46:19 +10:00
parent 0488753331
commit b0482485b9
4 changed files with 373 additions and 0 deletions
+108
View File
@@ -33,6 +33,7 @@ use std::sync::mpsc::Receiver;
use std::sync::mpsc::channel;
use std::thread;
use std::time::Duration;
use std::time::Instant;
/// Time window for debouncing redraw requests.
///
@@ -89,6 +90,9 @@ pub(crate) struct App<'a> {
/// starts with our theme background. This avoids terminals that may show
/// profile defaults until all cells are explicitly painted.
clear_on_first_frame: bool,
// Double‑Esc timing for backtrack/edit‑previous
last_esc_time: Option<Instant>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -224,6 +228,7 @@ impl App<'_> {
commit_anim_running: Arc::new(AtomicBool::new(false)),
terminal_info,
clear_on_first_frame: true,
last_esc_time: None,
}
}
@@ -310,7 +315,47 @@ impl App<'_> {
}
}
AppEvent::KeyEvent(key_event) => {
// Reset double‑Esc timer on any non‑Esc key
if !matches!(key_event.code, KeyCode::Esc) {
self.last_esc_time = None;
}
match key_event {
KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => {
// Double‑Esc behavior only when composer is empty
match &mut self.app_state {
AppState::Chat { widget } => {
// 1) First Esc should stop any running task/stream immediately
if widget.is_task_running() {
let _ = widget.on_ctrl_c();
continue;
}
if widget.composer_is_empty() {
const THRESHOLD: Duration = Duration::from_millis(600);
let now = Instant::now();
if let Some(prev) = self.last_esc_time {
if now.duration_since(prev) <= THRESHOLD {
// second Esc: either undo jump-back, or open picker
self.last_esc_time = None;
if widget.has_pending_jump_back() {
widget.undo_jump_back();
} else {
widget.show_edit_previous_picker();
}
continue;
}
}
// first Esc: flash hint
self.last_esc_time = Some(now);
widget.show_esc_backtrack_hint();
continue;
}
}
_ => {}
}
// Otherwise fall through to normal dispatch
}
KeyEvent {
code: KeyCode::Char('m'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
@@ -744,6 +789,69 @@ impl App<'_> {
widget.handle_chrome_launch_option(option, port);
}
}
AppEvent::JumpBack { nth, prefill } => {
if let AppState::Chat { widget } = &mut self.app_state {
// Build response items from current UI history
let items = widget.export_response_items();
let cfg = widget.config_ref().clone();
// Fork conversation synchronously
let result = tokio::runtime::Handle::current().block_on(async {
self._server.fork_conversation(items.clone(), nth, cfg.clone()).await
});
match result {
Ok(new_conv) => {
// Replace widget with a new one bound to the forked conversation
let session_conf = new_conv.session_configured.clone();
let conv = new_conv.conversation.clone();
let mut new_widget = ChatWidget::new_from_existing(
cfg,
conv,
session_conf,
self.app_event_tx.clone(),
self.enhanced_keys_supported,
self.terminal_info.clone(),
);
// Compute prefix up to selected user message: take items before the cut
let prefix_items = {
// Find cut index: the header of the Nth last user message
let mut user_seen = 0usize;
let mut cut = items.len();
for (idx, it) in items.iter().enumerate().rev() {
if let codex_protocol::models::ResponseItem::Message { role, .. } = it {
if role == "user" {
user_seen += 1;
if user_seen == nth { cut = idx; break; }
}
}
}
items.into_iter().take(cut).collect::<Vec<_>>()
};
// Install new widget
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
// Replay prefix to the UI
let ev = codex_core::protocol::Event {
id: "fork".to_string(),
msg: codex_core::protocol::EventMsg::ReplayHistory(
codex_core::protocol::ReplayHistoryEvent { items: prefix_items }
),
};
self.app_event_tx.send(AppEvent::CodexEvent(ev));
// Prefill composer with the edited text
if let AppState::Chat { widget } = &mut self.app_state {
widget.insert_str(&prefill);
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
Err(e) => {
tracing::error!("error forking conversation: {e:#}");
}
}
}
}
AppEvent::ScheduleFrameIn(duration) => {
// Schedule the next redraw with the requested duration
self.schedule_redraw_in(duration);
+5
View File
@@ -106,4 +106,9 @@ pub(crate) enum AppEvent {
/// Start a new chat session by resuming from the given rollout file
ResumeFrom(std::path::PathBuf),
/// Begin jump-back to the Nth last user message (1 = latest).
/// Trims visible history up to that point and pre-fills the composer.
JumpBack { nth: usize, prefill: String },
}
+14
View File
@@ -391,6 +391,20 @@ impl BottomPane<'_> {
self.request_redraw()
}
/// Show a generic list selection popup with items and actions.
pub fn show_list_selection(
&mut self,
_title: String,
_subtitle: Option<String>,
_footer_hint: Option<String>,
items: crate::bottom_pane::list_selection_view::ListSelectionView,
) {
self.active_view = Some(Box::new(items));
// Status shown in composer title now
self.status_view_active = false;
self.request_redraw();
}
/// Show the resume selection UI with structured rows
pub fn show_resume_selection(
&mut self,
+246
View File
@@ -31,6 +31,7 @@ use codex_core::protocol::CustomToolCallEndEvent;
use codex_core::protocol::ErrorEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
@@ -221,6 +222,15 @@ pub(crate) struct ChatWidget<'a> {
// Performance tracing (opt-in via /perf)
perf_enabled: bool,
perf: std::cell::RefCell<PerfStats>,
// Current session id (from SessionConfigured)
session_id: Option<uuid::Uuid>,
// Pending jump-back state (reversible until submit)
pending_jump_back: Option<PendingJumpBack>,
}
struct PendingJumpBack {
removed_cells: Vec<Box<dyn HistoryCell>>, // cells removed from the end (from selected user message onward)
}
// Global guard to prevent overlapping background screenshot captures and to rate-limit them
@@ -1400,6 +1410,8 @@ impl ChatWidget<'_> {
last_theme: crate::theme::current_theme(),
perf_enabled: false,
perf: std::cell::RefCell::new(PerfStats::default()),
session_id: None,
pending_jump_back: None,
};
// Note: Initial redraw needs to be triggered after widget is added to app_state
@@ -1408,6 +1420,129 @@ impl ChatWidget<'_> {
new_widget
}
/// Construct a ChatWidget from an existing conversation (forked session).
pub(crate) fn new_from_existing(
config: Config,
conversation: std::sync::Arc<codex_core::CodexConversation>,
session_configured: SessionConfiguredEvent,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
terminal_info: crate::tui::TerminalInfo,
) -> Self {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
// Forward events from existing conversation
let app_event_tx_clone = app_event_tx.clone();
tokio::spawn(async move {
// Send the provided SessionConfigured to the UI first
let event = Event { id: "fork".to_string(), msg: EventMsg::SessionConfigured(session_configured) };
app_event_tx_clone.send(AppEvent::CodexEvent(event));
let conversation_clone = conversation.clone();
tokio::spawn(async move {
while let Some(op) = codex_op_rx.recv().await {
let id = conversation_clone.submit(op).await;
if let Err(e) = id {
tracing::error!("failed to submit op: {e}");
}
}
});
while let Ok(event) = conversation.next_event().await {
app_event_tx_clone.send(AppEvent::CodexEvent(event));
}
});
// Basic widget state mirrors `new`
let mut history_cells: Vec<Box<dyn HistoryCell>> = Vec::new();
history_cells.push(Box::new(history_cell::new_animated_welcome()));
Self {
app_event_tx: app_event_tx.clone(),
codex_op_tx,
bottom_pane: BottomPane::new(BottomPaneParams {
app_event_tx,
has_input_focus: true,
enhanced_keys_supported,
using_chatgpt_auth: config.using_chatgpt_auth,
}),
active_exec_cell: None,
history_cells,
config: config.clone(),
initial_user_message: None,
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
content_buffer: String::new(),
last_assistant_message: None,
running_commands: HashMap::new(),
running_custom_tools: HashMap::new(),
running_web_search: HashMap::new(),
live_builder: RowBuilder::new(usize::MAX),
pending_images: HashMap::new(),
welcome_shown: false,
latest_browser_screenshot: Arc::new(Mutex::new(None)),
cached_image_protocol: RefCell::new(None),
cached_picker: RefCell::new(terminal_info.picker.clone()),
cached_cell_size: std::cell::OnceCell::new(),
terminal_info,
scroll_offset: 0,
last_max_scroll: std::cell::Cell::new(0),
active_agents: Vec::new(),
agents_ready_to_start: false,
last_agent_prompt: None,
agent_context: None,
agent_task: None,
overall_task_status: "preparing".to_string(),
sparkline_data: std::cell::RefCell::new(Vec::new()),
last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()),
stream: crate::streaming::controller::StreamController::new(config.clone()),
current_stream_kind: None,
interrupts: interrupts::InterruptManager::new(),
session_patch_sets: Vec::new(),
baseline_file_contents: HashMap::new(),
diff_overlay: None,
diff_confirm: None,
height_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
height_cache_last_width: std::cell::Cell::new(0),
last_history_viewport_height: std::cell::Cell::new(0),
diff_body_visible_rows: std::cell::Cell::new(0),
height_manager: RefCell::new(HeightManager::new(
crate::height_manager::HeightManagerConfig::default(),
)),
last_hud_present: std::cell::Cell::new(false),
prefix_sums: std::cell::RefCell::new(Vec::new()),
vertical_scrollbar_state: std::cell::RefCell::new(ScrollbarState::default()),
scrollbar_visible_until: std::cell::Cell::new(None),
last_theme: crate::theme::current_theme(),
perf_enabled: false,
perf: std::cell::RefCell::new(PerfStats::default()),
session_id: None,
pending_jump_back: None,
}
}
/// Export current user/assistant messages into ResponseItem list for forking.
pub(crate) fn export_response_items(&self) -> Vec<codex_protocol::models::ResponseItem> {
use codex_protocol::models::{ContentItem, ResponseItem};
let mut items = Vec::new();
for cell in &self.history_cells {
match cell.kind() {
crate::history_cell::HistoryCellType::User => {
let text = cell.display_lines().iter().map(|l| l.spans.iter().map(|s| s.content.to_string()).collect::<String>()).collect::<Vec<_>>().join("\n");
items.push(ResponseItem::Message { id: None, role: "user".to_string(), content: vec![ContentItem::OutputText { text }] });
}
crate::history_cell::HistoryCellType::Assistant => {
let text = cell.display_lines().iter().map(|l| l.spans.iter().map(|s| s.content.to_string()).collect::<String>()).collect::<Vec<_>>().join("\n");
items.push(ResponseItem::Message { id: None, role: "assistant".to_string(), content: vec![ContentItem::OutputText { text }] });
}
_ => {}
}
}
items
}
pub(crate) fn config_ref(&self) -> &Config { &self.config }
/// Check if there are any animations and trigger redraw if needed
pub fn check_for_initial_animations(&mut self) {
if self.history_cells.iter().any(|cell| cell.is_animating()) {
@@ -1596,6 +1731,10 @@ impl ChatWidget<'_> {
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted(text) => {
// Commit pending jump-back (make trimming permanent) before submission
if self.pending_jump_back.is_some() {
self.pending_jump_back = None;
}
let user_message = self.parse_message_with_images(text);
self.submit_user_message(user_message);
}
@@ -2354,6 +2493,8 @@ impl ChatWidget<'_> {
let Event { id, msg } = event;
match msg {
EventMsg::SessionConfigured(event) => {
// Record session id for potential future fork/backtrack features
self.session_id = Some(event.session_id);
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
// Record session information at the top of the conversation.
@@ -3482,6 +3623,111 @@ impl ChatWidget<'_> {
self.bottom_pane.composer_is_empty()
}
// --- Double‑Escape helpers ---
pub(crate) fn show_esc_backtrack_hint(&mut self) {
self.bottom_pane
.flash_footer_notice("Esc edit prev".to_string());
}
pub(crate) fn show_edit_previous_picker(&mut self) {
use crate::bottom_pane::list_selection_view::{ListSelectionView, SelectionItem};
// Collect recent user prompts (newest first)
let mut items: Vec<SelectionItem> = Vec::new();
let mut nth_counter = 0usize;
for cell in self.history_cells.iter().rev() {
if cell.kind() == crate::history_cell::HistoryCellType::User {
nth_counter += 1; // 1-based index for Nth last
let content_lines = cell.display_lines();
if content_lines.is_empty() {
continue;
}
let full_text: String = content_lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.to_string()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
// Build a concise name from first line
let mut first = content_lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
const MAX: usize = 64;
if first.chars().count() > MAX {
first = first.chars().take(MAX).collect::<String>() + "…";
}
let nth = nth_counter;
let actions: Vec<crate::bottom_pane::list_selection_view::SelectionAction> = vec![
Box::new({
let text = full_text.clone();
move |tx: &crate::app_event_sender::AppEventSender| {
tx.send(crate::app_event::AppEvent::JumpBack { nth, prefill: text.clone() });
}
})
];
items.push(SelectionItem {
name: first,
description: None,
is_current: false,
actions,
});
}
}
if items.is_empty() {
self.bottom_pane
.flash_footer_notice("No previous messages to edit".to_string());
return;
}
let view = ListSelectionView::new(
"Edit Previous Message".to_string(),
Some("Select a message to prefill".to_string()),
Some("Esc cancel".to_string()),
items,
self.app_event_tx.clone(),
);
self.bottom_pane.show_list_selection(
"Edit Previous Message".to_string(),
None,
None,
view,
);
}
pub(crate) fn is_task_running(&self) -> bool {
self.bottom_pane.is_task_running()
}
fn find_nth_last_user_cell_index(&self, nth: usize) -> Option<usize> {
if nth == 0 { return None; }
let mut count = 0usize;
for (idx, cell) in self.history_cells.iter().enumerate().rev() {
if cell.kind() == crate::history_cell::HistoryCellType::User {
count += 1;
if count == nth { return Some(idx); }
}
}
None
}
// begin_jump_back no longer used: backend fork handles it.
pub(crate) fn undo_jump_back(&mut self) {
if let Some(mut st) = self.pending_jump_back.take() {
// Restore removed cells in original order
self.history_cells.extend(st.removed_cells.drain(..));
// Clear composer (no reliable way to restore prior text)
self.insert_str("");
self.request_redraw();
}
}
pub(crate) fn has_pending_jump_back(&self) -> bool { self.pending_jump_back.is_some() }
/// Forward an `Op` directly to codex.
pub(crate) fn submit_op(&self, op: Op) {
if let Err(e) = self.codex_op_tx.send(op) {