Compare commits

..

1 Commits

Author SHA1 Message Date
easong-openai
ef1e259a23 vt100 tests 2025-08-02 19:31:13 -07:00
25 changed files with 874 additions and 2118 deletions

View File

@@ -147,8 +147,4 @@ const READ_ONLY_SEATBELT_POLICY = `
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
; Added on top of Chrome profile
; Needed for python multiprocessing on MacOS for the SemLock
(allow ipc-posix-sem)`.trim();
)`.trim();

103
codex-rs/Cargo.lock generated
View File

@@ -859,7 +859,6 @@ dependencies = [
"mcp-types",
"path-clean",
"pretty_assertions",
"rand 0.8.5",
"ratatui",
"ratatui-image",
"regex-lite",
@@ -869,17 +868,17 @@ dependencies = [
"shlex",
"strum 0.27.2",
"strum_macros 0.27.2",
"supports-color",
"textwrap 0.16.2",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"tui-input",
"tui-markdown",
"tui-textarea",
"unicode-segmentation",
"unicode-width 0.1.14",
"uuid",
"vt100",
]
[[package]]
@@ -1472,7 +1471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1552,7 +1551,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.0.8",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -1755,7 +1754,7 @@ version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1"
dependencies = [
"unicode-width 0.2.0",
"unicode-width 0.2.1",
]
[[package]]
@@ -2335,15 +2334,9 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
name = "is_ci"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -3391,7 +3384,7 @@ dependencies = [
[[package]]
name = "ratatui"
version = "0.29.0"
source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#bca287ddc5d38fe088c79e2eda22422b96226f2e"
source = "git+https://github.com/easong-openai/ratatui?branch=nornagon-v0.29.0-patch#159c1978c2f829cd322ec778df4168815ed9af96"
dependencies = [
"bitflags 2.9.1",
"cassowary",
@@ -3405,7 +3398,7 @@ dependencies = [
"strum 0.26.3",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
"unicode-width 0.2.1",
]
[[package]]
@@ -3719,7 +3712,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -3732,7 +3725,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.9.4",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4181,12 +4174,6 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "socket2"
version = "0.5.10"
@@ -4249,7 +4236,7 @@ dependencies = [
"starlark_syntax",
"static_assertions",
"strsim 0.10.0",
"textwrap 0.11.0",
"textwrap",
"thiserror 1.0.69",
]
@@ -4385,15 +4372,6 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "supports-color"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
dependencies = [
"is_ci",
]
[[package]]
name = "syn"
version = "1.0.109"
@@ -4508,7 +4486,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.8",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -4547,17 +4525,6 @@ dependencies = [
"unicode-width 0.1.14",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width 0.2.0",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -5003,7 +4970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911e93158bf80bbc94bad533b2b16e3d711e1132d69a6a6980c3920a63422c19"
dependencies = [
"ratatui",
"unicode-width 0.2.0",
"unicode-width 0.2.1",
]
[[package]]
@@ -5022,6 +4989,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "tui-textarea"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
dependencies = [
"crossterm",
"ratatui",
"unicode-width 0.2.1",
]
[[package]]
name = "typenum"
version = "1.18.0"
@@ -5040,12 +5018,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
@@ -5071,9 +5043,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]]
name = "unicode-xid"
@@ -5158,6 +5130,27 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vt100"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
dependencies = [
"itoa",
"unicode-width 0.2.1",
"vte",
]
[[package]]
name = "vte"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
"memchr",
]
[[package]]
name = "wait-timeout"
version = "0.2.1"
@@ -5346,7 +5339,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]

View File

@@ -44,4 +44,4 @@ codegen-units = 1
[patch.crates-io]
# ratatui = { path = "../../ratatui" }
ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" }
ratatui = { git = "https://github.com/easong-openai/ratatui", branch = "nornagon-v0.29.0-patch" }

View File

@@ -396,15 +396,11 @@ impl Session {
&self,
sub_id: &str,
call_id: &str,
output: &ExecToolCallOutput,
stdout: &str,
stderr: &str,
exit_code: i32,
is_apply_patch: bool,
) {
let ExecToolCallOutput {
stdout,
stderr,
duration,
exit_code,
} = output;
// Because stdout and stderr could each be up to 100 KiB, we send
// truncated versions.
const MAX_STREAM_OUTPUT: usize = 5 * 1024; // 5KiB
@@ -416,15 +412,14 @@ impl Session {
call_id: call_id.to_string(),
stdout,
stderr,
success: *exit_code == 0,
success: exit_code == 0,
})
} else {
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: call_id.to_string(),
stdout,
stderr,
duration: *duration,
exit_code: *exit_code,
exit_code,
})
};
@@ -1780,21 +1775,23 @@ async fn handle_container_exec_with_params(
stdout,
stderr,
duration,
} = &output;
} = output;
sess.notify_exec_command_end(
&sub_id,
&call_id,
&output,
&stdout,
&stderr,
exit_code,
exec_command_context.apply_patch.is_some(),
)
.await;
let is_success = *exit_code == 0;
let is_success = exit_code == 0;
let content = format_exec_output(
if is_success { stdout } else { stderr },
*exit_code,
*duration,
if is_success { &stdout } else { &stderr },
exit_code,
duration,
);
ResponseInputItem::FunctionCallOutput {
@@ -1903,16 +1900,23 @@ async fn handle_sandbox_error(
stdout,
stderr,
duration,
} = &retry_output;
} = retry_output;
sess.notify_exec_command_end(&sub_id, &call_id, &retry_output, is_apply_patch)
.await;
sess.notify_exec_command_end(
&sub_id,
&call_id,
&stdout,
&stderr,
exit_code,
is_apply_patch,
)
.await;
let is_success = *exit_code == 0;
let is_success = exit_code == 0;
let content = format_exec_output(
if is_success { stdout } else { stderr },
*exit_code,
*duration,
if is_success { &stdout } else { &stderr },
exit_code,
duration,
);
ResponseInputItem::FunctionCallOutput {

View File

@@ -523,8 +523,6 @@ pub struct ExecCommandEndEvent {
pub stderr: String,
/// The command's exit code.
pub exit_code: i32,
/// The duration of the command execution.
pub duration: Duration,
}
#[derive(Debug, Clone, Deserialize, Serialize)]

View File

@@ -65,7 +65,3 @@
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
; Added on top of Chrome profile
; Needed for python multiprocessing on MacOS for the SemLock
(allow ipc-posix-sem)

View File

@@ -177,7 +177,8 @@ async fn live_shell_function_call() {
match ev.msg {
EventMsg::ExecCommandBegin(codex_core::protocol::ExecCommandBeginEvent {
command,
..
call_id: _,
cwd: _,
}) => {
assert_eq!(command, vec!["echo", MARKER]);
saw_begin = true;
@@ -185,7 +186,8 @@ async fn live_shell_function_call() {
EventMsg::ExecCommandEnd(codex_core::protocol::ExecCommandEndEvent {
stdout,
exit_code,
..
call_id: _,
stderr: _,
}) => {
assert_eq!(exit_code, 0, "echo returned nonzero exit code");
assert!(stdout.contains(MARKER));

View File

@@ -106,6 +106,7 @@ impl EventProcessorWithHumanOutput {
struct ExecCommandBegin {
command: Vec<String>,
start_time: Instant,
}
struct PatchApplyBegin {
@@ -227,6 +228,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
call_id.clone(),
ExecCommandBegin {
command: command.clone(),
start_time: Instant::now(),
},
);
ts_println!(
@@ -242,14 +244,16 @@ impl EventProcessor for EventProcessorWithHumanOutput {
call_id,
stdout,
stderr,
duration,
exit_code,
}) => {
let exec_command = self.call_id_to_command.remove(&call_id);
let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command
let (duration, call) = if let Some(ExecCommandBegin {
command,
start_time,
}) = exec_command
{
(
format!(" in {}", format_duration(duration)),
format!(" in {}", format_elapsed(start_time)),
format!("{}", escape_command(&command).style(self.bold)),
)
} else {

View File

@@ -18,7 +18,7 @@ use crate::codex_tool_runner::INVALID_PARAMS_ERROR_CODE;
/// Conforms to [`mcp_types::ElicitRequestParams`] so that it can be used as the
/// `params` field of an [`ElicitRequest`].
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Serialize)]
pub struct ExecApprovalElicitRequestParams {
// These fields are required so that `params`
// conforms to ElicitRequestParams.

View File

@@ -89,18 +89,14 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
// This is the first request from the server, so the id should be 0 given
// how things are currently implemented.
let elicitation_request_id = RequestId::Integer(0);
let params = serde_json::from_value::<ExecApprovalElicitRequestParams>(
elicitation_request
.params
.clone()
.ok_or_else(|| anyhow::anyhow!("elicitation_request.params must be set"))?,
)?;
let expected_elicitation_request = create_expected_elicitation_request(
elicitation_request_id.clone(),
shell_command.clone(),
workdir_for_shell_function_call.path(),
codex_request_id.to_string(),
params.codex_event_id.clone(),
// Internal Codex id: empirically it is 1, but this is
// admittedly an internal detail that could change.
"1".to_string(),
)?;
assert_eq!(expected_elicitation_request, elicitation_request);

View File

@@ -11,6 +11,10 @@ path = "src/main.rs"
name = "codex_tui"
path = "src/lib.rs"
[features]
# Enable vt100-based tests (emulator) when running with `--features vt100-tests`.
vt100-tests = ["dep:vt100"]
[lints]
workspace = true
@@ -48,8 +52,6 @@ serde_json = { version = "1", features = ["preserve_order"] }
shlex = "1.3.0"
strum = "0.27.2"
strum_macros = "0.27.2"
supports-color = "3.0.2"
textwrap = "0.16.2"
tokio = { version = "1", features = [
"io-std",
"macros",
@@ -62,14 +64,17 @@ tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tui-input = "0.14.0"
tui-markdown = "0.3.3"
tui-textarea = "0.7.0"
unicode-segmentation = "1.12.0"
unicode-width = "0.1"
uuid = "1"
vt100 = { version = "0.16.2", optional = true }
[dev-dependencies]
insta = "1.43.1"
pretty_assertions = "1"
rand = "0.8"
chrono = { version = "0.4", features = ["serde"] }
#[target.'cfg(feature = "vt100-tests")'.dev-dependencies]
#vt100 = "0.16.2" # Revisit: conflicts with ratatui's pinned unicode-width (=0.2.0)

View File

@@ -5,7 +5,6 @@ use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff;
use crate::git_warning_screen::GitWarningOutcome;
use crate::git_warning_screen::GitWarningScreen;
use crate::shimmer_text::init_process_start;
use crate::slash_command::SlashCommand;
use crate::tui;
use codex_core::config::Config;
@@ -22,10 +21,13 @@ 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(10);
@@ -53,6 +55,9 @@ pub(crate) struct App<'a> {
file_search: FileSearchManager,
/// True when a redraw has been scheduled but not yet executed.
pending_redraw: Arc<AtomicBool>,
pending_history_lines: Vec<Line<'static>>,
/// Stored parameters needed to instantiate the ChatWidget later, e.g.,
@@ -60,10 +65,6 @@ pub(crate) struct App<'a> {
chat_args: Option<ChatWidgetArgs>,
enhanced_keys_supported: bool,
/// 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
@@ -85,6 +86,7 @@ impl App<'_> {
) -> Self {
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
let pending_redraw = Arc::new(AtomicBool::new(false));
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
@@ -131,9 +133,6 @@ impl App<'_> {
});
}
// Initialize process start time for synchronized animations.
init_process_start();
let (app_state, chat_args) = if show_git_warning {
(
AppState::GitWarning {
@@ -163,50 +162,6 @@ impl App<'_> {
};
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 no scheduled deadline, block until we get one.
if next_deadline.is_none() {
match frame_rx.recv() {
Ok(deadline) => next_deadline = Some(deadline),
Err(_) => break, // channel closed; exit thread
}
}
#[allow(clippy::expect_used)]
let deadline = next_deadline.expect("set above");
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) => {
// Coalesce by keeping the earliest deadline.
next_deadline =
Some(next_deadline.map_or(new_deadline, |d| d.min(new_deadline)));
}
Err(RecvTimeoutError::Timeout) => {
// Fire once, then clear the deadline.
app_event_tx.send(AppEvent::Redraw);
next_deadline = None;
}
Err(RecvTimeoutError::Disconnected) => break,
}
}
});
}
Self {
app_event_tx,
pending_history_lines: Vec::new(),
@@ -214,9 +169,9 @@ impl App<'_> {
app_state,
config,
file_search,
pending_redraw,
chat_args,
enhanced_keys_supported,
frame_schedule_tx: frame_tx,
}
}
@@ -226,13 +181,32 @@ impl App<'_> {
self.app_event_tx.clone()
}
fn schedule_frame_in(&self, dur: Duration) {
let _ = self.frame_schedule_tx.send(Instant::now() + dur);
/// Schedule a redraw if one is not already pending.
#[allow(clippy::unwrap_used)]
fn schedule_redraw(&self) {
// Attempt to set the flag to `true`. If it was already `true`, another
// redraw is already pending so we can return early.
if self
.pending_redraw
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
let tx = self.app_event_tx.clone();
let pending_redraw = self.pending_redraw.clone();
thread::spawn(move || {
thread::sleep(REDRAW_DEBOUNCE);
tx.send(AppEvent::Redraw);
pending_redraw.store(false, Ordering::SeqCst);
});
}
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
// Trigger the first render immediately via the frame scheduler.
let _ = self.frame_schedule_tx.send(Instant::now());
// Insert an event to trigger the first render.
let app_event_tx = self.app_event_tx.clone();
app_event_tx.send(AppEvent::RequestRedraw);
while let Ok(event) = self.app_event_rx.recv() {
match event {
@@ -241,10 +215,7 @@ impl App<'_> {
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::RequestRedraw => {
self.schedule_frame_in(REDRAW_DEBOUNCE);
}
AppEvent::ScheduleFrameIn(dur) => {
self.schedule_frame_in(dur);
self.schedule_redraw();
}
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
@@ -467,15 +438,14 @@ impl App<'_> {
);
self.pending_history_lines.clear();
}
terminal.draw(|frame| match &mut self.app_state {
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())
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
}
AppState::GitWarning { screen } => frame.render_widget_ref(&*screen, frame.area()),
})?;
AppState::GitWarning { screen } => {
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
}
}
Ok(())
}

View File

@@ -2,7 +2,6 @@ use codex_core::protocol::Event;
use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::text::Line;
use std::time::Duration;
use crate::slash_command::SlashCommand;
@@ -16,11 +15,6 @@ pub(crate) enum AppEvent {
/// Actually draw the next frame.
Redraw,
/// Schedule periodic frames from the main loop. The first frame will be
/// scheduled roughly after the provided duration and continue at that
/// cadence until the application exits.
ScheduleFrameIn(Duration),
KeyEvent(KeyEvent),
/// Text pasted from the terminal clipboard.

View File

@@ -1,11 +1,6 @@
use codex_core::protocol::TokenUsage;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Margin;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
@@ -13,11 +8,13 @@ use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use tui_textarea::Input;
use tui_textarea::Key;
use tui_textarea::TextArea;
use super::chat_composer_history::ChatComposerHistory;
use super::command_popup::CommandPopup;
@@ -25,10 +22,7 @@ use super::file_search_popup::FileSearchPopup;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use codex_file_search::FileMatch;
use std::cell::RefCell;
const BASE_PLACEHOLDER_TEXT: &str = "...";
/// If the pasted content exceeds this number of characters, replace it with a
@@ -41,14 +35,8 @@ pub enum InputResult {
None,
}
struct TokenUsageInfo {
token_usage: TokenUsage,
model_context_window: Option<u64>,
}
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
pub(crate) struct ChatComposer<'a> {
textarea: TextArea<'a>,
active_popup: ActivePopup,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
@@ -57,8 +45,6 @@ pub(crate) struct ChatComposer {
dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
}
/// Popup state at most one can be visible at any time.
@@ -68,17 +54,20 @@ enum ActivePopup {
File(FileSearchPopup),
}
impl ChatComposer {
impl ChatComposer<'_> {
pub fn new(
has_input_focus: bool,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
) -> Self {
let mut textarea = TextArea::default();
textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT);
textarea.set_cursor_line_style(ratatui::style::Style::default());
let use_shift_enter_hint = enhanced_keys_supported;
Self {
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
let mut this = Self {
textarea,
active_popup: ActivePopup::None,
app_event_tx,
history: ChatComposerHistory::new(),
@@ -87,13 +76,13 @@ impl ChatComposer {
dismissed_file_popup_token: None,
current_file_query: None,
pending_pastes: Vec::new(),
token_usage_info: None,
has_focus: has_input_focus,
}
};
this.update_border(has_input_focus);
this
}
pub fn desired_height(&self, width: u16) -> u16 {
self.textarea.desired_height(width - 1)
pub fn desired_height(&self) -> u16 {
self.textarea.lines().len().max(1) as u16
+ match &self.active_popup {
ActivePopup::None => 1u16,
ActivePopup::Command(c) => c.calculate_required_height(),
@@ -101,21 +90,6 @@ impl ChatComposer {
}
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let popup_height = match &self.active_popup {
ActivePopup::Command(popup) => popup.calculate_required_height(),
ActivePopup::File(popup) => popup.calculate_required_height(),
ActivePopup::None => 1,
};
let [textarea_rect, _] =
Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).areas(area);
let mut textarea_rect = textarea_rect;
textarea_rect.width = textarea_rect.width.saturating_sub(1);
textarea_rect.x += 1;
let state = self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, &state)
}
/// Returns true if the composer currently contains no user input.
pub(crate) fn is_empty(&self) -> bool {
self.textarea.is_empty()
@@ -129,10 +103,28 @@ impl ChatComposer {
token_usage: TokenUsage,
model_context_window: Option<u64>,
) {
self.token_usage_info = Some(TokenUsageInfo {
token_usage,
model_context_window,
});
let placeholder = match (token_usage.total_tokens, model_context_window) {
(total_tokens, Some(context_window)) => {
let percent_remaining: u8 = if context_window > 0 {
// Calculate the percentage of context left.
let percent = 100.0 - (total_tokens as f32 / context_window as f32 * 100.0);
percent.clamp(0.0, 100.0) as u8
} else {
// If we don't have a context window, we cannot compute the
// percentage.
100
};
// When https://github.com/openai/codex/issues/1257 is resolved,
// check if `percent_remaining < 25`, and if so, recommend
// /compact.
format!("{BASE_PLACEHOLDER_TEXT}{percent_remaining}% context left")
}
(total_tokens, None) => {
format!("{BASE_PLACEHOLDER_TEXT}{total_tokens} tokens used")
}
};
self.textarea.set_placeholder_text(placeholder);
}
/// Record the history metadata advertised by `SessionConfiguredEvent` so
@@ -150,12 +142,8 @@ impl ChatComposer {
offset: usize,
entry: Option<String>,
) -> bool {
let Some(text) = self.history.on_entry_response(log_id, offset, entry) else {
return false;
};
self.textarea.set_text(&text);
self.textarea.set_cursor(0);
true
self.history
.on_entry_response(log_id, offset, entry, &mut self.textarea)
}
pub fn handle_paste(&mut self, pasted: String) -> bool {
@@ -191,7 +179,7 @@ impl ChatComposer {
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
self.ctrl_c_quit_hint = show;
self.set_has_focus(has_focus);
self.update_border(has_focus);
}
/// Handle a key event coming from the main UI.
@@ -219,47 +207,49 @@ impl ChatComposer {
unreachable!();
};
match key_event {
KeyEvent {
code: KeyCode::Up, ..
} => {
match key_event.into() {
Input { key: Key::Up, .. } => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
} => {
Input { key: Key::Down, .. } => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
} => {
Input { key: Key::Tab, .. } => {
if let Some(cmd) = popup.selected_command() {
let first_line = self.textarea.text().lines().next().unwrap_or("");
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
let starts_with_cmd = first_line
.trim_start()
.starts_with(&format!("/{}", cmd.command()));
if !starts_with_cmd {
self.textarea.set_text(&format!("/{} ", cmd.command()));
self.textarea.select_all();
self.textarea.cut();
let _ = self.textarea.insert_str(format!("/{} ", cmd.command()));
}
}
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
Input {
key: Key::Enter,
shift: false,
alt: false,
ctrl: false,
} => {
if let Some(cmd) = popup.selected_command() {
// Send command to the app layer.
self.app_event_tx.send(AppEvent::DispatchCommand(*cmd));
// Clear textarea so no residual text remains.
self.textarea.set_text("");
self.textarea.select_all();
self.textarea.cut();
// Hide popup since the command has been dispatched.
self.active_popup = ActivePopup::None;
@@ -278,23 +268,16 @@ impl ChatComposer {
unreachable!();
};
match key_event {
KeyEvent {
code: KeyCode::Up, ..
} => {
match key_event.into() {
Input { key: Key::Up, .. } => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
} => {
Input { key: Key::Down, .. } => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
Input { key: Key::Esc, .. } => {
// Hide popup without modifying text, remember token to avoid immediate reopen.
if let Some(tok) = Self::current_at_token(&self.textarea) {
self.dismissed_file_popup_token = Some(tok.to_string());
@@ -302,13 +285,12 @@ impl ChatComposer {
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
Input { key: Key::Tab, .. }
| Input {
key: Key::Enter,
ctrl: false,
alt: false,
shift: false,
} => {
if let Some(sel) = popup.selected_match() {
let sel_path = sel.to_string();
@@ -333,89 +315,46 @@ impl ChatComposer {
/// - A token is delimited by ASCII whitespace (space, tab, newline).
/// - If the token under the cursor starts with `@` and contains at least
/// one additional character, that token (without `@`) is returned.
fn current_at_token(textarea: &TextArea) -> Option<String> {
let cursor_offset = textarea.cursor();
let text = textarea.text();
fn current_at_token(textarea: &tui_textarea::TextArea) -> Option<String> {
let (row, col) = textarea.cursor();
// Adjust the provided byte offset to the nearest valid char boundary at or before it.
let mut safe_cursor = cursor_offset.min(text.len());
// If we're not on a char boundary, move back to the start of the current char.
if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) {
// Find the last valid boundary <= cursor_offset.
safe_cursor = text
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= cursor_offset)
.last()
.unwrap_or(0);
}
// Guard against out-of-bounds rows.
let line = textarea.lines().get(row)?.as_str();
// Split the line around the (now safe) cursor position.
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
// Calculate byte offset for cursor position
let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>();
// Detect whether we're on whitespace at the cursor boundary.
let at_whitespace = if safe_cursor < text.len() {
text[safe_cursor..]
.chars()
.next()
.map(|c| c.is_whitespace())
.unwrap_or(false)
} else {
false
};
// Split the line at the cursor position so we can search for word
// boundaries on both sides.
let before_cursor = &line[..cursor_byte_offset];
let after_cursor = &line[cursor_byte_offset..];
// Left candidate: token containing the cursor position.
let start_left = before_cursor
// Find start index (first character **after** the previous multi-byte whitespace).
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_left_rel = after_cursor
// Find end index (first multi-byte whitespace **after** the cursor position).
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_left = safe_cursor + end_left_rel;
let token_left = if start_left < end_left {
Some(&text[start_left..end_left])
let end_idx = cursor_byte_offset + end_rel_idx;
if start_idx >= end_idx {
return None;
}
let token = &line[start_idx..end_idx];
if token.starts_with('@') && token.len() > 1 {
Some(token[1..].to_string())
} else {
None
};
// Right candidate: token immediately after any whitespace from the cursor.
let ws_len_right: usize = after_cursor
.chars()
.take_while(|c| c.is_whitespace())
.map(|c| c.len_utf8())
.sum();
let start_right = safe_cursor + ws_len_right;
let end_right_rel = text[start_right..]
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(text.len() - start_right);
let end_right = start_right + end_right_rel;
let token_right = if start_right < end_right {
Some(&text[start_right..end_right])
} else {
None
};
let left_at = token_left
.filter(|t| t.starts_with('@') && t.len() > 1)
.map(|t| t[1..].to_string());
let right_at = token_right
.filter(|t| t.starts_with('@') && t.len() > 1)
.map(|t| t[1..].to_string());
if at_whitespace {
return right_at.or(left_at);
}
if after_cursor.starts_with('@') {
return right_at.or(left_at);
}
left_at.or(right_at)
}
/// Replace the active `@token` (the one under the cursor) with `path`.
@@ -424,73 +363,94 @@ impl ChatComposer {
/// where the cursor is within the token and regardless of how many
/// `@tokens` exist in the line.
fn insert_selected_path(&mut self, path: &str) {
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
let (row, col) = self.textarea.cursor();
let before_cursor = &text[..cursor_offset];
let after_cursor = &text[cursor_offset..];
// Materialize the textarea lines so we can mutate them easily.
let mut lines: Vec<String> = self.textarea.lines().to_vec();
// Determine token boundaries.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
if let Some(line) = lines.get_mut(row) {
// Calculate byte offset for cursor position
let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>();
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = cursor_offset + end_rel_idx;
let before_cursor = &line[..cursor_byte_offset];
let after_cursor = &line[cursor_byte_offset..];
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
let mut new_text =
String::with_capacity(text.len() - (end_idx - start_idx) + path.len() + 1);
new_text.push_str(&text[..start_idx]);
new_text.push_str(path);
new_text.push(' ');
new_text.push_str(&text[end_idx..]);
// Determine token boundaries.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
self.textarea.set_text(&new_text);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = cursor_byte_offset + end_rel_idx;
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
let mut new_line =
String::with_capacity(line.len() - (end_idx - start_idx) + path.len() + 1);
new_line.push_str(&line[..start_idx]);
new_line.push_str(path);
new_line.push(' ');
new_line.push_str(&line[end_idx..]);
*line = new_line;
// Re-populate the textarea.
let new_text = lines.join("\n");
self.textarea.select_all();
self.textarea.cut();
let _ = self.textarea.insert_str(new_text);
// Note: tui-textarea currently exposes only relative cursor
// movements. Leaving the cursor position unchanged is acceptable
// as subsequent typing will move the cursor naturally.
}
}
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
match key_event {
let input: Input = key_event.into();
match input {
// -------------------------------------------------------------
// History navigation (Up / Down) only when the composer is not
// empty or when the cursor is at the correct position, to avoid
// interfering with normal cursor movement.
// -------------------------------------------------------------
KeyEvent {
code: KeyCode::Up | KeyCode::Down,
..
} => {
if self
.history
.should_handle_navigation(self.textarea.text(), self.textarea.cursor())
{
let replace_text = match key_event.code {
KeyCode::Up => self.history.navigate_up(&self.app_event_tx),
KeyCode::Down => self.history.navigate_down(&self.app_event_tx),
_ => unreachable!(),
};
if let Some(text) = replace_text {
self.textarea.set_text(&text);
self.textarea.set_cursor(0);
Input { key: Key::Up, .. } => {
if self.history.should_handle_navigation(&self.textarea) {
let consumed = self
.history
.navigate_up(&mut self.textarea, &self.app_event_tx);
if consumed {
return (InputResult::None, true);
}
}
self.handle_input_basic(key_event)
self.handle_input_basic(input)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
Input { key: Key::Down, .. } => {
if self.history.should_handle_navigation(&self.textarea) {
let consumed = self
.history
.navigate_down(&mut self.textarea, &self.app_event_tx);
if consumed {
return (InputResult::None, true);
}
}
self.handle_input_basic(input)
}
Input {
key: Key::Enter,
shift: false,
alt: false,
ctrl: false,
} => {
let mut text = self.textarea.text().to_string();
self.textarea.set_text("");
let mut text = self.textarea.lines().join("\n");
self.textarea.select_all();
self.textarea.cut();
// Replace all pending pastes in the text
for (placeholder, actual) in &self.pending_pastes {
@@ -507,15 +467,41 @@ impl ChatComposer {
(InputResult::Submitted(text), true)
}
}
Input {
key: Key::Enter, ..
}
| Input {
key: Key::Char('j'),
ctrl: true,
alt: false,
shift: false,
} => {
self.textarea.insert_newline();
(InputResult::None, true)
}
Input {
key: Key::Char('d'),
ctrl: true,
alt: false,
shift: false,
} => {
self.textarea.input(Input {
key: Key::Delete,
ctrl: false,
alt: false,
shift: false,
});
(InputResult::None, true)
}
input => self.handle_input_basic(input),
}
}
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
fn handle_input_basic(&mut self, input: Input) -> (InputResult, bool) {
// Special handling for backspace on placeholders
if let KeyEvent {
code: KeyCode::Backspace,
if let Input {
key: Key::Backspace,
..
} = input
{
@@ -524,9 +510,20 @@ impl ChatComposer {
}
}
if let Input {
key: Key::Char('u'),
ctrl: true,
alt: false,
..
} = input
{
self.textarea.delete_line_by_head();
return (InputResult::None, true);
}
// Normal input handling
self.textarea.input(input);
let text_after = self.textarea.text();
let text_after = self.textarea.lines().join("\n");
// Check if any placeholders were removed and remove their corresponding pending pastes
self.pending_pastes
@@ -538,16 +535,21 @@ impl ChatComposer {
/// Attempts to remove a placeholder if the cursor is at the end of one.
/// Returns true if a placeholder was removed.
fn try_remove_placeholder_at_cursor(&mut self) -> bool {
let p = self.textarea.cursor();
let text = self.textarea.text();
let (row, col) = self.textarea.cursor();
let line = self
.textarea
.lines()
.get(row)
.map(|s| s.as_str())
.unwrap_or("");
// Find any placeholder that ends at the cursor position
let placeholder_to_remove = self.pending_pastes.iter().find_map(|(ph, _)| {
if p < ph.len() {
if col < ph.len() {
return None;
}
let potential_ph_start = p - ph.len();
if text[potential_ph_start..p] == *ph {
let potential_ph_start = col - ph.len();
if line[potential_ph_start..col] == *ph {
Some(ph.clone())
} else {
None
@@ -555,7 +557,17 @@ impl ChatComposer {
});
if let Some(placeholder) = placeholder_to_remove {
self.textarea.replace_range(p - placeholder.len()..p, "");
// Remove the entire placeholder from the text
let placeholder_len = placeholder.len();
for _ in 0..placeholder_len {
self.textarea.input(Input {
key: Key::Backspace,
ctrl: false,
alt: false,
shift: false,
});
}
// Remove from pending pastes
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
true
} else {
@@ -567,7 +579,16 @@ impl ChatComposer {
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
fn sync_command_popup(&mut self) {
let first_line = self.textarea.text().lines().next().unwrap_or("");
// Inspect only the first line to decide whether to show the popup. In
// the common case (no leading slash) we avoid copying the entire
// textarea contents.
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
let input_starts_with_slash = first_line.starts_with('/');
match &mut self.active_popup {
ActivePopup::Command(popup) => {
@@ -623,29 +644,74 @@ impl ChatComposer {
self.dismissed_file_popup_token = None;
}
fn set_has_focus(&mut self, has_focus: bool) {
self.has_focus = has_focus;
fn update_border(&mut self, has_focus: bool) {
let border_style = if has_focus {
Style::default().fg(Color::Cyan)
} else {
Style::default().dim()
};
self.textarea.set_block(
ratatui::widgets::Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(border_style),
);
}
}
impl WidgetRef for &ChatComposer {
impl WidgetRef for &ChatComposer<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let popup_height = match &self.active_popup {
ActivePopup::Command(popup) => popup.calculate_required_height(),
ActivePopup::File(popup) => popup.calculate_required_height(),
ActivePopup::None => 1,
};
let [textarea_rect, popup_rect] =
Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).areas(area);
match &self.active_popup {
ActivePopup::Command(popup) => {
popup.render_ref(popup_rect, buf);
let popup_height = popup.calculate_required_height();
// Split the provided rect so that the popup is rendered at the
// **bottom** and the textarea occupies the remaining space above.
let popup_height = popup_height.min(area.height);
let textarea_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(popup_height),
};
let popup_rect = Rect {
x: area.x,
y: area.y + textarea_rect.height,
width: area.width,
height: popup_height,
};
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
}
ActivePopup::File(popup) => {
popup.render_ref(popup_rect, buf);
let popup_height = popup.calculate_required_height();
let popup_height = popup_height.min(area.height);
let textarea_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(popup_height),
};
let popup_rect = Rect {
x: area.x,
y: area.y + textarea_rect.height,
width: area.width,
height: popup_height,
};
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
}
ActivePopup::None => {
let bottom_line_rect = popup_rect;
let mut textarea_rect = area;
textarea_rect.height = textarea_rect.height.saturating_sub(1);
self.textarea.render(textarea_rect, buf);
let mut bottom_line_rect = area;
bottom_line_rect.y += textarea_rect.height;
bottom_line_rect.height = 1;
let key_hint_style = Style::default().fg(Color::Cyan);
let hint = if self.ctrl_c_quit_hint {
vec![
@@ -674,56 +740,6 @@ impl WidgetRef for &ChatComposer {
.render_ref(bottom_line_rect, buf);
}
}
Block::default()
.border_style(Style::default().dim())
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().fg(if self.has_focus {
Color::Cyan
} else {
Color::Gray
}))
.render_ref(
Rect::new(textarea_rect.x, textarea_rect.y, 1, textarea_rect.height),
buf,
);
let mut textarea_rect = textarea_rect;
textarea_rect.width = textarea_rect.width.saturating_sub(1);
textarea_rect.x += 1;
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
if self.textarea.text().is_empty() {
let placeholder = if let Some(token_usage_info) = &self.token_usage_info {
let token_usage = &token_usage_info.token_usage;
let model_context_window = token_usage_info.model_context_window;
match (token_usage.total_tokens, model_context_window) {
(total_tokens, Some(context_window)) => {
let percent_remaining: u8 = if context_window > 0 {
// Calculate the percentage of context left.
let percent =
100.0 - (total_tokens as f32 / context_window as f32 * 100.0);
percent.clamp(0.0, 100.0) as u8
} else {
// If we don't have a context window, we cannot compute the
// percentage.
100
};
// When https://github.com/openai/codex/issues/1257 is resolved,
// check if `percent_remaining < 25`, and if so, recommend
// /compact.
format!("{BASE_PLACEHOLDER_TEXT}{percent_remaining}% context left")
}
(total_tokens, None) => {
format!("{BASE_PLACEHOLDER_TEXT}{total_tokens} tokens used")
}
}
} else {
BASE_PLACEHOLDER_TEXT.to_string()
};
Line::from(placeholder)
.style(Style::default().dim())
.render_ref(textarea_rect.inner(Margin::new(1, 0)), buf);
}
}
}
@@ -733,7 +749,7 @@ mod tests {
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::textarea::TextArea;
use tui_textarea::TextArea;
#[test]
fn test_current_at_token_basic_cases() {
@@ -776,9 +792,9 @@ mod tests {
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::new();
let mut textarea = TextArea::default();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
@@ -810,9 +826,9 @@ mod tests {
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::new();
let mut textarea = TextArea::default();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
@@ -847,13 +863,13 @@ mod tests {
// Full-width space boundaries
(
"test @İstanbul",
8,
6,
Some("İstanbul".to_string()),
"@ token after full-width space",
),
(
"@ЙЦУ @诶",
10,
6,
Some("".to_string()),
"Full-width space between Unicode tokens",
),
@@ -867,9 +883,9 @@ mod tests {
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::new();
let mut textarea = TextArea::default();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
@@ -891,7 +907,7 @@ mod tests {
let needs_redraw = composer.handle_paste("hello".to_string());
assert!(needs_redraw);
assert_eq!(composer.textarea.text(), "hello");
assert_eq!(composer.textarea.lines(), ["hello"]);
assert!(composer.pending_pastes.is_empty());
let (result, _) =
@@ -916,7 +932,7 @@ mod tests {
let needs_redraw = composer.handle_paste(large.clone());
assert!(needs_redraw);
let placeholder = format!("[Pasted Content {} chars]", large.chars().count());
assert_eq!(composer.textarea.text(), placeholder);
assert_eq!(composer.textarea.lines(), [placeholder.as_str()]);
assert_eq!(composer.pending_pastes.len(), 1);
assert_eq!(composer.pending_pastes[0].0, placeholder);
assert_eq!(composer.pending_pastes[0].1, large);
@@ -992,7 +1008,7 @@ mod tests {
composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4));
composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6));
// Move cursor to end and press backspace
composer.textarea.set_cursor(composer.textarea.text().len());
composer.textarea.move_cursor(tui_textarea::CursorMove::End);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
}
@@ -1107,7 +1123,7 @@ mod tests {
current_pos += content.len();
}
(
composer.textarea.text().to_string(),
composer.textarea.lines().join("\n"),
composer.pending_pastes.len(),
current_pos,
)
@@ -1118,18 +1134,25 @@ mod tests {
let mut deletion_states = vec![];
// First deletion
composer.textarea.set_cursor(states[0].2);
composer
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(0, states[0].2 as u16));
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push((
composer.textarea.text().to_string(),
composer.textarea.lines().join("\n"),
composer.pending_pastes.len(),
));
// Second deletion
composer.textarea.set_cursor(composer.textarea.text().len());
composer
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(
0,
composer.textarea.lines().join("\n").len() as u16,
));
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push((
composer.textarea.text().to_string(),
composer.textarea.lines().join("\n"),
composer.pending_pastes.len(),
));
@@ -1168,13 +1191,17 @@ mod tests {
composer.handle_paste(paste.clone());
composer
.textarea
.set_cursor((placeholder.len() - pos_from_end) as usize);
.move_cursor(tui_textarea::CursorMove::Jump(
0,
(placeholder.len() - pos_from_end) as u16,
));
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let result = (
composer.textarea.text().contains(&placeholder),
composer.textarea.lines().join("\n").contains(&placeholder),
composer.pending_pastes.len(),
);
composer.textarea.set_text("");
composer.textarea.select_all();
composer.textarea.cut();
result
})
.collect();

View File

@@ -1,5 +1,8 @@
use std::collections::HashMap;
use tui_textarea::CursorMove;
use tui_textarea::TextArea;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use codex_core::protocol::Op;
@@ -64,52 +67,59 @@ impl ChatComposerHistory {
/// Should Up/Down key presses be interpreted as history navigation given
/// the current content and cursor position of `textarea`?
pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool {
pub fn should_handle_navigation(&self, textarea: &TextArea) -> bool {
if self.history_entry_count == 0 && self.local_history.is_empty() {
return false;
}
if text.is_empty() {
if textarea.is_empty() {
return true;
}
// Textarea is not empty only navigate when cursor is at start and
// text matches last recalled history entry so regular editing is not
// hijacked.
if cursor != 0 {
let (row, col) = textarea.cursor();
if row != 0 || col != 0 {
return false;
}
matches!(&self.last_history_text, Some(prev) if prev == text)
let lines = textarea.lines();
matches!(&self.last_history_text, Some(prev) if prev == &lines.join("\n"))
}
/// Handle <Up>. Returns true when the key was consumed and the caller
/// should request a redraw.
pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option<String> {
pub fn navigate_up(&mut self, textarea: &mut TextArea, app_event_tx: &AppEventSender) -> bool {
let total_entries = self.history_entry_count + self.local_history.len();
if total_entries == 0 {
return None;
return false;
}
let next_idx = match self.history_cursor {
None => (total_entries as isize) - 1,
Some(0) => return None, // already at oldest
Some(0) => return true, // already at oldest
Some(idx) => idx - 1,
};
self.history_cursor = Some(next_idx);
self.populate_history_at_index(next_idx as usize, app_event_tx)
self.populate_history_at_index(next_idx as usize, textarea, app_event_tx);
true
}
/// Handle <Down>.
pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option<String> {
pub fn navigate_down(
&mut self,
textarea: &mut TextArea,
app_event_tx: &AppEventSender,
) -> bool {
let total_entries = self.history_entry_count + self.local_history.len();
if total_entries == 0 {
return None;
return false;
}
let next_idx_opt = match self.history_cursor {
None => return None, // not browsing
None => return false, // not browsing
Some(idx) if (idx as usize) + 1 >= total_entries => None,
Some(idx) => Some(idx + 1),
};
@@ -117,15 +127,16 @@ impl ChatComposerHistory {
match next_idx_opt {
Some(idx) => {
self.history_cursor = Some(idx);
self.populate_history_at_index(idx as usize, app_event_tx)
self.populate_history_at_index(idx as usize, textarea, app_event_tx);
}
None => {
// Past newest clear and exit browsing mode.
self.history_cursor = None;
self.last_history_text = None;
Some(String::new())
self.replace_textarea_content(textarea, "");
}
}
true
}
/// Integrate a GetHistoryEntryResponse event.
@@ -134,18 +145,19 @@ impl ChatComposerHistory {
log_id: u64,
offset: usize,
entry: Option<String>,
) -> Option<String> {
textarea: &mut TextArea,
) -> bool {
if self.history_log_id != Some(log_id) {
return None;
return false;
}
let text = entry?;
let Some(text) = entry else { return false };
self.fetched_history.insert(offset, text.clone());
if self.history_cursor == Some(offset as isize) {
self.last_history_text = Some(text.clone());
return Some(text);
self.replace_textarea_content(textarea, &text);
return true;
}
None
false
}
// ---------------------------------------------------------------------
@@ -155,20 +167,21 @@ impl ChatComposerHistory {
fn populate_history_at_index(
&mut self,
global_idx: usize,
textarea: &mut TextArea,
app_event_tx: &AppEventSender,
) -> Option<String> {
) {
if global_idx >= self.history_entry_count {
// Local entry.
if let Some(text) = self
.local_history
.get(global_idx - self.history_entry_count)
{
self.last_history_text = Some(text.clone());
return Some(text.clone());
let t = text.clone();
self.replace_textarea_content(textarea, &t);
}
} else if let Some(text) = self.fetched_history.get(&global_idx) {
self.last_history_text = Some(text.clone());
return Some(text.clone());
let t = text.clone();
self.replace_textarea_content(textarea, &t);
} else if let Some(log_id) = self.history_log_id {
let op = Op::GetHistoryEntryRequest {
offset: global_idx,
@@ -176,7 +189,14 @@ impl ChatComposerHistory {
};
app_event_tx.send(AppEvent::CodexOp(op));
}
None
}
fn replace_textarea_content(&mut self, textarea: &mut TextArea, text: &str) {
textarea.select_all();
textarea.cut();
let _ = textarea.insert_str(text);
textarea.move_cursor(CursorMove::Jump(0, 0));
self.last_history_text = Some(text.to_string());
}
}
@@ -197,9 +217,11 @@ mod tests {
// Pretend there are 3 persistent entries.
history.set_metadata(1, 3);
let mut textarea = TextArea::default();
// First Up should request offset 2 (latest) and await async data.
assert!(history.should_handle_navigation("", 0));
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
assert!(history.should_handle_navigation(&textarea));
assert!(history.navigate_up(&mut textarea, &tx));
// Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent.
let event = rx.try_recv().expect("expected AppEvent to be sent");
@@ -213,15 +235,14 @@ mod tests {
},
history_request1
);
assert_eq!(textarea.lines().join("\n"), ""); // still empty
// Inject the async response.
assert_eq!(
Some("latest".into()),
history.on_entry_response(1, 2, Some("latest".into()))
);
assert!(history.on_entry_response(1, 2, Some("latest".into()), &mut textarea));
assert_eq!(textarea.lines().join("\n"), "latest");
// Next Up should move to offset 1.
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
assert!(history.navigate_up(&mut textarea, &tx));
// Verify second CodexOp event for offset 1.
let event2 = rx.try_recv().expect("expected second event");
@@ -236,9 +257,7 @@ mod tests {
history_request_2
);
assert_eq!(
Some("older".into()),
history.on_entry_response(1, 1, Some("older".into()))
);
history.on_entry_response(1, 1, Some("older".into()), &mut textarea);
assert_eq!(textarea.lines().join("\n"), "older");
}
}

View File

@@ -19,7 +19,6 @@ mod chat_composer_history;
mod command_popup;
mod file_search_popup;
mod status_indicator_view;
mod textarea;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
@@ -37,7 +36,7 @@ use status_indicator_view::StatusIndicatorView;
pub(crate) struct BottomPane<'a> {
/// Composer is retained even when a BottomPaneView is displayed so the
/// input state is retained when the view is closed.
composer: ChatComposer,
composer: ChatComposer<'a>,
/// If present, this is displayed instead of the `composer`.
active_view: Option<Box<dyn BottomPaneView<'a> + 'a>>,
@@ -75,19 +74,7 @@ impl BottomPane<'_> {
self.active_view
.as_ref()
.map(|v| v.desired_height(width))
.unwrap_or(self.composer.desired_height(width))
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
// Hide the cursor whenever an overlay view is active (e.g. the
// status indicator shown while a task is running, or approval modal).
// In these states the textarea is not interactable, so we should not
// show its caret.
if self.active_view.is_some() {
None
} else {
self.composer.cursor_pos(area)
}
.unwrap_or(self.composer.desired_height())
}
/// Forward a key event to the active view or the composer.

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use codex_core::codex_wrapper::CodexConversation;
use codex_core::codex_wrapper::init_codex;
@@ -26,8 +27,6 @@ use codex_core::protocol::TokenUsage;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
@@ -42,7 +41,6 @@ use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::CommandOutput;
use crate::history_cell::DynamicHeightWidgetRef;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use crate::user_approval_widget::ApprovalRequest;
@@ -58,7 +56,6 @@ pub(crate) struct ChatWidget<'a> {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>,
bottom_pane: BottomPane<'a>,
active_history_cell: Option<HistoryCell>,
config: Config,
initial_user_message: Option<UserMessage>,
token_usage: TokenUsage,
@@ -146,7 +143,6 @@ impl ChatWidget<'_> {
has_input_focus: true,
enhanced_keys_supported,
}),
active_history_cell: None,
config,
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
@@ -161,10 +157,6 @@ impl ChatWidget<'_> {
pub fn desired_height(&self, width: u16) -> u16 {
self.bottom_pane.desired_height(width)
+ self
.active_history_cell
.as_ref()
.map_or(0, |c| c.desired_height(width))
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
@@ -380,14 +372,9 @@ impl ChatWidget<'_> {
cwd: cwd.clone(),
},
);
self.active_history_cell = Some(HistoryCell::new_active_exec_command(
command,
self.app_event_tx.clone(),
));
}
EventMsg::ExecCommandOutputDelta(_) => {
// TODO
self.add_to_history(HistoryCell::new_active_exec_command(command));
}
EventMsg::ExecCommandOutputDelta(_) => {}
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: _,
auto_approved,
@@ -403,19 +390,17 @@ impl ChatWidget<'_> {
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
exit_code,
duration,
stdout,
stderr,
}) => {
let cmd = self.running_commands.remove(&call_id);
self.active_history_cell = None;
self.add_to_history(HistoryCell::new_completed_exec_command(
cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
CommandOutput {
exit_code,
stdout,
stderr,
duration,
duration: Duration::from_secs(0),
},
));
}
@@ -490,7 +475,6 @@ impl ChatWidget<'_> {
CancellationEvent::Ignored => {}
}
if self.bottom_pane.is_task_running() {
self.active_history_cell = None;
self.bottom_pane.clear_ctrl_c_quit_hint();
self.submit_op(Op::Interrupt);
self.answer_buffer.clear();
@@ -525,36 +509,14 @@ impl ChatWidget<'_> {
self.bottom_pane
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let [_, bottom_pane_area] = Layout::vertical([
Constraint::Max(
self.active_history_cell
.as_ref()
.map_or(0, |c| c.desired_height(area.width)),
),
Constraint::Min(self.bottom_pane.desired_height(area.width)),
])
.areas(area);
self.bottom_pane.cursor_pos(bottom_pane_area)
}
}
impl WidgetRef for &ChatWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let [active_cell_area, bottom_pane_area] = Layout::vertical([
Constraint::Max(
self.active_history_cell
.as_ref()
.map_or(0, |c| c.desired_height(area.width)),
),
Constraint::Min(self.bottom_pane.desired_height(area.width)),
])
.areas(area);
(&self.bottom_pane).render(bottom_pane_area, buf);
if let Some(cell) = &self.active_history_cell {
cell.render_ref(active_cell_area, buf);
}
// In the hybrid inline viewport mode we only draw the interactive
// bottom pane; history entries are injected directly into scrollback
// via `Terminal::insert_before`.
(&self.bottom_pane).render(area, buf);
}
}

View File

@@ -25,19 +25,12 @@ use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::PathBuf;
use std::time::Duration;
use tracing::error;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::shimmer_text::shimmer_spans;
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
pub(crate) stdout: String,
@@ -82,7 +75,7 @@ pub(crate) enum HistoryCell {
AgentReasoning { view: TextBlock },
/// An exec tool call that has not finished yet.
ActiveExecCommand { view: ActiveExecCommandView },
ActiveExecCommand { view: TextBlock },
/// Completed exec tool call.
CompletedExecCommand { view: TextBlock },
@@ -127,10 +120,6 @@ pub(crate) enum HistoryCell {
const TOOL_CALL_MAX_LINES: usize = 5;
pub trait DynamicHeightWidgetRef: WidgetRef {
fn desired_height(&self, width: u16) -> u16;
}
impl HistoryCell {
/// Return a cloned, plain representation of the cell's lines suitable for
/// oneshot insertion into the terminal scrollback. Image cells are
@@ -149,46 +138,16 @@ impl HistoryCell {
| HistoryCell::CompletedMcpToolCall { view }
| HistoryCell::PendingPatch { view }
| HistoryCell::PlanUpdate { view }
| HistoryCell::ActiveExecCommand { view, .. }
| HistoryCell::ActiveMcpToolCall { view, .. } => {
view.lines.iter().map(line_to_static).collect()
}
HistoryCell::ActiveExecCommand { view, .. } => {
let lines: Vec<Line<'static>> = vec![
Line::from(vec!["command".magenta(), " running...".dim()]),
Line::from(format!("$ {}", view.command)),
Line::from(""),
];
lines.iter().map(line_to_static).collect()
}
HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![
Line::from("tool result (image output omitted)"),
Line::from(""),
],
}
}
fn view(&self) -> Box<dyn DynamicHeightWidgetRef + '_> {
match self {
HistoryCell::WelcomeMessage { view }
| HistoryCell::UserPrompt { view }
| HistoryCell::AgentMessage { view }
| HistoryCell::AgentReasoning { view }
| HistoryCell::BackgroundEvent { view }
| HistoryCell::GitDiffOutput { view }
| HistoryCell::ErrorEvent { view }
| HistoryCell::SessionInfo { view }
| HistoryCell::CompletedExecCommand { view }
| HistoryCell::CompletedMcpToolCall { view }
| HistoryCell::PendingPatch { view }
| HistoryCell::PlanUpdate { view }
| HistoryCell::ActiveMcpToolCall { view, .. } => Box::new(view),
HistoryCell::ActiveExecCommand { view, .. } => Box::new(view),
HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => {
panic!("view() called on image output cell")
}
}
}
pub(crate) fn new_session_info(
config: &Config,
event: SessionConfiguredEvent,
@@ -294,14 +253,17 @@ impl HistoryCell {
}
}
pub(crate) fn new_active_exec_command(
command: Vec<String>,
app_event_tx: AppEventSender,
) -> Self {
pub(crate) fn new_active_exec_command(command: Vec<String>) -> Self {
let command_escaped = strip_bash_lc_and_escape(&command);
let lines: Vec<Line<'static>> = vec![
Line::from(vec!["command".magenta(), " running...".dim()]),
Line::from(format!("$ {command_escaped}")),
Line::from(""),
];
HistoryCell::ActiveExecCommand {
view: ActiveExecCommandView::new(command_escaped, app_event_tx),
view: TextBlock::new(lines),
}
}
@@ -669,62 +631,6 @@ impl HistoryCell {
}
}
impl DynamicHeightWidgetRef for &HistoryCell {
fn desired_height(&self, width: u16) -> u16 {
self.view().desired_height(width)
}
}
impl WidgetRef for &HistoryCell {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
self.view().render_ref(area, buf);
}
}
pub(crate) struct ActiveExecCommandView {
command: String,
_app_event_tx: AppEventSender,
}
impl ActiveExecCommandView {
fn new(command: String, app_event_tx: AppEventSender) -> Self {
Self {
command,
_app_event_tx: app_event_tx,
}
}
}
impl DynamicHeightWidgetRef for &ActiveExecCommandView {
fn desired_height(&self, width: u16) -> u16 {
let lines: Vec<Line<'static>> = vec![
Line::from("Running command"),
Line::from(format!("$ {}", self.command)),
Line::from(""),
];
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
}
impl WidgetRef for &ActiveExecCommandView {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Schedule a one-shot next frame to continue the shimmer.
self._app_event_tx
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(100)));
let lines: Vec<Line<'static>> = vec![
Line::from(shimmer_spans("Running command")),
Line::from(format!("$ {}", self.command)),
Line::from(""),
];
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.render(area, buf);
}
}
fn create_diff_summary(changes: HashMap<PathBuf, FileChange>) -> Vec<String> {
// Build a concise, humanreadable summary list similar to the
// `git status` short format so the user can reason about the

View File

@@ -14,7 +14,6 @@ use crossterm::style::SetBackgroundColor;
use crossterm::style::SetColors;
use crossterm::style::SetForegroundColor;
use ratatui::layout::Size;
use ratatui::prelude::Backend;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::text::Line;
@@ -22,6 +21,20 @@ use ratatui::text::Span;
/// Insert `lines` above the viewport.
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
let mut out = std::io::stdout();
insert_history_lines_to_writer(terminal, &mut out, lines);
}
/// Like `insert_history_lines`, but writes ANSI to the provided writer. This
/// is intended for testing where a capture buffer is used instead of stdout.
pub fn insert_history_lines_to_writer<B, W>(
terminal: &mut crate::custom_terminal::Terminal<B>,
writer: &mut W,
lines: Vec<Line>,
) where
B: ratatui::backend::Backend,
W: Write,
{
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
let cursor_pos = terminal.get_cursor_position().ok();
@@ -32,10 +45,22 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
// If the viewport is not at the bottom of the screen, scroll it down to make room.
// Don't scroll it past the bottom of the screen.
let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
terminal
.backend_mut()
.scroll_region_down(area.top()..screen_size.height, scroll_amount)
.ok();
// Emit ANSI to scroll the lower region (from the top of the viewport to the bottom
// of the screen) downward by `scroll_amount` lines. We do this by:
// 1) Limiting the scroll region to [area.top()+1 .. screen_height] (1-based bounds)
// 2) Placing the cursor at the top margin of that region
// 3) Emitting Reverse Index (RI, ESC M) `scroll_amount` times
// 4) Resetting the scroll region back to full screen
let top_1based = area.top() + 1; // Convert 0-based row to 1-based for DECSTBM
queue!(writer, SetScrollRegion(top_1based..screen_size.height)).ok();
queue!(writer, MoveTo(0, area.top())).ok();
for _ in 0..scroll_amount {
// Reverse Index (RI): ESC M
queue!(writer, Print("\x1bM")).ok();
}
queue!(writer, ResetScrollRegion).ok();
let cursor_top = area.top().saturating_sub(1);
area.y += scroll_amount;
terminal.set_viewport_area(area);
@@ -59,23 +84,23 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
// ││ ││
// │╰────────────────────────────╯│
// └──────────────────────────────┘
queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();
queue!(writer, SetScrollRegion(1..area.top())).ok();
// NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
// terminal's last_known_cursor_position, which hopefully will still be accurate after we
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
queue!(std::io::stdout(), MoveTo(0, cursor_top)).ok();
queue!(writer, MoveTo(0, cursor_top)).ok();
for line in lines {
queue!(std::io::stdout(), Print("\r\n")).ok();
write_spans(&mut std::io::stdout(), line.iter()).ok();
queue!(writer, Print("\r\n")).ok();
write_spans(writer, line.iter()).ok();
}
queue!(std::io::stdout(), ResetScrollRegion).ok();
queue!(writer, ResetScrollRegion).ok();
// Restore the cursor position to where it was before we started.
if let Some(cursor_pos) = cursor_pos {
queue!(std::io::stdout(), MoveTo(cursor_pos.x, cursor_pos.y)).ok();
queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok();
}
}

View File

@@ -25,16 +25,21 @@ mod bottom_pane;
mod chatwidget;
mod citation_regex;
mod cli;
#[cfg(feature = "vt100-tests")]
pub mod custom_terminal;
#[cfg(not(feature = "vt100-tests"))]
mod custom_terminal;
mod exec_command;
mod file_search;
mod get_git_diff;
mod git_warning_screen;
mod history_cell;
#[cfg(feature = "vt100-tests")]
pub mod insert_history;
#[cfg(not(feature = "vt100-tests"))]
mod insert_history;
mod log_layer;
mod markdown;
mod shimmer_text;
mod slash_command;
mod status_indicator_widget;
mod text_block;

View File

@@ -1,97 +0,0 @@
use std::sync::OnceLock;
use std::time::Duration;
use std::time::Instant;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
static PROCESS_START: OnceLock<Instant> = OnceLock::new();
/// Ensure the process start time is initialized. Call early in app startup
/// so all animations key off a common origin.
pub(crate) fn init_process_start() {
let _ = PROCESS_START.set(Instant::now());
}
fn elapsed_since_start() -> Duration {
let start = PROCESS_START.get_or_init(Instant::now);
start.elapsed()
}
/// Compute grayscale shimmer spans for the provided text based on elapsed
/// time since process start. Uses a cosine falloff across a small band to
/// achieve a smooth highlight that sweeps across the text.
pub(crate) fn shimmer_spans(text: &str) -> Vec<Span<'static>> {
let header_chars: Vec<char> = text.chars().collect();
// Synchronize the shimmer so that all instances start at the beginning
// and reach the end at the same time, regardless of length. We achieve
// this by mapping elapsed time into a global sweep fraction in [0, 1),
// then scaling that fraction across the character indices of this text.
// The bright band width (in characters) remains constant.
let len = header_chars.len();
if len == 0 {
return Vec::new();
}
// Width of the bright band (in characters).
let band_half_width = (len as f32) / 4.0;
// Use character-based padding: pretend the string is longer by
// `PADDING * 2` characters and move at a constant velocity over time.
// We compute the cycle duration in time (including pre/post time derived
// from character padding at constant velocity) and wrap using time modulo
// rather than modulo on character distance.
const SWEEP_SECONDS: f32 = 1.5; // time to traverse the visible text
let PADDING: f32 = band_half_width;
let elapsed = elapsed_since_start().as_secs_f32();
let pos = (elapsed % SWEEP_SECONDS) / SWEEP_SECONDS * (len as f32 + PADDING * 2.0) - PADDING;
let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
.unwrap_or(false);
let mut header_spans: Vec<Span<'static>> = Vec::with_capacity(header_chars.len());
for (i, ch) in header_chars.iter().enumerate() {
let i_pos = i as f32;
let dist = (i_pos - pos).abs();
let t = if dist <= band_half_width {
let x = std::f32::consts::PI * (dist / band_half_width);
0.5 * (1.0 + x.cos())
} else {
0.0
};
let brightness = 0.4 + 0.6 * t;
let level = (brightness * 255.0).clamp(0.0, 255.0) as u8;
let style = if has_true_color {
Style::default()
.fg(Color::Rgb(level, level, level))
.add_modifier(Modifier::BOLD)
} else {
// Bold makes dark gray and gray look the same, so don't use it
// when true color is not supported.
Style::default().fg(color_for_level(level))
};
header_spans.push(Span::styled(ch.to_string(), style));
}
header_spans
}
//
/// Utility used for 16-color terminals to approximate grayscale.
pub(crate) fn color_for_level(level: u8) -> Color {
if level < 128 {
Color::DarkGray
} else if level < 192 {
Color::Gray
} else {
Color::White
}
}

View File

@@ -1,6 +1,11 @@
//! A live status indicator that shows the *latest* log line emitted by the
//! application while the agent is processing a longrunning task.
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::thread;
use std::time::Duration;
use ratatui::buffer::Buffer;
@@ -21,7 +26,6 @@ use ratatui::widgets::WidgetRef;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::shimmer_text::shimmer_spans;
use codex_ansi_escape::ansi_escape_line;
@@ -29,15 +33,42 @@ pub(crate) struct StatusIndicatorWidget {
/// Latest text to display (truncated to the available width at render
/// time).
text: String,
// Keep one sender alive for scheduling frames.
frame_idx: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
// Keep one sender alive to prevent the channel from closing while the
// animation thread is still running. The field itself is currently not
// accessed anywhere, therefore the leading underscore silences the
// `dead_code` warning without affecting behavior.
_app_event_tx: AppEventSender,
}
impl StatusIndicatorWidget {
/// Create a new status indicator and start the animation timer.
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
let frame_idx = Arc::new(AtomicUsize::new(0));
let running = Arc::new(AtomicBool::new(true));
// Animation thread.
{
let frame_idx_clone = Arc::clone(&frame_idx);
let running_clone = Arc::clone(&running);
let app_event_tx_clone = app_event_tx.clone();
thread::spawn(move || {
let mut counter = 0usize;
while running_clone.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(200));
counter = counter.wrapping_add(1);
frame_idx_clone.store(counter, Ordering::Relaxed);
app_event_tx_clone.send(AppEvent::RequestRedraw);
}
});
}
Self {
text: String::from("waiting for logs…"),
frame_idx,
running,
_app_event_tx: app_event_tx,
}
}
@@ -52,22 +83,61 @@ impl StatusIndicatorWidget {
}
}
impl Drop for StatusIndicatorWidget {
fn drop(&mut self) {
use std::sync::atomic::Ordering;
self.running.store(false, Ordering::Relaxed);
}
}
impl WidgetRef for StatusIndicatorWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Schedule the next animation frame.
self._app_event_tx
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(100)));
let widget_style = Style::default();
let block = Block::default()
.padding(Padding::new(1, 0, 0, 0))
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(widget_style.dim());
let mut header_spans: Vec<Span<'static>> = shimmer_spans("Working");
// Animated 3dot pattern inside brackets. The *active* dot is bold
// white, the others are dim.
const DOT_COUNT: usize = 3;
let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
let phase = idx % (DOT_COUNT * 2 - 2);
let active = if phase < DOT_COUNT {
phase
} else {
(DOT_COUNT * 2 - 2) - phase
};
let mut header_spans: Vec<Span<'static>> = Vec::new();
header_spans.push(Span::styled(
" ",
"Working ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
header_spans.push(Span::styled(
"[",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
for i in 0..DOT_COUNT {
let style = if i == active {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().dim()
};
header_spans.push(Span::styled(".", style));
}
header_spans.push(Span::styled(
"] ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),

View File

@@ -1,9 +1,4 @@
use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::history_cell::DynamicHeightWidgetRef;
/// A simple widget that just displays a list of `Line`s via a `Paragraph`.
/// This is the default rendering backend for most `HistoryCell` variants.
@@ -17,21 +12,3 @@ impl TextBlock {
Self { lines }
}
}
impl DynamicHeightWidgetRef for &TextBlock {
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.lines.clone()))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
}
impl WidgetRef for &TextBlock {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Paragraph::new(Text::from(self.lines.clone()))
.wrap(Wrap { trim: false })
.render(area, buf);
}
}

View File

@@ -0,0 +1,211 @@
#![cfg(feature = "vt100-tests")]
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::style::{Color, Style};
/// HIST-001: Basic insertion at bottom, no wrap.
///
/// This test captures the ANSI bytes produced by `insert_history_lines_to_writer`
/// when the viewport is at the bottom of the screen (so no pre-scroll is
/// required). It feeds the bytes into a vt100 parser and asserts that the
/// inserted lines are visible near the bottom of the screen.
#[test]
fn hist_001_basic_insertion_no_wrap() {
// Screen of 20x6; viewport is the last row (height=1 at y=5)
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend).unwrap();
// Place the viewport at the bottom row
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
let lines = vec![Line::from("first"), Line::from("second")];
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
// Feed captured bytes into vt100 emulator
let mut parser = vt100::Parser::new(6, 20, 0);
parser.process(&buf);
let screen = parser.screen();
// Gather visible rows as strings
let mut rows: Vec<String> = Vec::new();
for row in 0..6 {
let mut s = String::new();
for col in 0..20 {
if let Some(cell) = screen.cell(row, col) {
let cont = cell.contents();
if let Some(ch) = cont.chars().next() {
s.push(ch);
} else {
s.push(' ');
}
} else {
s.push(' ');
}
}
rows.push(s);
}
// The inserted lines should appear somewhere above the viewport; in this
// simple case, they will occupy the two rows immediately above the final
// row of the scroll region.
let joined = rows.join("\n");
assert!(joined.contains("first"), "screen did not contain 'first'\n{joined}");
assert!(joined.contains("second"), "screen did not contain 'second'\n{joined}");
}
/// HIST-002: Long token wraps across rows within the scroll region.
#[test]
fn hist_002_long_token_wraps() {
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend).unwrap();
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
let long = "A".repeat(45); // > 2 lines at width 20
let lines = vec![Line::from(long.clone())];
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
let mut parser = vt100::Parser::new(6, 20, 0);
parser.process(&buf);
let screen = parser.screen();
// Count total A's on the screen
let mut count_a = 0usize;
for row in 0..6 {
for col in 0..20 {
if let Some(cell) = screen.cell(row, col) {
if let Some(ch) = cell.contents().chars().next() {
if ch == 'A' { count_a += 1; }
}
}
}
}
assert_eq!(count_a, long.len(), "wrapped content did not preserve all characters");
}
/// HIST-003: Emoji/CJK content renders fully (no broken graphemes).
#[test]
fn hist_003_emoji_and_cjk() {
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend).unwrap();
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
let text = String::from("😀😀😀😀😀 你好世界");
let lines = vec![Line::from(text.clone())];
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
let mut parser = vt100::Parser::new(6, 20, 0);
parser.process(&buf);
let screen = parser.screen();
// Reconstruct string by concatenating non-space cells; ensure all emojis and CJK are present.
let mut reconstructed = String::new();
for row in 0..6 {
for col in 0..20 {
if let Some(cell) = screen.cell(row, col) {
let cont = cell.contents();
if let Some(ch) = cont.chars().next() {
if ch != ' ' { reconstructed.push(ch); }
}
}
}
}
for ch in text.chars().filter(|c| !c.is_whitespace()) {
assert!(reconstructed.contains(ch), "missing character {:?} in reconstructed screen", ch);
}
}
/// HIST-004: Mixed ANSI spans render textual content correctly (styles stripped in emulator).
#[test]
fn hist_004_mixed_ansi_spans() {
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend).unwrap();
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
let line = Line::from(vec![
Span::styled("red", Style::default().fg(Color::Red)),
Span::raw("+plain"),
]);
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, vec![line]);
let mut parser = vt100::Parser::new(6, 20, 0);
parser.process(&buf);
let screen = parser.screen();
let mut rows: Vec<String> = Vec::new();
for row in 0..6 {
let mut s = String::new();
for col in 0..20 {
if let Some(cell) = screen.cell(row, col) {
let cont = cell.contents();
if let Some(ch) = cont.chars().next() { s.push(ch); } else { s.push(' '); }
} else {
s.push(' ');
}
}
rows.push(s);
}
let joined = rows.join("\n");
assert!(joined.contains("red+plain"), "styled text did not render as expected\n{joined}");
}
/// HIST-006: Cursor is restored after insertion (CUP to 1;1 when backend reports 0,0).
#[test]
fn hist_006_cursor_restoration() {
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend).unwrap();
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
let lines = vec![Line::from("x")];
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
let s = String::from_utf8_lossy(&buf);
// CUP to 1;1 (ANSI: ESC[1;1H)
assert!(s.contains("\u{1b}[1;1H"), "expected final CUP to 1;1 in output, got: {s:?}");
// Reset scroll region
assert!(s.contains("\u{1b}[r"), "expected reset scroll region in output, got: {s:?}");
}
/// HIST-005: Pre-scroll region is emitted via ANSI when viewport is not at bottom.
#[test]
fn hist_005_pre_scroll_region_down() {
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend).unwrap();
// Viewport not at bottom: y=3 (0-based), height=1
let area = Rect::new(0, 3, 20, 1);
term.set_viewport_area(area);
let lines = vec![Line::from("first"), Line::from("second")];
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
let s = String::from_utf8_lossy(&buf);
// Expect we limited scroll region to [top+1 .. screen_height] => [4 .. 6] (1-based)
assert!(s.contains("\u{1b}[4;6r"), "expected pre-scroll SetScrollRegion 4..6, got: {s:?}");
// Expect we moved cursor to top of that region: row 3 (0-based) => CUP 4;1H
assert!(s.contains("\u{1b}[4;1H"), "expected cursor at top of pre-scroll region, got: {s:?}");
// Expect at least two Reverse Index commands (ESC M) for two inserted lines
let ri_count = s.matches("\u{1b}M").count();
assert!(ri_count >= 1, "expected at least one RI (ESC M), got: {s:?}");
// After pre-scroll, we set insertion scroll region to [1 .. new_top] => [1 .. 5]
assert!(s.contains("\u{1b}[1;5r"), "expected insertion SetScrollRegion 1..5, got: {s:?}");
}