refactor onboarding screen to a separate "app" (#2524)

this is in preparation for adding more separate "modes" to the tui, in
particular, a "transcript mode" to view a full history once #2316 lands.

1. split apart "tui events" from "app events".
2. remove onboarding-related events from AppEvent.
3. move several general drawing tools out of App and into a new Tui
class
This commit is contained in:
Jeremy Rose
2025-08-20 13:47:24 -07:00
committed by GitHub
parent 1a1516a80b
commit 0d12380c3b
24 changed files with 755 additions and 803 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -926,6 +926,7 @@ name = "codex-tui"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"clap", "clap",

View File

@@ -22,6 +22,7 @@ workspace = true
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
async-stream = "0.3.6"
base64 = "0.22.1" base64 = "0.22.1"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }

View File

@@ -1,254 +1,142 @@
use crate::LoginStatus;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget; use crate::chatwidget::ChatWidget;
use crate::file_search::FileSearchManager; use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff; 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::slash_command::SlashCommand;
use crate::tui; use crate::tui;
use crate::tui::TuiEvent;
use codex_core::ConversationManager; use codex_core::ConversationManager;
use codex_core::config::Config; use codex_core::config::Config;
use codex_core::protocol::Event; use codex_core::protocol::Event;
use codex_core::protocol::Op; use codex_core::protocol::Op;
use codex_core::protocol::TokenUsage;
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use crossterm::SynchronizedUpdate;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind; use crossterm::event::KeyEventKind;
use crossterm::terminal::supports_keyboard_enhancement; use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::layout::Offset;
use ratatui::prelude::Backend;
use ratatui::text::Line;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use std::time::Instant;
use tokio::select; use tokio::select;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel; use tokio::sync::mpsc::unbounded_channel;
/// Time window for debouncing redraw requests. pub(crate) struct App {
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<ChatWidget<'a>>,
},
}
pub(crate) struct App<'a> {
server: Arc<ConversationManager>, server: Arc<ConversationManager>,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
app_event_rx: UnboundedReceiver<AppEvent>, chat_widget: ChatWidget,
app_state: AppState<'a>,
/// Config is stored here so we can recreate ChatWidgets as needed. /// Config is stored here so we can recreate ChatWidgets as needed.
config: Config, config: Config,
file_search: FileSearchManager, file_search: FileSearchManager,
pending_history_lines: Vec<Line<'static>>,
enhanced_keys_supported: bool, enhanced_keys_supported: bool,
/// Controls the animation thread that sends CommitTick events. /// Controls the animation thread that sends CommitTick events.
commit_anim_running: Arc<AtomicBool>, commit_anim_running: Arc<AtomicBool>,
/// Channel to schedule one-shot animation frames; coalesced by a single
/// scheduler thread.
frame_schedule_tx: std::sync::mpsc::Sender<Instant>,
} }
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be impl App {
/// deferred until after the Git warning screen is dismissed. pub async fn run(
#[derive(Clone, Debug)] tui: &mut tui::Tui,
pub(crate) struct ChatWidgetArgs {
pub(crate) config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
}
impl App<'_> {
pub(crate) fn new(
config: Config, config: Config,
initial_prompt: Option<String>, initial_prompt: Option<String>,
initial_images: Vec<std::path::PathBuf>, initial_images: Vec<PathBuf>,
show_trust_screen: bool, ) -> Result<TokenUsage> {
) -> Self { use tokio_stream::StreamExt;
let conversation_manager = Arc::new(ConversationManager::default()); let (app_event_tx, mut app_event_rx) = unbounded_channel();
let (app_event_tx, app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx); 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 enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
let login_status = get_login_status(&config); let chat_widget = ChatWidget::new(
let should_show_onboarding = config.clone(),
should_show_onboarding(login_status, &config, show_trust_screen); conversation_manager.clone(),
let app_state = if should_show_onboarding { tui.frame_requester(),
let show_login_screen = should_show_login_screen(login_status, &config); app_event_tx.clone(),
let chat_widget_args = ChatWidgetArgs { initial_prompt,
config: config.clone(), initial_images,
initial_prompt, enhanced_keys_supported,
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 file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
// Spawn a single scheduler thread that coalesces both debounced redraw let mut app = Self {
// 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::<Instant>();
{
let app_event_tx = app_event_tx.clone();
std::thread::spawn(move || {
use std::sync::mpsc::RecvTimeoutError;
let mut next_deadline: Option<Instant> = 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 {
server: conversation_manager, server: conversation_manager,
app_event_tx, app_event_tx,
pending_history_lines: Vec::new(), chat_widget,
app_event_rx,
app_state,
config, config,
file_search, file_search,
enhanced_keys_supported, enhanced_keys_supported,
commit_anim_running: Arc::new(AtomicBool::new(false)), commit_anim_running: Arc::new(AtomicBool::new(false)),
frame_schedule_tx: frame_tx, };
}
}
fn schedule_frame_in(&self, dur: Duration) { let tui_events = tui.event_stream();
let _ = self.frame_schedule_tx.send(Instant::now() + dur); tokio::pin!(tui_events);
}
pub(crate) async fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { tui.frame_requester().schedule_frame();
use tokio_stream::StreamExt;
self.handle_event(terminal, AppEvent::Redraw)?; while select! {
Some(event) = app_event_rx.recv() => {
let mut crossterm_events = crossterm::event::EventStream::new(); app.handle_event(tui, event)?
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
}
}
},
} }
} && self.handle_event(terminal, event)? Some(event) = tui_events.next() => {
{} app.handle_tui_event(tui, event).await?
terminal.clear()?; }
Ok(()) } {}
tui.terminal.clear()?;
Ok(app.token_usage())
} }
fn handle_event(&mut self, terminal: &mut tui::Tui, event: AppEvent) -> Result<bool> { pub(crate) async fn handle_tui_event(
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<bool> {
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<bool> {
match event { match event {
AppEvent::InsertHistory(lines) => { AppEvent::InsertHistory(lines) => {
self.pending_history_lines.extend(lines); tui.insert_history_lines(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))??;
} }
AppEvent::StartCommitAnimation => { AppEvent::StartCommitAnimation => {
if self if self
@@ -270,124 +158,48 @@ impl App<'_> {
self.commit_anim_running.store(false, Ordering::Release); self.commit_anim_running.store(false, Ordering::Release);
} }
AppEvent::CommitTick => { AppEvent::CommitTick => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.on_commit_tick();
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);
} }
AppEvent::CodexEvent(event) => { AppEvent::CodexEvent(event) => {
self.dispatch_codex_event(event); self.chat_widget.handle_codex_event(event);
} }
AppEvent::ExitRequest => { AppEvent::ExitRequest => {
return Ok(false); return Ok(false);
} }
AppEvent::CodexOp(op) => match &mut self.app_state { AppEvent::CodexOp(op) => self.chat_widget.submit_op(op),
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
},
AppEvent::DiffResult(text) => { AppEvent::DiffResult(text) => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.add_diff_output(text);
widget.add_diff_output(text);
}
} }
AppEvent::DispatchCommand(command) => match command { AppEvent::DispatchCommand(command) => match command {
SlashCommand::New => { SlashCommand::New => {
// User accepted switch to chat view. // User accepted switch to chat view.
let new_widget = Box::new(ChatWidget::new( let new_widget = ChatWidget::new(
self.config.clone(), self.config.clone(),
self.server.clone(), self.server.clone(),
tui.frame_requester(),
self.app_event_tx.clone(), self.app_event_tx.clone(),
None, None,
Vec::new(), Vec::new(),
self.enhanced_keys_supported, self.enhanced_keys_supported,
)); );
self.app_state = AppState::Chat { widget: new_widget }; self.chat_widget = new_widget;
self.app_event_tx.send(AppEvent::RequestRedraw); tui.frame_requester().schedule_frame();
} }
SlashCommand::Init => { SlashCommand::Init => {
// Guard: do not run if a task is active. // 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");
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); self.chat_widget
widget.submit_text_message(INIT_PROMPT.to_string()); .submit_text_message(INIT_PROMPT.to_string());
}
} }
SlashCommand::Compact => { SlashCommand::Compact => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.clear_token_usage();
widget.clear_token_usage(); self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
}
} }
SlashCommand::Model => { SlashCommand::Model => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.open_model_popup();
widget.open_model_popup();
}
} }
SlashCommand::Approvals => { SlashCommand::Approvals => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.open_approvals_popup();
widget.open_approvals_popup();
}
} }
SlashCommand::Quit => { SlashCommand::Quit => {
return Ok(false); return Ok(false);
@@ -399,10 +211,7 @@ impl App<'_> {
return Ok(false); return Ok(false);
} }
SlashCommand::Diff => { SlashCommand::Diff => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.add_diff_in_progress();
widget.add_diff_in_progress();
}
let tx = self.app_event_tx.clone(); let tx = self.app_event_tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
let text = match get_git_diff().await { let text = match get_git_diff().await {
@@ -419,19 +228,13 @@ impl App<'_> {
}); });
} }
SlashCommand::Mention => { SlashCommand::Mention => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.insert_str("@");
widget.insert_str("@");
}
} }
SlashCommand::Status => { SlashCommand::Status => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.add_status_output();
widget.add_status_output();
}
} }
SlashCommand::Mcp => { SlashCommand::Mcp => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.add_mcp_output();
widget.add_mcp_output();
}
} }
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
SlashCommand::TestApproval => { 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) => { AppEvent::StartFileSearch(query) => {
if !query.is_empty() { if !query.is_empty() {
self.file_search.on_user_query(query); self.file_search.on_user_query(query);
} }
} }
AppEvent::FileSearchResult { query, matches } => { AppEvent::FileSearchResult { query, matches } => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.apply_file_search_result(query, matches);
widget.apply_file_search_result(query, matches);
}
} }
AppEvent::UpdateReasoningEffort(effort) => { AppEvent::UpdateReasoningEffort(effort) => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.set_reasoning_effort(effort);
widget.set_reasoning_effort(effort);
}
} }
AppEvent::UpdateModel(model) => { AppEvent::UpdateModel(model) => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.set_model(model);
widget.set_model(model);
}
} }
AppEvent::UpdateAskForApprovalPolicy(policy) => { AppEvent::UpdateAskForApprovalPolicy(policy) => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.set_approval_policy(policy);
widget.set_approval_policy(policy);
}
} }
AppEvent::UpdateSandboxPolicy(policy) => { AppEvent::UpdateSandboxPolicy(policy) => {
if let AppState::Chat { widget } = &mut self.app_state { self.chat_widget.set_sandbox_policy(policy);
widget.set_sandbox_policy(policy);
}
} }
} }
Ok(true) 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 { pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
match &self.app_state { self.chat_widget.token_usage().clone()
AppState::Chat { widget } => widget.token_usage().clone(),
AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(),
}
} }
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> { async fn handle_key_event(&mut self, key_event: KeyEvent) {
if matches!(self.app_state, AppState::Onboarding { .. }) { match key_event {
terminal.clear()?; KeyEvent {
} code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
let screen_size = terminal.size()?; kind: KeyEventKind::Press,
let last_known_screen_size = terminal.last_known_screen_size; ..
if screen_size != last_known_screen_size { } => {
let cursor_pos = terminal.get_cursor_position()?; self.chat_widget.on_ctrl_c();
let last_known_cursor_pos = terminal.last_known_cursor_pos; }
if cursor_pos.y != last_known_cursor_pos.y { KeyEvent {
// The terminal was resized. The only point of reference we have for where our viewport code: KeyCode::Char('d'),
// was moved is the cursor position. modifiers: crossterm::event::KeyModifiers::CONTROL,
// NB this assumes that the cursor was not wrapped as part of the resize. kind: KeyEventKind::Press,
let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32; ..
} if self.chat_widget.composer_is_empty() => {
let new_viewport_area = terminal.viewport_area.offset(Offset { self.app_event_tx.send(AppEvent::ExitRequest);
x: 0, }
y: cursor_delta, KeyEvent {
}); kind: KeyEventKind::Press | KeyEventKind::Repeat,
terminal.set_viewport_area(new_viewport_area); ..
terminal.clear()?; } => {
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
))
} }
} }

View File

@@ -1,10 +1,7 @@
use codex_core::protocol::Event; use codex_core::protocol::Event;
use codex_file_search::FileMatch; use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::text::Line; use ratatui::text::Line;
use std::time::Duration;
use crate::app::ChatWidgetArgs;
use crate::slash_command::SlashCommand; use crate::slash_command::SlashCommand;
use codex_core::protocol::AskForApproval; use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SandboxPolicy;
@@ -15,21 +12,6 @@ use codex_core::protocol_config_types::ReasoningEffort;
pub(crate) enum AppEvent { pub(crate) enum AppEvent {
CodexEvent(Event), 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. /// Request to exit the application gracefully.
ExitRequest, ExitRequest,
@@ -63,10 +45,6 @@ pub(crate) enum AppEvent {
StopCommitAnimation, StopCommitAnimation,
CommitTick, CommitTick,
/// Onboarding: result of login_with_chatgpt.
OnboardingAuthComplete(Result<(), String>),
OnboardingComplete(ChatWidgetArgs),
/// Update the current reasoning effort in the running app and widget. /// Update the current reasoning effort in the running app and widget.
UpdateReasoningEffort(ReasoningEffort), UpdateReasoningEffort(ReasoningEffort),

View File

@@ -12,13 +12,13 @@ use super::BottomPaneView;
use super::CancellationEvent; use super::CancellationEvent;
/// Modal overlay asking the user to approve/deny a sequence of requests. /// Modal overlay asking the user to approve/deny a sequence of requests.
pub(crate) struct ApprovalModalView<'a> { pub(crate) struct ApprovalModalView {
current: UserApprovalWidget<'a>, current: UserApprovalWidget,
queue: Vec<ApprovalRequest>, queue: Vec<ApprovalRequest>,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
} }
impl ApprovalModalView<'_> { impl ApprovalModalView {
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
Self { Self {
current: UserApprovalWidget::new(request, app_event_tx.clone()), current: UserApprovalWidget::new(request, app_event_tx.clone()),
@@ -41,13 +41,13 @@ impl ApprovalModalView<'_> {
} }
} }
impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> { impl BottomPaneView for ApprovalModalView {
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) {
self.current.handle_key_event(key_event); self.current.handle_key_event(key_event);
self.maybe_advance(); 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.current.on_ctrl_c();
self.queue.clear(); self.queue.clear();
CancellationEvent::Handled CancellationEvent::Handled
@@ -96,6 +96,7 @@ mod tests {
let (tx2, _rx2) = unbounded_channel::<AppEvent>(); let (tx2, _rx2) = unbounded_channel::<AppEvent>();
let mut pane = BottomPane::new(super::super::BottomPaneParams { let mut pane = BottomPane::new(super::super::BottomPaneParams {
app_event_tx: AppEventSender::new(tx2), app_event_tx: AppEventSender::new(tx2),
frame_requester: crate::tui::FrameRequester::test_dummy(),
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported: false, enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(), placeholder_text: "Ask Codex to do anything".to_string(),

View File

@@ -7,10 +7,10 @@ use super::BottomPane;
use super::CancellationEvent; use super::CancellationEvent;
/// Trait implemented by every view that can be shown in the bottom pane. /// 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 /// Handle a key event while the view is active. A redraw is always
/// scheduled after this call. /// 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. /// Return `true` if the view has finished and should be removed.
fn is_complete(&self) -> bool { fn is_complete(&self) -> bool {
@@ -18,7 +18,7 @@ pub(crate) trait BottomPaneView<'a> {
} }
/// Handle Ctrl-C while this view is active. /// 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 CancellationEvent::Ignored
} }

View File

@@ -105,8 +105,8 @@ impl ListSelectionView {
} }
} }
impl BottomPaneView<'_> for ListSelectionView { impl BottomPaneView for ListSelectionView {
fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) { fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
match key_event { match key_event {
KeyEvent { KeyEvent {
code: KeyCode::Up, .. code: KeyCode::Up, ..
@@ -131,7 +131,7 @@ impl BottomPaneView<'_> for ListSelectionView {
self.complete 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; self.complete = true;
CancellationEvent::Handled CancellationEvent::Handled
} }

View File

@@ -1,7 +1,7 @@
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. //! 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::app_event_sender::AppEventSender;
use crate::tui::FrameRequester;
use crate::user_approval_widget::ApprovalRequest; use crate::user_approval_widget::ApprovalRequest;
use bottom_pane_view::BottomPaneView; use bottom_pane_view::BottomPaneView;
use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsage;
@@ -39,15 +39,17 @@ pub(crate) use list_selection_view::SelectionItem;
use status_indicator_view::StatusIndicatorView; use status_indicator_view::StatusIndicatorView;
/// Pane displayed in the lower half of the chat UI. /// 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 /// Composer is retained even when a BottomPaneView is displayed so the
/// input state is retained when the view is closed. /// input state is retained when the view is closed.
composer: ChatComposer, composer: ChatComposer,
/// If present, this is displayed instead of the `composer`. /// If present, this is displayed instead of the `composer`.
active_view: Option<Box<dyn BottomPaneView<'a> + 'a>>, active_view: Option<Box<dyn BottomPaneView>>,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
frame_requester: FrameRequester,
has_input_focus: bool, has_input_focus: bool,
is_task_running: bool, is_task_running: bool,
ctrl_c_quit_hint: bool, ctrl_c_quit_hint: bool,
@@ -59,12 +61,13 @@ pub(crate) struct BottomPane<'a> {
pub(crate) struct BottomPaneParams { pub(crate) struct BottomPaneParams {
pub(crate) app_event_tx: AppEventSender, pub(crate) app_event_tx: AppEventSender,
pub(crate) frame_requester: FrameRequester,
pub(crate) has_input_focus: bool, pub(crate) has_input_focus: bool,
pub(crate) enhanced_keys_supported: bool, pub(crate) enhanced_keys_supported: bool,
pub(crate) placeholder_text: String, pub(crate) placeholder_text: String,
} }
impl BottomPane<'_> { impl BottomPane {
const BOTTOM_PAD_LINES: u16 = 2; const BOTTOM_PAD_LINES: u16 = 2;
pub fn new(params: BottomPaneParams) -> Self { pub fn new(params: BottomPaneParams) -> Self {
let enhanced_keys_supported = params.enhanced_keys_supported; let enhanced_keys_supported = params.enhanced_keys_supported;
@@ -77,6 +80,7 @@ impl BottomPane<'_> {
), ),
active_view: None, active_view: None,
app_event_tx: params.app_event_tx, app_event_tx: params.app_event_tx,
frame_requester: params.frame_requester,
has_input_focus: params.has_input_focus, has_input_focus: params.has_input_focus,
is_task_running: false, is_task_running: false,
ctrl_c_quit_hint: false, ctrl_c_quit_hint: false,
@@ -113,7 +117,10 @@ impl BottomPane<'_> {
if !view.is_complete() { if !view.is_complete() {
self.active_view = Some(view); self.active_view = Some(view);
} else if self.is_task_running { } 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()); v.update_text("waiting for model".to_string());
self.active_view = Some(Box::new(v)); self.active_view = Some(Box::new(v));
self.status_view_active = true; self.status_view_active = true;
@@ -144,7 +151,10 @@ impl BottomPane<'_> {
self.active_view = Some(view); self.active_view = Some(view);
} else if self.is_task_running { } else if self.is_task_running {
// Modal aborted but task still running restore status indicator. // 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()); v.update_text("waiting for model".to_string());
self.active_view = Some(Box::new(v)); self.active_view = Some(Box::new(v));
self.status_view_active = true; self.status_view_active = true;
@@ -199,6 +209,7 @@ impl BottomPane<'_> {
if self.active_view.is_none() { if self.active_view.is_none() {
self.active_view = Some(Box::new(StatusIndicatorView::new( self.active_view = Some(Box::new(StatusIndicatorView::new(
self.app_event_tx.clone(), self.app_event_tx.clone(),
self.frame_requester.clone(),
))); )));
self.status_view_active = true; self.status_view_active = true;
} }
@@ -292,7 +303,7 @@ impl BottomPane<'_> {
/// Height (terminal rows) required by the current bottom pane. /// Height (terminal rows) required by the current bottom pane.
pub(crate) fn request_redraw(&self) { pub(crate) fn request_redraw(&self) {
self.app_event_tx.send(AppEvent::RequestRedraw) self.frame_requester.schedule_frame();
} }
// --- History helpers --- // --- 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) { fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if let Some(view) = &self.active_view { if let Some(view) = &self.active_view {
// Reserve bottom padding lines; keep at least 1 line for the 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 tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx, app_event_tx: tx,
frame_requester: crate::tui::FrameRequester::test_dummy(),
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported: false, enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(), placeholder_text: "Ask Codex to do anything".to_string(),
@@ -393,6 +405,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw); let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx, app_event_tx: tx,
frame_requester: crate::tui::FrameRequester::test_dummy(),
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported: false, enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(), placeholder_text: "Ask Codex to do anything".to_string(),
@@ -422,6 +435,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw); let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx.clone(), app_event_tx: tx.clone(),
frame_requester: crate::tui::FrameRequester::test_dummy(),
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported: false, enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(), placeholder_text: "Ask Codex to do anything".to_string(),
@@ -472,6 +486,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw); let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx, app_event_tx: tx,
frame_requester: crate::tui::FrameRequester::test_dummy(),
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported: false, enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(), placeholder_text: "Ask Codex to do anything".to_string(),
@@ -504,6 +519,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw); let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx, app_event_tx: tx,
frame_requester: crate::tui::FrameRequester::test_dummy(),
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported: false, enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(), placeholder_text: "Ask Codex to do anything".to_string(),
@@ -556,6 +572,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw); let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx, app_event_tx: tx,
frame_requester: crate::tui::FrameRequester::test_dummy(),
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported: false, enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(), placeholder_text: "Ask Codex to do anything".to_string(),

View File

@@ -6,6 +6,7 @@ use ratatui::widgets::WidgetRef;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPane;
use crate::status_indicator_widget::StatusIndicatorWidget; use crate::status_indicator_widget::StatusIndicatorWidget;
use crate::tui::FrameRequester;
use super::BottomPaneView; use super::BottomPaneView;
@@ -14,9 +15,9 @@ pub(crate) struct StatusIndicatorView {
} }
impl StatusIndicatorView { impl StatusIndicatorView {
pub fn new(app_event_tx: AppEventSender) -> Self { pub fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
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 { fn should_hide_when_task_is_done(&mut self) -> bool {
true true
} }
@@ -38,7 +39,7 @@ impl BottomPaneView<'_> for StatusIndicatorView {
self.view.render_ref(area, buf); 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 { if key_event.code == KeyCode::Esc {
self.view.interrupt(); self.view.interrupt();
} }

View File

@@ -52,6 +52,7 @@ use crate::history_cell::CommandOutput;
use crate::history_cell::ExecCell; use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell; use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType; use crate::history_cell::PatchEventType;
use crate::tui::FrameRequester;
// streaming internals are provided by crate::streaming and crate::markdown_stream // streaming internals are provided by crate::streaming and crate::markdown_stream
use crate::user_approval_widget::ApprovalRequest; use crate::user_approval_widget::ApprovalRequest;
mod interrupts; mod interrupts;
@@ -77,10 +78,10 @@ struct RunningCommand {
parsed_cmd: Vec<ParsedCommand>, parsed_cmd: Vec<ParsedCommand>,
} }
pub(crate) struct ChatWidget<'a> { pub(crate) struct ChatWidget {
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>, codex_op_tx: UnboundedSender<Op>,
bottom_pane: BottomPane<'a>, bottom_pane: BottomPane,
active_exec_cell: Option<ExecCell>, active_exec_cell: Option<ExecCell>,
config: Config, config: Config,
initial_user_message: Option<UserMessage>, initial_user_message: Option<UserMessage>,
@@ -98,6 +99,7 @@ pub(crate) struct ChatWidget<'a> {
// Whether a redraw is needed after handling the current event // Whether a redraw is needed after handling the current event
needs_redraw: bool, needs_redraw: bool,
session_id: Option<Uuid>, session_id: Option<Uuid>,
frame_requester: FrameRequester,
} }
struct UserMessage { struct UserMessage {
@@ -124,7 +126,7 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
} }
} }
impl ChatWidget<'_> { impl ChatWidget {
#[inline] #[inline]
fn mark_needs_redraw(&mut self) { fn mark_needs_redraw(&mut self) {
self.needs_redraw = true; self.needs_redraw = true;
@@ -500,6 +502,7 @@ impl ChatWidget<'_> {
pub(crate) fn new( pub(crate) fn new(
config: Config, config: Config,
conversation_manager: Arc<ConversationManager>, conversation_manager: Arc<ConversationManager>,
frame_requester: FrameRequester,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
initial_prompt: Option<String>, initial_prompt: Option<String>,
initial_images: Vec<PathBuf>, initial_images: Vec<PathBuf>,
@@ -511,8 +514,10 @@ impl ChatWidget<'_> {
Self { Self {
app_event_tx: app_event_tx.clone(), app_event_tx: app_event_tx.clone(),
frame_requester: frame_requester.clone(),
codex_op_tx, codex_op_tx,
bottom_pane: BottomPane::new(BottomPaneParams { bottom_pane: BottomPane::new(BottomPaneParams {
frame_requester,
app_event_tx, app_event_tx,
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported, enhanced_keys_supported,
@@ -672,7 +677,7 @@ impl ChatWidget<'_> {
} }
fn request_redraw(&mut self) { 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) { 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) { fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let [active_cell_area, bottom_pane_area] = self.layout_areas(area); let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
(&self.bottom_pane).render(bottom_pane_area, buf); (&self.bottom_pane).render(bottom_pane_area, buf);

View File

@@ -71,7 +71,7 @@ impl InterruptManager {
self.queue.push_back(QueuedInterrupt::PatchEnd(ev)); 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() { while let Some(q) = self.queue.pop_front() {
match q { match q {
QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev), QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev),

View File

@@ -104,14 +104,22 @@ async fn helpers_are_available_and_do_not_panic() {
let tx = AppEventSender::new(tx_raw); let tx = AppEventSender::new(tx_raw);
let cfg = test_config(); let cfg = test_config();
let conversation_manager = Arc::new(ConversationManager::default()); 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. // Basic construction sanity.
let _ = &mut w; let _ = &mut w;
} }
// --- Helpers for tests that need direct construction and event draining --- // --- Helpers for tests that need direct construction and event draining ---
fn make_chatwidget_manual() -> ( fn make_chatwidget_manual() -> (
ChatWidget<'static>, ChatWidget,
tokio::sync::mpsc::UnboundedReceiver<AppEvent>, tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
tokio::sync::mpsc::UnboundedReceiver<Op>, tokio::sync::mpsc::UnboundedReceiver<Op>,
) { ) {
@@ -121,6 +129,7 @@ fn make_chatwidget_manual() -> (
let cfg = test_config(); let cfg = test_config();
let bottom = BottomPane::new(BottomPaneParams { let bottom = BottomPane::new(BottomPaneParams {
app_event_tx: app_event_tx.clone(), app_event_tx: app_event_tx.clone(),
frame_requester: crate::tui::FrameRequester::test_dummy(),
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported: false, enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(), placeholder_text: "Ask Codex to do anything".to_string(),
@@ -142,6 +151,7 @@ fn make_chatwidget_manual() -> (
interrupts: InterruptManager::new(), interrupts: InterruptManager::new(),
needs_redraw: false, needs_redraw: false,
session_id: None, session_id: None,
frame_requester: crate::tui::FrameRequester::test_dummy(),
}; };
(widget, rx, op_rx) (widget, rx, op_rx)
} }

View File

@@ -245,7 +245,7 @@ where
/// Index of the current buffer in the previous array /// Index of the current buffer in the previous array
current: usize, current: usize,
/// Whether the cursor is currently hidden /// Whether the cursor is currently hidden
hidden_cursor: bool, pub hidden_cursor: bool,
/// Area of the viewport /// Area of the viewport
pub viewport_area: Rect, pub viewport_area: Rect,
/// Last known size of the terminal. Used to detect if the internal buffers have to be resized. /// Last known size of the terminal. Used to detect if the internal buffers have to be resized.

View File

@@ -22,7 +22,7 @@ use textwrap::Options as TwOptions;
use textwrap::WordSplitter; use textwrap::WordSplitter;
/// Insert `lines` above the viewport. /// Insert `lines` above the viewport.
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) { pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec<Line>) {
let mut out = std::io::stdout(); let mut out = std::io::stdout();
insert_history_lines_to_writer(terminal, &mut out, lines); insert_history_lines_to_writer(terminal, &mut out, lines);
} }
@@ -39,7 +39,7 @@ pub fn insert_history_lines_to_writer<B, W>(
{ {
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); 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 // 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. // formatting as the TUI. This avoids character-level hard wrapping by the terminal.

View File

@@ -64,6 +64,11 @@ use color_eyre::owo_colors::OwoColorize;
pub use cli::Cli; 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) // (tests access modules directly within the crate)
pub async fn run_main( pub async fn run_main(
@@ -256,6 +261,7 @@ async fn run_ratatui_app(
config: Config, config: Config,
should_show_trust_screen: bool, should_show_trust_screen: bool,
) -> color_eyre::Result<codex_core::protocol::TokenUsage> { ) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
let mut config = config;
color_eyre::install()?; color_eyre::install()?;
// Forward panic reports through tracing so they appear in the UI status // 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}"); tracing::error!("panic: {info}");
prev_hook(info); prev_hook(info);
})); }));
let mut terminal = tui::init(&config)?; let mut terminal = tui::init()?;
terminal.clear()?; terminal.clear()?;
let mut tui = Tui::new(terminal);
// Initialize high-fidelity session event logging if enabled. // Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&config); session_log::maybe_init(&config);
let Cli { prompt, images, .. } = cli; 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 login_status = get_login_status(&config);
let usage = app.token_usage(); 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(); restore();
// Mark the end of the recorded session. // Mark the end of the recorded session.
session_log::log_session_end(); session_log::log_session_end();
// ignore error when collecting usage report underlying error instead // ignore error when collecting usage report underlying error instead
app_result.map(|_| usage) app_result
} }
#[expect( #[expect(
@@ -357,3 +384,80 @@ fn determine_repo_trust_state(
Ok(true) 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
))
}
}

View File

@@ -1,3 +1,5 @@
#![allow(clippy::unwrap_used)]
use codex_login::CLIENT_ID; use codex_login::CLIENT_ID;
use codex_login::ServerOptions; use codex_login::ServerOptions;
use codex_login::ShutdownHandle; use codex_login::ShutdownHandle;
@@ -18,19 +20,19 @@ use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap; use ratatui::widgets::Wrap;
use codex_login::AuthMode; use codex_login::AuthMode;
use std::sync::RwLock;
use crate::LoginStatus; 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::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider; use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::shimmer::shimmer_spans; use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use super::onboarding_screen::StepState; use super::onboarding_screen::StepState;
// no additional imports
#[derive(Debug)] #[derive(Clone)]
pub(crate) enum SignInState { pub(crate) enum SignInState {
PickMode, PickMode,
ChatGptContinueInBrowser(ContinueInBrowserState), ChatGptContinueInBrowser(ContinueInBrowserState),
@@ -40,18 +42,17 @@ pub(crate) enum SignInState {
EnvVarFound, EnvVarFound,
} }
#[derive(Debug)] #[derive(Clone)]
/// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up. /// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up.
pub(crate) struct ContinueInBrowserState { pub(crate) struct ContinueInBrowserState {
auth_url: String, auth_url: String,
shutdown_handle: Option<ShutdownHandle>, shutdown_flag: Option<ShutdownHandle>,
_login_wait_handle: Option<tokio::task::JoinHandle<()>>,
} }
impl Drop for ContinueInBrowserState { impl Drop for ContinueInBrowserState {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(flag) = &self.shutdown_handle { if let Some(handle) = &self.shutdown_flag {
flag.shutdown(); handle.shutdown();
} }
} }
} }
@@ -69,20 +70,32 @@ impl KeyboardHandler for AuthModeWidget {
self.start_chatgpt_login(); self.start_chatgpt_login();
} }
KeyCode::Char('2') => self.verify_api_key(), KeyCode::Char('2') => self.verify_api_key(),
KeyCode::Enter => match self.sign_in_state { KeyCode::Enter => {
SignInState::PickMode => match self.highlighted_mode { let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
AuthMode::ChatGPT => self.start_chatgpt_login(), match sign_in_state {
AuthMode::ApiKey => self.verify_api_key(), SignInState::PickMode => match self.highlighted_mode {
}, AuthMode::ChatGPT => {
SignInState::EnvVarMissing => self.sign_in_state = SignInState::PickMode, self.start_chatgpt_login();
SignInState::ChatGptSuccessMessage => { }
self.sign_in_state = SignInState::ChatGptSuccess 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 => { KeyCode::Esc => {
if matches!(self.sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { tracing::info!("Esc pressed");
self.sign_in_state = SignInState::PickMode; 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(crate) struct AuthModeWidget {
pub event_tx: AppEventSender, pub request_frame: FrameRequester,
pub highlighted_mode: AuthMode, pub highlighted_mode: AuthMode,
pub error: Option<String>, pub error: Option<String>,
pub sign_in_state: SignInState, pub sign_in_state: Arc<RwLock<SignInState>>,
pub codex_home: PathBuf, pub codex_home: PathBuf,
pub login_status: LoginStatus, pub login_status: LoginStatus,
pub preferred_auth_method: AuthMode, pub preferred_auth_method: AuthMode,
@@ -215,14 +228,13 @@ impl AuthModeWidget {
fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) { fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) {
let mut spans = vec![Span::from("> ")]; let mut spans = vec![Span::from("> ")];
// Schedule a follow-up frame to keep the shimmer animation going. // Schedule a follow-up frame to keep the shimmer animation going.
self.event_tx self.request_frame
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis( .schedule_frame_in(std::time::Duration::from_millis(100));
100,
)));
spans.extend(shimmer_spans("Finish signing in via your browser")); spans.extend(shimmer_spans("Finish signing in via your browser"));
let mut lines = vec![Line::from(spans), Line::from("")]; 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() && !state.auth_url.is_empty()
{ {
lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:")); 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 // If we're already authenticated with ChatGPT, don't start a new login
// just proceed to the success message flow. // just proceed to the success message flow.
if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) {
self.sign_in_state = SignInState::ChatGptSuccess; *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
self.event_tx.send(AppEvent::RequestRedraw); self.request_frame.schedule_frame();
return; return;
} }
self.error = None; self.error = None;
let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string()); let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string());
let server = run_login_server(opts); match run_login_server(opts) {
match server {
Ok(child) => { Ok(child) => {
let auth_url = child.auth_url.clone(); let sign_in_state = self.sign_in_state.clone();
let shutdown_handle = child.cancel_handle(); let request_frame = self.request_frame.clone();
tokio::spawn(async move {
let event_tx = self.event_tx.clone(); let auth_url = child.auth_url.clone();
let join_handle = tokio::spawn(async move { {
spawn_completion_poller(child, event_tx).await; *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) => { Err(e) => {
self.sign_in_state = SignInState::PickMode; *self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.error = Some(e.to_string()); 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)) { if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ApiKey)) {
// We already have an API key configured (e.g., from auth.json or env), // We already have an API key configured (e.g., from auth.json or env),
// so mark this step complete immediately. // so mark this step complete immediately.
self.sign_in_state = SignInState::EnvVarFound; *self.sign_in_state.write().unwrap() = SignInState::EnvVarFound;
} else { } else {
self.sign_in_state = SignInState::EnvVarMissing; *self.sign_in_state.write().unwrap() = SignInState::EnvVarMissing;
} }
self.request_frame.schedule_frame();
self.event_tx.send(AppEvent::RequestRedraw);
} }
} }
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 { impl StepStateProvider for AuthModeWidget {
fn get_step_state(&self) -> StepState { 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::PickMode
| SignInState::EnvVarMissing | SignInState::EnvVarMissing
| SignInState::ChatGptContinueInBrowser(_) | SignInState::ChatGptContinueInBrowser(_)
@@ -391,7 +398,8 @@ impl StepStateProvider for AuthModeWidget {
impl WidgetRef for AuthModeWidget { impl WidgetRef for AuthModeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) { 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 => { SignInState::PickMode => {
self.render_pick_mode(area, buf); self.render_pick_mode(area, buf);
} }

View File

@@ -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<Mutex<ChatWidgetArgs>>,
}
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()));
}
}
}

View File

@@ -1,5 +1,5 @@
mod auth; mod auth;
mod continue_to_chat;
pub mod onboarding_screen; pub mod onboarding_screen;
mod trust_directory; mod trust_directory;
pub use trust_directory::TrustDirectorySelection;
mod welcome; mod welcome;

View File

@@ -1,5 +1,7 @@
use codex_core::util::is_inside_git_repo; use codex_core::util::is_inside_git_repo;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::prelude::Widget; use ratatui::prelude::Widget;
@@ -9,25 +11,24 @@ use ratatui::widgets::WidgetRef;
use codex_login::AuthMode; use codex_login::AuthMode;
use crate::LoginStatus; 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::AuthModeWidget;
use crate::onboarding::auth::SignInState; use crate::onboarding::auth::SignInState;
use crate::onboarding::continue_to_chat::ContinueToChatWidget;
use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectorySelection;
use crate::onboarding::trust_directory::TrustDirectoryWidget; use crate::onboarding::trust_directory::TrustDirectoryWidget;
use crate::onboarding::welcome::WelcomeWidget; 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::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex; use std::sync::RwLock;
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
enum Step { enum Step {
Welcome(WelcomeWidget), Welcome(WelcomeWidget),
Auth(AuthModeWidget), Auth(AuthModeWidget),
TrustDirectory(TrustDirectoryWidget), TrustDirectory(TrustDirectoryWidget),
ContinueToChat(ContinueToChatWidget),
} }
pub(crate) trait KeyboardHandler { pub(crate) trait KeyboardHandler {
@@ -45,43 +46,42 @@ pub(crate) trait StepStateProvider {
} }
pub(crate) struct OnboardingScreen { pub(crate) struct OnboardingScreen {
event_tx: AppEventSender, request_frame: FrameRequester,
steps: Vec<Step>, steps: Vec<Step>,
is_done: bool,
} }
pub(crate) struct OnboardingScreenArgs { pub(crate) struct OnboardingScreenArgs {
pub event_tx: AppEventSender,
pub chat_widget_args: ChatWidgetArgs,
pub codex_home: PathBuf, pub codex_home: PathBuf,
pub cwd: PathBuf, pub cwd: PathBuf,
pub show_trust_screen: bool, pub show_trust_screen: bool,
pub show_login_screen: bool, pub show_login_screen: bool,
pub login_status: LoginStatus, pub login_status: LoginStatus,
pub preferred_auth_method: AuthMode,
} }
impl OnboardingScreen { impl OnboardingScreen {
pub(crate) fn new(args: OnboardingScreenArgs) -> Self { pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs { let OnboardingScreenArgs {
event_tx,
chat_widget_args,
codex_home, codex_home,
cwd, cwd,
show_trust_screen, show_trust_screen,
show_login_screen, show_login_screen,
login_status, login_status,
preferred_auth_method,
} = args; } = args;
let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget { let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget {
is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated), is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated),
})]; })];
if show_login_screen { if show_login_screen {
steps.push(Step::Auth(AuthModeWidget { steps.push(Step::Auth(AuthModeWidget {
event_tx: event_tx.clone(), request_frame: tui.frame_requester(),
highlighted_mode: AuthMode::ChatGPT, highlighted_mode: AuthMode::ChatGPT,
error: None, error: None,
sign_in_state: SignInState::PickMode, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home.clone(), codex_home: codex_home.clone(),
login_status, login_status,
preferred_auth_method: chat_widget_args.config.preferred_auth_method, preferred_auth_method,
})) }))
} }
let is_git_repo = is_inside_git_repo(&cwd); 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. // Default to not trusting the directory if it's not a git repo.
TrustDirectorySelection::DontTrust 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 { if show_trust_screen {
steps.push(Step::TrustDirectory(TrustDirectoryWidget { steps.push(Step::TrustDirectory(TrustDirectoryWidget {
cwd, cwd,
@@ -102,39 +99,13 @@ impl OnboardingScreen {
selection: None, selection: None,
highlighted, highlighted,
error: None, 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. // TODO: add git warning.
Self { event_tx, steps } Self {
} request_frame: tui.frame_requester(),
steps,
pub(crate) fn on_auth_complete(&mut self, result: Result<(), String>) { is_done: false,
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);
}
}
} }
} }
@@ -168,19 +139,57 @@ impl OnboardingScreen {
out 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<TrustDirectorySelection> {
self.steps self.steps
.iter_mut() .iter()
.find(|step| matches!(step.get_step_state(), StepState::InProgress)) .find_map(|step| {
if let Step::TrustDirectory(TrustDirectoryWidget { selection, .. }) = step {
Some(*selection)
} else {
None
}
})
.flatten()
} }
} }
impl KeyboardHandler for OnboardingScreen { impl KeyboardHandler for OnboardingScreen {
fn handle_key_event(&mut self, key_event: KeyEvent) { fn handle_key_event(&mut self, key_event: KeyEvent) {
if let Some(active_step) = self.current_steps_mut().into_iter().last() { match key_event {
active_step.handle_key_event(key_event); KeyEvent {
} code: KeyCode::Char('d'),
self.event_tx.send(AppEvent::RequestRedraw); 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 { impl KeyboardHandler for Step {
fn handle_key_event(&mut self, key_event: KeyEvent) { fn handle_key_event(&mut self, key_event: KeyEvent) {
match self { match self {
Step::Welcome(_) | Step::ContinueToChat(_) => (), Step::Welcome(_) => (),
Step::Auth(widget) => widget.handle_key_event(key_event), Step::Auth(widget) => widget.handle_key_event(key_event),
Step::TrustDirectory(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::Welcome(w) => w.get_step_state(),
Step::Auth(w) => w.get_step_state(), Step::Auth(w) => w.get_step_state(),
Step::TrustDirectory(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) => { Step::TrustDirectory(widget) => {
widget.render_ref(area, buf); 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<Option<crate::onboarding::TrustDirectorySelection>> {
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())
}

View File

@@ -1,8 +1,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use codex_core::config::set_project_trusted; use codex_core::config::set_project_trusted;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
@@ -22,9 +20,6 @@ use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider; use crate::onboarding::onboarding_screen::StepStateProvider;
use super::onboarding_screen::StepState; use super::onboarding_screen::StepState;
use crate::app::ChatWidgetArgs;
use std::sync::Arc;
use std::sync::Mutex;
pub(crate) struct TrustDirectoryWidget { pub(crate) struct TrustDirectoryWidget {
pub codex_home: PathBuf, pub codex_home: PathBuf,
@@ -33,11 +28,10 @@ pub(crate) struct TrustDirectoryWidget {
pub selection: Option<TrustDirectorySelection>, pub selection: Option<TrustDirectorySelection>,
pub highlighted: TrustDirectorySelection, pub highlighted: TrustDirectorySelection,
pub error: Option<String>, pub error: Option<String>,
pub chat_widget_args: Arc<Mutex<ChatWidgetArgs>>,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TrustDirectorySelection { pub enum TrustDirectorySelection {
Trust, Trust,
DontTrust, DontTrust,
} }
@@ -156,13 +150,6 @@ impl TrustDirectoryWidget {
// self.error = Some("Failed to set project trusted".to_string()); // 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); self.selection = Some(TrustDirectorySelection::Trust);
} }

View File

@@ -132,24 +132,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
AppEvent::CodexEvent(ev) => { AppEvent::CodexEvent(ev) => {
write_record("to_tui", "codex_event", 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) => { AppEvent::DispatchCommand(cmd) => {
let value = json!({ let value = json!({
"ts": now_ts(), "ts": now_ts(),

View File

@@ -19,6 +19,7 @@ use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::shimmer::shimmer_spans; use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
// We render the live text using markdown so it visually matches the history // 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 // 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, reveal_len_at_base: usize,
start_time: Instant, start_time: Instant,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
frame_requester: FrameRequester,
} }
impl StatusIndicatorWidget { impl StatusIndicatorWidget {
pub(crate) fn new(app_event_tx: AppEventSender) -> Self { pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
Self { Self {
text: String::from("waiting for model"), text: String::from("waiting for model"),
last_target_len: 0, last_target_len: 0,
@@ -51,6 +53,7 @@ impl StatusIndicatorWidget {
start_time: Instant::now(), start_time: Instant::now(),
app_event_tx, app_event_tx,
frame_requester,
} }
} }
@@ -143,8 +146,8 @@ impl WidgetRef for StatusIndicatorWidget {
} }
// Schedule next animation frame. // Schedule next animation frame.
self.app_event_tx self.frame_requester
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(100))); .schedule_frame_in(Duration::from_millis(100));
let idx = self.current_frame(); let idx = self.current_frame();
let elapsed = self.start_time.elapsed().as_secs(); let elapsed = self.start_time.elapsed().as_secs();
let shown_now = self.current_shown_len(idx); let shown_now = self.current_shown_len(idx);
@@ -219,7 +222,7 @@ mod tests {
fn renders_without_left_border_or_padding() { fn renders_without_left_border_or_padding() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw); 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()); w.restart_with_text("Hello".to_string());
let area = ratatui::layout::Rect::new(0, 0, 30, 1); let area = ratatui::layout::Rect::new(0, 0, 30, 1);
@@ -237,7 +240,7 @@ mod tests {
fn working_header_is_present_on_last_line() { fn working_header_is_present_on_last_line() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw); 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()); w.restart_with_text("Hi".to_string());
// Ensure some frames elapse so we get a stable state. // Ensure some frames elapse so we get a stable state.
std::thread::sleep(std::time::Duration::from_millis(120)); std::thread::sleep(std::time::Duration::from_millis(120));
@@ -258,7 +261,7 @@ mod tests {
fn header_starts_at_expected_position() { fn header_starts_at_expected_position() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw); 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()); w.restart_with_text("Hello".to_string());
std::thread::sleep(std::time::Duration::from_millis(120)); std::thread::sleep(std::time::Duration::from_millis(120));

View File

@@ -1,28 +1,39 @@
use std::io::Result; use std::io::Result;
use std::io::Stdout; use std::io::Stdout;
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::cursor::MoveTo;
use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableBracketedPaste;
use crossterm::event::EnableBracketedPaste; use crossterm::event::EnableBracketedPaste;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::PopKeyboardEnhancementFlags; use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::event::PushKeyboardEnhancementFlags; use crossterm::event::PushKeyboardEnhancementFlags;
use crossterm::terminal::Clear; use crossterm::terminal::Clear;
use crossterm::terminal::ClearType; use crossterm::terminal::ClearType;
use ratatui::backend::Backend;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::execute; use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::disable_raw_mode;
use ratatui::crossterm::terminal::enable_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 /// A type alias for the terminal type used in this application
pub type Tui = Terminal<CrosstermBackend<Stdout>>; pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
/// Initialize the terminal (inline viewport; history stays in normal scrollback) pub fn set_modes() -> Result<()> {
pub fn init(_config: &Config) -> Result<Tui> {
execute!(stdout(), EnableBracketedPaste)?; execute!(stdout(), EnableBracketedPaste)?;
enable_raw_mode()?; enable_raw_mode()?;
@@ -40,13 +51,31 @@ pub fn init(_config: &Config) -> Result<Tui> {
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS | 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<Terminal> {
set_modes()?;
set_panic_hook(); set_panic_hook();
// Clear screen and move cursor to top-left before drawing UI // Clear screen and move cursor to top-left before drawing UI
execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?; execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?;
let backend = CrosstermBackend::new(stdout()); let backend = CrosstermBackend::new(stdout());
let tui = Terminal::with_options(backend)?; let tui = CustomTerminal::with_options(backend)?;
Ok(tui) Ok(tui)
} }
@@ -58,11 +87,223 @@ fn set_panic_hook() {
})); }));
} }
/// Restore the terminal to its original state #[derive(Debug)]
pub fn restore() -> Result<()> { pub enum TuiEvent {
// Pop may fail on platforms that didn't support the push; ignore errors. Key(KeyEvent),
let _ = execute!(stdout(), PopKeyboardEnhancementFlags); Paste(String),
execute!(stdout(), DisableBracketedPaste)?; Draw,
disable_raw_mode()?; #[cfg(unix)]
Ok(()) ResumeFromSuspend,
}
pub struct Tui {
frame_schedule_tx: tokio::sync::mpsc::UnboundedSender<Instant>,
draw_tx: tokio::sync::broadcast::Sender<()>,
pub(crate) terminal: Terminal,
pending_history_lines: Vec<Line<'static>>,
}
#[derive(Clone, Debug)]
pub struct FrameRequester {
frame_schedule_tx: tokio::sync::mpsc::UnboundedSender<Instant>,
}
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<Instant> = 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<Box<dyn Stream<Item = TuiEvent> + 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<Line<'static>>) {
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(())
})?
}
} }

View File

@@ -95,11 +95,11 @@ static PATCH_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
}); });
/// A modal prompting the user to approve or deny the pending request. /// A modal prompting the user to approve or deny the pending request.
pub(crate) struct UserApprovalWidget<'a> { pub(crate) struct UserApprovalWidget {
approval_request: ApprovalRequest, approval_request: ApprovalRequest,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
confirmation_prompt: Paragraph<'a>, confirmation_prompt: Paragraph<'static>,
select_options: &'a Vec<SelectOption>, select_options: &'static Vec<SelectOption>,
/// Currently selected index in *select* mode. /// Currently selected index in *select* mode.
selected_option: usize, selected_option: usize,
@@ -137,7 +137,7 @@ fn to_command_display<'a>(
lines lines
} }
impl UserApprovalWidget<'_> { impl UserApprovalWidget {
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let confirmation_prompt = match &approval_request { let confirmation_prompt = match &approval_request {
ApprovalRequest::Exec { 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) { fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let prompt_height = self.get_confirmation_prompt_height(area.width); let prompt_height = self.get_confirmation_prompt_height(area.width);
let [prompt_chunk, response_chunk] = Layout::default() let [prompt_chunk, response_chunk] = Layout::default()