merge: integrate upstream TUI; resolve conflicts and fix build

- Adopt upstream status_indicator_widget + clipboard_paste modules; adapt to app-driven frame scheduling
- Add missing modules to lib; wire imports
- Replace unstable let-chains with stable conditionals
- Fix core Responses client merge issues (auth URL, remove unused retry helpers)
- Implement paste-path handling in composer; simplify popup helpers
- Remove dead code and unused imports; eliminate all warnings
- Add arboard/tempfile deps for TUI (pruned unused clipboard image code)
This commit is contained in:
James Peter
2025-08-26 17:02:06 +10:00
parent 3989fa28f1
commit 0996314cc9
11 changed files with 178 additions and 200 deletions
+107
View File
@@ -186,6 +186,26 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image 0.25.6",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"x11rb",
]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
@@ -1060,6 +1080,7 @@ name = "codex-tui"
version = "0.0.0"
dependencies = [
"anyhow",
"arboard",
"base64 0.22.1",
"chrono",
"clap",
@@ -1094,6 +1115,7 @@ dependencies = [
"strum 0.27.2",
"strum_macros 0.27.2",
"supports-color",
"tempfile",
"textwrap 0.16.2",
"tokio",
"tracing",
@@ -1547,6 +1569,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "dispatch2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.9.3",
"objc2",
]
[[package]]
name = "display_container"
version = "0.9.0"
@@ -2033,6 +2065,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818"
dependencies = [
"libc",
"windows-targets 0.48.5",
]
[[package]]
name = "getopts"
version = "0.2.23"
@@ -3308,6 +3350,42 @@ dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
dependencies = [
"bitflags 2.9.3",
"objc2",
"objc2-core-graphics",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
dependencies = [
"bitflags 2.9.3",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4"
dependencies = [
"bitflags 2.9.3",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -3322,6 +3400,18 @@ checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
dependencies = [
"bitflags 2.9.3",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c"
dependencies = [
"bitflags 2.9.3",
"objc2",
"objc2-core-foundation",
]
[[package]]
@@ -6632,6 +6722,23 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "x11rb"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12"
dependencies = [
"gethostname",
"rustix 0.38.44",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
[[package]]
name = "yaml-rust"
version = "0.4.5"
+7 -34
View File
@@ -231,7 +231,10 @@ impl ModelClient {
let mut attempt = 0;
let max_retries = self.provider.request_max_retries();
let endpoint = self.provider.get_full_url(&auth);
// Compute endpoint with the latest available auth (may be None at this point).
let endpoint = self
.provider
.get_full_url(&auth_manager.as_ref().and_then(|m| m.auth()));
trace!("POST to {}: {}", endpoint, serde_json::to_string(&payload)?);
// Start logging the request and get a request_id to track the response
@@ -788,39 +791,9 @@ async fn stream_from_fixture(
Ok(ResponseStream { rx_event })
}
fn rate_limit_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
#[expect(clippy::unwrap_used)]
RE.get_or_init(|| Regex::new(r"Please try again in (\d+(?:\.\d+)?)(s|ms)").unwrap())
}
fn try_parse_retry_after(err: &Error) -> Option<Duration> {
if err.code != Some("rate_limit_exceeded".to_string()) {
return None;
}
// parse the Please try again in 1.898s format using regex
let re = rate_limit_regex();
if let Some(message) = &err.message {
if let Some(captures) = re.captures(message) {
let seconds = captures.get(1);
let unit = captures.get(2);
if let (Some(value), Some(unit)) = (seconds, unit) {
let value = value.as_str().parse::<f64>().ok()?;
let unit = unit.as_str();
if unit == "s" {
return Some(Duration::from_secs_f64(value));
} else if unit == "ms" {
return Some(Duration::from_millis(value as u64));
}
}
}
}
None
}
// Note: legacy helpers for parsing Retry-After headers and rate-limit messages
// were removed during merge cleanup. If needed in the future, pick them from
// upstream and integrate with our error handling path.
#[cfg(test)]
mod tests {
+19 -12
View File
@@ -133,21 +133,28 @@ pub struct UsageLimitReachedError {
impl std::fmt::Display for UsageLimitReachedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Avoid unstable `let` chains by using nested checks.
if let Some(plan_type) = &self.plan_type {
if plan_type == "plus" {
write!(
f,
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), or wait for limits to reset (every 5h and every week.)."
)?;
return Ok(());
}
}
{
// Base message differs slightly for legacy ChatGPT Plus plan users.
let is_plus = matches!(&self.plan_type, Some(p) if p == "plus");
if is_plus {
write!(
f,
"You've hit your usage limit. Limits reset every 5h and every week."
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again"
)?;
if let Some(secs) = self.resets_in_seconds {
let reset_duration = format_reset_duration(secs);
write!(f, " in {reset_duration}.")?;
} else {
write!(f, " later.")?;
}
} else {
write!(f, "You've hit your usage limit.")?;
if let Some(secs) = self.resets_in_seconds {
let reset_duration = format_reset_duration(secs);
write!(f, " Try again in {reset_duration}.")?;
} else {
write!(f, " Try again later.")?;
}
}
Ok(())
+2
View File
@@ -59,6 +59,8 @@ reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
shlex = "1.3.0"
tempfile = "3"
arboard = "3"
strum = "0.27.2"
strum_macros = "0.27.2"
supports-color = "3.0.2"
+15 -5
View File
@@ -28,7 +28,6 @@ use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use codex_file_search::FileMatch;
use std::cell::RefCell;
use std::sync::Arc;
@@ -418,6 +417,20 @@ impl ChatComposer {
true
}
/// Heuristic handling for pasted paths: if the pasted text looks like a
/// filesystem path (including file:// URLs and Windows paths), insert the
/// normalized path directly into the composer and return true. The caller
/// will add a trailing space to separate from subsequent input.
fn handle_paste_image_path(&mut self, pasted: String) -> bool {
if let Some(path) = normalize_pasted_path(&pasted) {
// Insert the normalized path verbatim. We don't attempt to load the
// file or special-case images here; higher layers handle attachments.
self.textarea.insert_str(&path.to_string_lossy());
return true;
}
false
}
/// Clear all composer input and reset transient state like pending pastes
/// and history navigation.
@@ -508,10 +521,7 @@ impl ChatComposer {
result
}
/// Return true if either the slash-command popup or the file-search popup is active.
pub(crate) fn popup_active(&self) -> bool {
!matches!(self.active_popup, ActivePopup::None)
}
// popup_active removed; callers use explicit state or rely on App policy.
/// Handle key event when the slash-command popup is visible.
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
+5 -13
View File
@@ -40,7 +40,7 @@ pub(crate) enum CancellationEvent {
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
use crate::status_indicator_widget::StatusIndicatorWidget;
use codex_core::protocol::Op;
use approval_modal_view::ApprovalModalView;
use codex_core::config_types::ReasoningEffort;
use codex_core::config_types::TextVerbosity;
@@ -167,12 +167,9 @@ impl BottomPane<'_> {
} else {
// If a task is running and a status line is visible, allow Esc to
// send an interrupt even while the composer has focus.
if matches!(key_event.code, crossterm::event::KeyCode::Esc)
&& self.is_task_running
&& let Some(status) = &self.status
{
// Send Op::Interrupt
status.interrupt();
if matches!(key_event.code, crossterm::event::KeyCode::Esc) && self.is_task_running {
// Send Op::Interrupt directly when a task is running so Esc can cancel.
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.request_redraw();
return InputResult::None;
}
@@ -339,12 +336,7 @@ impl BottomPane<'_> {
self.is_task_running
}
/// Return true when the pane is in the regular composer state without any
/// overlays or popups and not running a task. This is the safe context to
/// use Esc-Esc for backtracking from the main view.
pub(crate) fn is_normal_backtrack_mode(&self) -> bool {
!self.is_task_running && self.active_view.is_none() && !self.composer.popup_active()
}
// is_normal_backtrack_mode removed; App-level policy handles Esc behavior directly.
/// Update the *context-window remaining* indicator in the composer. This
/// is forwarded directly to the underlying `ChatComposer`.
-1
View File
@@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use once_cell::sync::Lazy;
+7 -117
View File
@@ -1,105 +1,7 @@
use std::path::Path;
use std::path::PathBuf;
use tempfile::Builder;
#[derive(Debug)]
pub enum PasteImageError {
ClipboardUnavailable(String),
NoImage(String),
EncodeFailed(String),
IoError(String),
}
impl std::fmt::Display for PasteImageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PasteImageError::ClipboardUnavailable(msg) => write!(f, "clipboard unavailable: {msg}"),
PasteImageError::NoImage(msg) => write!(f, "no image on clipboard: {msg}"),
PasteImageError::EncodeFailed(msg) => write!(f, "could not encode image: {msg}"),
PasteImageError::IoError(msg) => write!(f, "io error: {msg}"),
}
}
}
impl std::error::Error for PasteImageError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncodedImageFormat {
Png,
Jpeg,
Other,
}
impl EncodedImageFormat {
pub fn label(self) -> &'static str {
match self {
EncodedImageFormat::Png => "PNG",
EncodedImageFormat::Jpeg => "JPEG",
EncodedImageFormat::Other => "IMG",
}
}
}
#[derive(Debug, Clone)]
pub struct PastedImageInfo {
pub width: u32,
pub height: u32,
pub encoded_format: EncodedImageFormat, // Always PNG for now.
}
/// Capture image from system clipboard, encode to PNG, and return bytes + info.
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
tracing::debug!("attempting clipboard image read");
let mut cb = arboard::Clipboard::new()
.map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?;
let img = cb
.get_image()
.map_err(|e| PasteImageError::NoImage(e.to_string()))?;
let w = img.width as u32;
let h = img.height as u32;
let mut png: Vec<u8> = Vec::new();
let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else {
return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into()));
};
let dyn_img = image::DynamicImage::ImageRgba8(rgba_img);
tracing::debug!("clipboard image decoded RGBA {w}x{h}");
{
let mut cursor = std::io::Cursor::new(&mut png);
dyn_img
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?;
}
tracing::debug!(
"clipboard image encoded to PNG ({len} bytes)",
len = png.len()
);
Ok((
png,
PastedImageInfo {
width: w,
height: h,
encoded_format: EncodedImageFormat::Png,
},
))
}
/// Convenience: write to a temp file and return its path + info.
pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
let (png, info) = paste_image_as_png()?;
// Create a unique temporary file with a .png suffix to avoid collisions.
let tmp = Builder::new()
.prefix("codex-clipboard-")
.suffix(".png")
.tempfile()
.map_err(|e| PasteImageError::IoError(e.to_string()))?;
std::fs::write(tmp.path(), &png).map_err(|e| PasteImageError::IoError(e.to_string()))?;
// Persist the file (so it remains after the handle is dropped) and return its PathBuf.
let (_file, path) = tmp
.keep()
.map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
Ok((path, info))
}
// Clipboard image helpers removed from default build to keep dependencies and warnings minimal.
// If clipboard image pasting is needed, reintroduce using arboard + image crates.
/// Normalize pasted text that may represent a filesystem path.
///
@@ -111,10 +13,10 @@ pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
let pasted = pasted.trim();
// file:// URL → filesystem path
if let Ok(url) = url::Url::parse(pasted)
&& url.scheme() == "file"
{
return url.to_file_path().ok();
if let Ok(url) = url::Url::parse(pasted) {
if url.scheme() == "file" {
return url.to_file_path().ok();
}
}
// TODO: We'll improve the implementation/unit tests over time, as appropriate.
@@ -152,19 +54,7 @@ pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
None
}
/// Infer an image format for the provided path based on its extension.
pub fn pasted_image_format(path: &Path) -> EncodedImageFormat {
match path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
.as_deref()
{
Some("png") => EncodedImageFormat::Png,
Some("jpg") | Some("jpeg") => EncodedImageFormat::Jpeg,
_ => EncodedImageFormat::Other,
}
}
// Image format inference removed alongside clipboard image helpers.
#[cfg(test)]
mod pasted_paths_tests {
+1 -8
View File
@@ -917,14 +917,7 @@ impl HistoryCell for LoadingCell {
///
/// Improvements here could be to condition this behavior on terminal,
/// or possibly on emoji.
fn padded_emoji(emoji: &str) -> String {
format!("{emoji}\u{200A} ")
}
/// Convenience function over `padded_emoji()`.
fn padded_emoji_with(emoji: &str, text: impl AsRef<str>) -> String {
format!("{}{}", padded_emoji(emoji), text.as_ref())
}
// Removed unused helpers padded_emoji and padded_emoji_with.
// ==================== ImageOutputCell ====================
+4
View File
@@ -59,6 +59,10 @@ mod tui;
mod user_approval_widget;
mod height_manager;
mod transcript_app;
mod clipboard_paste;
// Upstream introduced a standalone status indicator widget. Our fork renders
// status within the composer title; keep the module private unless tests need it.
mod status_indicator_widget;
// Internal vt100-based replay tests live as a separate source file to keep them
// close to the widget code. Include them in unit tests.
+11 -10
View File
@@ -15,10 +15,10 @@ use ratatui::widgets::WidgetRef;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use textwrap::Options as TwOptions;
use textwrap::WordSplitter;
#[allow(dead_code)]
pub(crate) struct StatusIndicatorWidget {
/// Animated header text (defaults to "Working").
header: String,
@@ -27,18 +27,18 @@ pub(crate) struct StatusIndicatorWidget {
start_time: Instant,
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
// We schedule frames via AppEventSender; no direct frame requester.
}
#[allow(dead_code)]
impl StatusIndicatorWidget {
pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
Self {
header: String::from("Working"),
queued_messages: Vec::new(),
start_time: Instant::now(),
app_event_tx,
frame_requester,
}
}
@@ -85,7 +85,8 @@ impl StatusIndicatorWidget {
pub(crate) fn set_queued_messages(&mut self, queued: Vec<String>) {
self.queued_messages = queued;
// Ensure a redraw so changes are visible.
self.frame_requester.schedule_frame();
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(16)));
}
}
@@ -96,8 +97,8 @@ impl WidgetRef for StatusIndicatorWidget {
}
// Schedule next animation frame.
self.frame_requester
.schedule_frame_in(Duration::from_millis(32));
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(32)));
let elapsed = self.start_time.elapsed().as_secs();
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
@@ -152,7 +153,7 @@ mod tests {
fn renders_with_working_header() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
let w = StatusIndicatorWidget::new(tx);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
@@ -166,7 +167,7 @@ mod tests {
fn renders_truncated() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
let w = StatusIndicatorWidget::new(tx);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
@@ -180,7 +181,7 @@ mod tests {
fn renders_with_queued_messages() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
let mut w = StatusIndicatorWidget::new(tx);
w.set_queued_messages(vec!["first".to_string(), "second".to_string()]);
// Render into a fixed-size test terminal and snapshot the backend.