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:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
})?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user