mirror of
https://github.com/openai/codex.git
synced 2026-05-01 03:42:05 +03:00
- 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
1069 lines
43 KiB
Rust
1069 lines
43 KiB
Rust
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
|
||
))
|
||
}
|
||
}
|