Files
dev/code-rs/tui/src/app.rs
T

3442 lines
158 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::app_event::{AppEvent, TerminalRunController, TerminalRunEvent};
use crate::app_event_sender::AppEventSender;
use crate::chatwidget::{ChatWidget, EscIntent, GhostState};
use crate::bottom_pane::SettingsSection;
use crate::cloud_tasks_service;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff;
use crate::get_login_status;
use crate::history::state::HistorySnapshot;
use crate::history_cell;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::OnboardingScreen;
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
use crate::slash_command::SlashCommand;
use crate::thread_spawner;
use crate::tui;
use crate::tui::TerminalInfo;
use code_core::config::add_project_allowed_command;
use code_core::config::Config;
use code_core::config_types::Notifications;
use code_core::protocol::Event;
use code_core::protocol::Op;
use code_core::protocol::SandboxPolicy;
use code_core::ConversationManager;
use code_login::{AuthManager, AuthMode, ServerOptions};
use code_cloud_tasks_client::TaskId;
use code_cloud_tasks_client::CloudTaskError;
use code_protocol::protocol::SessionSource;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::execute;
use crossterm::terminal::supports_keyboard_enhancement;
use crossterm::SynchronizedUpdate; // trait for stdout().sync_update
use futures::FutureExt;
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtyPair, PtySize};
use ratatui::buffer::Buffer;
use ratatui::CompletedFrame;
use shlex::try_join;
use std::cmp::Reverse;
use std::collections::{BTreeSet, BinaryHeap, HashMap, HashSet};
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::sync::mpsc::{channel, Receiver, Sender as StdSender};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::Duration;
use std::time::Instant;
#[cfg(unix)]
use signal_hook::consts::signal::SIGTERM;
#[cfg(unix)]
use signal_hook::{flag, SigId};
use tokio::sync::oneshot;
/// Time window for debouncing redraw requests.
///
/// Temporarily widened to ~30 FPS (33 ms) to coalesce bursts of updates while
/// we smooth out per-frame hotspots; keeps redraws responsive without pegging
/// the main thread.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(33);
const DEFAULT_PTY_ROWS: u16 = 24;
const DEFAULT_PTY_COLS: u16 = 80;
/// Top-level application state: which full-screen view is currently active.
#[allow(clippy::large_enum_variant)]
enum AppState<'a> {
Onboarding {
screen: OnboardingScreen,
},
/// The main chat UI is visible.
Chat {
/// Boxed to avoid a large enum variant and reduce the overall size of
/// `AppState`.
widget: Box<ChatWidget<'a>>,
},
}
struct TerminalRunState {
command: Vec<String>,
display: String,
cancel_tx: Option<oneshot::Sender<()>>,
running: bool,
controller: Option<TerminalRunController>,
writer_tx: Option<Arc<Mutex<Option<StdSender<Vec<u8>>>>>>,
pty: Option<Arc<Mutex<Box<dyn MasterPty + Send>>>>,
}
struct FrameTimer {
state: Mutex<FrameTimerState>,
cv: Condvar,
}
struct FrameTimerState {
deadlines: BinaryHeap<Reverse<Instant>>,
worker_running: bool,
}
impl FrameTimer {
fn new() -> Self {
Self {
state: Mutex::new(FrameTimerState {
deadlines: BinaryHeap::new(),
worker_running: false,
}),
cv: Condvar::new(),
}
}
fn schedule(self: &Arc<Self>, duration: Duration, tx: AppEventSender) {
let deadline = Instant::now() + duration;
let mut state = self.state.lock().unwrap();
state.deadlines.push(Reverse(deadline));
let should_spawn = if !state.worker_running {
state.worker_running = true;
true
} else {
false
};
self.cv.notify_one();
drop(state);
if should_spawn {
let timer = Arc::clone(self);
let tx_for_thread = tx.clone();
if thread_spawner::spawn_lightweight("frame-timer", move || timer.run(tx_for_thread)).is_none() {
let mut state = self.state.lock().unwrap();
state.worker_running = false;
let drained = state.deadlines.len();
state.deadlines.clear();
drop(state);
for _ in 0..drained.max(1) {
tx.send(AppEvent::RequestRedraw);
}
tracing::warn!(
drained_deadlines = drained,
"frame timer spawn rejected: background thread limit reached; flushed deadlines"
);
}
}
}
fn run(self: Arc<Self>, tx: AppEventSender) {
let mut state = self.state.lock().unwrap();
loop {
let deadline = match state.deadlines.peek().copied() {
Some(Reverse(deadline)) => deadline,
None => {
state.worker_running = false;
break;
}
};
let now = Instant::now();
if deadline <= now {
state.deadlines.pop();
drop(state);
tx.send(AppEvent::RequestRedraw);
state = self.state.lock().unwrap();
continue;
}
let wait_dur = deadline.saturating_duration_since(now);
let (new_state, result) = self.cv.wait_timeout(state, wait_dur).unwrap();
state = new_state;
if result.timed_out() {
continue;
}
}
}
}
struct LoginFlowState {
shutdown: code_login::ShutdownHandle,
join_handle: tokio::task::JoinHandle<()>,
}
pub(crate) struct App<'a> {
_server: Arc<ConversationManager>,
app_event_tx: AppEventSender,
// Split event receivers: high‑priority (input) and bulk (streaming)
app_event_rx_high: Receiver<AppEvent>,
app_event_rx_bulk: Receiver<AppEvent>,
app_state: AppState<'a>,
/// Config is stored here so we can recreate ChatWidgets as needed.
config: Config,
/// Latest available release version (if detected) so new widgets can surface it.
latest_upgrade_version: Option<String>,
file_search: FileSearchManager,
/// True when a redraw has been scheduled but not yet executed (debounce window).
pending_redraw: Arc<AtomicBool>,
/// Tracks whether a frame is currently queued or being drawn. Used to coalesce
/// rapid-fire redraw requests without dropping the final state.
redraw_inflight: Arc<AtomicBool>,
/// Set if a redraw request arrived while another frame was in flight. Ensures we
/// queue one more frame immediately after the current draw completes.
post_frame_redraw: Arc<AtomicBool>,
/// Shared scheduler for future animation frames. Ensures the shortest
/// requested interval wins while preserving later deadlines.
frame_timer: Arc<FrameTimer>,
/// Controls the input reader thread spawned at startup.
input_running: Arc<AtomicBool>,
enhanced_keys_supported: bool,
/// Tracks keys seen as pressed when keyboard enhancements are unavailable
/// so duplicate release events can be filtered and release-only terminals
/// still synthesize a press.
non_enhanced_pressed_keys: HashSet<KeyCode>,
/// Debug flag for logging LLM requests/responses
_debug: bool,
/// Show per-cell ordering overlay when true
show_order_overlay: bool,
/// Controls the animation thread that sends CommitTick events.
commit_anim_running: Arc<AtomicBool>,
/// Terminal information queried at startup
terminal_info: TerminalInfo,
#[cfg(unix)]
sigterm_guard: Option<SigId>,
#[cfg(unix)]
sigterm_flag: Arc<AtomicBool>,
/// Perform a hard clear on the first frame to ensure the entire buffer
/// starts with our theme background. This avoids terminals that may show
/// profile defaults until all cells are explicitly painted.
clear_on_first_frame: bool,
/// Pending ghost snapshot state to apply after a conversation fork completes.
pending_jump_back_ghost_state: Option<GhostState>,
/// Pending history snapshot to seed the next widget after a jump-back fork.
pending_jump_back_history_snapshot: Option<HistorySnapshot>,
/// Track last known terminal size. If it changes (true resize or a
/// tab switch that altered the viewport), perform a full clear on the next
/// draw to avoid ghost cells from the previous size. This is cheap and
/// happens rarely, but fixes Windows/macOS terminals that don't fully
/// repaint after focus/size changes until a manual resize occurs.
last_frame_size: Option<ratatui::prelude::Size>,
// Double‑Esc timing for undo timeline
last_esc_time: Option<Instant>,
/// If true, enable lightweight timing collection and report on exit.
timing_enabled: bool,
timing: TimingStats,
buffer_diff_profiler: BufferDiffProfiler,
/// True when TUI is currently rendering in the terminal's alternate screen.
alt_screen_active: bool,
terminal_runs: HashMap<u64, TerminalRunState>,
terminal_title_override: Option<String>,
login_flow: Option<LoginFlowState>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
/// deferred until after the Git warning screen is dismissed.
#[derive(Clone, Debug)]
pub(crate) struct ChatWidgetArgs {
pub(crate) config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
terminal_info: TerminalInfo,
show_order_overlay: bool,
enable_perf: bool,
resume_picker: bool,
latest_upgrade_version: Option<String>,
}
impl App<'_> {
const DEFAULT_TERMINAL_TITLE: &'static str = "Code";
pub(crate) fn new(
config: Config,
initial_prompt: Option<String>,
initial_images: Vec<std::path::PathBuf>,
show_trust_screen: bool,
debug: bool,
show_order_overlay: bool,
terminal_info: TerminalInfo,
enable_perf: bool,
resume_picker: bool,
startup_footer_notice: Option<String>,
latest_upgrade_version: Option<String>,
) -> Self {
let auth_manager = AuthManager::shared_with_mode_and_originator(
config.code_home.clone(),
AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::Cli,
));
// Split queues so interactive input never waits behind bulk updates.
let (high_tx, app_event_rx_high) = channel();
let (bulk_tx, app_event_rx_bulk) = channel();
let app_event_tx = AppEventSender::new_dual(high_tx.clone(), bulk_tx.clone());
let pending_redraw = Arc::new(AtomicBool::new(false));
let redraw_inflight = Arc::new(AtomicBool::new(false));
let post_frame_redraw = Arc::new(AtomicBool::new(false));
let frame_timer = Arc::new(FrameTimer::new());
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
// Spawn a dedicated thread for reading the crossterm event loop and
// re-publishing the events as AppEvents, as appropriate.
// Create the input thread stop flag up front so we can store it on `Self`.
let input_running = Arc::new(AtomicBool::new(true));
#[cfg(unix)]
let mut sigterm_guard = None;
#[cfg(unix)]
let sigterm_flag = Arc::new(AtomicBool::new(false));
{
let app_event_tx = app_event_tx.clone();
let input_running_thread = input_running.clone();
let drop_release_events = enhanced_keys_supported;
std::thread::spawn(move || {
// Track recent typing to temporarily increase poll frequency for low latency.
let mut last_key_time = Instant::now();
loop {
if !input_running_thread.load(Ordering::Relaxed) { break; }
// This timeout is necessary to avoid holding the event lock
// that crossterm::event::read() acquires. In particular,
// reading the cursor position (crossterm::cursor::position())
// needs to acquire the event lock, and so will fail if it
// can't acquire it within 2 sec. Resizing the terminal
// crashes the app if the cursor position can't be read.
// Keep the timeout small to minimize input-to-echo latency.
// Dynamically adapt poll timeout: when the user is actively typing,
// use a very small timeout to minimize key->echo latency; otherwise
// back off to reduce CPU when idle.
let hot_typing = Instant::now().duration_since(last_key_time) <= Duration::from_millis(250);
let poll_timeout = if hot_typing { Duration::from_millis(2) } else { Duration::from_millis(10) };
match crossterm::event::poll(poll_timeout) {
Ok(true) => match crossterm::event::read() {
Ok(event) => {
match event {
crossterm::event::Event::Key(key_event) => {
// Some Windows terminals (e.g., legacy conhost) only report
// `Release` events when keyboard enhancement flags are not
// supported. Preserve those events so onboarding works there.
if !drop_release_events
|| matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
{
last_key_time = Instant::now();
app_event_tx.send(AppEvent::KeyEvent(key_event));
}
}
crossterm::event::Event::Resize(_, _) => {
app_event_tx.send(AppEvent::RequestRedraw);
}
// When the terminal/tab regains focus, issue a redraw.
// Some terminals clear the alt‑screen buffer on focus switches,
// which can leave the status bar and inline images blank until
// the next resize. A focus‑gain repaint fixes this immediately.
crossterm::event::Event::FocusGained => {
app_event_tx.send(AppEvent::RequestRedraw);
}
crossterm::event::Event::FocusLost => {
// No action needed; keep state as‑is.
}
crossterm::event::Event::Paste(pasted) => {
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
// but tui-textarea expects \n. Normalize CR to LF.
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let pasted = pasted.replace("\r", "\n");
app_event_tx.send(AppEvent::Paste(pasted));
}
crossterm::event::Event::Mouse(mouse_event) => {
app_event_tx.send(AppEvent::MouseEvent(mouse_event));
}
// All other event variants are explicitly handled above.
}
}
Err(err) => {
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
tracing::error!("input thread failed to read event: {err}");
input_running_thread.store(false, Ordering::Release);
app_event_tx.send(AppEvent::ExitRequest);
break;
}
},
Ok(false) => {
// Timeout expired, no `Event` is available. If the user is typing
// keep the loop hot; otherwise sleep briefly to cut idle CPU.
if !hot_typing {
std::thread::sleep(Duration::from_millis(5));
}
}
Err(err) => {
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
tracing::error!("input thread failed to poll events: {err}");
input_running_thread.store(false, Ordering::Release);
app_event_tx.send(AppEvent::ExitRequest);
break;
}
}
}
});
}
#[cfg(unix)]
{
let term_trigger = Arc::new(AtomicBool::new(false));
let tx = app_event_tx.clone();
let running_for_thread = input_running.clone();
let trigger_for_thread = Arc::clone(&term_trigger);
let flag_for_thread = sigterm_flag.clone();
let listener = move || {
while running_for_thread.load(Ordering::Relaxed) {
if trigger_for_thread.swap(false, Ordering::SeqCst) {
running_for_thread.store(false, Ordering::Release);
flag_for_thread.store(true, Ordering::Release);
tx.send(AppEvent::ExitRequest);
break;
}
std::thread::sleep(Duration::from_millis(50));
}
};
if thread_spawner::spawn_lightweight("sigterm-listener", listener).is_some() {
match flag::register(SIGTERM, Arc::clone(&term_trigger)) {
Ok(sig_id) => {
sigterm_guard = Some(sig_id);
}
Err(err) => {
tracing::warn!("failed to register SIGTERM handler: {err}");
input_running.store(false, Ordering::Release);
}
}
} else {
tracing::warn!("SIGTERM listener spawn skipped: background thread limit reached");
}
}
let login_status = get_login_status(&config);
let should_show_onboarding =
should_show_onboarding(login_status, &config, show_trust_screen);
let app_state = if should_show_onboarding {
let show_login_screen = should_show_login_screen(login_status, &config);
let chat_widget_args = ChatWidgetArgs {
config: config.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
terminal_info: terminal_info.clone(),
show_order_overlay,
enable_perf,
resume_picker,
latest_upgrade_version: latest_upgrade_version.clone(),
};
AppState::Onboarding {
screen: OnboardingScreen::new(OnboardingScreenArgs {
event_tx: app_event_tx.clone(),
code_home: config.code_home.clone(),
cwd: config.cwd.clone(),
show_trust_screen,
show_login_screen,
chat_widget_args,
login_status,
}),
}
} else {
let mut chat_widget = ChatWidget::new(
config.clone(),
app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
terminal_info.clone(),
show_order_overlay,
latest_upgrade_version.clone(),
);
chat_widget.enable_perf(enable_perf);
if resume_picker {
chat_widget.show_resume_picker();
}
// Check for initial animations after widget is created
chat_widget.check_for_initial_animations();
if let Some(notice) = startup_footer_notice {
chat_widget.debug_notice(notice);
}
AppState::Chat {
widget: Box::new(chat_widget),
}
};
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
let start_in_alt = config.tui.alternate_screen;
Self {
_server: conversation_manager,
app_event_tx,
app_event_rx_high,
app_event_rx_bulk,
app_state,
config,
latest_upgrade_version,
file_search,
pending_redraw,
redraw_inflight,
post_frame_redraw,
frame_timer,
input_running,
enhanced_keys_supported,
non_enhanced_pressed_keys: HashSet::new(),
_debug: debug,
show_order_overlay,
commit_anim_running: Arc::new(AtomicBool::new(false)),
terminal_info,
clear_on_first_frame: true,
pending_jump_back_ghost_state: None,
pending_jump_back_history_snapshot: None,
last_frame_size: None,
last_esc_time: None,
timing_enabled: enable_perf,
timing: TimingStats::default(),
buffer_diff_profiler: BufferDiffProfiler::new_from_env(),
alt_screen_active: start_in_alt,
terminal_runs: HashMap::new(),
terminal_title_override: None,
login_flow: None,
#[cfg(unix)]
sigterm_guard,
#[cfg(unix)]
sigterm_flag,
}
}
#[cfg(unix)]
pub(crate) fn sigterm_triggered(&self) -> bool {
self.sigterm_flag.load(Ordering::Relaxed)
}
#[cfg(unix)]
pub(crate) fn clear_sigterm_guard(&mut self) {
self.sigterm_guard.take();
}
fn apply_terminal_title(&self) {
let title = self
.terminal_title_override
.as_deref()
.unwrap_or(Self::DEFAULT_TERMINAL_TITLE);
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::SetTitle(title.to_string())
);
}
fn sanitize_notification_text(input: &str) -> String {
let mut sanitized = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'\u{00}'..='\u{08}' | '\u{0B}' | '\u{0C}' | '\u{0E}'..='\u{1F}' | '\u{7F}' => {}
'\n' | '\r' | '\t' => {
if !sanitized.ends_with(' ') {
sanitized.push(' ');
}
}
_ => sanitized.push(ch),
}
}
sanitized
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn format_notification_message(title: &str, body: Option<&str>) -> Option<String> {
let title = Self::sanitize_notification_text(title);
let body = body.map(Self::sanitize_notification_text);
let mut message = match body {
Some(ref b) if !b.is_empty() => {
if title.is_empty() {
b.clone()
} else {
format!("{}: {}", title, b)
}
}
_ => title.clone(),
};
if message.is_empty() {
return None;
}
const MAX_LEN: usize = 160;
if message.chars().count() > MAX_LEN {
let mut truncated = String::new();
for ch in message.chars() {
if truncated.chars().count() >= MAX_LEN.saturating_sub(3) {
break;
}
truncated.push(ch);
}
truncated.push_str("...");
message = truncated;
}
Some(message)
}
fn emit_osc9_notification(message: &str) {
let payload = format!("\u{1b}]9;{}\u{7}", message);
let mut stdout = std::io::stdout();
let _ = stdout.write_all(payload.as_bytes());
let _ = stdout.flush();
}
/// Schedule a redraw immediately and open a short debounce window to coalesce
/// subsequent requests. Crucially, even if a timer is already armed (e.g., an
/// animation scheduled a future frame), we still trigger an immediate redraw
/// to keep keypress echo latency low.
#[allow(clippy::unwrap_used)]
fn schedule_redraw(&self) {
// Only queue a new frame when one is not already in flight; otherwise record
// that we owe a follow-up immediately after the active frame completes.
let should_send = self
.redraw_inflight
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
.is_ok();
if should_send {
self.app_event_tx.send(AppEvent::Redraw);
} else {
self.post_frame_redraw.store(true, Ordering::Release);
}
// Arm debounce window if not already armed.
if self
.pending_redraw
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
let pending_redraw = self.pending_redraw.clone();
let pending_redraw_for_thread = pending_redraw.clone();
if thread_spawner::spawn_lightweight("redraw-debounce", move || {
thread::sleep(REDRAW_DEBOUNCE);
pending_redraw_for_thread.store(false, Ordering::Release);
})
.is_none()
{
pending_redraw.store(false, Ordering::Release);
}
}
}
/// Schedule a redraw after the specified duration.
fn schedule_redraw_in(&self, duration: Duration) {
self.frame_timer
.schedule(duration, self.app_event_tx.clone());
}
fn handle_login_mode_change(&mut self, using_chatgpt_auth: bool) {
self.config.using_chatgpt_auth = using_chatgpt_auth;
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_using_chatgpt_auth(using_chatgpt_auth);
let _ = widget.reload_auth();
}
}
fn start_terminal_run(
&mut self,
id: u64,
command: Vec<String>,
display: Option<String>,
controller: Option<TerminalRunController>,
) {
if command.is_empty() {
self.app_event_tx.send(AppEvent::TerminalChunk {
id,
chunk: b"Install command not resolved".to_vec(),
_is_stderr: true,
});
self.app_event_tx.send(AppEvent::TerminalExit {
id,
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
return;
}
let joined_display = try_join(command.iter().map(|s| s.as_str()))
.ok()
.unwrap_or_else(|| command.join(" "));
let display_line = display.clone().unwrap_or_else(|| joined_display.clone());
if !display_line.trim().is_empty() {
let line = format!("$ {display_line}\n");
self.app_event_tx.send(AppEvent::TerminalChunk {
id,
chunk: line.into_bytes(),
_is_stderr: false,
});
}
let stored_command = command.clone();
let (cancel_tx, cancel_rx) = oneshot::channel();
let (writer_tx_raw, writer_rx) = channel::<Vec<u8>>();
let writer_tx_shared = Arc::new(Mutex::new(Some(writer_tx_raw)));
let controller_clone = controller.clone();
let cwd = self.config.cwd.clone();
let controller_tx = controller.map(|c| c.tx);
let (pty_rows, pty_cols) = match &self.app_state {
AppState::Chat { widget } => widget
.terminal_dimensions_hint()
.unwrap_or((DEFAULT_PTY_ROWS, DEFAULT_PTY_COLS)),
_ => (DEFAULT_PTY_ROWS, DEFAULT_PTY_COLS),
};
let pty_system = native_pty_system();
let pair = match pty_system.openpty(PtySize {
rows: pty_rows,
cols: pty_cols,
pixel_width: 0,
pixel_height: 0,
}) {
Ok(pair) => pair,
Err(err) => {
let msg = format!("Failed to open PTY: {err}\n");
self.app_event_tx.send(AppEvent::TerminalChunk {
id,
chunk: msg.clone().into_bytes(),
_is_stderr: true,
});
if let Some(ref ctrl) = controller_tx {
let _ = ctrl.send(TerminalRunEvent::Chunk {
data: msg.clone().into_bytes(),
_is_stderr: true,
});
let _ = ctrl.send(TerminalRunEvent::Exit {
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
}
self.app_event_tx.send(AppEvent::TerminalExit {
id,
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
return;
}
};
let PtyPair { master, slave } = pair;
let master = Arc::new(Mutex::new(master));
let writer = {
let guard = match master.lock() {
Ok(guard) => guard,
Err(_) => {
let msg = "Failed to acquire terminal writer: poisoned lock\n".to_string();
self.app_event_tx.send(AppEvent::TerminalChunk {
id,
chunk: msg.clone().into_bytes(),
_is_stderr: true,
});
if let Some(ref ctrl) = controller_tx {
let _ = ctrl.send(TerminalRunEvent::Chunk {
data: msg.clone().into_bytes(),
_is_stderr: true,
});
let _ = ctrl.send(TerminalRunEvent::Exit {
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
}
self.app_event_tx.send(AppEvent::TerminalExit {
id,
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
return;
}
};
let result = guard.take_writer();
drop(guard);
match result {
Ok(writer) => writer,
Err(err) => {
let msg = format!("Failed to acquire terminal writer: {err}\n");
self.app_event_tx.send(AppEvent::TerminalChunk {
id,
chunk: msg.clone().into_bytes(),
_is_stderr: true,
});
if let Some(ref ctrl) = controller_tx {
let _ = ctrl.send(TerminalRunEvent::Chunk {
data: msg.clone().into_bytes(),
_is_stderr: true,
});
let _ = ctrl.send(TerminalRunEvent::Exit {
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
}
self.app_event_tx.send(AppEvent::TerminalExit {
id,
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
return;
}
}
};
let reader = {
let guard = match master.lock() {
Ok(guard) => guard,
Err(_) => {
let msg = "Failed to read terminal output: poisoned lock\n".to_string();
self.app_event_tx.send(AppEvent::TerminalChunk {
id,
chunk: msg.clone().into_bytes(),
_is_stderr: true,
});
if let Some(ref ctrl) = controller_tx {
let _ = ctrl.send(TerminalRunEvent::Chunk {
data: msg.clone().into_bytes(),
_is_stderr: true,
});
let _ = ctrl.send(TerminalRunEvent::Exit {
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
}
self.app_event_tx.send(AppEvent::TerminalExit {
id,
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
return;
}
};
let result = guard.try_clone_reader();
drop(guard);
match result {
Ok(reader) => reader,
Err(err) => {
let msg = format!("Failed to read terminal output: {err}\n");
self.app_event_tx.send(AppEvent::TerminalChunk {
id,
chunk: msg.clone().into_bytes(),
_is_stderr: true,
});
if let Some(ref ctrl) = controller_tx {
let _ = ctrl.send(TerminalRunEvent::Chunk {
data: msg.clone().into_bytes(),
_is_stderr: true,
});
let _ = ctrl.send(TerminalRunEvent::Exit {
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
}
self.app_event_tx.send(AppEvent::TerminalExit {
id,
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
return;
}
}
};
let mut command_builder = CommandBuilder::new(command[0].clone());
for arg in &command[1..] {
command_builder.arg(arg);
}
command_builder.cwd(&cwd);
let mut child = match slave.spawn_command(command_builder) {
Ok(child) => child,
Err(err) => {
let msg = format!("Failed to spawn command: {err}\n");
self.app_event_tx.send(AppEvent::TerminalChunk {
id,
chunk: msg.clone().into_bytes(),
_is_stderr: true,
});
if let Some(ref ctrl) = controller_tx {
let _ = ctrl.send(TerminalRunEvent::Chunk {
data: msg.clone().into_bytes(),
_is_stderr: true,
});
let _ = ctrl.send(TerminalRunEvent::Exit {
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
}
self.app_event_tx.send(AppEvent::TerminalExit {
id,
exit_code: Some(1),
_duration: Duration::from_millis(0),
});
return;
}
};
let mut killer = child.clone_killer();
let master_for_state = Arc::clone(&master);
self.terminal_runs.insert(
id,
TerminalRunState {
command: stored_command,
display: display_line.clone(),
cancel_tx: Some(cancel_tx),
running: true,
controller: controller_clone,
writer_tx: Some(writer_tx_shared.clone()),
pty: Some(master_for_state),
},
);
let tx = self.app_event_tx.clone();
let controller_tx_task = controller_tx.clone();
let master_for_task = Arc::clone(&master);
let writer_tx_for_task = writer_tx_shared.clone();
tokio::spawn(async move {
let start_time = Instant::now();
let controller_tx = controller_tx_task;
let _master = master_for_task;
let writer_handle = tokio::task::spawn_blocking(move || {
let mut writer = writer;
while let Ok(bytes) = writer_rx.recv() {
if writer.write_all(&bytes).is_err() {
break;
}
if writer.flush().is_err() {
break;
}
}
});
let tx_reader = tx.clone();
let controller_tx_reader = controller_tx.clone();
let reader_handle = tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 8192];
let mut reader = reader;
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let chunk = buf[..n].to_vec();
tx_reader.send(AppEvent::TerminalChunk {
id,
chunk: chunk.clone(),
_is_stderr: false,
});
if let Some(ref ctrl) = controller_tx_reader {
let _ = ctrl.send(TerminalRunEvent::Chunk {
data: chunk,
_is_stderr: false,
});
}
}
Err(err) => {
let msg = format!("Error reading terminal output: {err}\n");
tx_reader.send(AppEvent::TerminalChunk {
id,
chunk: msg.clone().into_bytes(),
_is_stderr: true,
});
if let Some(ref ctrl) = controller_tx_reader {
let _ = ctrl.send(TerminalRunEvent::Chunk {
data: msg.into_bytes(),
_is_stderr: true,
});
}
break;
}
}
}
});
let mut cancel_rx = cancel_rx.fuse();
let mut cancel_triggered = false;
let wait_handle = tokio::task::spawn_blocking(move || child.wait());
futures::pin_mut!(wait_handle);
let wait_status = loop {
tokio::select! {
res = &mut wait_handle => break res,
res = &mut cancel_rx, if !cancel_triggered => {
if res.is_ok() {
cancel_triggered = true;
let _ = killer.kill();
}
}
}
};
{
let mut guard = writer_tx_for_task.lock().unwrap();
guard.take();
}
let _ = reader_handle.await;
let _ = writer_handle.await;
let (exit_code, duration) = match wait_status {
Ok(Ok(status)) => (Some(status.exit_code() as i32), start_time.elapsed()),
Ok(Err(err)) => {
let msg = format!("Process wait failed: {err}\n");
tx.send(AppEvent::TerminalChunk {
id,
chunk: msg.clone().into_bytes(),
_is_stderr: true,
});
if let Some(ref ctrl) = controller_tx {
let _ = ctrl.send(TerminalRunEvent::Chunk {
data: msg.clone().into_bytes(),
_is_stderr: true,
});
}
(None, start_time.elapsed())
}
Err(err) => {
let msg = format!("Process join failed: {err}\n");
tx.send(AppEvent::TerminalChunk {
id,
chunk: msg.clone().into_bytes(),
_is_stderr: true,
});
if let Some(ref ctrl) = controller_tx {
let _ = ctrl.send(TerminalRunEvent::Chunk {
data: msg.clone().into_bytes(),
_is_stderr: true,
});
}
(None, start_time.elapsed())
}
};
if let Some(ref ctrl) = controller_tx {
let _ = ctrl.send(TerminalRunEvent::Exit {
exit_code,
_duration: duration,
});
}
tx.send(AppEvent::TerminalExit {
id,
exit_code,
_duration: duration,
});
});
}
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
// Insert an event to trigger the first render.
let app_event_tx = self.app_event_tx.clone();
app_event_tx.send(AppEvent::RequestRedraw);
// Some Windows/macOS terminals report an initial size that stabilizes
// shortly after entering the alt screen. Schedule one follow‑up frame
// to catch any late size change without polling.
app_event_tx.send(AppEvent::ScheduleFrameIn(Duration::from_millis(120)));
'main: loop {
let event = match self.next_event_priority() { Some(e) => e, None => break 'main };
match event {
AppEvent::InsertHistory(mut lines) => match &mut self.app_state {
AppState::Chat { widget } => {
// Coalesce consecutive InsertHistory events to reduce redraw churn.
while let Ok(AppEvent::InsertHistory(mut more)) = self.app_event_rx_bulk.try_recv() {
lines.append(&mut more);
}
tracing::debug!("app: InsertHistory lines={}", lines.len());
if self.alt_screen_active {
widget.insert_history_lines(lines)
} else {
use std::io::stdout;
// Compute desired bottom height now, so growing/shrinking input
// adjusts the reserved region immediately even before the next frame.
let width = terminal.size().map(|s| s.width).unwrap_or(80);
let reserve = widget.desired_bottom_height(width).max(1);
let _ = execute!(stdout(), crossterm::terminal::BeginSynchronizedUpdate);
crate::insert_history::insert_history_lines_above(terminal, reserve, lines);
let _ = execute!(stdout(), crossterm::terminal::EndSynchronizedUpdate);
self.schedule_redraw();
}
},
AppState::Onboarding { .. } => {}
},
AppEvent::InsertHistoryWithKind { id, kind, lines } => match &mut self.app_state {
AppState::Chat { widget } => {
tracing::debug!("app: InsertHistoryWithKind kind={:?} id={:?} lines={}", kind, id, lines.len());
// Always update widget history, even in terminal mode.
// In terminal mode, the widget will emit an InsertHistory event
// which we will mirror to scrollback in the handler above.
let to_mirror = lines.clone();
widget.insert_history_lines_with_kind(kind, id, lines);
if !self.alt_screen_active {
use std::io::stdout;
let width = terminal.size().map(|s| s.width).unwrap_or(80);
let reserve = widget.desired_bottom_height(width).max(1);
let _ = execute!(stdout(), crossterm::terminal::BeginSynchronizedUpdate);
crate::insert_history::insert_history_lines_above(terminal, reserve, to_mirror);
let _ = execute!(stdout(), crossterm::terminal::EndSynchronizedUpdate);
self.schedule_redraw();
}
},
AppState::Onboarding { .. } => {}
},
AppEvent::InsertFinalAnswer { id, lines, source } => match &mut self.app_state {
AppState::Chat { widget } => {
tracing::debug!("app: InsertFinalAnswer id={:?} lines={} source_len={}", id, lines.len(), source.len());
let to_mirror = lines.clone();
widget.insert_final_answer_with_id(id, lines, source);
if !self.alt_screen_active {
use std::io::stdout;
let width = terminal.size().map(|s| s.width).unwrap_or(80);
let reserve = widget.desired_bottom_height(width).max(1);
let _ = execute!(stdout(), crossterm::terminal::BeginSynchronizedUpdate);
crate::insert_history::insert_history_lines_above(terminal, reserve, to_mirror);
let _ = execute!(stdout(), crossterm::terminal::EndSynchronizedUpdate);
self.schedule_redraw();
}
},
AppState::Onboarding { .. } => {}
},
AppEvent::InsertBackgroundEvent { message, placement, order } => match &mut self.app_state {
AppState::Chat { widget } => {
tracing::debug!(
"app: InsertBackgroundEvent placement={:?} len={}",
placement,
message.len()
);
widget.insert_background_event_with_placement(message, placement, order);
}
AppState::Onboarding { .. } => {}
},
AppEvent::AutoUpgradeCompleted { version } => match &mut self.app_state {
AppState::Chat { widget } => widget.on_auto_upgrade_completed(version),
AppState::Onboarding { .. } => {}
},
AppEvent::RateLimitFetchFailed { message } => match &mut self.app_state {
AppState::Chat { widget } => widget.on_rate_limit_refresh_failed(message),
AppState::Onboarding { .. } => {}
},
AppEvent::RequestRedraw => {
self.schedule_redraw();
}
AppEvent::FlushPendingExecEnds => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.flush_pending_exec_ends();
}
self.schedule_redraw();
}
AppEvent::Redraw => {
if self.timing_enabled { self.timing.on_redraw_begin(); }
let t0 = Instant::now();
let draw_result = std::io::stdout().sync_update(|_| self.draw_next_frame(terminal));
self.redraw_inflight.store(false, Ordering::Release);
let needs_follow_up = self.post_frame_redraw.swap(false, Ordering::AcqRel);
if needs_follow_up {
self.schedule_redraw();
}
draw_result??;
if self.timing_enabled { self.timing.on_redraw_end(t0); }
}
AppEvent::StartCommitAnimation => {
if self
.commit_anim_running
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
let tx = self.app_event_tx.clone();
let running = self.commit_anim_running.clone();
let running_for_thread = running.clone();
let tick_ms: u64 = self
.config
.tui
.stream
.commit_tick_ms
.or(if self.config.tui.stream.responsive { Some(30) } else { None })
.unwrap_or(50);
if thread_spawner::spawn_lightweight("commit-anim", move || {
while running_for_thread.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(tick_ms));
tx.send(AppEvent::CommitTick);
}
})
.is_none()
{
running.store(false, Ordering::Release);
}
}
}
AppEvent::StopCommitAnimation => {
self.commit_anim_running.store(false, Ordering::Release);
}
AppEvent::CommitTick => {
if self.pending_redraw.load(Ordering::Relaxed) { continue; }
// Advance streaming animation: commit at most one queued line
if let AppState::Chat { widget } = &mut self.app_state {
widget.on_commit_tick();
}
}
AppEvent::KeyEvent(mut key_event) => {
if self.timing_enabled { self.timing.on_key(); }
#[cfg(windows)]
{
use crossterm::event::KeyCode;
use crossterm::event::KeyEventKind;
if matches!(key_event.kind, KeyEventKind::Repeat) {
match key_event.code {
KeyCode::Left
| KeyCode::Right
| KeyCode::Up
| KeyCode::Down
| KeyCode::Home
| KeyCode::End
| KeyCode::Backspace
| KeyCode::Delete => {}
_ => continue,
}
}
}
// On terminals without keyboard enhancement flags (notably some Windows
// Git Bash/mintty setups), crossterm may emit duplicate key-up events or
// only report releases. Track which keys were seen as pressed so matching
// releases can be dropped, and synthesize a press when a release arrives
// without a prior press.
if !self.enhanced_keys_supported {
let key_code = key_event.code.clone();
match key_event.kind {
KeyEventKind::Press | KeyEventKind::Repeat => {
self.non_enhanced_pressed_keys.insert(key_code);
}
KeyEventKind::Release => {
if self.non_enhanced_pressed_keys.remove(&key_code) {
continue;
}
let mut release_handled = false;
if let KeyCode::Char(ch) = key_code {
let alts: Vec<char> = ch
.to_lowercase()
.chain(ch.to_uppercase())
.filter(|&c| c != ch)
.collect();
for alt in alts {
if self
.non_enhanced_pressed_keys
.remove(&KeyCode::Char(alt))
{
release_handled = true;
break;
}
}
}
if release_handled {
continue;
}
key_event = KeyEvent::new(
Self::normalize_non_enhanced_release_code(key_event.code),
key_event.modifiers,
);
}
}
}
// 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, .. } => {
if let AppState::Chat { widget } = &mut self.app_state {
let now = Instant::now();
const THRESHOLD: Duration = Duration::from_millis(600);
let double_ready = self
.last_esc_time
.is_some_and(|prev| now.duration_since(prev) <= THRESHOLD);
let mut handled = false;
let mut attempts = 0;
let esc_event = key_event;
while attempts < 8 {
attempts += 1;
let route = widget.describe_esc_context();
let mut intent = route.intent;
if intent == EscIntent::None {
break;
}
if intent == EscIntent::ShowUndoHint
&& route.allows_double_esc
&& double_ready
{
intent = EscIntent::OpenUndoTimeline;
}
let performed = widget.execute_esc_intent(intent, esc_event);
match intent {
EscIntent::CloseFilePopup if !route.consume => {
if !performed {
break;
}
continue;
}
EscIntent::CloseFilePopup => {
handled = true;
break;
}
EscIntent::ShowUndoHint => {
if route.allows_double_esc && !double_ready {
self.last_esc_time = Some(now);
} else {
self.last_esc_time = None;
}
handled = true;
break;
}
EscIntent::OpenUndoTimeline => {
self.last_esc_time = None;
handled = true;
break;
}
EscIntent::CancelTask | EscIntent::ClearComposer => {
self.last_esc_time = Some(now);
handled = true;
break;
}
_ => {
handled = true;
break;
}
}
}
if handled {
continue;
}
}
// Otherwise fall through
}
// Fallback: attempt clipboard image paste on common shortcuts.
// Many terminals (e.g., iTerm2) do not emit Event::Paste for raw-image
// clipboards. When the user presses paste shortcuts, try an image read
// by dispatching a paste with an empty string. The composer will then
// attempt `paste_image_to_temp_png()` and no-op if no image exists.
KeyEvent {
code: KeyCode::Char('v'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.dispatch_paste_event(String::new());
}
KeyEvent {
code: KeyCode::Char('v'),
modifiers: m,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} if m.contains(crossterm::event::KeyModifiers::CONTROL)
&& m.contains(crossterm::event::KeyModifiers::SHIFT) =>
{
self.dispatch_paste_event(String::new());
}
KeyEvent {
code: KeyCode::Insert,
modifiers: crossterm::event::KeyModifiers::SHIFT,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.dispatch_paste_event(String::new());
}
KeyEvent {
code: KeyCode::Char('m'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Toggle mouse capture to allow text selection
use crossterm::event::DisableMouseCapture;
use crossterm::event::EnableMouseCapture;
use crossterm::execute;
use std::io::stdout;
// Static variable to track mouse capture state
static mut MOUSE_CAPTURE_ENABLED: bool = true;
unsafe {
MOUSE_CAPTURE_ENABLED = !MOUSE_CAPTURE_ENABLED;
if MOUSE_CAPTURE_ENABLED {
let _ = execute!(stdout(), EnableMouseCapture);
} else {
let _ = execute!(stdout(), DisableMouseCapture);
}
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => match &mut self.app_state {
AppState::Chat { widget } => {
match widget.on_ctrl_c() {
crate::bottom_pane::CancellationEvent::Handled => {
if widget.ctrl_c_requests_exit() {
self.app_event_tx.send(AppEvent::ExitRequest);
}
}
crate::bottom_pane::CancellationEvent::Ignored => {}
}
}
AppState::Onboarding { .. } => { self.app_event_tx.send(AppEvent::ExitRequest); }
},
KeyEvent {
code: KeyCode::Char('z'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// 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.
}
}
}
KeyEvent {
code: KeyCode::Char('r'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('r'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Repeat,
..
} => {
// Toggle reasoning/thinking visibility (Ctrl+R)
match &mut self.app_state {
AppState::Chat { widget } => {
widget.toggle_reasoning_visibility();
}
AppState::Onboarding { .. } => {}
}
}
KeyEvent {
code: KeyCode::Char('c'),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
&& modifiers.contains(crossterm::event::KeyModifiers::SHIFT) =>
{
if let AppState::Chat { widget } = &mut self.app_state {
widget.toggle_context_expansion();
}
}
KeyEvent {
code: KeyCode::Char('t'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let _ = self.toggle_screen_mode(terminal);
// Propagate mode to widget so it can adapt layout
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_standard_terminal_mode(!self.alt_screen_active);
}
}
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Toggle diffs overlay
if let AppState::Chat { widget } = &mut self.app_state {
widget.toggle_diffs_popup();
}
}
// (Ctrl+Y disabled): Previously cycled syntax themes; now intentionally no-op
KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.dispatch_key_event(key_event);
}
_ => {
// Ignore Release key events.
}
};
}
AppEvent::MouseEvent(mouse_event) => {
self.dispatch_mouse_event(mouse_event);
}
AppEvent::Paste(text) => {
self.dispatch_paste_event(text);
}
AppEvent::RegisterPastedImage { placeholder, path } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.register_pasted_image(placeholder, path);
}
}
AppEvent::CodexEvent(event) => {
self.dispatch_code_event(event);
}
AppEvent::ExitRequest => {
// Stop background threads and break the UI loop.
self.commit_anim_running.store(false, Ordering::Release);
self.input_running.store(false, Ordering::Release);
break 'main;
}
AppEvent::CancelRunningTask => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.cancel_running_task_from_approval();
}
}
AppEvent::RegisterApprovedCommand { command, match_kind, persist, semantic_prefix } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.register_approved_command(
command.clone(),
match_kind.clone(),
semantic_prefix.clone(),
);
if persist {
if let Err(err) = add_project_allowed_command(
&self.config.code_home,
&self.config.cwd,
&command,
match_kind.clone(),
) {
widget.history_push_plain_state(history_cell::new_error_event(format!(
"Failed to persist always-allow command: {err:#}",
)));
} else {
let display = strip_bash_lc_and_escape(&command);
widget.push_background_tail(format!(
"Always allowing `{display}` for this project.",
));
}
}
}
}
AppEvent::MarkTaskIdle => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.mark_task_idle_after_denied();
}
}
AppEvent::OpenTerminal(launch) => {
let mut spawn = None;
let requires_immediate_command = !launch.command.is_empty();
let restricted = !matches!(self.config.sandbox_policy, SandboxPolicy::DangerFullAccess);
if let AppState::Chat { widget } = &mut self.app_state {
if restricted && requires_immediate_command {
widget.history_push_plain_state(history_cell::new_error_event(
"Terminal requires Full Access to auto-run install commands.".to_string(),
));
widget.show_agents_overview_ui();
} else {
widget.terminal_open(&launch);
if requires_immediate_command {
spawn = Some((
launch.id,
launch.command.clone(),
Some(launch.command_display.clone()),
launch.controller.clone(),
));
}
}
}
if let Some((id, command, display, controller)) = spawn {
self.start_terminal_run(id, command, display, controller);
}
}
AppEvent::TerminalChunk {
id,
chunk,
_is_stderr: is_stderr,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.terminal_append_chunk(id, &chunk, is_stderr);
}
}
AppEvent::TerminalExit {
id,
exit_code,
_duration: duration,
} => {
let after = if let AppState::Chat { widget } = &mut self.app_state {
widget.terminal_finalize(id, exit_code, duration)
} else {
None
};
let controller_present = if let Some(run) = self.terminal_runs.get_mut(&id) {
run.running = false;
run.cancel_tx = None;
if let Some(writer_shared) = run.writer_tx.take() {
let mut guard = writer_shared.lock().unwrap();
guard.take();
}
run.pty = None;
run.controller.is_some()
} else {
false
};
if exit_code == Some(0) && !controller_present {
self.terminal_runs.remove(&id);
}
if let Some(after) = after {
self.app_event_tx.send(AppEvent::TerminalAfter(after));
}
}
AppEvent::TerminalCancel { id } => {
let mut remove_entry = false;
if let Some(run) = self.terminal_runs.get_mut(&id) {
let had_controller = run.controller.is_some();
if let Some(tx) = run.cancel_tx.take() {
if !tx.is_closed() {
let _ = tx.send(());
}
}
run.running = false;
run.controller = None;
if let Some(writer_shared) = run.writer_tx.take() {
let mut guard = writer_shared.lock().unwrap();
guard.take();
}
run.pty = None;
remove_entry = had_controller;
}
if remove_entry {
self.terminal_runs.remove(&id);
}
}
AppEvent::TerminalRerun { id } => {
let command_and_controller = self
.terminal_runs
.get(&id)
.and_then(|run| {
(!run.running).then(|| {
(
run.command.clone(),
run.display.clone(),
run.controller.clone(),
)
})
});
if let Some((command, display, controller)) = command_and_controller {
if let AppState::Chat { widget } = &mut self.app_state {
widget.terminal_mark_running(id);
}
self.start_terminal_run(id, command, Some(display), controller);
}
}
AppEvent::TerminalRunCommand {
id,
command,
command_display,
controller,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.terminal_set_command_display(id, command_display.clone());
widget.terminal_mark_running(id);
}
self.start_terminal_run(id, command, Some(command_display), controller);
}
AppEvent::TerminalSendInput { id, data } => {
if let Some(run) = self.terminal_runs.get_mut(&id) {
if let Some(writer_shared) = run.writer_tx.as_ref() {
let mut guard = writer_shared.lock().unwrap();
if let Some(tx) = guard.as_ref() {
if tx.send(data).is_err() {
guard.take();
}
}
}
}
}
AppEvent::TerminalResize { id, rows, cols } => {
if rows == 0 || cols == 0 {
continue;
}
if let AppState::Chat { widget } = &mut self.app_state {
widget.terminal_apply_resize(id, rows, cols);
}
if let Some(run) = self.terminal_runs.get(&id) {
if let Some(pty) = run.pty.as_ref() {
if let Ok(guard) = pty.lock() {
let _ = guard.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
}
}
}
AppEvent::TerminalUpdateMessage { id, message } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.terminal_update_message(id, message);
}
}
AppEvent::TerminalSetAssistantMessage { id, message } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.terminal_set_assistant_message(id, message);
}
}
AppEvent::TerminalAwaitCommand { id, suggestion, ack } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.terminal_prepare_command(id, suggestion, ack.0);
}
}
AppEvent::TerminalForceClose { id } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.close_terminal_overlay();
}
self.terminal_runs.remove(&id);
}
AppEvent::TerminalApprovalDecision { id, approved } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_terminal_approval_decision(id, approved);
}
}
AppEvent::StartAutoDriveCelebration { message } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.start_auto_drive_card_celebration(message);
}
}
AppEvent::StopAutoDriveCelebration => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.stop_auto_drive_card_celebration();
}
}
AppEvent::TerminalAfter(after) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_terminal_after(after);
}
}
AppEvent::RequestValidationToolInstall { name, command } => {
if let AppState::Chat { widget } = &mut self.app_state {
if let Some(launch) = widget.launch_validation_tool_install(&name, &command) {
self.app_event_tx.send(AppEvent::OpenTerminal(launch));
}
}
}
AppEvent::RunUpdateCommand { command, display, latest_version } => {
if crate::updates::upgrade_ui_enabled() {
if let AppState::Chat { widget } = &mut self.app_state {
if let Some(launch) = widget.launch_update_command(command, display, latest_version) {
self.app_event_tx.send(AppEvent::OpenTerminal(launch));
}
}
}
}
AppEvent::SetAutoUpgradeEnabled(enabled) => {
if crate::updates::upgrade_ui_enabled() {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_auto_upgrade_enabled(enabled);
}
self.config.auto_upgrade_enabled = enabled;
}
}
AppEvent::ShowAutoDriveSettings => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_auto_drive_settings();
}
}
AppEvent::CloseAutoDriveSettings => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.close_auto_drive_settings();
}
}
AppEvent::AutoDriveSettingsChanged {
review_enabled,
agents_enabled,
cross_check_enabled,
qa_automation_enabled,
continue_mode,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_auto_drive_settings(
review_enabled,
agents_enabled,
cross_check_enabled,
qa_automation_enabled,
continue_mode,
);
}
}
AppEvent::RequestAgentInstall { name, selected_index } => {
if let AppState::Chat { widget } = &mut self.app_state {
if let Some(launch) = widget.launch_agent_install(name, selected_index) {
self.app_event_tx.send(AppEvent::OpenTerminal(launch));
}
}
}
AppEvent::AgentsOverviewSelectionChanged { index } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_agents_overview_selection(index);
}
}
// fallthrough handled by break
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
},
AppEvent::AutoCoordinatorDecision {
seq,
status,
status_title,
status_sent_to_user,
goal,
cli,
agents_timing,
agents,
transcript,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_handle_decision(
seq,
status,
status_title,
status_sent_to_user,
goal,
cli,
agents_timing,
agents,
transcript,
);
}
}
AppEvent::AutoCoordinatorUserReply {
user_response,
cli_command,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_handle_user_reply(user_response, cli_command);
}
}
AppEvent::AutoCoordinatorThinking { delta, summary_index } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_handle_thinking(delta, summary_index);
}
}
AppEvent::AutoCoordinatorTokenMetrics {
total_usage,
last_turn_usage,
turn_count,
duplicate_items,
replay_updates,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_handle_token_metrics(
total_usage,
last_turn_usage,
turn_count,
duplicate_items,
replay_updates,
);
}
}
AppEvent::AutoCoordinatorStopAck => {
// Coordinator acknowledged stop; no additional action required currently.
}
AppEvent::AutoCoordinatorCompactedHistory { conversation } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_handle_compacted_history(conversation);
}
}
AppEvent::AutoCoordinatorCountdown { countdown_id, seconds_left } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_handle_countdown(countdown_id, seconds_left);
}
}
AppEvent::AutoCoordinatorRestart { token, attempt } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_handle_restart(token, attempt);
}
}
AppEvent::PerformUndoRestore {
commit,
restore_files,
restore_conversation,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.perform_undo_restore(commit.as_deref(), restore_files, restore_conversation);
}
}
AppEvent::DispatchCommand(command, command_text) => {
// Persist UI-only slash commands to cross-session history.
// For prompt-expanding commands (/plan, /solve, /code) we let the
// expanded prompt be recorded by the normal submission path.
if !command.is_prompt_expanding() {
let _ = self
.app_event_tx
.send(AppEvent::CodexOp(Op::AddToHistory { text: command_text.clone() }));
}
// Extract command arguments by removing the slash command from the beginning
// e.g., "/browser status" -> "status", "/chrome 9222" -> "9222"
let command_args = {
let cmd_with_slash = format!("/{}", command.command());
if command_text.starts_with(&cmd_with_slash) {
command_text[cmd_with_slash.len()..].trim().to_string()
} else {
// Fallback: if format doesn't match, use the full text
command_text.clone()
}
};
match command {
SlashCommand::Undo => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_undo_command();
}
}
SlashCommand::Review => {
if let AppState::Chat { widget } = &mut self.app_state {
if command_args.is_empty() {
widget.open_review_dialog();
} else {
widget.handle_review_command(command_args);
}
}
}
SlashCommand::Cloud => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_cloud_command(command_args);
}
}
SlashCommand::Branch => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_branch_command(command_args);
}
}
SlashCommand::Merge => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_merge_command();
}
}
SlashCommand::Resume => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_resume_picker();
}
}
SlashCommand::New => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.abort_active_turn_for_new_chat();
}
// Start a brand new conversation (core session) with no carried history.
// Replace the chat widget entirely, mirroring SwitchCwd flow but without import.
let mut new_widget = ChatWidget::new(
self.config.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
self.terminal_info.clone(),
self.show_order_overlay,
self.latest_upgrade_version.clone(),
);
new_widget.enable_perf(self.timing_enabled);
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
self.terminal_runs.clear();
self.app_event_tx.send(AppEvent::RequestRedraw);
}
SlashCommand::Init => {
// Guard: do not run if a task is active.
if let AppState::Chat { widget } = &mut self.app_state {
const INIT_PROMPT: &str =
include_str!("../prompt_for_init_command.md");
widget.submit_text_message(INIT_PROMPT.to_string());
}
}
SlashCommand::Compact => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.clear_token_usage();
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
}
}
SlashCommand::Quit => { break 'main; }
SlashCommand::Login => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_login_command();
}
}
SlashCommand::Logout => {
if let Err(e) = code_login::logout(&self.config.code_home) { tracing::error!("failed to logout: {e}"); }
break 'main;
}
SlashCommand::Diff => {
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
match get_git_diff().await {
Ok((is_git_repo, diff_text)) => {
let text = if is_git_repo {
diff_text
} else {
"`/diff` — _not inside a git repository_".to_string()
};
tx.send(AppEvent::DiffResult(text));
}
Err(e) => {
tx.send(AppEvent::DiffResult(format!("Failed to compute diff: {e}")));
}
}
});
}
SlashCommand::Mention => {
// The mention feature is handled differently in our fork
// For now, just add @ to the composer
if let AppState::Chat { widget } = &mut self.app_state {
widget.insert_str("@");
}
}
SlashCommand::Cmd => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_project_command(command_args);
}
}
SlashCommand::Auto => {
if let AppState::Chat { widget } = &mut self.app_state {
let goal = if command_args.is_empty() {
None
} else {
Some(command_args.clone())
};
widget.handle_auto_command(goal);
}
}
SlashCommand::Status => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_status_output();
}
}
SlashCommand::Limits => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_limits_command(command_args);
}
}
SlashCommand::Update => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_update_command(command_args.trim());
}
}
SlashCommand::Settings => {
if let AppState::Chat { widget } = &mut self.app_state {
let section = command
.settings_section_from_args(&command_args)
.and_then(ChatWidget::settings_section_from_hint);
widget.show_settings_overlay(section);
}
}
SlashCommand::Notifications => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_notifications_command(command_args);
}
}
SlashCommand::Agents => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_agents_command(command_args);
}
}
SlashCommand::Github => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_github_command(command_args);
}
}
SlashCommand::Validation => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_validation_command(command_args);
}
}
SlashCommand::Mcp => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_mcp_command(command_args);
}
}
SlashCommand::Model => {
if let AppState::Chat { widget } = &mut self.app_state {
if command_args.trim().is_empty() {
widget.show_settings_overlay(Some(SettingsSection::Model));
} else {
widget.handle_model_command(command_args);
}
}
}
SlashCommand::Reasoning => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_reasoning_command(command_args);
}
}
SlashCommand::Verbosity => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_verbosity_command(command_args);
}
}
SlashCommand::Theme => {
// Theme selection is handled in submit_user_message
// This case is here for completeness
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_settings_overlay(Some(SettingsSection::Theme));
}
}
SlashCommand::Prompts => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_prompts_output();
}
}
SlashCommand::Perf => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_perf_command(command_args);
}
}
SlashCommand::Demo => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_demo_command();
}
}
// Prompt-expanding commands should have been handled in submit_user_message
// but add a fallback just in case. Use a helper that shows the original
// slash command in history while sending the expanded prompt to the model.
SlashCommand::Plan | SlashCommand::Solve | SlashCommand::Code => {
// These should have been expanded already, but handle them anyway
if let AppState::Chat { widget } = &mut self.app_state {
let expanded = command.expand_prompt(command_args.trim());
if let Some(prompt) = expanded {
widget.submit_prompt_with_display(command_text.clone(), prompt);
}
}
}
SlashCommand::Browser => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_browser_command(command_args);
}
}
SlashCommand::Chrome => {
if let AppState::Chat { widget } = &mut self.app_state {
tracing::info!("[cdp] /chrome invoked, args='{}'", command_args);
if command_args.trim().is_empty() {
widget.show_settings_overlay(Some(SettingsSection::Chrome));
} else {
widget.handle_chrome_command(command_args);
}
}
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
use code_core::protocol::EventMsg;
use std::collections::HashMap;
use code_core::protocol::ApplyPatchApprovalRequestEvent;
use code_core::protocol::FileChange;
self.app_event_tx.send(AppEvent::CodexEvent(Event {
id: "1".to_string(),
event_seq: 0,
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
// call_id: "1".to_string(),
// command: vec!["git".into(), "apply".into()],
// cwd: self.config.cwd.clone(),
// reason: Some("test".to_string()),
// }),
msg: EventMsg::ApplyPatchApprovalRequest(
ApplyPatchApprovalRequestEvent {
call_id: "1".to_string(),
changes: HashMap::from([
(
PathBuf::from("/tmp/test.txt"),
FileChange::Add {
content: "test".to_string(),
},
),
(
PathBuf::from("/tmp/test2.txt"),
FileChange::Update {
unified_diff: "+test\n-test2".to_string(),
move_path: None,
original_content: "test2".to_string(),
new_content: "test".to_string(),
},
),
]),
reason: None,
grant_root: Some(PathBuf::from("/tmp")),
},
),
order: None,
}));
}
}
}
AppEvent::SwitchCwd(new_cwd, initial_prompt) => {
let target = new_cwd.clone();
self.config.cwd = target.clone();
if let AppState::Chat { widget } = &mut self.app_state {
widget.switch_cwd(target, initial_prompt);
}
}
AppEvent::ResumeFrom(path) => {
// Replace the current chat widget with a new one configured to resume
let mut cfg = self.config.clone();
cfg.experimental_resume = Some(path);
if let AppState::Chat { .. } = &self.app_state {
let mut new_widget = ChatWidget::new(
cfg,
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
self.terminal_info.clone(),
self.show_order_overlay,
self.latest_upgrade_version.clone(),
);
new_widget.enable_perf(self.timing_enabled);
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
self.terminal_runs.clear();
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
AppEvent::PrepareAgents => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.prepare_agents();
}
}
AppEvent::ShowAgentEditor { name } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.ensure_settings_overlay_section(SettingsSection::Agents);
widget.show_agent_editor_ui(name);
}
}
AppEvent::ShowAgentEditorNew => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.ensure_settings_overlay_section(SettingsSection::Agents);
widget.show_agent_editor_new_ui();
}
}
AppEvent::UpdateModelSelection { model, effort } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_model_selection(model, effort);
}
}
AppEvent::UpdateReviewModelSelection { model, effort } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_review_model_selection(model, effort);
}
}
AppEvent::UpdateTextVerbosity(new_verbosity) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_text_verbosity(new_verbosity);
}
}
AppEvent::UpdateGithubWatcher(enabled) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_github_watcher(enabled);
}
}
AppEvent::UpdateTuiNotifications(enabled) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_tui_notifications(enabled);
}
self.config.tui.notifications = Notifications::Enabled(enabled);
self.config.tui_notifications = Notifications::Enabled(enabled);
}
AppEvent::UpdateValidationTool { name, enable } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.toggle_validation_tool(&name, enable);
}
}
AppEvent::UpdateValidationGroup { group, enable } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.toggle_validation_group(group, enable);
}
}
AppEvent::SetTerminalTitle { title } => {
self.terminal_title_override = title;
self.apply_terminal_title();
}
AppEvent::EmitTuiNotification { title, body } => {
if let Some(message) = Self::format_notification_message(&title, body.as_deref()) {
Self::emit_osc9_notification(&message);
}
}
AppEvent::UpdateMcpServer { name, enable } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.toggle_mcp_server(&name, enable);
}
}
AppEvent::UpdateSubagentCommand(cmd) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_subagent_update(cmd);
}
}
AppEvent::DeleteSubagentCommand(name) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.delete_subagent_by_name(&name);
}
}
// ShowAgentsSettings removed
AppEvent::ShowAgentsOverview => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.ensure_settings_overlay_section(SettingsSection::Agents);
widget.show_agents_overview_ui();
}
}
// ShowSubagentEditor removed; use ShowSubagentEditorForName/ShowSubagentEditorNew
AppEvent::ShowSubagentEditorForName { name } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.ensure_settings_overlay_section(SettingsSection::Agents);
widget.show_subagent_editor_for_name(name);
}
}
AppEvent::ShowSubagentEditorNew => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.ensure_settings_overlay_section(SettingsSection::Agents);
widget.show_new_subagent_editor();
}
}
AppEvent::UpdateAgentConfig { name, enabled, args_read_only, args_write, instructions, description, command } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_agent_update(&name, enabled, args_read_only, args_write, instructions, description, command);
}
}
AppEvent::AgentValidationFinished { name, result, attempt_id } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_agent_validation_finished(&name, attempt_id, result);
}
}
AppEvent::PrefillComposer(text) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.insert_str(&text);
}
}
AppEvent::SubmitTextWithPreface { visible, preface } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.submit_text_message_with_preface(visible, preface);
}
}
AppEvent::RunReviewCommand(args) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_review_command(args);
}
}
AppEvent::UpdateReviewAutoResolveEnabled(enabled) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_review_auto_resolve_enabled(enabled);
}
}
AppEvent::UpdateReviewAutoResolveAttempts(attempts) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_review_auto_resolve_attempts(attempts);
}
}
AppEvent::ShowReviewModelSelector => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_review_model_selector();
}
}
AppEvent::RunReviewWithScope {
prompt,
hint,
preparation_label,
metadata,
auto_resolve,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.start_review_with_scope(
prompt,
hint,
preparation_label,
metadata,
auto_resolve,
);
}
}
AppEvent::OpenReviewCustomPrompt => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_review_custom_prompt();
}
}
AppEvent::FetchCloudTasks { environment } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_tasks_loading();
}
let tx = self.app_event_tx.clone();
let env_clone = environment.clone();
tokio::spawn(async move {
match cloud_tasks_service::fetch_tasks(environment).await {
Ok(tasks) => tx.send(AppEvent::PresentCloudTasks {
environment: env_clone,
tasks,
}),
Err(err) => tx.send(AppEvent::CloudTasksError {
message: err.to_string(),
}),
}
});
}
AppEvent::PresentCloudTasks { environment, tasks } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.present_cloud_tasks(environment, tasks);
}
}
AppEvent::CloudTasksError { message } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_tasks_error(message);
}
}
AppEvent::FetchCloudEnvironments => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_environment_loading();
}
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
match cloud_tasks_service::fetch_environments().await {
Ok(envs) => tx.send(AppEvent::PresentCloudEnvironments { environments: envs }),
Err(err) => tx.send(AppEvent::CloudTasksError { message: err.to_string() }),
}
});
}
AppEvent::PresentCloudEnvironments { environments } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.present_cloud_environment_picker(environments);
}
}
AppEvent::SetCloudEnvironment { environment } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_cloud_environment(environment);
}
}
AppEvent::ShowCloudTaskActions { task_id } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_task_actions(task_id);
}
}
AppEvent::FetchCloudTaskDiff { task_id } => {
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let task = TaskId(task_id.clone());
match cloud_tasks_service::fetch_task_diff(task.clone()).await {
Ok(Some(diff)) => {
tx.send(AppEvent::DiffResult(diff));
}
Ok(None) => tx.send(AppEvent::CloudTasksError {
message: format!("Task {} has no diff available", task.0),
}),
Err(err) => tx.send(AppEvent::CloudTasksError { message: err.to_string() }),
}
});
}
AppEvent::FetchCloudTaskMessages { task_id } => {
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let task = TaskId(task_id.clone());
match cloud_tasks_service::fetch_task_messages(task).await {
Ok(messages) if !messages.is_empty() => {
let joined = messages.join("\n\n");
tx.send(AppEvent::InsertBackgroundEvent {
message: format!("Cloud task output for {task_id}:\n{joined}"),
placement: crate::app_event::BackgroundPlacement::Tail,
order: None,
});
}
Ok(_) => tx.send(AppEvent::CloudTasksError {
message: format!("Task {task_id} has no assistant messages"),
}),
Err(err) => tx.send(AppEvent::CloudTasksError { message: err.to_string() }),
}
});
}
AppEvent::ApplyCloudTask { task_id, preflight } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_task_apply_status(&task_id, preflight);
}
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let task = TaskId(task_id.clone());
let result = cloud_tasks_service::apply_task(task, preflight).await;
tx.send(AppEvent::CloudTaskApplyFinished {
task_id,
outcome: result.map_err(|err| CloudTaskError::Msg(err.to_string())),
preflight,
});
});
}
AppEvent::CloudTaskApplyFinished { task_id, outcome, preflight } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_cloud_task_apply_finished(task_id, outcome, preflight);
}
}
AppEvent::OpenCloudTaskCreate => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_task_create_prompt();
}
}
AppEvent::SubmitCloudTaskCreate { env_id, prompt, best_of_n } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_task_create_progress();
}
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = cloud_tasks_service::create_task(env_id.clone(), prompt.clone(), best_of_n).await;
tx.send(AppEvent::CloudTaskCreated {
env_id,
result: result.map_err(|err| CloudTaskError::Msg(err.to_string())),
});
});
}
AppEvent::CloudTaskCreated { env_id, result } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_cloud_task_created(env_id.clone(), result);
}
}
AppEvent::StartReviewCommitPicker => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_review_commit_loading();
}
let cwd = self.config.cwd.clone();
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let commits = code_core::git_info::recent_commits(&cwd, 60).await;
tx.send(AppEvent::PresentReviewCommitPicker { commits });
});
}
AppEvent::PresentReviewCommitPicker { commits } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.present_review_commit_picker(commits);
}
}
AppEvent::StartReviewBranchPicker => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_review_branch_loading();
}
let cwd = self.config.cwd.clone();
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let (branches, current_branch) = tokio::join!(
code_core::git_info::local_git_branches(&cwd),
code_core::git_info::current_branch_name(&cwd),
);
tx.send(AppEvent::PresentReviewBranchPicker {
current_branch,
branches,
});
});
}
AppEvent::PresentReviewBranchPicker {
current_branch,
branches,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.present_review_branch_picker(current_branch, branches);
}
}
AppEvent::DiffResult(text) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_output(text);
}
}
AppEvent::UpdateTheme(new_theme) => {
// Switch the theme immediately
if matches!(new_theme, code_core::config_types::ThemeName::Custom) {
// Prefer runtime custom colors; fall back to config on disk
if let Some(colors) = crate::theme::custom_theme_colors() {
crate::theme::init_theme(&code_core::config_types::ThemeConfig { name: new_theme, colors, label: crate::theme::custom_theme_label(), is_dark: crate::theme::custom_theme_is_dark() });
} else if let Ok(cfg) = code_core::config::Config::load_with_cli_overrides(vec![], code_core::config::ConfigOverrides::default()) {
crate::theme::init_theme(&cfg.tui.theme);
} else {
crate::theme::switch_theme(new_theme);
}
} else {
crate::theme::switch_theme(new_theme);
}
// Clear terminal with new theme colors
let theme_bg = crate::colors::background();
let theme_fg = crate::colors::text();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::style::SetColors(crossterm::style::Colors::new(
theme_fg.into(),
theme_bg.into()
)),
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::terminal::EnableLineWrap
);
self.apply_terminal_title();
// Update config and save to file
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_theme(new_theme);
}
// Force a full redraw on the next frame so the entire
// ratatui back buffer is cleared and repainted with the
// new theme. This avoids any stale cells lingering on
// terminals that preserve previous cell attributes.
self.clear_on_first_frame = true;
self.schedule_redraw();
}
AppEvent::PreviewTheme(new_theme) => {
// Switch the theme immediately for preview (no history event)
if matches!(new_theme, code_core::config_types::ThemeName::Custom) {
if let Some(colors) = crate::theme::custom_theme_colors() {
crate::theme::init_theme(&code_core::config_types::ThemeConfig { name: new_theme, colors, label: crate::theme::custom_theme_label(), is_dark: crate::theme::custom_theme_is_dark() });
} else if let Ok(cfg) = code_core::config::Config::load_with_cli_overrides(vec![], code_core::config::ConfigOverrides::default()) {
crate::theme::init_theme(&cfg.tui.theme);
} else {
crate::theme::switch_theme(new_theme);
}
} else {
crate::theme::switch_theme(new_theme);
}
// Clear terminal with new theme colors
let theme_bg = crate::colors::background();
let theme_fg = crate::colors::text();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::style::SetColors(crossterm::style::Colors::new(
theme_fg.into(),
theme_bg.into()
)),
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::terminal::EnableLineWrap
);
self.apply_terminal_title();
// Retint pre-rendered history cells so the preview reflects immediately
if let AppState::Chat { widget } = &mut self.app_state {
widget.retint_history_for_preview();
}
// Don't update config or add to history for previews
// Force a full redraw so previews repaint cleanly as you cycle
self.clear_on_first_frame = true;
self.schedule_redraw();
}
AppEvent::UpdateSpinner(name) => {
// Switch spinner immediately
crate::spinner::switch_spinner(&name);
// Update config and save to file
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_spinner(name.clone());
}
self.schedule_redraw();
}
AppEvent::PreviewSpinner(name) => {
// Switch spinner immediately for preview (no history event)
crate::spinner::switch_spinner(&name);
// No config change on preview
self.schedule_redraw();
}
AppEvent::ComposerExpanded => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.on_composer_expanded();
}
self.schedule_redraw();
}
AppEvent::ShowLoginAccounts => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_login_accounts_view();
}
}
AppEvent::ShowLoginAddAccount => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_login_add_account_view();
}
}
AppEvent::CycleAccessMode => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.cycle_access_mode();
}
self.schedule_redraw();
}
AppEvent::CycleAutoDriveVariant => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.cycle_auto_drive_variant();
}
self.schedule_redraw();
}
AppEvent::LoginStartChatGpt => {
if let AppState::Chat { widget } = &mut self.app_state {
if !widget.login_add_view_active() {
continue 'main;
}
if let Some(flow) = self.login_flow.take() {
flow.shutdown.shutdown();
flow.join_handle.abort();
}
let opts = ServerOptions::new(
self.config.code_home.clone(),
code_login::CLIENT_ID.to_string(),
self.config.responses_originator_header.clone(),
);
match code_login::run_login_server(opts) {
Ok(server) => {
widget.notify_login_chatgpt_started(server.auth_url.clone());
let shutdown = server.cancel_handle();
let tx = self.app_event_tx.clone();
let join_handle = tokio::spawn(async move {
let result = server
.block_until_done()
.await
.map_err(|e| e.to_string());
tx.send(AppEvent::LoginChatGptComplete { result });
});
self.login_flow = Some(LoginFlowState { shutdown, join_handle });
}
Err(err) => {
widget.notify_login_chatgpt_failed(format!(
"Failed to start ChatGPT login: {err}"
));
}
}
}
}
AppEvent::LoginCancelChatGpt => {
if let Some(flow) = self.login_flow.take() {
flow.shutdown.shutdown();
flow.join_handle.abort();
}
if let AppState::Chat { widget } = &mut self.app_state {
widget.notify_login_chatgpt_cancelled();
}
}
AppEvent::LoginChatGptComplete { result } => {
if let Some(flow) = self.login_flow.take() {
flow.shutdown.shutdown();
// Allow the task to finish naturally; if still running, abort.
if !flow.join_handle.is_finished() {
flow.join_handle.abort();
}
}
if let AppState::Chat { widget } = &mut self.app_state {
widget.notify_login_chatgpt_complete(result);
}
}
AppEvent::LoginUsingChatGptChanged { using_chatgpt_auth } => {
self.handle_login_mode_change(using_chatgpt_auth);
}
AppEvent::OnboardingAuthComplete(result) => {
if let AppState::Onboarding { screen } = &mut self.app_state {
screen.on_auth_complete(result);
}
}
AppEvent::OnboardingComplete(ChatWidgetArgs {
config,
enhanced_keys_supported,
initial_images,
initial_prompt,
terminal_info,
show_order_overlay,
enable_perf,
resume_picker,
latest_upgrade_version,
}) => {
let mut w = ChatWidget::new(
config,
app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
terminal_info,
show_order_overlay,
latest_upgrade_version,
);
w.enable_perf(enable_perf);
if resume_picker {
w.show_resume_picker();
}
self.app_state = AppState::Chat { widget: Box::new(w) };
self.terminal_runs.clear();
}
AppEvent::StartFileSearch(query) => {
if !query.is_empty() {
self.file_search.on_user_query(query);
}
}
AppEvent::FileSearchResult { query, matches } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_file_search_result(query, matches);
}
}
AppEvent::ShowChromeOptions(port) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_chrome_options(port);
}
}
AppEvent::ChromeLaunchOptionSelected(option, port) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_chrome_launch_option(option, port);
}
}
AppEvent::JumpBack {
nth,
prefill,
history_snapshot,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
let ghost_state = widget.snapshot_ghost_state();
// Build response items from current UI history
let items = widget.export_response_items();
let cfg = widget.config_ref().clone();
// Compute prefix up to selected user message now
let prefix_items = {
let mut user_seen = 0usize;
let mut cut = items.len();
for (idx, it) in items.iter().enumerate().rev() {
if let code_protocol::models::ResponseItem::Message { role, .. } = it {
if role == "user" {
user_seen += 1;
if user_seen == nth { cut = idx; break; }
}
}
}
items.iter().take(cut).cloned().collect::<Vec<_>>()
};
self.pending_jump_back_ghost_state = Some(ghost_state);
self.pending_jump_back_history_snapshot = history_snapshot;
// Perform the fork off the UI thread to avoid nested runtimes
let server = self._server.clone();
let tx = self.app_event_tx.clone();
let prefill_clone = prefill.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("build tokio runtime");
// Clone cfg for the async block to keep original for the event
let cfg_for_rt = cfg.clone();
let result = rt.block_on(async move {
// Fallback: start a new conversation instead of forking
server.new_conversation(cfg_for_rt).await
});
if let Ok(new_conv) = result {
tx.send(AppEvent::JumpBackForked { cfg, new_conv: crate::app_event::Redacted(new_conv), prefix_items, prefill: prefill_clone });
} else if let Err(e) = result {
tracing::error!("error forking conversation: {e:#}");
}
});
}
}
AppEvent::JumpBackForked { cfg, new_conv, prefix_items, prefill } => {
// Replace widget with a new one bound to the forked conversation
let session_conf = new_conv.0.session_configured.clone();
let conv = new_conv.0.conversation.clone();
let mut ghost_state = self.pending_jump_back_ghost_state.take();
let history_snapshot = self.pending_jump_back_history_snapshot.take();
let emit_prefix = history_snapshot.is_none();
if let AppState::Chat { widget } = &mut self.app_state {
let auth_manager = widget.auth_manager();
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(),
self.show_order_overlay,
self.latest_upgrade_version.clone(),
auth_manager,
false,
);
if let Some(state) = ghost_state.take() {
new_widget.adopt_ghost_state(state);
} else {
tracing::warn!("jump-back fork missing ghost snapshot state; redo may be unavailable");
}
if let Some(snapshot) = history_snapshot.as_ref() {
new_widget.restore_history_snapshot(snapshot);
}
new_widget.enable_perf(self.timing_enabled);
new_widget.check_for_initial_animations();
*widget = Box::new(new_widget);
} else {
let auth_manager = AuthManager::shared_with_mode_and_originator(
cfg.code_home.clone(),
AuthMode::ApiKey,
cfg.responses_originator_header.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(),
self.show_order_overlay,
self.latest_upgrade_version.clone(),
auth_manager,
false,
);
if let Some(state) = ghost_state.take() {
new_widget.adopt_ghost_state(state);
}
if let Some(snapshot) = history_snapshot.as_ref() {
new_widget.restore_history_snapshot(snapshot);
}
new_widget.enable_perf(self.timing_enabled);
new_widget.check_for_initial_animations();
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
}
self.terminal_runs.clear();
// Reset any transient state from the previous widget/session
self.commit_anim_running.store(false, Ordering::Release);
self.last_esc_time = None;
// Force a clean repaint of the new UI state
self.clear_on_first_frame = true;
// Replay prefix to the UI
if emit_prefix {
let ev = code_core::protocol::Event {
id: "fork".to_string(),
event_seq: 0,
msg: code_core::protocol::EventMsg::ReplayHistory(
code_core::protocol::ReplayHistoryEvent {
items: prefix_items,
history_snapshot: None,
}
),
order: None,
};
self.app_event_tx.send(AppEvent::CodexEvent(ev));
}
// Prefill composer with the edited text
if let AppState::Chat { widget } = &mut self.app_state {
if !prefill.is_empty() { widget.insert_str(&prefill); }
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::ScheduleFrameIn(duration) => {
// Schedule the next redraw with the requested duration
self.schedule_redraw_in(duration);
}
AppEvent::GhostSnapshotFinished { job_id, result, elapsed } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_ghost_snapshot_finished(job_id, result, elapsed);
}
}
}
}
if self.alt_screen_active {
terminal.clear()?;
}
Ok(())
}
/// Pull the next event with priority for interactive input.
/// Never returns None due to idleness; only returns None if both channels disconnect.
fn next_event_priority(&self) -> Option<AppEvent> {
use std::sync::mpsc::RecvTimeoutError::{Timeout, Disconnected};
loop {
if let Ok(ev) = self.app_event_rx_high.try_recv() { return Some(ev); }
if let Ok(ev) = self.app_event_rx_bulk.try_recv() { return Some(ev); }
match self.app_event_rx_high.recv_timeout(Duration::from_millis(10)) {
Ok(ev) => return Some(ev),
Err(Timeout) => continue,
Err(Disconnected) => break,
}
}
// High channel disconnected; try blocking on bulk as a last resort
self.app_event_rx_bulk.recv().ok()
}
#[cfg(unix)]
fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> {
tui::restore()?;
// SAFETY: Unix-only code path. We intentionally send SIGTSTP to the
// current process group (pid 0) to trigger standard job-control
// suspension semantics. This FFI does not involve any raw pointers,
// is not called from a signal handler, and uses a constant signal.
// Errors from kill are acceptable (e.g., if already stopped) — the
// subsequent re-init path will still leave the terminal in a good state.
// We considered `nix`, but didn't think it was worth pulling in for this one call.
unsafe { libc::kill(0, libc::SIGTSTP) };
let (new_terminal, new_terminal_info) = tui::init(&self.config)?;
*terminal = new_terminal;
self.terminal_info = new_terminal_info;
terminal.clear()?;
self.app_event_tx.send(AppEvent::RequestRedraw);
Ok(())
}
/// Toggle between alternate-screen TUI and standard terminal buffer (Ctrl+T).
fn toggle_screen_mode(&mut self, _terminal: &mut tui::Tui) -> Result<()> {
if self.alt_screen_active {
// Leave alt screen only; keep raw mode enabled for key handling.
let _ = crate::tui::leave_alt_screen_only();
// Clear the normal buffer so our buffered transcript starts at a clean screen
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::style::ResetColor,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::terminal::EnableLineWrap
);
self.alt_screen_active = false;
// Persist preference
let _ = code_core::config::set_tui_alternate_screen(&self.config.code_home, false);
// Immediately mirror the entire transcript into the terminal scrollback so
// the user sees full history when entering standard mode.
if let AppState::Chat { widget } = &self.app_state {
let transcript = widget.export_transcript_lines_for_buffer();
if !transcript.is_empty() {
// Best-effort: compute current width and bottom reservation.
// We don't have `terminal` here; schedule a one-shot redraw event
// that carries the transcript via InsertHistory to reuse the normal path.
self.app_event_tx.send(AppEvent::InsertHistory(transcript));
}
}
// Ensure the input is painted in its reserved region immediately.
self.schedule_redraw();
} else {
// Re-enter alt screen and force a clean repaint.
let fg = crate::colors::text();
let bg = crate::colors::background();
let _ = crate::tui::enter_alt_screen_only(fg, bg);
self.clear_on_first_frame = true;
self.alt_screen_active = true;
// Persist preference
let _ = code_core::config::set_tui_alternate_screen(&self.config.code_home, true);
// Request immediate redraw
self.schedule_redraw();
}
Ok(())
}
pub(crate) fn token_usage(&self) -> code_core::protocol::TokenUsage {
let usage = match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
AppState::Onboarding { .. } => code_core::protocol::TokenUsage::default(),
};
// ensure background helpers stop before returning
self.commit_anim_running.store(false, Ordering::Release);
self.input_running.store(false, Ordering::Release);
usage
}
pub(crate) fn session_id(&self) -> Option<uuid::Uuid> {
match &self.app_state {
AppState::Chat { widget } => widget.session_id(),
AppState::Onboarding { .. } => None,
}
}
/// Return a human-readable performance summary if timing was enabled.
pub(crate) fn perf_summary(&self) -> Option<String> {
if !self.timing_enabled {
return None;
}
let mut out = String::new();
if let AppState::Chat { widget } = &self.app_state {
out.push_str(&widget.perf_summary());
out.push_str("\n\n");
}
out.push_str(&self.timing.summarize());
Some(out)
}
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
// Always render a frame. In standard-terminal mode we still draw the
// chat UI (without status/HUD) directly into the normal buffer.
// Hard clear on the very first frame (and while onboarding) to ensure a
// clean background across terminals that don't respect our color attrs
// during EnterAlternateScreen.
if self.alt_screen_active && (self.clear_on_first_frame || matches!(self.app_state, AppState::Onboarding { .. })) {
terminal.clear()?;
self.clear_on_first_frame = false;
}
// If the terminal area changed (actual resize or tab switch that altered
// viewport), force a full clear once to prevent ghost artifacts. Some
// terminals on Windows/macOS do not reliably deliver Resize events on
// focus switches; querying the size each frame is cheap and lets us
// detect the change without extra event wiring.
let screen_size = terminal.size()?;
if self
.last_frame_size
.map(|prev| prev != screen_size)
.unwrap_or(false)
{
terminal.clear()?;
}
self.last_frame_size = Some(screen_size);
let completed_frame = terminal.draw(|frame| {
match &mut self.app_state {
AppState::Chat { widget } => {
if let Some((x, y)) = widget.cursor_pos(frame.area()) {
frame.set_cursor_position((x, y));
}
frame.render_widget_ref(&**widget, frame.area())
}
AppState::Onboarding { screen } => frame.render_widget_ref(&*screen, frame.area()),
}
})?;
self.buffer_diff_profiler.record(&completed_frame);
Ok(())
}
/// Dispatch a KeyEvent to the current view and let it decide what to do
/// with it.
fn dispatch_key_event(&mut self, key_event: KeyEvent) {
match &mut self.app_state {
AppState::Chat { widget } => {
widget.handle_key_event(key_event);
}
AppState::Onboarding { screen } => match key_event.code {
KeyCode::Char('q') => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
_ => screen.handle_key_event(key_event),
},
}
}
fn dispatch_paste_event(&mut self, pasted: String) {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_paste(pasted),
AppState::Onboarding { .. } => {}
}
}
fn dispatch_mouse_event(&mut self, mouse_event: crossterm::event::MouseEvent) {
match &mut self.app_state {
AppState::Chat { widget } => {
widget.handle_mouse_event(mouse_event);
}
AppState::Onboarding { .. } => {}
}
}
fn dispatch_code_event(&mut self, event: Event) {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_code_event(event),
AppState::Onboarding { .. } => {}
}
}
fn normalize_non_enhanced_release_code(code: KeyCode) -> KeyCode {
match code {
KeyCode::Char('\r') | KeyCode::Char('\n') => KeyCode::Enter,
KeyCode::Char('\t') => KeyCode::Tab,
KeyCode::Char('\u{1b}') => KeyCode::Esc,
other => other,
}
}
}
struct BufferDiffProfiler {
enabled: bool,
prev: Option<Buffer>,
frame_seq: u64,
log_every: usize,
min_changed: usize,
min_percent: f64,
}
impl BufferDiffProfiler {
fn new_from_env() -> Self {
match std::env::var("CODE_BUFFER_DIFF_METRICS") {
Ok(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed == "0" {
Self::disabled()
} else {
let log_every = trimmed.parse::<usize>().unwrap_or(1).max(1);
let min_changed = std::env::var("CODE_BUFFER_DIFF_MIN_CHANGED")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(100);
let min_percent = std::env::var("CODE_BUFFER_DIFF_MIN_PERCENT")
.ok()
.and_then(|v| v.trim().parse::<f64>().ok())
.unwrap_or(1.0_f64);
Self {
enabled: true,
prev: None,
frame_seq: 0,
log_every,
min_changed,
min_percent,
}
}
}
Err(_) => Self::disabled(),
}
}
fn disabled() -> Self {
Self {
enabled: false,
prev: None,
frame_seq: 0,
log_every: 1,
min_changed: usize::MAX,
min_percent: f64::MAX,
}
}
fn record(&mut self, frame: &CompletedFrame<'_>) {
if !self.enabled {
return;
}
let current_buffer = frame.buffer.clone();
self.frame_seq = self.frame_seq.saturating_add(1);
if let Some(prev_buffer) = &self.prev {
if self.should_log_frame() {
if prev_buffer.area != current_buffer.area {
tracing::info!(
target: "code_tui::buffer_diff",
frame = self.frame_seq,
prev_width = prev_buffer.area.width,
prev_height = prev_buffer.area.height,
width = current_buffer.area.width,
height = current_buffer.area.height,
"Buffer area changed; skipping diff metrics for this frame"
);
} else {
let inspected = prev_buffer.content.len().min(current_buffer.content.len());
let updates = prev_buffer.diff(&current_buffer);
let changed = updates.len();
if changed == 0 {
self.prev = Some(current_buffer);
return;
}
let percent = if inspected > 0 {
(changed as f64 / inspected as f64) * 100.0
} else {
0.0
};
if changed < self.min_changed && percent < self.min_percent {
self.prev = Some(current_buffer);
return;
}
let mut min_col = u16::MAX;
let mut max_col = 0u16;
let mut rows = BTreeSet::new();
let mut longest_run = 0usize;
let mut current_run = 0usize;
let mut last_cell = None;
for (x, y, _) in &updates {
min_col = min_col.min(*x);
max_col = max_col.max(*x);
rows.insert(*y);
match last_cell {
Some((last_x, last_y)) if *y == last_y && *x == last_x + 1 => {
current_run += 1;
}
_ => {
current_run = 1;
}
}
if current_run > longest_run {
longest_run = current_run;
}
last_cell = Some((*x, *y));
}
let row_min = rows.iter().copied().min().unwrap_or(0);
let row_max = rows.iter().copied().max().unwrap_or(0);
let mut spans: Vec<(u16, u16)> = Vec::new();
if !rows.is_empty() {
let mut iter = rows.iter();
let mut start = *iter.next().unwrap();
let mut prev = start;
for &row in iter {
if row == prev + 1 {
prev = row;
continue;
}
spans.push((start, prev));
start = row;
prev = row;
}
spans.push((start, prev));
}
spans.sort_by(|(a_start, a_end), (b_start, b_end)| {
let a_len = usize::from(*a_end) - usize::from(*a_start) + 1;
let b_len = usize::from(*b_end) - usize::from(*b_start) + 1;
b_len.cmp(&a_len)
});
let top_spans: Vec<(u16, u16)> = spans.into_iter().take(3).collect();
let (col_min, col_max) = if min_col == u16::MAX {
(0u16, 0u16)
} else {
(min_col, max_col)
};
let skipped_cells = current_buffer.content.iter().filter(|cell| cell.skip).count();
tracing::info!(
target: "code_tui::buffer_diff",
frame = self.frame_seq,
inspected,
changed,
percent = format!("{percent:.2}"),
width = current_buffer.area.width,
height = current_buffer.area.height,
dirty_rows = rows.len(),
longest_run,
row_min,
row_max,
col_min,
col_max,
row_spans = ?top_spans,
skipped_cells,
"Buffer diff metrics"
);
}
}
}
self.prev = Some(current_buffer);
}
fn should_log_frame(&self) -> bool {
let interval = self.log_every.max(1) as u64;
interval == 1 || self.frame_seq % interval == 0
}
}
fn should_show_onboarding(
login_status: crate::LoginStatus,
_config: &Config,
show_trust_screen: bool,
) -> bool {
if show_trust_screen {
return true;
}
matches!(login_status, crate::LoginStatus::NotAuthenticated)
}
fn should_show_login_screen(login_status: crate::LoginStatus, _config: &Config) -> bool {
matches!(login_status, crate::LoginStatus::NotAuthenticated)
}
// (legacy tests removed)
#[derive(Default, Clone, Debug)]
struct TimingStats {
frames_drawn: u64,
redraw_events: u64,
key_events: u64,
draw_ns: Vec<u64>,
key_to_frame_ns: Vec<u64>,
last_key_event: Option<Instant>,
key_waiting_for_frame: bool,
}
impl TimingStats {
fn on_key(&mut self) {
self.key_events = self.key_events.saturating_add(1);
self.last_key_event = Some(Instant::now());
self.key_waiting_for_frame = true;
}
fn on_redraw_begin(&mut self) { self.redraw_events = self.redraw_events.saturating_add(1); }
fn on_redraw_end(&mut self, started: Instant) {
self.frames_drawn = self.frames_drawn.saturating_add(1);
let dt = started.elapsed().as_nanos() as u64;
self.draw_ns.push(dt);
if self.key_waiting_for_frame {
if let Some(t0) = self.last_key_event.take() {
let d = t0.elapsed().as_nanos() as u64;
self.key_to_frame_ns.push(d);
}
self.key_waiting_for_frame = false;
}
}
fn pct(ns: &[u64], p: f64) -> f64 {
if ns.is_empty() { return 0.0; }
let mut v = ns.to_vec();
v.sort_unstable();
let idx = ((v.len() as f64 - 1.0) * p).round() as usize;
(v[idx] as f64) / 1_000_000.0
}
fn summarize(&self) -> String {
let draw_p50 = Self::pct(&self.draw_ns, 0.50);
let draw_p95 = Self::pct(&self.draw_ns, 0.95);
let kf_p50 = Self::pct(&self.key_to_frame_ns, 0.50);
let kf_p95 = Self::pct(&self.key_to_frame_ns, 0.95);
format!(
"app-timing: frames={}\n redraw_events={} key_events={}\n draw_ms: p50={:.2} p95={:.2}\n key->frame_ms: p50={:.2} p95={:.2}",
self.frames_drawn,
self.redraw_events,
self.key_events,
draw_p50, draw_p95,
kf_p50, kf_p95,
)
}
}