diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d879d196..d6b98fcd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -926,6 +926,7 @@ name = "codex-tui" version = "0.0.0" dependencies = [ "anyhow", + "async-stream", "base64 0.22.1", "chrono", "clap", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index b5ec8d04..20ceb0b7 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -22,6 +22,7 @@ workspace = true [dependencies] anyhow = "1" +async-stream = "0.3.6" base64 = "0.22.1" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6ce51e5d..399acd40 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,254 +1,142 @@ -use crate::LoginStatus; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; use crate::get_git_diff::get_git_diff; -use crate::get_login_status; -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::tui; +use crate::tui::TuiEvent; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::protocol::Event; use codex_core::protocol::Op; +use codex_core::protocol::TokenUsage; use color_eyre::eyre::Result; -use crossterm::SynchronizedUpdate; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::terminal::supports_keyboard_enhancement; -use ratatui::layout::Offset; -use ratatui::prelude::Backend; -use ratatui::text::Line; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; -use std::time::Instant; use tokio::select; -use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::unbounded_channel; -/// Time window for debouncing redraw requests. -const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1); - -/// 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>, - }, -} - -pub(crate) struct App<'a> { +pub(crate) struct App { server: Arc, app_event_tx: AppEventSender, - app_event_rx: UnboundedReceiver, - app_state: AppState<'a>, + chat_widget: ChatWidget, /// Config is stored here so we can recreate ChatWidgets as needed. config: Config, file_search: FileSearchManager, - pending_history_lines: Vec>, - enhanced_keys_supported: bool, /// Controls the animation thread that sends CommitTick events. commit_anim_running: Arc, - - /// Channel to schedule one-shot animation frames; coalesced by a single - /// scheduler thread. - frame_schedule_tx: std::sync::mpsc::Sender, } -/// 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, - initial_images: Vec, - enhanced_keys_supported: bool, -} - -impl App<'_> { - pub(crate) fn new( +impl App { + pub async fn run( + tui: &mut tui::Tui, config: Config, initial_prompt: Option, - initial_images: Vec, - show_trust_screen: bool, - ) -> Self { - let conversation_manager = Arc::new(ConversationManager::default()); - - let (app_event_tx, app_event_rx) = unbounded_channel(); + initial_images: Vec, + ) -> Result { + use tokio_stream::StreamExt; + let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); + let conversation_manager = Arc::new(ConversationManager::default()); + let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); - 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, - }; - AppState::Onboarding { - screen: OnboardingScreen::new(OnboardingScreenArgs { - event_tx: app_event_tx.clone(), - codex_home: config.codex_home.clone(), - cwd: config.cwd.clone(), - show_trust_screen, - show_login_screen, - chat_widget_args, - login_status, - }), - } - } else { - let chat_widget = ChatWidget::new( - config.clone(), - conversation_manager.clone(), - app_event_tx.clone(), - initial_prompt, - initial_images, - enhanced_keys_supported, - ); - AppState::Chat { - widget: Box::new(chat_widget), - } - }; + let chat_widget = ChatWidget::new( + config.clone(), + conversation_manager.clone(), + tui.frame_requester(), + app_event_tx.clone(), + initial_prompt, + initial_images, + enhanced_keys_supported, + ); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); - // Spawn a single scheduler thread that coalesces both debounced redraw - // requests and animation frame requests, and emits a single Redraw event - // at the earliest requested time. - let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); - { - let app_event_tx = app_event_tx.clone(); - std::thread::spawn(move || { - use std::sync::mpsc::RecvTimeoutError; - let mut next_deadline: Option = None; - loop { - if next_deadline.is_none() { - match frame_rx.recv() { - Ok(deadline) => next_deadline = Some(deadline), - Err(_) => break, - } - } - - #[expect(clippy::expect_used)] - let deadline = next_deadline.expect("deadline set"); - let now = Instant::now(); - let timeout = if deadline > now { - deadline - now - } else { - Duration::from_millis(0) - }; - - match frame_rx.recv_timeout(timeout) { - Ok(new_deadline) => { - next_deadline = - Some(next_deadline.map_or(new_deadline, |d| d.min(new_deadline))); - } - Err(RecvTimeoutError::Timeout) => { - app_event_tx.send(AppEvent::Redraw); - next_deadline = None; - } - Err(RecvTimeoutError::Disconnected) => break, - } - } - }); - } - Self { + let mut app = Self { server: conversation_manager, app_event_tx, - pending_history_lines: Vec::new(), - app_event_rx, - app_state, + chat_widget, config, file_search, enhanced_keys_supported, commit_anim_running: Arc::new(AtomicBool::new(false)), - frame_schedule_tx: frame_tx, - } - } + }; - fn schedule_frame_in(&self, dur: Duration) { - let _ = self.frame_schedule_tx.send(Instant::now() + dur); - } + let tui_events = tui.event_stream(); + tokio::pin!(tui_events); - pub(crate) async fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { - use tokio_stream::StreamExt; + tui.frame_requester().schedule_frame(); - self.handle_event(terminal, AppEvent::Redraw)?; - - let mut crossterm_events = crossterm::event::EventStream::new(); - - while let Some(event) = { - select! { - maybe_app_event = self.app_event_rx.recv() => { - maybe_app_event - }, - Some(Ok(event)) = crossterm_events.next() => { - match event { - crossterm::event::Event::Key(key_event) => { - Some(AppEvent::KeyEvent(key_event)) - } - crossterm::event::Event::Resize(_, _) => { - Some(AppEvent::Redraw) - } - 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"); - Some(AppEvent::Paste(pasted)) - } - _ => { - // Ignore any other events. - None - } - } - }, + while select! { + Some(event) = app_event_rx.recv() => { + app.handle_event(tui, event)? } - } && self.handle_event(terminal, event)? - {} - terminal.clear()?; - Ok(()) + Some(event) = tui_events.next() => { + app.handle_tui_event(tui, event).await? + } + } {} + tui.terminal.clear()?; + Ok(app.token_usage()) } - fn handle_event(&mut self, terminal: &mut tui::Tui, event: AppEvent) -> Result { + pub(crate) async fn handle_tui_event( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result { + match event { + TuiEvent::Key(key_event) => { + self.handle_key_event(key_event).await; + } + TuiEvent::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"); + self.chat_widget.handle_paste(pasted); + } + TuiEvent::Draw => { + tui.draw( + self.chat_widget.desired_height(tui.terminal.size()?.width), + |frame| { + frame.render_widget_ref(&self.chat_widget, frame.area()); + if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + frame.set_cursor_position((x, y)); + } + }, + )?; + } + #[cfg(unix)] + TuiEvent::ResumeFromSuspend => { + let cursor_pos = tui.terminal.get_cursor_position()?; + tui.terminal + .set_viewport_area(ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0)); + } + } + Ok(true) + } + + fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::InsertHistory(lines) => { - self.pending_history_lines.extend(lines); - self.app_event_tx.send(AppEvent::RequestRedraw); - } - AppEvent::RequestRedraw => { - self.schedule_frame_in(REDRAW_DEBOUNCE); - } - AppEvent::ScheduleFrameIn(dur) => { - self.schedule_frame_in(dur); - } - AppEvent::Redraw => { - std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??; + tui.insert_history_lines(lines); } AppEvent::StartCommitAnimation => { if self @@ -270,124 +158,48 @@ impl App<'_> { self.commit_anim_running.store(false, Ordering::Release); } AppEvent::CommitTick => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.on_commit_tick(); - } - } - AppEvent::KeyEvent(key_event) => { - match key_event { - KeyEvent { - code: KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => match &mut self.app_state { - AppState::Chat { widget } => { - widget.on_ctrl_c(); - } - AppState::Onboarding { .. } => { - self.app_event_tx.send(AppEvent::ExitRequest); - } - }, - KeyEvent { - code: KeyCode::Char('z'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - #[cfg(unix)] - { - self.suspend(terminal)?; - } - // No-op on non-Unix platforms. - } - KeyEvent { - code: KeyCode::Char('d'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - match &mut self.app_state { - AppState::Chat { widget } => { - if widget.composer_is_empty() { - self.app_event_tx.send(AppEvent::ExitRequest); - } else { - // Treat Ctrl+D as a normal key event when the composer - // is not empty so that it doesn't quit the application - // prematurely. - self.dispatch_key_event(key_event); - } - } - AppState::Onboarding { .. } => { - self.app_event_tx.send(AppEvent::ExitRequest); - } - } - } - KeyEvent { - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - self.dispatch_key_event(key_event); - } - _ => { - // Ignore Release key events. - } - }; - } - AppEvent::Paste(text) => { - self.dispatch_paste_event(text); + self.chat_widget.on_commit_tick(); } AppEvent::CodexEvent(event) => { - self.dispatch_codex_event(event); + self.chat_widget.handle_codex_event(event); } AppEvent::ExitRequest => { return Ok(false); } - AppEvent::CodexOp(op) => match &mut self.app_state { - AppState::Chat { widget } => widget.submit_op(op), - AppState::Onboarding { .. } => {} - }, + AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), AppEvent::DiffResult(text) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_diff_output(text); - } + self.chat_widget.add_diff_output(text); } AppEvent::DispatchCommand(command) => match command { SlashCommand::New => { // User accepted – switch to chat view. - let new_widget = Box::new(ChatWidget::new( + let new_widget = ChatWidget::new( self.config.clone(), self.server.clone(), + tui.frame_requester(), self.app_event_tx.clone(), None, Vec::new(), self.enhanced_keys_supported, - )); - self.app_state = AppState::Chat { widget: new_widget }; - self.app_event_tx.send(AppEvent::RequestRedraw); + ); + self.chat_widget = new_widget; + tui.frame_requester().schedule_frame(); } 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()); - } + const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); + self.chat_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)); - } + self.chat_widget.clear_token_usage(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } SlashCommand::Model => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.open_model_popup(); - } + self.chat_widget.open_model_popup(); } SlashCommand::Approvals => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.open_approvals_popup(); - } + self.chat_widget.open_approvals_popup(); } SlashCommand::Quit => { return Ok(false); @@ -399,10 +211,7 @@ impl App<'_> { return Ok(false); } SlashCommand::Diff => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_diff_in_progress(); - } - + self.chat_widget.add_diff_in_progress(); let tx = self.app_event_tx.clone(); tokio::spawn(async move { let text = match get_git_diff().await { @@ -419,19 +228,13 @@ impl App<'_> { }); } SlashCommand::Mention => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.insert_str("@"); - } + self.chat_widget.insert_str("@"); } SlashCommand::Status => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_status_output(); - } + self.chat_widget.add_status_output(); } SlashCommand::Mcp => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_mcp_output(); - } + self.chat_widget.add_mcp_output(); } #[cfg(debug_assertions)] SlashCommand::TestApproval => { @@ -472,256 +275,61 @@ impl App<'_> { })); } }, - 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, - }) => { - self.app_state = AppState::Chat { - widget: Box::new(ChatWidget::new( - config, - self.server.clone(), - self.app_event_tx.clone(), - initial_prompt, - initial_images, - enhanced_keys_supported, - )), - } - } 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); - } + self.chat_widget.apply_file_search_result(query, matches); } AppEvent::UpdateReasoningEffort(effort) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_reasoning_effort(effort); - } + self.chat_widget.set_reasoning_effort(effort); } AppEvent::UpdateModel(model) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_model(model); - } + self.chat_widget.set_model(model); } AppEvent::UpdateAskForApprovalPolicy(policy) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_approval_policy(policy); - } + self.chat_widget.set_approval_policy(policy); } AppEvent::UpdateSandboxPolicy(policy) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_sandbox_policy(policy); - } + self.chat_widget.set_sandbox_policy(policy); } } Ok(true) } - #[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) }; - *terminal = tui::init(&self.config)?; - terminal.clear()?; - self.app_event_tx.send(AppEvent::RequestRedraw); - Ok(()) - } - pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { - match &self.app_state { - AppState::Chat { widget } => widget.token_usage().clone(), - AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(), - } + self.chat_widget.token_usage().clone() } - fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> { - if matches!(self.app_state, AppState::Onboarding { .. }) { - terminal.clear()?; - } - - let screen_size = terminal.size()?; - let last_known_screen_size = terminal.last_known_screen_size; - if screen_size != last_known_screen_size { - let cursor_pos = terminal.get_cursor_position()?; - let last_known_cursor_pos = terminal.last_known_cursor_pos; - if cursor_pos.y != last_known_cursor_pos.y { - // The terminal was resized. The only point of reference we have for where our viewport - // was moved is the cursor position. - // NB this assumes that the cursor was not wrapped as part of the resize. - let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32; - - let new_viewport_area = terminal.viewport_area.offset(Offset { - x: 0, - y: cursor_delta, - }); - terminal.set_viewport_area(new_viewport_area); - terminal.clear()?; + async fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + self.chat_widget.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } if self.chat_widget.composer_is_empty() => { + self.app_event_tx.send(AppEvent::ExitRequest); + } + KeyEvent { + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.chat_widget.handle_key_event(key_event); + } + _ => { + // Ignore Release key events. } - } - - let size = terminal.size()?; - let desired_height = match &self.app_state { - AppState::Chat { widget } => widget.desired_height(size.width), - AppState::Onboarding { .. } => size.height, }; - - let mut area = terminal.viewport_area; - area.height = desired_height.min(size.height); - area.width = size.width; - if area.bottom() > size.height { - terminal - .backend_mut() - .scroll_region_up(0..area.top(), area.bottom() - size.height)?; - area.y = size.height - area.height; - } - if area != terminal.viewport_area { - terminal.clear()?; - terminal.set_viewport_area(area); - } - if !self.pending_history_lines.is_empty() { - crate::insert_history::insert_history_lines( - terminal, - self.pending_history_lines.clone(), - ); - self.pending_history_lines.clear(); - } - 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()), - })?; - 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_codex_event(&mut self, event: Event) { - match &mut self.app_state { - AppState::Chat { widget } => widget.handle_codex_event(event), - AppState::Onboarding { .. } => {} - } - } -} - -fn should_show_onboarding( - login_status: LoginStatus, - config: &Config, - show_trust_screen: bool, -) -> bool { - if show_trust_screen { - return true; - } - - should_show_login_screen(login_status, config) -} - -fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool { - // Only show the login screen for providers that actually require OpenAI auth - // (OpenAI or equivalents). For OSS/other providers, skip login entirely. - if !config.model_provider.requires_openai_auth { - return false; - } - - match login_status { - LoginStatus::NotAuthenticated => true, - LoginStatus::AuthMode(method) => method != config.preferred_auth_method, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_core::config::ConfigOverrides; - use codex_core::config::ConfigToml; - use codex_login::AuthMode; - - fn make_config(preferred: AuthMode) -> Config { - let mut cfg = Config::load_from_base_config_with_overrides( - ConfigToml::default(), - ConfigOverrides::default(), - std::env::temp_dir(), - ) - .expect("load default config"); - cfg.preferred_auth_method = preferred; - cfg - } - - #[test] - fn shows_login_when_not_authenticated() { - let cfg = make_config(AuthMode::ChatGPT); - assert!(should_show_login_screen( - LoginStatus::NotAuthenticated, - &cfg - )); - } - - #[test] - fn shows_login_when_api_key_but_prefers_chatgpt() { - let cfg = make_config(AuthMode::ChatGPT); - assert!(should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ApiKey), - &cfg - )) - } - - #[test] - fn hides_login_when_api_key_and_prefers_api_key() { - let cfg = make_config(AuthMode::ApiKey); - assert!(!should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ApiKey), - &cfg - )) - } - - #[test] - fn hides_login_when_chatgpt_and_prefers_chatgpt() { - let cfg = make_config(AuthMode::ChatGPT); - assert!(!should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ChatGPT), - &cfg - )) } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fc8b510f..325b2d5b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,10 +1,7 @@ use codex_core::protocol::Event; use codex_file_search::FileMatch; -use crossterm::event::KeyEvent; use ratatui::text::Line; -use std::time::Duration; -use crate::app::ChatWidgetArgs; use crate::slash_command::SlashCommand; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; @@ -15,21 +12,6 @@ use codex_core::protocol_config_types::ReasoningEffort; pub(crate) enum AppEvent { CodexEvent(Event), - /// Request a redraw which will be debounced by the [`App`]. - RequestRedraw, - - /// Actually draw the next frame. - Redraw, - - /// Schedule a one-shot animation frame roughly after the given duration. - /// Multiple requests are coalesced by the central frame scheduler. - ScheduleFrameIn(Duration), - - KeyEvent(KeyEvent), - - /// Text pasted from the terminal clipboard. - Paste(String), - /// Request to exit the application gracefully. ExitRequest, @@ -63,10 +45,6 @@ pub(crate) enum AppEvent { StopCommitAnimation, CommitTick, - /// Onboarding: result of login_with_chatgpt. - OnboardingAuthComplete(Result<(), String>), - OnboardingComplete(ChatWidgetArgs), - /// Update the current reasoning effort in the running app and widget. UpdateReasoningEffort(ReasoningEffort), diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index 1b23acb5..518d9d03 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -12,13 +12,13 @@ use super::BottomPaneView; use super::CancellationEvent; /// Modal overlay asking the user to approve/deny a sequence of requests. -pub(crate) struct ApprovalModalView<'a> { - current: UserApprovalWidget<'a>, +pub(crate) struct ApprovalModalView { + current: UserApprovalWidget, queue: Vec, app_event_tx: AppEventSender, } -impl ApprovalModalView<'_> { +impl ApprovalModalView { pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { Self { current: UserApprovalWidget::new(request, app_event_tx.clone()), @@ -41,13 +41,13 @@ impl ApprovalModalView<'_> { } } -impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> { - fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) { +impl BottomPaneView for ApprovalModalView { + fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { self.current.handle_key_event(key_event); self.maybe_advance(); } - fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent { + fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { self.current.on_ctrl_c(); self.queue.clear(); CancellationEvent::Handled @@ -96,6 +96,7 @@ mod tests { let (tx2, _rx2) = unbounded_channel::(); let mut pane = BottomPane::new(super::super::BottomPaneParams { app_event_tx: AppEventSender::new(tx2), + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 31a32140..c98ed43e 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -7,10 +7,10 @@ use super::BottomPane; use super::CancellationEvent; /// Trait implemented by every view that can be shown in the bottom pane. -pub(crate) trait BottomPaneView<'a> { +pub(crate) trait BottomPaneView { /// Handle a key event while the view is active. A redraw is always /// scheduled after this call. - fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, _key_event: KeyEvent) {} + fn handle_key_event(&mut self, _pane: &mut BottomPane, _key_event: KeyEvent) {} /// Return `true` if the view has finished and should be removed. fn is_complete(&self) -> bool { @@ -18,7 +18,7 @@ pub(crate) trait BottomPaneView<'a> { } /// Handle Ctrl-C while this view is active. - fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent { + fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { CancellationEvent::Ignored } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 3b03eb9c..74b5274f 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -105,8 +105,8 @@ impl ListSelectionView { } } -impl BottomPaneView<'_> for ListSelectionView { - fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) { +impl BottomPaneView for ListSelectionView { + fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Up, .. @@ -131,7 +131,7 @@ impl BottomPaneView<'_> for ListSelectionView { self.complete } - fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'_>) -> CancellationEvent { + fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { self.complete = true; CancellationEvent::Handled } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 71fb0bbb..b6d71198 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1,7 +1,7 @@ //! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. -use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::tui::FrameRequester; use crate::user_approval_widget::ApprovalRequest; use bottom_pane_view::BottomPaneView; use codex_core::protocol::TokenUsage; @@ -39,15 +39,17 @@ pub(crate) use list_selection_view::SelectionItem; use status_indicator_view::StatusIndicatorView; /// Pane displayed in the lower half of the chat UI. -pub(crate) struct BottomPane<'a> { +pub(crate) struct BottomPane { /// Composer is retained even when a BottomPaneView is displayed so the /// input state is retained when the view is closed. composer: ChatComposer, /// If present, this is displayed instead of the `composer`. - active_view: Option + 'a>>, + active_view: Option>, app_event_tx: AppEventSender, + frame_requester: FrameRequester, + has_input_focus: bool, is_task_running: bool, ctrl_c_quit_hint: bool, @@ -59,12 +61,13 @@ pub(crate) struct BottomPane<'a> { pub(crate) struct BottomPaneParams { pub(crate) app_event_tx: AppEventSender, + pub(crate) frame_requester: FrameRequester, pub(crate) has_input_focus: bool, pub(crate) enhanced_keys_supported: bool, pub(crate) placeholder_text: String, } -impl BottomPane<'_> { +impl BottomPane { const BOTTOM_PAD_LINES: u16 = 2; pub fn new(params: BottomPaneParams) -> Self { let enhanced_keys_supported = params.enhanced_keys_supported; @@ -77,6 +80,7 @@ impl BottomPane<'_> { ), active_view: None, app_event_tx: params.app_event_tx, + frame_requester: params.frame_requester, has_input_focus: params.has_input_focus, is_task_running: false, ctrl_c_quit_hint: false, @@ -113,7 +117,10 @@ impl BottomPane<'_> { if !view.is_complete() { self.active_view = Some(view); } else if self.is_task_running { - let mut v = StatusIndicatorView::new(self.app_event_tx.clone()); + let mut v = StatusIndicatorView::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + ); v.update_text("waiting for model".to_string()); self.active_view = Some(Box::new(v)); self.status_view_active = true; @@ -144,7 +151,10 @@ impl BottomPane<'_> { self.active_view = Some(view); } else if self.is_task_running { // Modal aborted but task still running – restore status indicator. - let mut v = StatusIndicatorView::new(self.app_event_tx.clone()); + let mut v = StatusIndicatorView::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + ); v.update_text("waiting for model".to_string()); self.active_view = Some(Box::new(v)); self.status_view_active = true; @@ -199,6 +209,7 @@ impl BottomPane<'_> { if self.active_view.is_none() { self.active_view = Some(Box::new(StatusIndicatorView::new( self.app_event_tx.clone(), + self.frame_requester.clone(), ))); self.status_view_active = true; } @@ -292,7 +303,7 @@ impl BottomPane<'_> { /// Height (terminal rows) required by the current bottom pane. pub(crate) fn request_redraw(&self) { - self.app_event_tx.send(AppEvent::RequestRedraw) + self.frame_requester.schedule_frame(); } // --- History helpers --- @@ -322,7 +333,7 @@ impl BottomPane<'_> { } } -impl WidgetRef for &BottomPane<'_> { +impl WidgetRef for &BottomPane { fn render_ref(&self, area: Rect, buf: &mut Buffer) { if let Some(view) = &self.active_view { // Reserve bottom padding lines; keep at least 1 line for the view. @@ -375,6 +386,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -393,6 +405,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -422,6 +435,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx.clone(), + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -472,6 +486,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -504,6 +519,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -556,6 +572,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs index ccfd240b..dcd1fc0b 100644 --- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs +++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs @@ -6,6 +6,7 @@ use ratatui::widgets::WidgetRef; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPane; use crate::status_indicator_widget::StatusIndicatorWidget; +use crate::tui::FrameRequester; use super::BottomPaneView; @@ -14,9 +15,9 @@ pub(crate) struct StatusIndicatorView { } impl StatusIndicatorView { - pub fn new(app_event_tx: AppEventSender) -> Self { + pub fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { Self { - view: StatusIndicatorWidget::new(app_event_tx), + view: StatusIndicatorWidget::new(app_event_tx, frame_requester), } } @@ -25,7 +26,7 @@ impl StatusIndicatorView { } } -impl BottomPaneView<'_> for StatusIndicatorView { +impl BottomPaneView for StatusIndicatorView { fn should_hide_when_task_is_done(&mut self) -> bool { true } @@ -38,7 +39,7 @@ impl BottomPaneView<'_> for StatusIndicatorView { self.view.render_ref(area, buf); } - fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) { + fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { if key_event.code == KeyCode::Esc { self.view.interrupt(); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8d981b94..d10f6e3c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -52,6 +52,7 @@ use crate::history_cell::CommandOutput; use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; +use crate::tui::FrameRequester; // streaming internals are provided by crate::streaming and crate::markdown_stream use crate::user_approval_widget::ApprovalRequest; mod interrupts; @@ -77,10 +78,10 @@ struct RunningCommand { parsed_cmd: Vec, } -pub(crate) struct ChatWidget<'a> { +pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, - bottom_pane: BottomPane<'a>, + bottom_pane: BottomPane, active_exec_cell: Option, config: Config, initial_user_message: Option, @@ -98,6 +99,7 @@ pub(crate) struct ChatWidget<'a> { // Whether a redraw is needed after handling the current event needs_redraw: bool, session_id: Option, + frame_requester: FrameRequester, } struct UserMessage { @@ -124,7 +126,7 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio } } -impl ChatWidget<'_> { +impl ChatWidget { #[inline] fn mark_needs_redraw(&mut self) { self.needs_redraw = true; @@ -500,6 +502,7 @@ impl ChatWidget<'_> { pub(crate) fn new( config: Config, conversation_manager: Arc, + frame_requester: FrameRequester, app_event_tx: AppEventSender, initial_prompt: Option, initial_images: Vec, @@ -511,8 +514,10 @@ impl ChatWidget<'_> { Self { app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), codex_op_tx, bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, app_event_tx, has_input_focus: true, enhanced_keys_supported, @@ -672,7 +677,7 @@ impl ChatWidget<'_> { } fn request_redraw(&mut self) { - self.app_event_tx.send(AppEvent::RequestRedraw); + self.frame_requester.schedule_frame(); } pub(crate) fn add_diff_in_progress(&mut self) { @@ -880,7 +885,7 @@ impl ChatWidget<'_> { } } -impl WidgetRef for &ChatWidget<'_> { +impl WidgetRef for &ChatWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let [active_cell_area, bottom_pane_area] = self.layout_areas(area); (&self.bottom_pane).render(bottom_pane_area, buf); diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 40fecb72..531de3e6 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -71,7 +71,7 @@ impl InterruptManager { self.queue.push_back(QueuedInterrupt::PatchEnd(ev)); } - pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget<'_>) { + pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) { while let Some(q) = self.queue.pop_front() { match q { QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 82e7470c..fab64c88 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -104,14 +104,22 @@ async fn helpers_are_available_and_do_not_panic() { let tx = AppEventSender::new(tx_raw); let cfg = test_config(); let conversation_manager = Arc::new(ConversationManager::default()); - let mut w = ChatWidget::new(cfg, conversation_manager, tx, None, Vec::new(), false); + let mut w = ChatWidget::new( + cfg, + conversation_manager, + crate::tui::FrameRequester::test_dummy(), + tx, + None, + Vec::new(), + false, + ); // Basic construction sanity. let _ = &mut w; } // --- Helpers for tests that need direct construction and event draining --- fn make_chatwidget_manual() -> ( - ChatWidget<'static>, + ChatWidget, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, ) { @@ -121,6 +129,7 @@ fn make_chatwidget_manual() -> ( let cfg = test_config(); let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -142,6 +151,7 @@ fn make_chatwidget_manual() -> ( interrupts: InterruptManager::new(), needs_redraw: false, session_id: None, + frame_requester: crate::tui::FrameRequester::test_dummy(), }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 91153f92..ef642b1a 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -245,7 +245,7 @@ where /// Index of the current buffer in the previous array current: usize, /// Whether the cursor is currently hidden - hidden_cursor: bool, + pub hidden_cursor: bool, /// Area of the viewport pub viewport_area: Rect, /// Last known size of the terminal. Used to detect if the internal buffers have to be resized. diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 63826bbf..b6388709 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -22,7 +22,7 @@ use textwrap::Options as TwOptions; use textwrap::WordSplitter; /// Insert `lines` above the viewport. -pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { +pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec) { let mut out = std::io::stdout(); insert_history_lines_to_writer(terminal, &mut out, lines); } @@ -39,7 +39,7 @@ pub fn insert_history_lines_to_writer( { let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); - let mut area = terminal.get_frame().area(); + let mut area = terminal.viewport_area; // Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same // formatting as the TUI. This avoids character-level hard wrapping by the terminal. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 76487ef3..ac463be0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -64,6 +64,11 @@ use color_eyre::owo_colors::OwoColorize; pub use cli::Cli; +use crate::onboarding::TrustDirectorySelection; +use crate::onboarding::onboarding_screen::OnboardingScreenArgs; +use crate::onboarding::onboarding_screen::run_onboarding_app; +use crate::tui::Tui; + // (tests access modules directly within the crate) pub async fn run_main( @@ -256,6 +261,7 @@ async fn run_ratatui_app( config: Config, should_show_trust_screen: bool, ) -> color_eyre::Result { + let mut config = config; color_eyre::install()?; // Forward panic reports through tracing so they appear in the UI status @@ -267,23 +273,44 @@ async fn run_ratatui_app( tracing::error!("panic: {info}"); prev_hook(info); })); - let mut terminal = tui::init(&config)?; + let mut terminal = tui::init()?; terminal.clear()?; + let mut tui = Tui::new(terminal); // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&config); let Cli { prompt, images, .. } = cli; - let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen); - let app_result = app.run(&mut terminal).await; - let usage = app.token_usage(); + let login_status = get_login_status(&config); + let should_show_onboarding = + should_show_onboarding(login_status, &config, should_show_trust_screen); + if should_show_onboarding { + let directory_trust_decision = run_onboarding_app( + OnboardingScreenArgs { + codex_home: config.codex_home.clone(), + cwd: config.cwd.clone(), + show_login_screen: should_show_login_screen(login_status, &config), + show_trust_screen: should_show_trust_screen, + login_status, + preferred_auth_method: config.preferred_auth_method, + }, + &mut tui, + ) + .await?; + if let Some(TrustDirectorySelection::Trust) = directory_trust_decision { + config.approval_policy = AskForApproval::OnRequest; + config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); + } + } + + let app_result = App::run(&mut tui, config, prompt, images).await; restore(); // Mark the end of the recorded session. session_log::log_session_end(); // ignore error when collecting usage – report underlying error instead - app_result.map(|_| usage) + app_result } #[expect( @@ -357,3 +384,80 @@ fn determine_repo_trust_state( Ok(true) } } + +fn should_show_onboarding( + login_status: LoginStatus, + config: &Config, + show_trust_screen: bool, +) -> bool { + if show_trust_screen { + return true; + } + + should_show_login_screen(login_status, config) +} + +fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool { + // Only show the login screen for providers that actually require OpenAI auth + // (OpenAI or equivalents). For OSS/other providers, skip login entirely. + if !config.model_provider.requires_openai_auth { + return false; + } + + match login_status { + LoginStatus::NotAuthenticated => true, + LoginStatus::AuthMode(method) => method != config.preferred_auth_method, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_config(preferred: AuthMode) -> Config { + let mut cfg = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + std::env::temp_dir(), + ) + .expect("load default config"); + cfg.preferred_auth_method = preferred; + cfg + } + + #[test] + fn shows_login_when_not_authenticated() { + let cfg = make_config(AuthMode::ChatGPT); + assert!(should_show_login_screen( + LoginStatus::NotAuthenticated, + &cfg + )); + } + + #[test] + fn shows_login_when_api_key_but_prefers_chatgpt() { + let cfg = make_config(AuthMode::ChatGPT); + assert!(should_show_login_screen( + LoginStatus::AuthMode(AuthMode::ApiKey), + &cfg + )) + } + + #[test] + fn hides_login_when_api_key_and_prefers_api_key() { + let cfg = make_config(AuthMode::ApiKey); + assert!(!should_show_login_screen( + LoginStatus::AuthMode(AuthMode::ApiKey), + &cfg + )) + } + + #[test] + fn hides_login_when_chatgpt_and_prefers_chatgpt() { + let cfg = make_config(AuthMode::ChatGPT); + assert!(!should_show_login_screen( + LoginStatus::AuthMode(AuthMode::ChatGPT), + &cfg + )) + } +} diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 2ef70ca2..6f653cd0 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1,3 +1,5 @@ +#![allow(clippy::unwrap_used)] + use codex_login::CLIENT_ID; use codex_login::ServerOptions; use codex_login::ShutdownHandle; @@ -18,19 +20,19 @@ use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use codex_login::AuthMode; +use std::sync::RwLock; use crate::LoginStatus; -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; use std::path::PathBuf; +use std::sync::Arc; use super::onboarding_screen::StepState; -// no additional imports -#[derive(Debug)] +#[derive(Clone)] pub(crate) enum SignInState { PickMode, ChatGptContinueInBrowser(ContinueInBrowserState), @@ -40,18 +42,17 @@ pub(crate) enum SignInState { EnvVarFound, } -#[derive(Debug)] +#[derive(Clone)] /// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up. pub(crate) struct ContinueInBrowserState { auth_url: String, - shutdown_handle: Option, - _login_wait_handle: Option>, + shutdown_flag: Option, } impl Drop for ContinueInBrowserState { fn drop(&mut self) { - if let Some(flag) = &self.shutdown_handle { - flag.shutdown(); + if let Some(handle) = &self.shutdown_flag { + handle.shutdown(); } } } @@ -69,20 +70,32 @@ impl KeyboardHandler for AuthModeWidget { self.start_chatgpt_login(); } KeyCode::Char('2') => self.verify_api_key(), - KeyCode::Enter => match self.sign_in_state { - SignInState::PickMode => match self.highlighted_mode { - AuthMode::ChatGPT => self.start_chatgpt_login(), - AuthMode::ApiKey => self.verify_api_key(), - }, - SignInState::EnvVarMissing => self.sign_in_state = SignInState::PickMode, - SignInState::ChatGptSuccessMessage => { - self.sign_in_state = SignInState::ChatGptSuccess + KeyCode::Enter => { + let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; + match sign_in_state { + SignInState::PickMode => match self.highlighted_mode { + AuthMode::ChatGPT => { + self.start_chatgpt_login(); + } + AuthMode::ApiKey => { + self.verify_api_key(); + } + }, + SignInState::EnvVarMissing => { + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + } + SignInState::ChatGptSuccessMessage => { + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; + } + _ => {} } - _ => {} - }, + } KeyCode::Esc => { - if matches!(self.sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { - self.sign_in_state = SignInState::PickMode; + tracing::info!("Esc pressed"); + let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; + if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + self.request_frame.schedule_frame(); } } _ => {} @@ -90,12 +103,12 @@ impl KeyboardHandler for AuthModeWidget { } } -#[derive(Debug)] +#[derive(Clone)] pub(crate) struct AuthModeWidget { - pub event_tx: AppEventSender, + pub request_frame: FrameRequester, pub highlighted_mode: AuthMode, pub error: Option, - pub sign_in_state: SignInState, + pub sign_in_state: Arc>, pub codex_home: PathBuf, pub login_status: LoginStatus, pub preferred_auth_method: AuthMode, @@ -215,14 +228,13 @@ impl AuthModeWidget { fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) { let mut spans = vec![Span::from("> ")]; // Schedule a follow-up frame to keep the shimmer animation going. - self.event_tx - .send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis( - 100, - ))); + self.request_frame + .schedule_frame_in(std::time::Duration::from_millis(100)); spans.extend(shimmer_spans("Finish signing in via your browser")); let mut lines = vec![Line::from(spans), Line::from("")]; - if let SignInState::ChatGptContinueInBrowser(state) = &self.sign_in_state + let sign_in_state = self.sign_in_state.read().unwrap(); + if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state && !state.auth_url.is_empty() { lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:")); @@ -315,35 +327,45 @@ impl AuthModeWidget { // If we're already authenticated with ChatGPT, don't start a new login – // just proceed to the success message flow. if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { - self.sign_in_state = SignInState::ChatGptSuccess; - self.event_tx.send(AppEvent::RequestRedraw); + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; + self.request_frame.schedule_frame(); return; } self.error = None; let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string()); - let server = run_login_server(opts); - match server { + match run_login_server(opts) { Ok(child) => { - let auth_url = child.auth_url.clone(); - let shutdown_handle = child.cancel_handle(); - - let event_tx = self.event_tx.clone(); - let join_handle = tokio::spawn(async move { - spawn_completion_poller(child, event_tx).await; + let sign_in_state = self.sign_in_state.clone(); + let request_frame = self.request_frame.clone(); + tokio::spawn(async move { + let auth_url = child.auth_url.clone(); + { + *sign_in_state.write().unwrap() = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + auth_url, + shutdown_flag: Some(child.cancel_handle()), + }); + } + request_frame.schedule_frame(); + let r = child.block_until_done().await; + match r { + Ok(()) => { + *sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage; + request_frame.schedule_frame(); + } + _ => { + *sign_in_state.write().unwrap() = SignInState::PickMode; + // self.error = Some(e.to_string()); + request_frame.schedule_frame(); + } + } }); - self.sign_in_state = - SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { - auth_url, - shutdown_handle: Some(shutdown_handle), - _login_wait_handle: Some(join_handle), - }); - self.event_tx.send(AppEvent::RequestRedraw); } Err(e) => { - self.sign_in_state = SignInState::PickMode; + *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.error = Some(e.to_string()); - self.event_tx.send(AppEvent::RequestRedraw); + self.request_frame.schedule_frame(); } } } @@ -353,33 +375,18 @@ impl AuthModeWidget { if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ApiKey)) { // We already have an API key configured (e.g., from auth.json or env), // so mark this step complete immediately. - self.sign_in_state = SignInState::EnvVarFound; + *self.sign_in_state.write().unwrap() = SignInState::EnvVarFound; } else { - self.sign_in_state = SignInState::EnvVarMissing; + *self.sign_in_state.write().unwrap() = SignInState::EnvVarMissing; } - - self.event_tx.send(AppEvent::RequestRedraw); + self.request_frame.schedule_frame(); } } -async fn spawn_completion_poller( - child: codex_login::LoginServer, - event_tx: AppEventSender, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - if let Ok(()) = child.block_until_done().await { - event_tx.send(AppEvent::OnboardingAuthComplete(Ok(()))); - } else { - event_tx.send(AppEvent::OnboardingAuthComplete(Err( - "login failed".to_string() - ))); - } - }) -} - impl StepStateProvider for AuthModeWidget { fn get_step_state(&self) -> StepState { - match &self.sign_in_state { + let sign_in_state = self.sign_in_state.read().unwrap(); + match &*sign_in_state { SignInState::PickMode | SignInState::EnvVarMissing | SignInState::ChatGptContinueInBrowser(_) @@ -391,7 +398,8 @@ impl StepStateProvider for AuthModeWidget { impl WidgetRef for AuthModeWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - match self.sign_in_state { + let sign_in_state = self.sign_in_state.read().unwrap(); + match &*sign_in_state { SignInState::PickMode => { self.render_pick_mode(area, buf); } diff --git a/codex-rs/tui/src/onboarding/continue_to_chat.rs b/codex-rs/tui/src/onboarding/continue_to_chat.rs deleted file mode 100644 index 01e31d90..00000000 --- a/codex-rs/tui/src/onboarding/continue_to_chat.rs +++ /dev/null @@ -1,34 +0,0 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; - -use crate::app::ChatWidgetArgs; -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::onboarding::onboarding_screen::StepStateProvider; - -use super::onboarding_screen::StepState; -use std::sync::Arc; -use std::sync::Mutex; - -/// This doesn't render anything explicitly but serves as a signal that we made it to the end and -/// we should continue to the chat. -pub(crate) struct ContinueToChatWidget { - pub event_tx: AppEventSender, - pub chat_widget_args: Arc>, -} - -impl StepStateProvider for ContinueToChatWidget { - fn get_step_state(&self) -> StepState { - StepState::Complete - } -} - -impl WidgetRef for &ContinueToChatWidget { - fn render_ref(&self, _area: Rect, _buf: &mut Buffer) { - if let Ok(args) = self.chat_widget_args.lock() { - self.event_tx - .send(AppEvent::OnboardingComplete(args.clone())); - } - } -} diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index c1169368..d4cfd6d1 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,5 +1,5 @@ mod auth; -mod continue_to_chat; pub mod onboarding_screen; mod trust_directory; +pub use trust_directory::TrustDirectorySelection; mod welcome; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index ea473512..5721430c 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,5 +1,7 @@ use codex_core::util::is_inside_git_repo; +use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -9,25 +11,24 @@ use ratatui::widgets::WidgetRef; use codex_login::AuthMode; use crate::LoginStatus; -use crate::app::ChatWidgetArgs; -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; use crate::onboarding::auth::AuthModeWidget; use crate::onboarding::auth::SignInState; -use crate::onboarding::continue_to_chat::ContinueToChatWidget; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; use crate::onboarding::welcome::WelcomeWidget; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use color_eyre::eyre::Result; use std::path::PathBuf; use std::sync::Arc; -use std::sync::Mutex; +use std::sync::RwLock; #[allow(clippy::large_enum_variant)] enum Step { Welcome(WelcomeWidget), Auth(AuthModeWidget), TrustDirectory(TrustDirectoryWidget), - ContinueToChat(ContinueToChatWidget), } pub(crate) trait KeyboardHandler { @@ -45,43 +46,42 @@ pub(crate) trait StepStateProvider { } pub(crate) struct OnboardingScreen { - event_tx: AppEventSender, + request_frame: FrameRequester, steps: Vec, + is_done: bool, } pub(crate) struct OnboardingScreenArgs { - pub event_tx: AppEventSender, - pub chat_widget_args: ChatWidgetArgs, pub codex_home: PathBuf, pub cwd: PathBuf, pub show_trust_screen: bool, pub show_login_screen: bool, pub login_status: LoginStatus, + pub preferred_auth_method: AuthMode, } impl OnboardingScreen { - pub(crate) fn new(args: OnboardingScreenArgs) -> Self { + pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self { let OnboardingScreenArgs { - event_tx, - chat_widget_args, codex_home, cwd, show_trust_screen, show_login_screen, login_status, + preferred_auth_method, } = args; let mut steps: Vec = vec![Step::Welcome(WelcomeWidget { is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated), })]; if show_login_screen { steps.push(Step::Auth(AuthModeWidget { - event_tx: event_tx.clone(), + request_frame: tui.frame_requester(), highlighted_mode: AuthMode::ChatGPT, error: None, - sign_in_state: SignInState::PickMode, + sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home.clone(), login_status, - preferred_auth_method: chat_widget_args.config.preferred_auth_method, + preferred_auth_method, })) } let is_git_repo = is_inside_git_repo(&cwd); @@ -91,9 +91,6 @@ impl OnboardingScreen { // Default to not trusting the directory if it's not a git repo. TrustDirectorySelection::DontTrust }; - // Share ChatWidgetArgs between steps so changes in the TrustDirectory step - // are reflected when continuing to chat. - let shared_chat_args = Arc::new(Mutex::new(chat_widget_args)); if show_trust_screen { steps.push(Step::TrustDirectory(TrustDirectoryWidget { cwd, @@ -102,39 +99,13 @@ impl OnboardingScreen { selection: None, highlighted, error: None, - chat_widget_args: shared_chat_args.clone(), })) } - steps.push(Step::ContinueToChat(ContinueToChatWidget { - event_tx: event_tx.clone(), - chat_widget_args: shared_chat_args, - })); // TODO: add git warning. - Self { event_tx, steps } - } - - pub(crate) fn on_auth_complete(&mut self, result: Result<(), String>) { - let current_step = self.current_step_mut(); - if let Some(Step::Auth(state)) = current_step { - match result { - Ok(()) => { - state.sign_in_state = SignInState::ChatGptSuccessMessage; - self.event_tx.send(AppEvent::RequestRedraw); - let tx1 = self.event_tx.clone(); - let tx2 = self.event_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(150)); - tx1.send(AppEvent::RequestRedraw); - std::thread::sleep(std::time::Duration::from_millis(200)); - tx2.send(AppEvent::RequestRedraw); - }); - } - Err(e) => { - state.sign_in_state = SignInState::PickMode; - state.error = Some(e); - self.event_tx.send(AppEvent::RequestRedraw); - } - } + Self { + request_frame: tui.frame_requester(), + steps, + is_done: false, } } @@ -168,19 +139,57 @@ impl OnboardingScreen { out } - fn current_step_mut(&mut self) -> Option<&mut Step> { + pub(crate) fn is_done(&self) -> bool { + self.is_done + || !self + .steps + .iter() + .any(|step| matches!(step.get_step_state(), StepState::InProgress)) + } + + pub fn directory_trust_decision(&self) -> Option { self.steps - .iter_mut() - .find(|step| matches!(step.get_step_state(), StepState::InProgress)) + .iter() + .find_map(|step| { + if let Step::TrustDirectory(TrustDirectoryWidget { selection, .. }) = step { + Some(*selection) + } else { + None + } + }) + .flatten() } } impl KeyboardHandler for OnboardingScreen { fn handle_key_event(&mut self, key_event: KeyEvent) { - if let Some(active_step) = self.current_steps_mut().into_iter().last() { - active_step.handle_key_event(key_event); - } - self.event_tx.send(AppEvent::RequestRedraw); + match key_event { + KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('q'), + kind: KeyEventKind::Press, + .. + } => { + self.is_done = true; + } + _ => { + if let Some(active_step) = self.current_steps_mut().into_iter().last() { + active_step.handle_key_event(key_event); + } + } + }; + self.request_frame.schedule_frame(); } } @@ -246,7 +255,7 @@ impl WidgetRef for &OnboardingScreen { impl KeyboardHandler for Step { fn handle_key_event(&mut self, key_event: KeyEvent) { match self { - Step::Welcome(_) | Step::ContinueToChat(_) => (), + Step::Welcome(_) => (), Step::Auth(widget) => widget.handle_key_event(key_event), Step::TrustDirectory(widget) => widget.handle_key_event(key_event), } @@ -259,7 +268,6 @@ impl StepStateProvider for Step { Step::Welcome(w) => w.get_step_state(), Step::Auth(w) => w.get_step_state(), Step::TrustDirectory(w) => w.get_step_state(), - Step::ContinueToChat(w) => w.get_step_state(), } } } @@ -276,9 +284,39 @@ impl WidgetRef for Step { Step::TrustDirectory(widget) => { widget.render_ref(area, buf); } - Step::ContinueToChat(widget) => { - widget.render_ref(area, buf); - } } } } + +pub(crate) async fn run_onboarding_app( + args: OnboardingScreenArgs, + tui: &mut Tui, +) -> Result> { + use tokio_stream::StreamExt; + + let mut onboarding_screen = OnboardingScreen::new(tui, args); + + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&onboarding_screen, frame.area()); + })?; + + let tui_events = tui.event_stream(); + tokio::pin!(tui_events); + + while !onboarding_screen.is_done() { + if let Some(event) = tui_events.next().await { + match event { + TuiEvent::Key(key_event) => { + onboarding_screen.handle_key_event(key_event); + } + TuiEvent::Draw => { + let _ = tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&onboarding_screen, frame.area()); + }); + } + _ => {} + } + } + } + Ok(onboarding_screen.directory_trust_decision()) +} diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 84f7aef8..7e41ae05 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -1,8 +1,6 @@ use std::path::PathBuf; use codex_core::config::set_project_trusted; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; @@ -22,9 +20,6 @@ use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use super::onboarding_screen::StepState; -use crate::app::ChatWidgetArgs; -use std::sync::Arc; -use std::sync::Mutex; pub(crate) struct TrustDirectoryWidget { pub codex_home: PathBuf, @@ -33,11 +28,10 @@ pub(crate) struct TrustDirectoryWidget { pub selection: Option, pub highlighted: TrustDirectorySelection, pub error: Option, - pub chat_widget_args: Arc>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum TrustDirectorySelection { +pub enum TrustDirectorySelection { Trust, DontTrust, } @@ -156,13 +150,6 @@ impl TrustDirectoryWidget { // self.error = Some("Failed to set project trusted".to_string()); } - // Update the in-memory chat config for this session to a more permissive - // policy suitable for a trusted workspace. - if let Ok(mut args) = self.chat_widget_args.lock() { - args.config.approval_policy = AskForApproval::OnRequest; - args.config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); - } - self.selection = Some(TrustDirectorySelection::Trust); } diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index e94f5843..03334f7f 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -132,24 +132,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { AppEvent::CodexEvent(ev) => { write_record("to_tui", "codex_event", ev); } - AppEvent::KeyEvent(k) => { - let value = json!({ - "ts": now_ts(), - "dir": "to_tui", - "kind": "key_event", - "event": format!("{:?}", k), - }); - LOGGER.write_json_line(value); - } - AppEvent::Paste(s) => { - let value = json!({ - "ts": now_ts(), - "dir": "to_tui", - "kind": "paste", - "text": s, - }); - LOGGER.write_json_line(value); - } + AppEvent::DispatchCommand(cmd) => { let value = json!({ "ts": now_ts(), diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 70dd2ed0..2b261941 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -19,6 +19,7 @@ use unicode_width::UnicodeWidthStr; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; // We render the live text using markdown so it visually matches the history // cells. Before rendering we strip any ANSI escape sequences to avoid writing @@ -39,10 +40,11 @@ pub(crate) struct StatusIndicatorWidget { reveal_len_at_base: usize, start_time: Instant, app_event_tx: AppEventSender, + frame_requester: FrameRequester, } impl StatusIndicatorWidget { - pub(crate) fn new(app_event_tx: AppEventSender) -> Self { + pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { Self { text: String::from("waiting for model"), last_target_len: 0, @@ -51,6 +53,7 @@ impl StatusIndicatorWidget { start_time: Instant::now(), app_event_tx, + frame_requester, } } @@ -143,8 +146,8 @@ impl WidgetRef for StatusIndicatorWidget { } // Schedule next animation frame. - self.app_event_tx - .send(AppEvent::ScheduleFrameIn(Duration::from_millis(100))); + self.frame_requester + .schedule_frame_in(Duration::from_millis(100)); let idx = self.current_frame(); let elapsed = self.start_time.elapsed().as_secs(); let shown_now = self.current_shown_len(idx); @@ -219,7 +222,7 @@ mod tests { fn renders_without_left_border_or_padding() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); w.restart_with_text("Hello".to_string()); let area = ratatui::layout::Rect::new(0, 0, 30, 1); @@ -237,7 +240,7 @@ mod tests { fn working_header_is_present_on_last_line() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); w.restart_with_text("Hi".to_string()); // Ensure some frames elapse so we get a stable state. std::thread::sleep(std::time::Duration::from_millis(120)); @@ -258,7 +261,7 @@ mod tests { fn header_starts_at_expected_position() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); w.restart_with_text("Hello".to_string()); std::thread::sleep(std::time::Duration::from_millis(120)); diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 0447e32a..6af0d391 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -1,28 +1,39 @@ use std::io::Result; use std::io::Stdout; use std::io::stdout; +use std::pin::Pin; +use std::time::Duration; +use std::time::Instant; -use codex_core::config::Config; +use crossterm::SynchronizedUpdate; use crossterm::cursor::MoveTo; use crossterm::event::DisableBracketedPaste; use crossterm::event::EnableBracketedPaste; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::PopKeyboardEnhancementFlags; use crossterm::event::PushKeyboardEnhancementFlags; use crossterm::terminal::Clear; use crossterm::terminal::ClearType; +use ratatui::backend::Backend; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode; +use ratatui::layout::Offset; +use ratatui::text::Line; -use crate::custom_terminal::Terminal; +use crate::custom_terminal; +use crate::custom_terminal::Terminal as CustomTerminal; +use tokio::select; +use tokio_stream::Stream; /// A type alias for the terminal type used in this application -pub type Tui = Terminal>; +pub type Terminal = CustomTerminal>; -/// Initialize the terminal (inline viewport; history stays in normal scrollback) -pub fn init(_config: &Config) -> Result { +pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; enable_raw_mode()?; @@ -40,13 +51,31 @@ pub fn init(_config: &Config) -> Result { | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS ) ); + Ok(()) +} + +/// Restore the terminal to its original state. +/// Inverse of `set_modes`. +pub fn restore() -> Result<()> { + // Pop may fail on platforms that didn't support the push; ignore errors. + let _ = execute!(stdout(), PopKeyboardEnhancementFlags); + execute!(stdout(), DisableBracketedPaste)?; + disable_raw_mode()?; + let _ = execute!(stdout(), crossterm::cursor::Show); + Ok(()) +} + +/// Initialize the terminal (inline viewport; history stays in normal scrollback) +pub fn init() -> Result { + set_modes()?; + set_panic_hook(); // Clear screen and move cursor to top-left before drawing UI execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?; let backend = CrosstermBackend::new(stdout()); - let tui = Terminal::with_options(backend)?; + let tui = CustomTerminal::with_options(backend)?; Ok(tui) } @@ -58,11 +87,223 @@ fn set_panic_hook() { })); } -/// Restore the terminal to its original state -pub fn restore() -> Result<()> { - // Pop may fail on platforms that didn't support the push; ignore errors. - let _ = execute!(stdout(), PopKeyboardEnhancementFlags); - execute!(stdout(), DisableBracketedPaste)?; - disable_raw_mode()?; - Ok(()) +#[derive(Debug)] +pub enum TuiEvent { + Key(KeyEvent), + Paste(String), + Draw, + #[cfg(unix)] + ResumeFromSuspend, +} + +pub struct Tui { + frame_schedule_tx: tokio::sync::mpsc::UnboundedSender, + draw_tx: tokio::sync::broadcast::Sender<()>, + pub(crate) terminal: Terminal, + pending_history_lines: Vec>, +} + +#[derive(Clone, Debug)] +pub struct FrameRequester { + frame_schedule_tx: tokio::sync::mpsc::UnboundedSender, +} +impl FrameRequester { + pub fn schedule_frame(&self) { + let _ = self.frame_schedule_tx.send(Instant::now()); + } + pub fn schedule_frame_in(&self, dur: Duration) { + let _ = self.frame_schedule_tx.send(Instant::now() + dur); + } +} + +#[cfg(test)] +impl FrameRequester { + /// Create a no-op frame requester for tests. + pub(crate) fn test_dummy() -> Self { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + FrameRequester { + frame_schedule_tx: tx, + } + } +} + +impl Tui { + pub fn new(terminal: Terminal) -> Self { + let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel(); + let (draw_tx, _) = tokio::sync::broadcast::channel(1); + + // Spawn background scheduler to coalesce frame requests and emit draws at deadlines. + let draw_tx_clone = draw_tx.clone(); + tokio::spawn(async move { + use tokio::select; + use tokio::time::Instant as TokioInstant; + use tokio::time::sleep_until; + + let mut rx = frame_schedule_rx; + let mut next_deadline: Option = None; + + loop { + let target = next_deadline + .unwrap_or_else(|| Instant::now() + Duration::from_secs(60 * 60 * 24 * 365)); + let sleep_fut = sleep_until(TokioInstant::from_std(target)); + tokio::pin!(sleep_fut); + + select! { + recv = rx.recv() => { + match recv { + Some(at) => { + if next_deadline.is_none_or(|cur| at < cur) { + next_deadline = Some(at); + } + if at <= Instant::now() { + next_deadline = None; + let _ = draw_tx_clone.send(()); + } + } + None => break, + } + } + _ = &mut sleep_fut => { + if next_deadline.is_some() { + next_deadline = None; + let _ = draw_tx_clone.send(()); + } + } + } + } + }); + + Self { + frame_schedule_tx, + draw_tx, + terminal, + pending_history_lines: vec![], + } + } + + pub fn frame_requester(&self) -> FrameRequester { + FrameRequester { + frame_schedule_tx: self.frame_schedule_tx.clone(), + } + } + + pub fn event_stream(&self) -> Pin + Send + 'static>> { + use tokio_stream::StreamExt; + let mut crossterm_events = crossterm::event::EventStream::new(); + let mut draw_rx = self.draw_tx.subscribe(); + let event_stream = async_stream::stream! { + loop { + select! { + Some(Ok(event)) = crossterm_events.next() => { + match event { + crossterm::event::Event::Key(KeyEvent { + code: KeyCode::Char('z'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + }) => { + #[cfg(unix)] + { + let _ = Tui::suspend(); + yield TuiEvent::ResumeFromSuspend; + yield TuiEvent::Draw; + } + } + crossterm::event::Event::Key(key_event) => { + yield TuiEvent::Key(key_event); + } + crossterm::event::Event::Resize(_, _) => { + yield TuiEvent::Draw; + } + crossterm::event::Event::Paste(pasted) => { + yield TuiEvent::Paste(pasted); + } + _ => {} + } + } + result = draw_rx.recv() => { + match result { + Ok(_) => { + yield TuiEvent::Draw; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + // We dropped one or more draw notifications; coalesce to a single draw. + yield TuiEvent::Draw; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + // Sender dropped; stop emitting draws from this source. + } + } + } + } + } + }; + Box::pin(event_stream) + } + + #[cfg(unix)] + fn suspend() -> Result<()> { + restore()?; + unsafe { libc::kill(0, libc::SIGTSTP) }; + set_modes()?; + Ok(()) + } + + pub fn insert_history_lines(&mut self, lines: Vec>) { + self.pending_history_lines.extend(lines); + self.frame_requester().schedule_frame(); + } + + pub fn draw( + &mut self, + height: u16, + draw_fn: impl FnOnce(&mut custom_terminal::Frame), + ) -> Result<()> { + std::io::stdout().sync_update(|_| { + let terminal = &mut self.terminal; + let screen_size = terminal.size()?; + let last_known_screen_size = terminal.last_known_screen_size; + if screen_size != last_known_screen_size { + let cursor_pos = terminal.get_cursor_position()?; + let last_known_cursor_pos = terminal.last_known_cursor_pos; + if cursor_pos.y != last_known_cursor_pos.y { + let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32; + + let new_viewport_area = terminal.viewport_area.offset(Offset { + x: 0, + y: cursor_delta, + }); + terminal.set_viewport_area(new_viewport_area); + terminal.clear()?; + } + } + + let size = terminal.size()?; + + let mut area = terminal.viewport_area; + area.height = height.min(size.height); + area.width = size.width; + if area.bottom() > size.height { + terminal + .backend_mut() + .scroll_region_up(0..area.top(), area.bottom() - size.height)?; + area.y = size.height - area.height; + } + if area != terminal.viewport_area { + terminal.clear()?; + terminal.set_viewport_area(area); + } + if !self.pending_history_lines.is_empty() { + crate::insert_history::insert_history_lines( + terminal, + self.pending_history_lines.clone(), + ); + self.pending_history_lines.clear(); + } + terminal.draw(|frame| { + draw_fn(frame); + })?; + Ok(()) + })? + } } diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index d317ff0d..f55ebba7 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -95,11 +95,11 @@ static PATCH_SELECT_OPTIONS: LazyLock> = LazyLock::new(|| { }); /// A modal prompting the user to approve or deny the pending request. -pub(crate) struct UserApprovalWidget<'a> { +pub(crate) struct UserApprovalWidget { approval_request: ApprovalRequest, app_event_tx: AppEventSender, - confirmation_prompt: Paragraph<'a>, - select_options: &'a Vec, + confirmation_prompt: Paragraph<'static>, + select_options: &'static Vec, /// Currently selected index in *select* mode. selected_option: usize, @@ -137,7 +137,7 @@ fn to_command_display<'a>( lines } -impl UserApprovalWidget<'_> { +impl UserApprovalWidget { pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { let confirmation_prompt = match &approval_request { ApprovalRequest::Exec { @@ -356,7 +356,7 @@ impl UserApprovalWidget<'_> { } } -impl WidgetRef for &UserApprovalWidget<'_> { +impl WidgetRef for &UserApprovalWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let prompt_height = self.get_confirmation_prompt_height(area.width); let [prompt_chunk, response_chunk] = Layout::default()