Compare commits

...

10 Commits

Author SHA1 Message Date
Jeremy Rose
1f538dcb07 wip 2025-08-04 15:53:28 -07:00
Jeremy Rose
d448975aae wip 2025-08-04 15:53:28 -07:00
Jeremy Rose
ec4cf9f5d3 wip 2025-08-04 15:53:27 -07:00
Jeremy Rose
8c9e932cb1 wip 2025-08-04 15:44:23 -07:00
Jeremy Rose
2195e6956e show a transient history cell for commands 2025-08-04 11:26:51 -07:00
Jeremy Rose
2576fadc74 shimmer on working (#1807)
change the animation on "working" to be a text shimmer


https://github.com/user-attachments/assets/f64529eb-1c64-493a-8d97-0f68b964bdd0
2025-08-03 18:51:33 +00:00
Jeremy Rose
78a1d49fac fix command duration display (#1806)
we were always displaying "0ms" before.

<img width="731" height="101" alt="Screenshot 2025-08-02 at 10 51 22 PM"
src="https://github.com/user-attachments/assets/f56814ed-b9a4-4164-9e78-181c60ce19b7"
/>
2025-08-03 11:33:44 -07:00
Jeremy Rose
d62b703a21 custom textarea (#1794)
This replaces tui-textarea with a custom textarea component.

Key differences:
1. wrapped lines
2. better unicode handling
3. uses the native terminal cursor

This should perhaps be spun out into its own separate crate at some
point, but for now it's convenient to have it in-tree.
2025-08-03 11:31:35 -07:00
Gabriel Peal
4c9f7b6bcc Fix flaky test_shell_command_approval_triggers_elicitation test (#1802)
This doesn't flake very often but this should fix it.
2025-08-03 10:19:12 -04:00
David Z Hao
75eecb656e Fix MacOS multiprocessing by relaxing sandbox (#1808)
The following test script fails in the codex sandbox:
```
import multiprocessing
from multiprocessing import Lock, Process

def f(lock):
    with lock:
        print("Lock acquired in child process")

if __name__ == '__main__':
    lock = Lock()
    p = Process(target=f, args=(lock,))
    p.start()
    p.join()
```

with 
```
Traceback (most recent call last):
  File "/Users/david.hao/code/codex/codex-rs/cli/test.py", line 9, in <module>
    lock = Lock()
           ^^^^^^
  File "/Users/david.hao/.local/share/uv/python/cpython-3.12.9-macos-aarch64-none/lib/python3.12/multiprocessing/context.py", line 68, in Lock
    return Lock(ctx=self.get_context())
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/david.hao/.local/share/uv/python/cpython-3.12.9-macos-aarch64-none/lib/python3.12/multiprocessing/synchronize.py", line 169, in __init__
    SemLock.__init__(self, SEMAPHORE, 1, 1, ctx=ctx)
  File "/Users/david.hao/.local/share/uv/python/cpython-3.12.9-macos-aarch64-none/lib/python3.12/multiprocessing/synchronize.py", line 57, in __init__
    sl = self._semlock = _multiprocessing.SemLock(
                         ^^^^^^^^^^^^^^^^^^^^^^^^^
PermissionError: [Errno 1] Operation not permitted
```

After reading, adding this line to the sandbox configs fixes things -
MacOS multiprocessing appears to use sem_lock(), which opens an IPC
which is considered a disk write even though no file is created. I
interrogated ChatGPT about whether it's okay to loosen, and my
impression after reading is that it is, although would appreciate a
close look


Breadcrumb: You can run `cargo run -- debug seatbelt --full-auto <cmd>`
to test the sandbox
2025-08-03 06:59:26 -07:00
22 changed files with 2097 additions and 581 deletions

View File

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

55
codex-rs/Cargo.lock generated
View File

@@ -859,6 +859,7 @@ dependencies = [
"mcp-types",
"path-clean",
"pretty_assertions",
"rand 0.8.5",
"ratatui",
"ratatui-image",
"regex-lite",
@@ -868,13 +869,14 @@ 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",
@@ -2336,6 +2338,12 @@ dependencies = [
"windows-sys 0.59.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"
@@ -4173,6 +4181,12 @@ 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"
@@ -4235,7 +4249,7 @@ dependencies = [
"starlark_syntax",
"static_assertions",
"strsim 0.10.0",
"textwrap",
"textwrap 0.11.0",
"thiserror 1.0.69",
]
@@ -4371,6 +4385,15 @@ 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"
@@ -4524,6 +4547,17 @@ 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"
@@ -4988,17 +5022,6 @@ 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.0",
]
[[package]]
name = "typenum"
version = "1.18.0"
@@ -5017,6 +5040,12 @@ 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"

View File

@@ -396,11 +396,15 @@ impl Session {
&self,
sub_id: &str,
call_id: &str,
stdout: &str,
stderr: &str,
exit_code: i32,
output: &ExecToolCallOutput,
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
@@ -412,14 +416,15 @@ 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,
exit_code,
duration: *duration,
exit_code: *exit_code,
})
};
@@ -1775,23 +1780,21 @@ async fn handle_container_exec_with_params(
stdout,
stderr,
duration,
} = output;
} = &output;
sess.notify_exec_command_end(
&sub_id,
&call_id,
&stdout,
&stderr,
exit_code,
&output,
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 {
@@ -1900,23 +1903,16 @@ async fn handle_sandbox_error(
stdout,
stderr,
duration,
} = retry_output;
} = &retry_output;
sess.notify_exec_command_end(
&sub_id,
&call_id,
&stdout,
&stderr,
exit_code,
is_apply_patch,
)
.await;
sess.notify_exec_command_end(&sub_id, &call_id, &retry_output, 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,6 +523,8 @@ 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,3 +65,7 @@
(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,8 +177,7 @@ 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;
@@ -186,8 +185,7 @@ 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,7 +106,6 @@ impl EventProcessorWithHumanOutput {
struct ExecCommandBegin {
command: Vec<String>,
start_time: Instant,
}
struct PatchApplyBegin {
@@ -228,7 +227,6 @@ impl EventProcessor for EventProcessorWithHumanOutput {
call_id.clone(),
ExecCommandBegin {
command: command.clone(),
start_time: Instant::now(),
},
);
ts_println!(
@@ -244,16 +242,14 @@ 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,
start_time,
}) = exec_command
let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command
{
(
format!(" in {}", format_elapsed(start_time)),
format!(" in {}", format_duration(duration)),
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, Serialize)]
#[derive(Debug, Deserialize, Serialize)]
pub struct ExecApprovalElicitRequestParams {
// These fields are required so that `params`
// conforms to ElicitRequestParams.

View File

@@ -89,14 +89,18 @@ 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(),
// Internal Codex id: empirically it is 1, but this is
// admittedly an internal detail that could change.
"1".to_string(),
params.codex_event_id.clone(),
)?;
assert_eq!(expected_elicitation_request, elicitation_request);

View File

@@ -48,6 +48,8 @@ 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",
@@ -60,7 +62,6 @@ 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"
@@ -70,3 +71,5 @@ uuid = "1"
[dev-dependencies]
insta = "1.43.1"
pretty_assertions = "1"
rand = "0.8"
chrono = { version = "0.4", features = ["serde"] }

View File

@@ -5,6 +5,7 @@ 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;
@@ -21,13 +22,10 @@ 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);
@@ -55,9 +53,6 @@ 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.,
@@ -65,6 +60,10 @@ 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
@@ -86,7 +85,6 @@ 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);
@@ -133,6 +131,9 @@ impl App<'_> {
});
}
// Initialize process start time for synchronized animations.
init_process_start();
let (app_state, chat_args) = if show_git_warning {
(
AppState::GitWarning {
@@ -162,6 +163,50 @@ 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(),
@@ -169,9 +214,9 @@ impl App<'_> {
app_state,
config,
file_search,
pending_redraw,
chat_args,
enhanced_keys_supported,
frame_schedule_tx: frame_tx,
}
}
@@ -181,32 +226,13 @@ impl App<'_> {
self.app_event_tx.clone()
}
/// 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);
});
fn schedule_frame_in(&self, dur: Duration) {
let _ = self.frame_schedule_tx.send(Instant::now() + dur);
}
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
// Insert an event to trigger the first render.
let app_event_tx = self.app_event_tx.clone();
app_event_tx.send(AppEvent::RequestRedraw);
// Trigger the first render immediately via the frame scheduler.
let _ = self.frame_schedule_tx.send(Instant::now());
while let Ok(event) = self.app_event_rx.recv() {
match event {
@@ -215,7 +241,10 @@ impl App<'_> {
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::RequestRedraw => {
self.schedule_redraw();
self.schedule_frame_in(REDRAW_DEBOUNCE);
}
AppEvent::ScheduleFrameIn(dur) => {
self.schedule_frame_in(dur);
}
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
@@ -438,14 +467,15 @@ impl App<'_> {
);
self.pending_history_lines.clear();
}
match &mut self.app_state {
terminal.draw(|frame| match &mut self.app_state {
AppState::Chat { widget } => {
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
if let Some((x, y)) = widget.cursor_pos(frame.area()) {
frame.set_cursor_position((x, y));
}
frame.render_widget_ref(&**widget, frame.area())
}
AppState::GitWarning { screen } => {
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
}
}
AppState::GitWarning { screen } => frame.render_widget_ref(&*screen, frame.area()),
})?;
Ok(())
}

View File

@@ -2,6 +2,7 @@ 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;
@@ -15,6 +16,11 @@ 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,6 +1,11 @@
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;
@@ -8,13 +13,11 @@ 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::Widget;
use ratatui::widgets::StatefulWidgetRef;
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;
@@ -22,7 +25,10 @@ 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
@@ -35,8 +41,14 @@ pub enum InputResult {
None,
}
pub(crate) struct ChatComposer<'a> {
textarea: TextArea<'a>,
struct TokenUsageInfo {
token_usage: TokenUsage,
model_context_window: Option<u64>,
}
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
active_popup: ActivePopup,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
@@ -45,6 +57,8 @@ pub(crate) struct ChatComposer<'a> {
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.
@@ -54,20 +68,17 @@ 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;
let mut this = Self {
textarea,
Self {
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
active_popup: ActivePopup::None,
app_event_tx,
history: ChatComposerHistory::new(),
@@ -76,13 +87,13 @@ impl ChatComposer<'_> {
dismissed_file_popup_token: None,
current_file_query: None,
pending_pastes: Vec::new(),
};
this.update_border(has_input_focus);
this
token_usage_info: None,
has_focus: has_input_focus,
}
}
pub fn desired_height(&self) -> u16 {
self.textarea.lines().len().max(1) as u16
pub fn desired_height(&self, width: u16) -> u16 {
self.textarea.desired_height(width - 1)
+ match &self.active_popup {
ActivePopup::None => 1u16,
ActivePopup::Command(c) => c.calculate_required_height(),
@@ -90,6 +101,21 @@ 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()
@@ -103,28 +129,10 @@ impl ChatComposer<'_> {
token_usage: TokenUsage,
model_context_window: Option<u64>,
) {
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);
self.token_usage_info = Some(TokenUsageInfo {
token_usage,
model_context_window,
});
}
/// Record the history metadata advertised by `SessionConfiguredEvent` so
@@ -142,8 +150,12 @@ impl ChatComposer<'_> {
offset: usize,
entry: Option<String>,
) -> bool {
self.history
.on_entry_response(log_id, offset, entry, &mut self.textarea)
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
}
pub fn handle_paste(&mut self, pasted: String) -> bool {
@@ -179,7 +191,7 @@ impl ChatComposer<'_> {
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
self.ctrl_c_quit_hint = show;
self.update_border(has_focus);
self.set_has_focus(has_focus);
}
/// Handle a key event coming from the main UI.
@@ -207,49 +219,47 @@ impl ChatComposer<'_> {
unreachable!();
};
match key_event.into() {
Input { key: Key::Up, .. } => {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
} => {
popup.move_up();
(InputResult::None, true)
}
Input { key: Key::Down, .. } => {
KeyEvent {
code: KeyCode::Down,
..
} => {
popup.move_down();
(InputResult::None, true)
}
Input { key: Key::Tab, .. } => {
KeyEvent {
code: KeyCode::Tab, ..
} => {
if let Some(cmd) = popup.selected_command() {
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
let first_line = self.textarea.text().lines().next().unwrap_or("");
let starts_with_cmd = first_line
.trim_start()
.starts_with(&format!("/{}", cmd.command()));
if !starts_with_cmd {
self.textarea.select_all();
self.textarea.cut();
let _ = self.textarea.insert_str(format!("/{} ", cmd.command()));
self.textarea.set_text(&format!("/{} ", cmd.command()));
}
}
(InputResult::None, true)
}
Input {
key: Key::Enter,
shift: false,
alt: false,
ctrl: false,
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
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.select_all();
self.textarea.cut();
self.textarea.set_text("");
// Hide popup since the command has been dispatched.
self.active_popup = ActivePopup::None;
@@ -268,16 +278,23 @@ impl ChatComposer<'_> {
unreachable!();
};
match key_event.into() {
Input { key: Key::Up, .. } => {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
} => {
popup.move_up();
(InputResult::None, true)
}
Input { key: Key::Down, .. } => {
KeyEvent {
code: KeyCode::Down,
..
} => {
popup.move_down();
(InputResult::None, true)
}
Input { key: Key::Esc, .. } => {
KeyEvent {
code: KeyCode::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());
@@ -285,12 +302,13 @@ impl ChatComposer<'_> {
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
Input { key: Key::Tab, .. }
| Input {
key: Key::Enter,
ctrl: false,
alt: false,
shift: false,
KeyEvent {
code: KeyCode::Tab, ..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
if let Some(sel) = popup.selected_match() {
let sel_path = sel.to_string();
@@ -315,46 +333,89 @@ 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: &tui_textarea::TextArea) -> Option<String> {
let (row, col) = textarea.cursor();
fn current_at_token(textarea: &TextArea) -> Option<String> {
let cursor_offset = textarea.cursor();
let text = textarea.text();
// Guard against out-of-bounds rows.
let line = textarea.lines().get(row)?.as_str();
// 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);
}
// Calculate byte offset for cursor position
let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>();
// Split the line around the (now safe) cursor position.
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
// 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..];
// 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
};
// Find start index (first character **after** the previous multi-byte whitespace).
let start_idx = before_cursor
// Left candidate: token containing the cursor position.
let start_left = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
// Find end index (first multi-byte whitespace **after** the cursor position).
let end_rel_idx = after_cursor
let end_left_rel = 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;
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())
let end_left = safe_cursor + end_left_rel;
let token_left = if start_left < end_left {
Some(&text[start_left..end_left])
} 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`.
@@ -363,94 +424,73 @@ 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 (row, col) = self.textarea.cursor();
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
// Materialize the textarea lines so we can mutate them easily.
let mut lines: Vec<String> = self.textarea.lines().to_vec();
let before_cursor = &text[..cursor_offset];
let after_cursor = &text[cursor_offset..];
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>();
// 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);
let before_cursor = &line[..cursor_byte_offset];
let after_cursor = &line[cursor_byte_offset..];
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;
// 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);
// 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..]);
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.
}
self.textarea.set_text(&new_text);
}
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let input: Input = key_event.into();
match input {
match key_event {
// -------------------------------------------------------------
// 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.
// -------------------------------------------------------------
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(input)
}
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,
KeyEvent {
code: KeyCode::Up | KeyCode::Down,
..
} => {
let mut text = self.textarea.lines().join("\n");
self.textarea.select_all();
self.textarea.cut();
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);
return (InputResult::None, true);
}
}
self.handle_input_basic(key_event)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
let mut text = self.textarea.text().to_string();
self.textarea.set_text("");
// Replace all pending pastes in the text
for (placeholder, actual) in &self.pending_pastes {
@@ -467,41 +507,15 @@ 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: Input) -> (InputResult, bool) {
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// Special handling for backspace on placeholders
if let Input {
key: Key::Backspace,
if let KeyEvent {
code: KeyCode::Backspace,
..
} = input
{
@@ -510,20 +524,9 @@ 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.lines().join("\n");
let text_after = self.textarea.text();
// Check if any placeholders were removed and remove their corresponding pending pastes
self.pending_pastes
@@ -535,21 +538,16 @@ 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 (row, col) = self.textarea.cursor();
let line = self
.textarea
.lines()
.get(row)
.map(|s| s.as_str())
.unwrap_or("");
let p = self.textarea.cursor();
let text = self.textarea.text();
// Find any placeholder that ends at the cursor position
let placeholder_to_remove = self.pending_pastes.iter().find_map(|(ph, _)| {
if col < ph.len() {
if p < ph.len() {
return None;
}
let potential_ph_start = col - ph.len();
if line[potential_ph_start..col] == *ph {
let potential_ph_start = p - ph.len();
if text[potential_ph_start..p] == *ph {
Some(ph.clone())
} else {
None
@@ -557,17 +555,7 @@ impl ChatComposer<'_> {
});
if let Some(placeholder) = placeholder_to_remove {
// 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.textarea.replace_range(p - placeholder.len()..p, "");
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
true
} else {
@@ -579,16 +567,7 @@ 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) {
// 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 first_line = self.textarea.text().lines().next().unwrap_or("");
let input_starts_with_slash = first_line.starts_with('/');
match &mut self.active_popup {
ActivePopup::Command(popup) => {
@@ -644,74 +623,29 @@ impl ChatComposer<'_> {
self.dismissed_file_popup_token = None;
}
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),
);
fn set_has_focus(&mut self, has_focus: bool) {
self.has_focus = has_focus;
}
}
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) => {
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);
popup.render_ref(popup_rect, buf);
}
ActivePopup::File(popup) => {
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);
popup.render_ref(popup_rect, buf);
}
ActivePopup::None => {
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 bottom_line_rect = popup_rect;
let key_hint_style = Style::default().fg(Color::Cyan);
let hint = if self.ctrl_c_quit_hint {
vec![
@@ -740,6 +674,56 @@ 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);
}
}
}
@@ -749,7 +733,7 @@ mod tests {
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use tui_textarea::TextArea;
use crate::bottom_pane::textarea::TextArea;
#[test]
fn test_current_at_token_basic_cases() {
@@ -792,9 +776,9 @@ mod tests {
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::default();
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
@@ -826,9 +810,9 @@ mod tests {
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::default();
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
@@ -863,13 +847,13 @@ mod tests {
// Full-width space boundaries
(
"test @İstanbul",
6,
8,
Some("İstanbul".to_string()),
"@ token after full-width space",
),
(
"@ЙЦУ @诶",
6,
10,
Some("".to_string()),
"Full-width space between Unicode tokens",
),
@@ -883,9 +867,9 @@ mod tests {
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::default();
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
@@ -907,7 +891,7 @@ mod tests {
let needs_redraw = composer.handle_paste("hello".to_string());
assert!(needs_redraw);
assert_eq!(composer.textarea.lines(), ["hello"]);
assert_eq!(composer.textarea.text(), "hello");
assert!(composer.pending_pastes.is_empty());
let (result, _) =
@@ -932,7 +916,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.lines(), [placeholder.as_str()]);
assert_eq!(composer.textarea.text(), placeholder);
assert_eq!(composer.pending_pastes.len(), 1);
assert_eq!(composer.pending_pastes[0].0, placeholder);
assert_eq!(composer.pending_pastes[0].1, large);
@@ -1008,7 +992,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.move_cursor(tui_textarea::CursorMove::End);
composer.textarea.set_cursor(composer.textarea.text().len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
}
@@ -1123,7 +1107,7 @@ mod tests {
current_pos += content.len();
}
(
composer.textarea.lines().join("\n"),
composer.textarea.text().to_string(),
composer.pending_pastes.len(),
current_pos,
)
@@ -1134,25 +1118,18 @@ mod tests {
let mut deletion_states = vec![];
// First deletion
composer
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(0, states[0].2 as u16));
composer.textarea.set_cursor(states[0].2);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push((
composer.textarea.lines().join("\n"),
composer.textarea.text().to_string(),
composer.pending_pastes.len(),
));
// Second deletion
composer
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(
0,
composer.textarea.lines().join("\n").len() as u16,
));
composer.textarea.set_cursor(composer.textarea.text().len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push((
composer.textarea.lines().join("\n"),
composer.textarea.text().to_string(),
composer.pending_pastes.len(),
));
@@ -1191,17 +1168,13 @@ mod tests {
composer.handle_paste(paste.clone());
composer
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(
0,
(placeholder.len() - pos_from_end) as u16,
));
.set_cursor((placeholder.len() - pos_from_end) as usize);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let result = (
composer.textarea.lines().join("\n").contains(&placeholder),
composer.textarea.text().contains(&placeholder),
composer.pending_pastes.len(),
);
composer.textarea.select_all();
composer.textarea.cut();
composer.textarea.set_text("");
result
})
.collect();

View File

@@ -1,8 +1,5 @@
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;
@@ -67,59 +64,52 @@ 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, textarea: &TextArea) -> bool {
pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool {
if self.history_entry_count == 0 && self.local_history.is_empty() {
return false;
}
if textarea.is_empty() {
if text.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.
let (row, col) = textarea.cursor();
if row != 0 || col != 0 {
if cursor != 0 {
return false;
}
let lines = textarea.lines();
matches!(&self.last_history_text, Some(prev) if prev == &lines.join("\n"))
matches!(&self.last_history_text, Some(prev) if prev == text)
}
/// Handle <Up>. Returns true when the key was consumed and the caller
/// should request a redraw.
pub fn navigate_up(&mut self, textarea: &mut TextArea, app_event_tx: &AppEventSender) -> bool {
pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option<String> {
let total_entries = self.history_entry_count + self.local_history.len();
if total_entries == 0 {
return false;
return None;
}
let next_idx = match self.history_cursor {
None => (total_entries as isize) - 1,
Some(0) => return true, // already at oldest
Some(0) => return None, // already at oldest
Some(idx) => idx - 1,
};
self.history_cursor = Some(next_idx);
self.populate_history_at_index(next_idx as usize, textarea, app_event_tx);
true
self.populate_history_at_index(next_idx as usize, app_event_tx)
}
/// Handle <Down>.
pub fn navigate_down(
&mut self,
textarea: &mut TextArea,
app_event_tx: &AppEventSender,
) -> bool {
pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option<String> {
let total_entries = self.history_entry_count + self.local_history.len();
if total_entries == 0 {
return false;
return None;
}
let next_idx_opt = match self.history_cursor {
None => return false, // not browsing
None => return None, // not browsing
Some(idx) if (idx as usize) + 1 >= total_entries => None,
Some(idx) => Some(idx + 1),
};
@@ -127,16 +117,15 @@ impl ChatComposerHistory {
match next_idx_opt {
Some(idx) => {
self.history_cursor = Some(idx);
self.populate_history_at_index(idx as usize, textarea, app_event_tx);
self.populate_history_at_index(idx as usize, app_event_tx)
}
None => {
// Past newest clear and exit browsing mode.
self.history_cursor = None;
self.last_history_text = None;
self.replace_textarea_content(textarea, "");
Some(String::new())
}
}
true
}
/// Integrate a GetHistoryEntryResponse event.
@@ -145,19 +134,18 @@ impl ChatComposerHistory {
log_id: u64,
offset: usize,
entry: Option<String>,
textarea: &mut TextArea,
) -> bool {
) -> Option<String> {
if self.history_log_id != Some(log_id) {
return false;
return None;
}
let Some(text) = entry else { return false };
let text = entry?;
self.fetched_history.insert(offset, text.clone());
if self.history_cursor == Some(offset as isize) {
self.replace_textarea_content(textarea, &text);
return true;
self.last_history_text = Some(text.clone());
return Some(text);
}
false
None
}
// ---------------------------------------------------------------------
@@ -167,21 +155,20 @@ 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)
{
let t = text.clone();
self.replace_textarea_content(textarea, &t);
self.last_history_text = Some(text.clone());
return Some(text.clone());
}
} else if let Some(text) = self.fetched_history.get(&global_idx) {
let t = text.clone();
self.replace_textarea_content(textarea, &t);
self.last_history_text = Some(text.clone());
return Some(text.clone());
} else if let Some(log_id) = self.history_log_id {
let op = Op::GetHistoryEntryRequest {
offset: global_idx,
@@ -189,14 +176,7 @@ impl ChatComposerHistory {
};
app_event_tx.send(AppEvent::CodexOp(op));
}
}
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());
None
}
}
@@ -217,11 +197,9 @@ 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(&textarea));
assert!(history.navigate_up(&mut textarea, &tx));
assert!(history.should_handle_navigation("", 0));
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent.
let event = rx.try_recv().expect("expected AppEvent to be sent");
@@ -235,14 +213,15 @@ mod tests {
},
history_request1
);
assert_eq!(textarea.lines().join("\n"), ""); // still empty
// Inject the async response.
assert!(history.on_entry_response(1, 2, Some("latest".into()), &mut textarea));
assert_eq!(textarea.lines().join("\n"), "latest");
assert_eq!(
Some("latest".into()),
history.on_entry_response(1, 2, Some("latest".into()))
);
// Next Up should move to offset 1.
assert!(history.navigate_up(&mut textarea, &tx));
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify second CodexOp event for offset 1.
let event2 = rx.try_recv().expect("expected second event");
@@ -257,7 +236,9 @@ mod tests {
history_request_2
);
history.on_entry_response(1, 1, Some("older".into()), &mut textarea);
assert_eq!(textarea.lines().join("\n"), "older");
assert_eq!(
Some("older".into()),
history.on_entry_response(1, 1, Some("older".into()))
);
}
}

View File

@@ -19,6 +19,7 @@ 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 {
@@ -36,7 +37,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<'a>,
composer: ChatComposer,
/// If present, this is displayed instead of the `composer`.
active_view: Option<Box<dyn BottomPaneView<'a> + 'a>>,
@@ -74,7 +75,19 @@ impl BottomPane<'_> {
self.active_view
.as_ref()
.map(|v| v.desired_height(width))
.unwrap_or(self.composer.desired_height())
.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)
}
}
/// Forward a key event to the active view or the composer.

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
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;
@@ -27,6 +26,8 @@ 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;
@@ -41,6 +42,7 @@ 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;
@@ -56,6 +58,7 @@ 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,
@@ -143,6 +146,7 @@ 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(),
@@ -157,6 +161,10 @@ 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) {
@@ -372,9 +380,14 @@ impl ChatWidget<'_> {
cwd: cwd.clone(),
},
);
self.add_to_history(HistoryCell::new_active_exec_command(command));
self.active_history_cell = Some(HistoryCell::new_active_exec_command(
command,
self.app_event_tx.clone(),
));
}
EventMsg::ExecCommandOutputDelta(_) => {
// TODO
}
EventMsg::ExecCommandOutputDelta(_) => {}
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: _,
auto_approved,
@@ -390,17 +403,19 @@ 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::from_secs(0),
duration,
},
));
}
@@ -475,6 +490,7 @@ 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();
@@ -509,14 +525,36 @@ 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) {
// 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);
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);
}
}
}

View File

@@ -25,12 +25,19 @@ 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,
@@ -75,7 +82,7 @@ pub(crate) enum HistoryCell {
AgentReasoning { view: TextBlock },
/// An exec tool call that has not finished yet.
ActiveExecCommand { view: TextBlock },
ActiveExecCommand { view: ActiveExecCommandView },
/// Completed exec tool call.
CompletedExecCommand { view: TextBlock },
@@ -120,6 +127,10 @@ 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
@@ -138,16 +149,46 @@ 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,
@@ -253,17 +294,14 @@ impl HistoryCell {
}
}
pub(crate) fn new_active_exec_command(command: Vec<String>) -> Self {
pub(crate) fn new_active_exec_command(
command: Vec<String>,
app_event_tx: AppEventSender,
) -> 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: TextBlock::new(lines),
view: ActiveExecCommandView::new(command_escaped, app_event_tx),
}
}
@@ -631,6 +669,62 @@ 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

@@ -34,6 +34,7 @@ mod history_cell;
mod insert_history;
mod log_layer;
mod markdown;
mod shimmer_text;
mod slash_command;
mod status_indicator_widget;
mod text_block;

View File

@@ -0,0 +1,97 @@
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,11 +1,6 @@
//! 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;
@@ -26,6 +21,7 @@ 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;
@@ -33,42 +29,15 @@ pub(crate) struct StatusIndicatorWidget {
/// Latest text to display (truncated to the available width at render
/// time).
text: String,
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.
// Keep one sender alive for scheduling frames.
_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,
}
}
@@ -83,61 +52,22 @@ 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());
// 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();
let mut header_spans: Vec<Span<'static>> = shimmer_spans("Working");
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,4 +1,9 @@
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.
@@ -12,3 +17,21 @@ 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);
}
}