Files
codex/codex-rs/tui/src/app.rs
Daniel Edrisian 2273d18302 tui: attach real image file on drag/drop
- Parse shell-escaped paths via shlex and unescape backslashes
- Recognize and percent-decode file:// URLs
- Prefer existing .png/.jpg/.jpeg file paths over clipboard bitmap
- Keep ~/ expansion and quoting handling
- Fix composer tests to pass placeholder_text to ChatComposer::new
2025-08-19 18:26:59 -07:00

1069 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::LoginStatus;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff;
use crate::get_login_status;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::OnboardingScreen;
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
use crate::slash_command::SlashCommand;
use crate::tui;
use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::protocol::Event;
use codex_core::protocol::Op;
use color_eyre::eyre::Result;
use crossterm::SynchronizedUpdate;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::layout::Offset;
use ratatui::prelude::Backend;
use ratatui::text::Line;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::sync::mpsc::Receiver;
use std::sync::mpsc::channel;
use std::thread;
use std::time::Duration;
use std::time::Instant;
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
/// Naive percent-decoding for file:// URL paths; returns None on invalid UTF-8.
fn percent_decode_to_string(input: &str) -> Option<String> {
let bytes = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let h1 = bytes[i + 1];
let h2 = bytes[i + 2];
let hex = |c: u8| -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
};
if let (Some(x), Some(y)) = (hex(h1), hex(h2)) {
out.push(x * 16 + y);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).ok()
}
/// Convert a file:// URL into a local path (macOS/Unix only, UTF-8).
fn file_url_to_path(s: &str) -> Option<PathBuf> {
if let Some(rest) = s.strip_prefix("file://") {
// Strip optional host like file://localhost/...
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
// Ensure leading slash remains for absolute paths
let decoded = percent_decode_to_string(rest)?;
let p = PathBuf::from(decoded);
return Some(p);
}
None
}
/// Unescape simple bash-style backslash escapes (e.g., spaces, parens).
fn unescape_backslashes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(n) = chars.next() {
out.push(n);
} else {
// Trailing backslash; keep it.
out.push('\\');
}
} else {
out.push(c);
}
}
out
}
// Testable helper: generic over paste function so we can inject stubs in unit tests.
fn try_handle_ctrl_v_with<F>(
app_event_tx: &AppEventSender,
key_event: &KeyEvent,
paste_fn: F,
) -> bool
where
F: Fn() -> Result<
(std::path::PathBuf, crate::clipboard_paste::PastedImageInfo),
crate::clipboard_paste::PasteImageError,
>,
{
// Treat both Ctrl+V and Cmd+V (SUPER on macOS) as the "paste image" hotkey.
let is_v = matches!(key_event.code, KeyCode::Char('v'));
let mods = key_event.modifiers;
let has_paste_modifier = mods.contains(crossterm::event::KeyModifiers::CONTROL)
|| mods.contains(crossterm::event::KeyModifiers::SUPER);
if key_event.kind == KeyEventKind::Press && is_v && has_paste_modifier {
// On macOS, prefer attaching a file URL from the pasteboard if present.
#[cfg(target_os = "macos")]
{
if let Some(path) = crate::clipboard_paste::image_file_from_clipboard_macos() {
let (mut w, mut h) = (0u32, 0u32);
if let Ok((dw, dh)) = image::image_dimensions(&path) {
w = dw;
h = dh;
}
let fmt = match path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
.as_deref()
{
Some("png") => "PNG",
Some("jpg") | Some("jpeg") => "JPEG",
_ => "IMG",
};
app_event_tx.send(AppEvent::AttachImage {
path,
width: w,
height: h,
format_label: fmt,
});
return true;
}
}
match paste_fn() {
Ok((path, info)) => {
tracing::info!(
"ctrl_v_image imported path={:?} width={} height={} format={}",
path,
info.width,
info.height,
info.encoded_format_label
);
app_event_tx.send(AppEvent::AttachImage {
path,
width: info.width,
height: info.height,
format_label: info.encoded_format_label,
});
return true; // consumed
}
Err(err) => {
tracing::debug!("Ctrl+V image import failed: {err}");
}
}
}
false
}
#[cfg(test)]
mod paste_tests {
use super::*;
use crossterm::event::KeyModifiers;
#[test]
fn ctrl_v_success_attaches_image() {
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let key_event = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
let dummy_info = crate::clipboard_paste::PastedImageInfo {
width: 10,
height: 5,
encoded_format_label: "PNG",
};
let handled = try_handle_ctrl_v_with(&sender, &key_event, || {
Ok((
std::path::PathBuf::from("/tmp/test.png"),
dummy_info.clone(),
))
});
assert!(handled, "expected ctrl+v to be handled on success");
match rx
.recv()
.unwrap_or_else(|e| panic!("failed to receive event: {e}"))
{
AppEvent::AttachImage {
path,
width,
height,
format_label,
} => {
assert_eq!(path, std::path::PathBuf::from("/tmp/test.png"));
assert_eq!(width, 10);
assert_eq!(height, 5);
assert_eq!(format_label, "PNG");
}
_ => panic!("unexpected event (not AttachImage)"),
}
}
#[test]
fn cmd_v_success_attaches_image() {
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let key_event = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER);
let dummy_info = crate::clipboard_paste::PastedImageInfo {
width: 12,
height: 8,
encoded_format_label: "PNG",
};
let handled = try_handle_ctrl_v_with(&sender, &key_event, || {
Ok((
std::path::PathBuf::from("/tmp/test2.png"),
dummy_info.clone(),
))
});
assert!(handled, "expected cmd+v to be handled on success");
match rx
.recv()
.unwrap_or_else(|e| panic!("failed to receive event: {e}"))
{
AppEvent::AttachImage {
path,
width,
height,
format_label,
} => {
assert_eq!(path, std::path::PathBuf::from("/tmp/test2.png"));
assert_eq!(width, 12);
assert_eq!(height, 8);
assert_eq!(format_label, "PNG");
}
_ => panic!("unexpected event (not AttachImage)"),
}
}
#[test]
fn ctrl_v_failure_not_consumed() {
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let key_event = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
let handled = try_handle_ctrl_v_with(&sender, &key_event, || {
Err(crate::clipboard_paste::PasteImageError::NoImage(
"none".into(),
))
});
assert!(
!handled,
"on failure ctrl+v should not be considered consumed"
);
assert!(
rx.try_recv().is_err(),
"no events should be sent on failure"
);
}
}
fn try_handle_ctrl_v(app_event_tx: &AppEventSender, key_event: &KeyEvent) -> bool {
try_handle_ctrl_v_with(app_event_tx, key_event, || {
crate::clipboard_paste::paste_image_to_temp_png()
})
}
/// 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>,
app_event_tx: AppEventSender,
app_event_rx: Receiver<AppEvent>,
app_state: AppState<'a>,
/// Config is stored here so we can recreate ChatWidgets as needed.
config: Config,
file_search: FileSearchManager,
pending_history_lines: Vec<Line<'static>>,
enhanced_keys_supported: bool,
/// Controls the animation thread that sends CommitTick events.
commit_anim_running: Arc<AtomicBool>,
/// Channel to schedule one-shot animation frames; coalesced by a single
/// scheduler thread.
frame_schedule_tx: std::sync::mpsc::Sender<Instant>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
/// deferred until after the Git warning screen is dismissed.
#[derive(Clone, Debug)]
pub(crate) struct ChatWidgetArgs {
pub(crate) config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
}
impl App<'_> {
pub(crate) fn new(
config: Config,
initial_prompt: Option<String>,
initial_images: Vec<std::path::PathBuf>,
show_trust_screen: bool,
) -> Self {
let conversation_manager = Arc::new(ConversationManager::default());
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
// Spawn a dedicated thread for reading the crossterm event loop and
// re-publishing the events as AppEvents, as appropriate.
{
let app_event_tx = app_event_tx.clone();
std::thread::spawn(move || {
loop {
// This timeout is necessary to avoid holding the event lock
// that crossterm::event::read() acquires. In particular,
// reading the cursor position (crossterm::cursor::position())
// needs to acquire the event lock, and so will fail if it
// can't acquire it within 2 sec. Resizing the terminal
// crashes the app if the cursor position can't be read.
if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) {
if let Ok(event) = crossterm::event::read() {
match event {
crossterm::event::Event::Key(key_event) => {
if try_handle_ctrl_v(&app_event_tx, &key_event) {
continue;
}
app_event_tx.send(AppEvent::KeyEvent(key_event));
}
crossterm::event::Event::Resize(_, _) => {
app_event_tx.send(AppEvent::RequestRedraw);
}
crossterm::event::Event::Paste(pasted) => {
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
// but tui-textarea expects \n. Normalize CR to LF.
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let pasted = pasted.replace("\r", "\n");
app_event_tx.send(AppEvent::Paste(pasted));
}
_ => {
// Ignore any other events.
}
}
}
} else {
// Timeout expired, no `Event` is available
}
}
});
}
let login_status = get_login_status(&config);
let should_show_onboarding =
should_show_onboarding(login_status, &config, show_trust_screen);
let app_state = if should_show_onboarding {
let show_login_screen = should_show_login_screen(login_status, &config);
let chat_widget_args = ChatWidgetArgs {
config: config.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
};
AppState::Onboarding {
screen: OnboardingScreen::new(OnboardingScreenArgs {
event_tx: app_event_tx.clone(),
codex_home: config.codex_home.clone(),
cwd: config.cwd.clone(),
show_trust_screen,
show_login_screen,
chat_widget_args,
login_status,
}),
}
} else {
let chat_widget = ChatWidget::new(
config.clone(),
conversation_manager.clone(),
app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
);
AppState::Chat {
widget: Box::new(chat_widget),
}
};
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
// Spawn a single scheduler thread that coalesces both debounced redraw
// requests and animation frame requests, and emits a single Redraw event
// at the earliest requested time.
let (frame_tx, frame_rx) = 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,
app_event_tx,
pending_history_lines: Vec::new(),
app_event_rx,
app_state,
config,
file_search,
enhanced_keys_supported,
commit_anim_running: Arc::new(AtomicBool::new(false)),
frame_schedule_tx: frame_tx,
}
}
fn schedule_frame_in(&self, dur: Duration) {
let _ = self.frame_schedule_tx.send(Instant::now() + dur);
}
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
// Schedule the first render immediately.
let _ = self.frame_schedule_tx.send(Instant::now());
while let Ok(event) = self.app_event_rx.recv() {
match event {
AppEvent::InsertHistory(lines) => {
self.pending_history_lines.extend(lines);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::RequestRedraw => {
self.schedule_frame_in(REDRAW_DEBOUNCE);
}
AppEvent::ScheduleFrameIn(dur) => {
self.schedule_frame_in(dur);
}
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
}
AppEvent::StartCommitAnimation => {
if self
.commit_anim_running
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
let tx = self.app_event_tx.clone();
let running = self.commit_anim_running.clone();
thread::spawn(move || {
while running.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(50));
tx.send(AppEvent::CommitTick);
}
});
}
}
AppEvent::StopCommitAnimation => {
self.commit_anim_running.store(false, Ordering::Release);
}
AppEvent::CommitTick => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.on_commit_tick();
}
}
AppEvent::KeyEvent(key_event) => {
match key_event {
KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => match &mut self.app_state {
AppState::Chat { widget } => {
widget.on_ctrl_c();
}
AppState::Onboarding { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
},
KeyEvent {
code: KeyCode::Char('z'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
#[cfg(unix)]
{
self.suspend(terminal)?;
}
// No-op on non-Unix platforms.
}
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
match &mut self.app_state {
AppState::Chat { widget } => {
if widget.composer_is_empty() {
self.app_event_tx.send(AppEvent::ExitRequest);
} else {
// Treat Ctrl+D as a normal key event when the composer
// is not empty so that it doesn't quit the application
// prematurely.
self.dispatch_key_event(key_event);
}
}
AppState::Onboarding { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
}
}
KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.dispatch_key_event(key_event);
}
_ => {
// Ignore Release key events.
}
};
}
AppEvent::Paste(text) => {
// Prefer attaching a pasted image file path, if the text looks
// like an existing image file. This avoids grabbing the Finder
// icon bitmap from the clipboard when a user copied a file.
let mut handled = false;
let mut s = text.trim().to_string();
if !s.is_empty() {
// Strip surrounding quotes (common for paths with spaces)
if (s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\''))
{
s = s[1..s.len() - 1].to_string();
}
// Expand leading ~/ to HOME
if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
let mut p = std::path::PathBuf::from(home);
p.push(rest);
s = p.to_string_lossy().into_owned();
}
}
let path = std::path::PathBuf::from(&s);
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext_l = ext.to_ascii_lowercase();
if matches!(ext_l.as_str(), "png" | "jpg" | "jpeg") {
let (mut w, mut h) = (0u32, 0u32);
if let Ok((dw, dh)) = image::image_dimensions(&path) {
w = dw;
h = dh;
}
let fmt = if ext_l == "png" { "PNG" } else { "JPEG" };
if let AppState::Chat { widget } = &mut self.app_state {
widget.attach_image(path, w, h, fmt);
}
handled = true;
}
}
}
}
if !handled {
// Try to parse shell-escaped or URL-style file paths from the paste.
let candidates: Vec<String> = if let Some(tokens) = shlex::split(&text) {
tokens
} else {
vec![text.clone()]
};
'outer: for raw in candidates {
let mut s = raw.trim().to_string();
// Strip surrounding quotes if present (redundant with shlex, but safe)
if (s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\''))
{
s = s[1..s.len() - 1].to_string();
}
// Expand leading ~/ to HOME
if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
let mut p = std::path::PathBuf::from(home);
p.push(rest);
s = p.to_string_lossy().into_owned();
}
}
let mut try_paths: Vec<PathBuf> = Vec::new();
if let Some(p) = file_url_to_path(&s) {
try_paths.push(p);
}
// As-is path
try_paths.push(PathBuf::from(&s));
// Unescaped variant (e.g., My\ Photo.png)
let unescaped = unescape_backslashes(&s);
if unescaped != s {
try_paths.push(PathBuf::from(unescaped));
}
for path in try_paths {
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext_l = ext.to_ascii_lowercase();
if matches!(ext_l.as_str(), "png" | "jpg" | "jpeg") {
let (mut w, mut h) = (0u32, 0u32);
if let Ok((dw, dh)) = image::image_dimensions(&path) {
w = dw;
h = dh;
}
let fmt = if ext_l == "png" { "PNG" } else { "JPEG" };
if let AppState::Chat { widget } = &mut self.app_state {
widget.attach_image(path, w, h, fmt);
}
handled = true;
break 'outer;
}
}
}
}
}
if !handled {
// If no usable path was pasted, try to read an image bitmap
// from the clipboard; otherwise, fall back to text paste.
match crate::clipboard_paste::paste_image_to_temp_png() {
Ok((path, info)) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.attach_image(
path,
info.width,
info.height,
info.encoded_format_label,
);
}
}
Err(_) => {
self.dispatch_paste_event(text);
}
}
}
}
}
AppEvent::CodexEvent(event) => {
self.dispatch_codex_event(event);
}
AppEvent::ExitRequest => {
break;
}
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
},
AppEvent::DiffResult(text) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_output(text);
}
}
AppEvent::DispatchCommand(command) => match command {
SlashCommand::New => {
// User accepted switch to chat view.
let new_widget = Box::new(ChatWidget::new(
self.config.clone(),
self.server.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
));
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
SlashCommand::Init => {
// Guard: do not run if a task is active.
if let AppState::Chat { widget } = &mut self.app_state {
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
widget.submit_text_message(INIT_PROMPT.to_string());
}
}
SlashCommand::Compact => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.clear_token_usage();
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
}
}
SlashCommand::Quit => {
break;
}
SlashCommand::Logout => {
if let Err(e) = codex_login::logout(&self.config.codex_home) {
tracing::error!("failed to logout: {e}");
}
break;
}
SlashCommand::Diff => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_in_progress();
}
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let text = match get_git_diff().await {
Ok((is_git_repo, diff_text)) => {
if is_git_repo {
diff_text
} else {
"`/diff` — _not inside a git repository_".to_string()
}
}
Err(e) => format!("Failed to compute diff: {e}"),
};
tx.send(AppEvent::DiffResult(text));
});
}
SlashCommand::Mention => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.insert_str("@");
}
}
SlashCommand::Status => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_status_output();
}
}
SlashCommand::Mcp => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_mcp_output();
}
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
use codex_core::protocol::EventMsg;
use std::collections::HashMap;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::FileChange;
self.app_event_tx.send(AppEvent::CodexEvent(Event {
id: "1".to_string(),
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
// call_id: "1".to_string(),
// command: vec!["git".into(), "apply".into()],
// cwd: self.config.cwd.clone(),
// reason: Some("test".to_string()),
// }),
msg: EventMsg::ApplyPatchApprovalRequest(
ApplyPatchApprovalRequestEvent {
call_id: "1".to_string(),
changes: HashMap::from([
(
PathBuf::from("/tmp/test.txt"),
FileChange::Add {
content: "test".to_string(),
},
),
(
PathBuf::from("/tmp/test2.txt"),
FileChange::Update {
unified_diff: "+test\n-test2".to_string(),
move_path: None,
},
),
]),
reason: None,
grant_root: Some(PathBuf::from("/tmp")),
},
),
}));
}
},
AppEvent::OnboardingAuthComplete(result) => {
if let AppState::Onboarding { screen } = &mut self.app_state {
screen.on_auth_complete(result);
}
}
AppEvent::OnboardingComplete(ChatWidgetArgs {
config,
enhanced_keys_supported,
initial_images,
initial_prompt,
}) => {
self.app_state = AppState::Chat {
widget: Box::new(ChatWidget::new(
config,
self.server.clone(),
self.app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
)),
}
}
AppEvent::StartFileSearch(query) => {
if !query.is_empty() {
self.file_search.on_user_query(query);
}
}
AppEvent::FileSearchResult { query, matches } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_file_search_result(query, matches);
}
}
AppEvent::AttachImage {
path,
width,
height,
format_label,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.attach_image(path, width, height, format_label);
}
}
}
}
terminal.clear()?;
Ok(())
}
#[cfg(unix)]
fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> {
tui::restore()?;
// SAFETY: Unix-only code path. We intentionally send SIGTSTP to the
// current process group (pid 0) to trigger standard job-control
// suspension semantics. This FFI does not involve any raw pointers,
// is not called from a signal handler, and uses a constant signal.
// Errors from kill are acceptable (e.g., if already stopped) — the
// subsequent re-init path will still leave the terminal in a good state.
// We considered `nix`, but didn't think it was worth pulling in for this one call.
unsafe { libc::kill(0, libc::SIGTSTP) };
*terminal = tui::init(&self.config)?;
terminal.clear()?;
self.app_event_tx.send(AppEvent::RequestRedraw);
Ok(())
}
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(),
}
}
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
if matches!(self.app_state, AppState::Onboarding { .. }) {
terminal.clear()?;
}
let screen_size = terminal.size()?;
let last_known_screen_size = terminal.last_known_screen_size;
if screen_size != last_known_screen_size {
let cursor_pos = terminal.get_cursor_position()?;
let last_known_cursor_pos = terminal.last_known_cursor_pos;
if cursor_pos.y != last_known_cursor_pos.y {
// The terminal was resized. The only point of reference we have for where our viewport
// was moved is the cursor position.
// NB this assumes that the cursor was not wrapped as part of the resize.
let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
let new_viewport_area = terminal.viewport_area.offset(Offset {
x: 0,
y: cursor_delta,
});
terminal.set_viewport_area(new_viewport_area);
terminal.clear()?;
}
}
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 { .. } => {}
}
}
}
// merged tests
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 {
match login_status {
LoginStatus::NotAuthenticated => true,
LoginStatus::AuthMode(method) => method != config.preferred_auth_method,
}
}
#[cfg(test)]
mod onboarding_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
))
}
}