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"
dependencies = [
"anyhow",
"async-stream",
"base64 0.22.1",
"chrono",
"clap",

View File

@@ -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"] }

View File

@@ -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<ChatWidget<'a>>,
},
}
pub(crate) struct App<'a> {
pub(crate) struct App {
server: Arc<ConversationManager>,
app_event_tx: AppEventSender,
app_event_rx: UnboundedReceiver<AppEvent>,
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<Line<'static>>,
enhanced_keys_supported: bool,
/// Controls the animation thread that sends CommitTick events.
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
/// deferred until after the Git warning screen is dismissed.
#[derive(Clone, Debug)]
pub(crate) struct ChatWidgetArgs {
pub(crate) config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
}
impl App<'_> {
pub(crate) fn new(
impl App {
pub async fn run(
tui: &mut tui::Tui,
config: Config,
initial_prompt: Option<String>,
initial_images: Vec<std::path::PathBuf>,
show_trust_screen: bool,
) -> Self {
let conversation_manager = Arc::new(ConversationManager::default());
let (app_event_tx, app_event_rx) = unbounded_channel();
initial_images: Vec<PathBuf>,
) -> Result<TokenUsage> {
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::<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 {
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<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 {
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
))
}
}

View File

@@ -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),

View File

@@ -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<ApprovalRequest>,
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::<AppEvent>();
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(),

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<Box<dyn BottomPaneView<'a> + 'a>>,
active_view: Option<Box<dyn BottomPaneView>>,
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(),

View File

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

View File

@@ -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<ParsedCommand>,
}
pub(crate) struct ChatWidget<'a> {
pub(crate) struct ChatWidget {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>,
bottom_pane: BottomPane<'a>,
bottom_pane: BottomPane,
active_exec_cell: Option<ExecCell>,
config: Config,
initial_user_message: Option<UserMessage>,
@@ -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<Uuid>,
frame_requester: FrameRequester,
}
struct UserMessage {
@@ -124,7 +126,7 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> 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<ConversationManager>,
frame_requester: FrameRequester,
app_event_tx: AppEventSender,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
@@ -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);

View File

@@ -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),

View File

@@ -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<AppEvent>,
tokio::sync::mpsc::UnboundedReceiver<Op>,
) {
@@ -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)
}

View File

@@ -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.

View File

@@ -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<Line>) {
pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec<Line>) {
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<B, W>(
{
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.

View File

@@ -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<codex_core::protocol::TokenUsage> {
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
))
}
}

View File

@@ -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<ShutdownHandle>,
_login_wait_handle: Option<tokio::task::JoinHandle<()>>,
shutdown_flag: Option<ShutdownHandle>,
}
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<String>,
pub sign_in_state: SignInState,
pub sign_in_state: Arc<RwLock<SignInState>>,
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);
}

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 continue_to_chat;
pub mod onboarding_screen;
mod trust_directory;
pub use trust_directory::TrustDirectorySelection;
mod welcome;

View File

@@ -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<Step>,
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<Step> = 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<TrustDirectorySelection> {
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<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 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<TrustDirectorySelection>,
pub highlighted: TrustDirectorySelection,
pub error: Option<String>,
pub chat_widget_args: Arc<Mutex<ChatWidgetArgs>>,
}
#[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);
}

View File

@@ -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(),

View File

@@ -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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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));

View File

@@ -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<CrosstermBackend<Stdout>>;
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
pub fn init(_config: &Config) -> Result<Tui> {
pub fn set_modes() -> Result<()> {
execute!(stdout(), EnableBracketedPaste)?;
enable_raw_mode()?;
@@ -40,13 +51,31 @@ pub fn init(_config: &Config) -> Result<Tui> {
| 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();
// 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<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.
pub(crate) struct UserApprovalWidget<'a> {
pub(crate) struct UserApprovalWidget {
approval_request: ApprovalRequest,
app_event_tx: AppEventSender,
confirmation_prompt: Paragraph<'a>,
select_options: &'a Vec<SelectOption>,
confirmation_prompt: Paragraph<'static>,
select_options: &'static Vec<SelectOption>,
/// 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()