mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user