This commit is contained in:
easong-openai
2025-08-03 11:47:25 -07:00
parent ea1312c90f
commit 1f1f149948
14 changed files with 278 additions and 381 deletions

View File

@@ -220,11 +220,7 @@ impl App<'_> {
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
}
AppEvent::LiveStatusRevealComplete => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.on_live_status_reveal_complete();
}
}
AppEvent::LiveStatusRevealComplete => {}
AppEvent::KeyEvent(key_event) => {
match key_event {
KeyEvent {

View File

@@ -39,13 +39,6 @@ pub(crate) trait BottomPaneView<'a> {
ConditionalUpdate::NoRedraw
}
/// Restart the live status animation with fresh text. Returns true if the
/// view handled the restart itself (e.g. status indicator view), false if
/// the caller should fall back to another mechanism (e.g. overlay).
fn restart_live_status_with_text(&mut self, _pane: &mut BottomPane<'a>, _text: String) -> bool {
false
}
/// Called when task completes to check if the view should be hidden.
fn should_hide_when_task_is_done(&mut self) -> bool {
false

View File

@@ -12,26 +12,34 @@ pub(crate) struct LiveRingWidget {
impl LiveRingWidget {
pub fn new() -> Self {
Self { max_rows: 3, rows: Vec::new() }
Self {
max_rows: 3,
rows: Vec::new(),
}
}
pub fn set_max_rows(&mut self, n: u16) { self.max_rows = n.max(1); }
pub fn set_max_rows(&mut self, n: u16) {
self.max_rows = n.max(1);
}
pub fn set_rows(&mut self, rows: Vec<Line<'static>>) { self.rows = rows; }
pub fn set_rows(&mut self, rows: Vec<Line<'static>>) {
self.rows = rows;
}
pub fn desired_height(&self, _width: u16) -> u16 {
let len = self.rows.len() as u16;
len.min(self.max_rows).max(0)
len.min(self.max_rows)
}
}
impl WidgetRef for LiveRingWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 { return; }
if area.height == 0 {
return;
}
let visible = self.rows.len().saturating_sub(self.max_rows as usize);
let slice = &self.rows[visible..];
let para = Paragraph::new(slice.to_vec());
para.render_ref(area, buf);
}
}

View File

@@ -17,8 +17,8 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
mod file_search_popup;
mod status_indicator_view;
mod live_ring_widget;
mod status_indicator_view;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
@@ -101,7 +101,11 @@ impl BottomPane<'_> {
if let Some(view) = self.active_view.as_ref() {
// Add a single blank spacer line between live ring and status view when active.
let spacer = if self.live_ring.is_some() && self.status_view_active { 1 } else { 0 };
let spacer = if self.live_ring.is_some() && self.status_view_active {
1
} else {
0
};
overlay_status_h
.saturating_add(ring_h)
.saturating_add(spacer)
@@ -199,7 +203,6 @@ impl BottomPane<'_> {
self.request_redraw();
}
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
self.ctrl_c_quit_hint = true;
self.composer
@@ -319,7 +322,11 @@ impl BottomPane<'_> {
}
/// Set the rows and cap for the transient live ring overlay.
pub(crate) fn set_live_ring_rows(&mut self, max_rows: u16, rows: Vec<ratatui::text::Line<'static>>) {
pub(crate) fn set_live_ring_rows(
&mut self,
max_rows: u16,
rows: Vec<ratatui::text::Line<'static>>,
) {
let mut w = live_ring_widget::LiveRingWidget::new();
w.set_max_rows(max_rows);
w.set_rows(rows);
@@ -330,59 +337,7 @@ impl BottomPane<'_> {
self.live_ring = None;
}
/// Clear the live status cell (e.g., when the streamed text has been
/// inserted into history and we no longer need the inline preview).
pub(crate) fn clear_live_status(&mut self) {
self.live_status = None;
self.request_redraw();
}
/// Restart the live status animation for the next entry. Prefer taking
/// over the composer when possible; if another view is active (e.g. a
/// modal), fall back to using the overlay so animation can continue.
pub(crate) fn restart_live_status_with_text(&mut self, text: String) {
// Try to restart in the active view (if it's the status view).
let mut handled = false;
if let Some(mut view) = self.active_view.take() {
if view.restart_live_status_with_text(self, text.clone()) {
handled = true;
}
self.status_view_active = true;
self.active_view = Some(view);
} else {
// No view create a fresh status view which replaces the composer.
let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
v.restart_with_text(text);
self.active_view = Some(Box::new(v));
self.status_view_active = true;
self.request_redraw();
return;
}
if handled {
self.request_redraw();
return;
}
// Fallback: show a fresh overlay widget if another view is active.
self.live_status = Some(crate::status_indicator_widget::StatusIndicatorWidget::new(
self.app_event_tx.clone(),
));
if let Some(status) = &mut self.live_status {
status.restart_with_text(text);
}
self.request_redraw();
}
/// Remove the active StatusIndicatorView (composer takeover) if present,
/// restoring the composer for user input.
pub(crate) fn clear_status_view(&mut self) {
if self.status_view_active {
self.active_view = None;
self.status_view_active = false;
self.request_redraw();
}
}
// Removed restart_live_status_with_text no longer used by the current streaming UI.
}
impl WidgetRef for &BottomPane<'_> {
@@ -391,7 +346,12 @@ impl WidgetRef for &BottomPane<'_> {
if let Some(ring) = &self.live_ring {
let live_h = ring.desired_height(area.width).min(area.height);
if live_h > 0 {
let live_rect = Rect { x: area.x, y: area.y, width: area.width, height: live_h };
let live_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: live_h,
};
ring.render_ref(live_rect, buf);
y_offset = live_h;
}
@@ -435,8 +395,7 @@ impl WidgetRef for &BottomPane<'_> {
width: area.width,
// Reserve bottom padding
height: (area.height - y_offset)
- BottomPane::BOTTOM_PAD_LINES
.min((area.height - y_offset).saturating_sub(1)),
- BottomPane::BOTTOM_PAD_LINES.min((area.height - y_offset).saturating_sub(1)),
};
(&self.composer).render_ref(composer_rect, buf);
}
@@ -447,7 +406,9 @@ impl WidgetRef for &BottomPane<'_> {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use ratatui::{buffer::Buffer, layout::Rect, text::Line};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use std::path::PathBuf;
use std::sync::mpsc::channel;
@@ -504,12 +465,23 @@ mod tests {
let mut lines: Vec<String> = Vec::new();
for y in 0..3 {
let mut s = String::new();
for x in 0..area.width { s.push(buf.get(x, y).symbol().chars().next().unwrap_or(' ')); }
for x in 0..area.width {
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
lines.push(s.trim_end().to_string());
}
assert!(lines[0].contains("two"), "top row should be 'two': {lines:?}");
assert!(lines[1].contains("three"), "middle row should be 'three': {lines:?}");
assert!(lines[2].contains("four"), "bottom row should be 'four': {lines:?}");
assert!(
lines[0].contains("two"),
"top row should be 'two': {lines:?}"
);
assert!(
lines[1].contains("three"),
"middle row should be 'three': {lines:?}"
);
assert!(
lines[2].contains("four"),
"bottom row should be 'four': {lines:?}"
);
}
#[test]
@@ -530,7 +502,10 @@ mod tests {
// status indicator remains visible below them.
pane.set_live_ring_rows(
2,
vec![Line::from("cot1".to_string()), Line::from("cot2".to_string())],
vec![
Line::from("cot1".to_string()),
Line::from("cot2".to_string()),
],
);
// Allow some frames so the dot animation is present.
@@ -553,7 +528,9 @@ mod tests {
// Row 2 is the spacer (blank)
let mut r2 = String::new();
for x in 0..area.width { r2.push(buf[(x, 2)].symbol().chars().next().unwrap_or(' ')); }
for x in 0..area.width {
r2.push(buf[(x, 2)].symbol().chars().next().unwrap_or(' '));
}
assert!(r2.trim().is_empty(), "expected blank spacer line: {r2:?}");
// Bottom row is the status line; it should contain the left bar and "Working [".
@@ -562,7 +539,10 @@ mod tests {
r3.push(buf[(x, 3)].symbol().chars().next().unwrap_or(' '));
}
assert_eq!(buf[(0, 3)].symbol().chars().next().unwrap_or(' '), '▌');
assert!(r3.contains("Working ["), "expected spinner prefix in status line: {r3:?}");
assert!(
r3.contains("Working ["),
"expected spinner prefix in status line: {r3:?}"
);
}
#[test]
@@ -581,16 +561,24 @@ mod tests {
// Use height == desired_height; expect 1 status row at top and 2 bottom padding rows.
let height = pane.desired_height(30);
assert!(height >= 3, "expected at least 3 rows with bottom padding; got {height}");
assert!(
height >= 3,
"expected at least 3 rows with bottom padding; got {height}"
);
let area = Rect::new(0, 0, 30, height);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
// Top row contains the spinner
let mut top = String::new();
for x in 0..area.width { top.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); }
for x in 0..area.width {
top.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
}
assert_eq!(buf[(0, 0)].symbol().chars().next().unwrap_or(' '), '▌');
assert!(top.contains("Working ["), "expected spinner on top row: {top:?}");
assert!(
top.contains("Working ["),
"expected spinner on top row: {top:?}"
);
// Bottom two rows are blank padding
let mut r_last = String::new();
@@ -599,8 +587,14 @@ mod tests {
r_last.push(buf[(x, height - 1)].symbol().chars().next().unwrap_or(' '));
r_last2.push(buf[(x, height - 2)].symbol().chars().next().unwrap_or(' '));
}
assert!(r_last.trim().is_empty(), "expected last row blank: {r_last:?}");
assert!(r_last2.trim().is_empty(), "expected second-to-last row blank: {r_last2:?}");
assert!(
r_last.trim().is_empty(),
"expected last row blank: {r_last:?}"
);
assert!(
r_last2.trim().is_empty(),
"expected second-to-last row blank: {r_last2:?}"
);
}
#[test]
@@ -626,15 +620,26 @@ mod tests {
row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' '));
row1.push(buf2[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert!(row0.contains("Working ["), "expected spinner on row 0: {row0:?}");
assert!(row1.trim().is_empty(), "expected bottom padding on row 1: {row1:?}");
assert!(
row0.contains("Working ["),
"expected spinner on row 0: {row0:?}"
);
assert!(
row1.trim().is_empty(),
"expected bottom padding on row 1: {row1:?}"
);
// Height=1 → no padding; single row is the spinner.
let area1 = Rect::new(0, 0, 20, 1);
let mut buf1 = Buffer::empty(area1);
(&pane).render_ref(area1, &mut buf1);
let mut only = String::new();
for x in 0..area1.width { only.push(buf1[(x, 0)].symbol().chars().next().unwrap_or(' ')); }
assert!(only.contains("Working ["), "expected spinner only with no padding: {only:?}");
for x in 0..area1.width {
only.push(buf1[(x, 0)].symbol().chars().next().unwrap_or(' '));
}
assert!(
only.contains("Working ["),
"expected spinner only with no padding: {only:?}"
);
}
}

View File

@@ -21,10 +21,6 @@ impl StatusIndicatorView {
pub fn update_text(&mut self, text: String) {
self.view.update_text(text);
}
pub fn restart_with_text(&mut self, text: String) {
self.view.restart_with_text(text);
}
}
impl BottomPaneView<'_> for StatusIndicatorView {
@@ -33,15 +29,6 @@ impl BottomPaneView<'_> for StatusIndicatorView {
ConditionalUpdate::NeedsRedraw
}
fn restart_live_status_with_text(
&mut self,
_pane: &mut super::BottomPane<'_>,
text: String,
) -> bool {
self.restart_with_text(text);
true
}
fn should_hide_when_task_is_done(&mut self) -> bool {
true
}

View File

@@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@@ -44,9 +43,9 @@ use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::CommandOutput;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use crate::live_wrap::RowBuilder;
use crate::user_approval_widget::ApprovalRequest;
use codex_file_search::FileMatch;
use crate::live_wrap::RowBuilder;
use ratatui::style::Stylize;
struct RunningCommand {
@@ -68,9 +67,6 @@ pub(crate) struct ChatWidget<'a> {
// at once into scrollback so the history contains a single message.
answer_buffer: String,
running_commands: HashMap<String, RunningCommand>,
pending_commits: VecDeque<PendingHistoryCommit>,
queued_status_text: Option<String>,
defer_task_stop: bool,
live_builder: RowBuilder,
current_stream: Option<StreamKind>,
stream_header_emitted: bool,
@@ -82,18 +78,6 @@ struct UserMessage {
image_paths: Vec<PathBuf>,
}
enum PendingHistoryCommit {
AgentMessage(String),
AgentReasoning(String),
/// Generic deferred history commit with a preview string to animate
/// in the live cell before committing the full `HistoryCell` to
/// scrollback.
HistoryCellWithPreview {
cell: HistoryCell,
preview: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StreamKind {
Answer,
@@ -180,9 +164,6 @@ impl ChatWidget<'_> {
reasoning_buffer: String::new(),
answer_buffer: String::new(),
running_commands: HashMap::new(),
pending_commits: VecDeque::new(),
queued_status_text: None,
defer_task_stop: false,
live_builder: RowBuilder::new(80),
current_stream: None,
stream_header_emitted: false,
@@ -216,20 +197,6 @@ impl ChatWidget<'_> {
.send(AppEvent::InsertHistory(cell.plain_lines()));
}
/// Queue a history cell to be inserted after the current typewriter
/// animation completes. If no animation is active, start one now.
fn queue_commit_with_preview(&mut self, cell: HistoryCell, preview: String) {
self.pending_commits
.push_back(PendingHistoryCommit::HistoryCellWithPreview {
cell,
preview: preview.clone(),
});
if self.pending_commits.len() == 1 {
self.bottom_pane.restart_live_status_with_text(preview);
}
self.request_redraw();
}
fn submit_user_message(&mut self, user_message: UserMessage) {
let UserMessage { text, image_paths } = user_message;
let mut items: Vec<InputItem> = Vec::new();
@@ -323,14 +290,8 @@ impl ChatWidget<'_> {
EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: _,
}) => {
if self.pending_commits.is_empty() {
self.bottom_pane.set_task_running(false);
self.bottom_pane.clear_live_ring();
} else {
// Defer stopping the task UI until after the final
// animated commit has been written to history.
self.defer_task_stop = true;
}
self.bottom_pane.set_task_running(false);
self.bottom_pane.clear_live_ring();
self.request_redraw();
}
EventMsg::TokenCount(token_usage) => {
@@ -388,13 +349,6 @@ impl ChatWidget<'_> {
// approval dialog) and avoids surprising the user with a modal
// prompt before they have seen *what* is being requested.
// ------------------------------------------------------------------
let file_count = changes.len();
let reason_suffix = reason
.as_ref()
.map(|r| format!(" {r}"))
.unwrap_or_default();
let preview =
format!("patch approval requested for {file_count} file(s){reason_suffix}");
self.add_to_history(HistoryCell::new_patch_event(
PatchEventType::ApprovalRequest,
changes,
@@ -414,7 +368,6 @@ impl ChatWidget<'_> {
command,
cwd,
}) => {
let cmdline = strip_bash_lc_and_escape(&command);
self.running_commands.insert(
call_id,
RunningCommand {
@@ -430,11 +383,6 @@ impl ChatWidget<'_> {
auto_approved,
changes,
}) => {
let prefix = if auto_approved {
"applying patch (auto-approved)"
} else {
"applying patch"
};
self.add_to_history(HistoryCell::new_patch_event(
PatchEventType::ApplyBegin { auto_approved },
changes,
@@ -462,15 +410,6 @@ impl ChatWidget<'_> {
call_id: _,
invocation,
}) => {
// Build brief one-line invocation summary before moving `invocation`.
let args_str = invocation
.arguments
.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()))
.unwrap_or_default();
let server = invocation.server.clone();
let tool = invocation.tool.clone();
let preview = format!("MCP {server}.{tool}({args_str})");
self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation));
}
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
@@ -514,13 +453,7 @@ impl ChatWidget<'_> {
/// Update the live log preview while a task is running.
pub(crate) fn update_latest_log(&mut self, line: String) {
// If we have pending commits waiting to be flushed, hold off on
// updating the live cell so the current entry can finish its animation.
if !self.pending_commits.is_empty() {
self.queued_status_text = Some(line);
} else {
self.bottom_pane.update_status_text(line);
}
self.bottom_pane.update_status_text(line);
}
fn request_redraw(&mut self) {
@@ -536,51 +469,6 @@ impl ChatWidget<'_> {
self.bottom_pane.on_file_search_result(query, matches);
}
/// Called by the app when the live status widget has fully revealed its
/// current text. We then commit the corresponding pending entry to
/// history and, if another entry is waiting, start animating it next.
pub(crate) fn on_live_status_reveal_complete(&mut self) {
// If there are no pending commits, we are simply showing the waiting
// spinner; keep it visible and do nothing.
let Some(pending) = self.pending_commits.pop_front() else { return };
match pending {
PendingHistoryCommit::AgentMessage(text) => {
self.add_to_history(HistoryCell::new_agent_message(&self.config, text));
}
PendingHistoryCommit::AgentReasoning(text) => {
self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, text));
}
PendingHistoryCommit::HistoryCellWithPreview { cell, .. } => {
self.add_to_history(cell);
}
}
// If there is another pending entry, start animating it fresh. We do
// not remove it from the queue yet; we will commit it on the next
// completion callback.
if let Some(next) = self.pending_commits.front() {
let text = match next {
PendingHistoryCommit::AgentMessage(t) | PendingHistoryCommit::AgentReasoning(t) => {
t.clone()
}
PendingHistoryCommit::HistoryCellWithPreview { preview, .. } => preview.clone(),
};
self.bottom_pane.restart_live_status_with_text(text);
} else if let Some(queued) = self.queued_status_text.take() {
self.bottom_pane.update_status_text(queued);
} else {
// No more animated entries; leave the waiting spinner in place until
// TaskComplete arrives. If TaskComplete was deferred, clear it now.
if self.defer_task_stop {
self.bottom_pane.set_task_running(false);
self.defer_task_stop = false;
}
}
self.request_redraw();
}
/// Handle Ctrl-C key press.
/// Returns CancellationEvent::Handled if the event was consumed by the UI, or
/// CancellationEvent::Ignored if the caller should handle it (e.g. exit).
@@ -651,9 +539,7 @@ impl ChatWidget<'_> {
if !self.stream_header_emitted {
match self.current_stream {
Some(StreamKind::Reasoning) => {
lines.push(ratatui::text::Line::from(
"thinking".magenta().italic(),
));
lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
}
Some(StreamKind::Answer) => {
lines.push(ratatui::text::Line::from("codex".magenta().bold()));
@@ -675,7 +561,8 @@ impl ChatWidget<'_> {
.into_iter()
.map(|r| ratatui::text::Line::from(r.text))
.collect::<Vec<_>>();
self.bottom_pane.set_live_ring_rows(self.live_max_rows, rows);
self.bottom_pane
.set_live_ring_rows(self.live_max_rows, rows);
}
fn finalize_stream(&mut self, kind: StreamKind) {
@@ -686,14 +573,15 @@ impl ChatWidget<'_> {
// Flush any partial line as a full row, then drain all remaining rows.
self.live_builder.end_line();
let remaining = self.live_builder.drain_rows();
// TODO: Re-add markdown rendering for assistant answers and reasoning.
// When finalizing, pass the accumulated text through `markdown::append_markdown`
// to build styled `Line<'static>` entries instead of raw plain text lines.
if !remaining.is_empty() || !self.stream_header_emitted {
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
if !self.stream_header_emitted {
match kind {
StreamKind::Reasoning => {
lines.push(ratatui::text::Line::from(
"thinking".magenta().italic(),
));
lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
}
StreamKind::Answer => {
lines.push(ratatui::text::Line::from("codex".magenta().bold()));

View File

@@ -1,5 +1,4 @@
use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::append_markdown;
use crate::text_block::TextBlock;
use crate::text_formatting::format_and_truncate_tool_result;
use base64::Engine;
@@ -68,12 +67,7 @@ pub(crate) enum HistoryCell {
/// Message from the user.
UserPrompt { view: TextBlock },
/// Message from the agent.
AgentMessage { view: TextBlock },
/// Reasoning event from the agent.
AgentReasoning { view: TextBlock },
// AgentMessage and AgentReasoning variants were unused and have been removed.
/// An exec tool call that has not finished yet.
ActiveExecCommand { view: TextBlock },
@@ -128,8 +122,6 @@ impl HistoryCell {
match self {
HistoryCell::WelcomeMessage { view }
| HistoryCell::UserPrompt { view }
| HistoryCell::AgentMessage { view }
| HistoryCell::AgentReasoning { view }
| HistoryCell::BackgroundEvent { view }
| HistoryCell::GitDiffOutput { view }
| HistoryCell::ErrorEvent { view }
@@ -231,27 +223,7 @@ impl HistoryCell {
}
}
pub(crate) fn new_agent_message(config: &Config, message: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("codex".magenta().bold()));
append_markdown(&message, &mut lines, config);
lines.push(Line::from(""));
HistoryCell::AgentMessage {
view: TextBlock::new(lines),
}
}
pub(crate) fn new_agent_reasoning(config: &Config, text: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("thinking".magenta().italic()));
append_markdown(&text, &mut lines, config);
lines.push(Line::from(""));
HistoryCell::AgentReasoning {
view: TextBlock::new(lines),
}
}
// Removed unused new_agent_message and new_agent_reasoning constructors.
pub(crate) fn new_active_exec_command(command: Vec<String>) -> Self {
let command_escaped = strip_bash_lc_and_escape(&command);

View File

@@ -126,7 +126,9 @@ fn line_height(line: &Line, width: u16) -> u16 {
while !remaining.is_empty() {
let (_prefix, suffix, taken) = crate::live_wrap::take_prefix_by_width(&remaining, w);
rows = rows.saturating_add(1);
if taken >= remaining.len() { break; }
if taken >= remaining.len() {
break;
}
remaining = suffix.to_string();
}
rows.max(1)

View File

@@ -38,11 +38,11 @@ mod history_cell;
pub mod insert_history;
#[cfg(not(feature = "vt100-tests"))]
mod insert_history;
pub mod live_wrap;
mod log_layer;
mod markdown;
mod slash_command;
mod status_indicator_widget;
pub mod live_wrap;
mod text_block;
mod text_formatting;
mod tui;

View File

@@ -1,4 +1,5 @@
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
/// A single visual row produced by RowBuilder.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -94,7 +95,10 @@ impl RowBuilder {
pub fn display_rows(&self) -> Vec<Row> {
let mut out = self.rows.clone();
if !self.current_line.is_empty() {
out.push(Row { text: self.current_line.clone(), explicit_break: false });
out.push(Row {
text: self.current_line.clone(),
explicit_break: false,
});
}
out
}
@@ -103,7 +107,9 @@ impl RowBuilder {
/// current partial line, if any). Returns the drained rows in order.
pub fn drain_commit_ready(&mut self, max_keep: usize) -> Vec<Row> {
let display_count = self.rows.len() + if self.current_line.is_empty() { 0 } else { 1 };
if display_count <= max_keep { return Vec::new(); }
if display_count <= max_keep {
return Vec::new();
}
let to_commit = display_count - max_keep;
let commit_count = to_commit.min(self.rows.len());
let mut drained = Vec::with_capacity(commit_count);
@@ -121,12 +127,18 @@ impl RowBuilder {
if explicit_break {
if self.current_line.is_empty() {
// We ended on a boundary previously; add an empty explicit row.
self.rows.push(Row { text: String::new(), explicit_break: true });
self.rows.push(Row {
text: String::new(),
explicit_break: true,
});
} else {
// There is leftover content that did not wrap yet; push it now with the explicit flag.
let mut s = String::new();
std::mem::swap(&mut s, &mut self.current_line);
self.rows.push(Row { text: s, explicit_break: true });
self.rows.push(Row {
text: s,
explicit_break: true,
});
}
}
// Reset current line buffer for next logical line.
@@ -139,13 +151,17 @@ impl RowBuilder {
if self.current_line.is_empty() {
break;
}
let (prefix, suffix, taken) = take_prefix_by_width(&self.current_line, self.target_width);
let (prefix, suffix, taken) =
take_prefix_by_width(&self.current_line, self.target_width);
if taken == 0 {
// Avoid infinite loop on pathological inputs; take one scalar and continue.
if let Some((i, ch)) = self.current_line.char_indices().next() {
let len = i + ch.len_utf8();
let p = self.current_line[..len].to_string();
self.rows.push(Row { text: p, explicit_break: false });
self.rows.push(Row {
text: p,
explicit_break: false,
});
self.current_line = self.current_line[len..].to_string();
continue;
}
@@ -156,7 +172,10 @@ impl RowBuilder {
break;
} else {
// Emit wrapped prefix as a non-explicit row and continue with the remainder.
self.rows.push(Row { text: prefix, explicit_break: false });
self.rows.push(Row {
text: prefix,
explicit_break: false,
});
self.current_line = suffix.to_string();
}
}
@@ -198,7 +217,7 @@ mod tests {
let rows = rb.rows();
assert!(!rows.is_empty());
for r in rows {
assert!(r.width() <= 10, "row exceeds width: {:?}", r);
assert!(r.width() <= 10, "row exceeds width: {r:?}");
}
}
@@ -208,7 +227,7 @@ mod tests {
let mut rb = RowBuilder::new(6);
rb.push_fragment("😀😀 你好");
for r in rb.rows() {
assert!(r.width() <= 6, "row exceeds width: {:?}", r);
assert!(r.width() <= 6, "row exceeds width: {r:?}");
}
}
@@ -220,8 +239,9 @@ mod tests {
let all_rows = rb_all.rows().to_vec();
let mut rb_chunks = RowBuilder::new(7);
for chunk in s.as_bytes().chunks(3) {
rb_chunks.push_fragment(std::str::from_utf8(chunk).unwrap());
for i in (0..s.len()).step_by(3) {
let end = (i + 3).min(s.len());
rb_chunks.push_fragment(&s[i..end]);
}
let chunk_rows = rb_chunks.rows().to_vec();
@@ -243,7 +263,7 @@ mod tests {
fn rewrap_on_width_change() {
let mut rb = RowBuilder::new(10);
rb.push_fragment("abcdefghijK");
assert!(rb.rows().len() >= 1);
assert!(!rb.rows().is_empty());
rb.set_width(5);
for r in rb.rows() {
assert!(r.width() <= 5);

View File

@@ -1,3 +1,4 @@
use crate::citation_regex::CITATION_REGEX;
use codex_core::config::Config;
use codex_core::config_types::UriBasedFileOpener;
use ratatui::text::Line;
@@ -5,8 +6,7 @@ use ratatui::text::Span;
use std::borrow::Cow;
use std::path::Path;
use crate::citation_regex::CITATION_REGEX;
#[allow(dead_code)]
pub(crate) fn append_markdown(
markdown_source: &str,
lines: &mut Vec<Line<'static>>,
@@ -15,6 +15,7 @@ pub(crate) fn append_markdown(
append_markdown_with_opener_and_cwd(markdown_source, lines, config.file_opener, &config.cwd);
}
#[allow(dead_code)]
fn append_markdown_with_opener_and_cwd(
markdown_source: &str,
lines: &mut Vec<Line<'static>>,
@@ -60,6 +61,7 @@ fn append_markdown_with_opener_and_cwd(
/// ```text
/// <scheme>://file<ABS_PATH>:<LINE>
/// ```
#[allow(dead_code)]
fn rewrite_file_citations<'a>(
src: &'a str,
file_opener: UriBasedFileOpener,

View File

@@ -12,16 +12,10 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
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::Padding;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent;
@@ -91,7 +85,9 @@ impl StatusIndicatorWidget {
}
}
pub fn desired_height(&self, _width: u16) -> u16 { 1 }
pub fn desired_height(&self, _width: u16) -> u16 {
1
}
/// Update the line that is displayed in the widget.
pub(crate) fn update_text(&mut self, text: String) {
@@ -125,6 +121,7 @@ impl StatusIndicatorWidget {
}
/// Reset the animation and start revealing `text` from the beginning.
#[cfg(test)]
pub(crate) fn restart_with_text(&mut self, text: String) {
let sanitized = text.replace(['\n', '\r'], " ");
let stripped = {
@@ -170,7 +167,9 @@ impl Drop for StatusIndicatorWidget {
impl WidgetRef for StatusIndicatorWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Ensure minimal height
if area.height == 0 || area.width == 0 { return; }
if area.height == 0 || area.width == 0 {
return;
}
// Plain rendering: no borders or padding so the live cell is visually
// indistinguishable from terminal scrollback. No left bar.
let inner_width = area.width as usize;
@@ -198,7 +197,12 @@ impl WidgetRef for StatusIndicatorWidget {
let mut used = 0usize;
for s in spans {
let w = s.content.width();
if used + w <= inner_width { acc.push(s); used += w; } else { break; }
if used + w <= inner_width {
acc.push(s);
used += w;
} else {
break;
}
}
let lines = vec![Line::from(acc)];
@@ -221,69 +225,6 @@ impl WidgetRef for StatusIndicatorWidget {
}
}
/// Strip ANSI escapes from a multi-line string.
fn strip_ansi_all(s: &str) -> String {
s.split('\n')
.map(|line| {
let l = ansi_escape_line(line);
l.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<Vec<_>>()
.join("")
})
.collect::<Vec<_>>()
.join("\n")
}
/// Hard-wrap plain text to a given terminal width using display cells.
fn wrap_plain_text_to_width(s: &str, width: usize) -> Vec<Line<'static>> {
let w = width.max(1);
let mut out: Vec<Line<'static>> = Vec::new();
for raw_line in s.split('\n') {
if raw_line.is_empty() {
out.push(Line::from(String::new()));
continue;
}
let mut remaining = raw_line;
while !remaining.is_empty() {
let (prefix, suffix, taken_w) = take_prefix_by_width(remaining, w);
out.push(Line::from(Span::raw(prefix)));
if taken_w >= remaining.width() {
break;
}
remaining = suffix;
}
}
if out.is_empty() {
out.push(Line::from(String::new()));
}
out
}
/// Take a prefix of `s` whose display width is at most `max_cols` terminal cells.
/// Returns (prefix, suffix, prefix_width).
fn take_prefix_by_width(s: &str, max_cols: usize) -> (String, &str, usize) {
if max_cols == 0 || s.is_empty() {
return (String::new(), s, 0);
}
let mut cols = 0usize;
let mut end_idx = 0usize;
for (i, ch) in s.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if cols.saturating_add(ch_width) > max_cols {
break;
}
cols += ch_width;
end_idx = i + ch.len_utf8();
}
let prefix = s[..end_idx].to_string();
let suffix = &s[end_idx..];
(prefix, suffix, cols)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -304,7 +245,7 @@ mod tests {
let mut buf = ratatui::buffer::Buffer::empty(area);
w.render_ref(area, &mut buf);
// Leftmost column has the left bar
// Leftmost column has the left bar
let ch0 = buf[(0, 0)].symbol().chars().next().unwrap_or(' ');
assert_eq!(ch0, '▌', "expected left bar at col 0: {ch0:?}");
}
@@ -324,9 +265,14 @@ mod tests {
// Single line; it should contain "Working [" and closing "]" and the provided text.
let mut row = String::new();
for x in 0..area.width { row.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); }
for x in 0..area.width {
row.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
}
assert!(row.contains("Working ["), "expected status prefix: {row:?}");
assert!(row.contains("]"), "expected bracket: {row:?}");
assert!(row.contains("Hi"), "expected provided text in status: {row:?}");
assert!(
row.contains("Hi"),
"expected provided text in status: {row:?}"
);
}
}

View File

@@ -2,9 +2,10 @@
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::style::{Color, Style};
/// HIST-001: Basic insertion at bottom, no wrap.
///
@@ -16,7 +17,10 @@ use ratatui::style::{Color, Style};
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();
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
// Place the viewport at the bottom row
let area = Rect::new(0, 5, 20, 1);
@@ -55,15 +59,24 @@ fn hist_001_basic_insertion_no_wrap() {
// 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}");
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 mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
@@ -83,20 +96,29 @@ fn hist_002_long_token_wraps() {
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; }
if ch == 'A' {
count_a += 1;
}
}
}
}
}
assert_eq!(count_a, long.len(), "wrapped content did not preserve all characters");
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 mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
@@ -117,14 +139,19 @@ fn hist_003_emoji_and_cjk() {
if let Some(cell) = screen.cell(row, col) {
let cont = cell.contents();
if let Some(ch) = cont.chars().next() {
if ch != ' ' { reconstructed.push(ch); }
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);
assert!(
reconstructed.contains(ch),
"missing character {ch:?} in reconstructed screen"
);
}
}
@@ -132,7 +159,10 @@ fn hist_003_emoji_and_cjk() {
#[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 mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
@@ -154,7 +184,11 @@ fn hist_004_mixed_ansi_spans() {
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(' '); }
if let Some(ch) = cont.chars().next() {
s.push(ch);
} else {
s.push(' ');
}
} else {
s.push(' ');
}
@@ -162,14 +196,20 @@ fn hist_004_mixed_ansi_spans() {
rows.push(s);
}
let joined = rows.join("\n");
assert!(joined.contains("red+plain"), "styled text did not render as expected\n{joined}");
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 mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
@@ -180,16 +220,25 @@ fn hist_006_cursor_restoration() {
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:?}");
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:?}");
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();
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
// Viewport not at bottom: y=3 (0-based), height=1
let area = Rect::new(0, 3, 20, 1);
term.set_viewport_area(area);
@@ -200,12 +249,24 @@ fn hist_005_pre_scroll_region_down() {
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:?}");
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:?}");
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:?}");
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:?}");
assert!(
s.contains("\u{1b}[1;5r"),
"expected insertion SetScrollRegion 1..5, got: {s:?}"
);
}

View File

@@ -7,7 +7,10 @@ use ratatui::text::Line;
#[test]
fn live_001_commit_on_overflow() {
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend).unwrap();
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
@@ -47,15 +50,24 @@ fn live_001_commit_on_overflow() {
}
joined.push('\n');
}
assert!(joined.contains("one"), "expected committed 'one' to be visible\n{joined}");
assert!(joined.contains("two"), "expected committed 'two' to be visible\n{joined}");
assert!(
joined.contains("one"),
"expected committed 'one' to be visible\n{joined}"
);
assert!(
joined.contains("two"),
"expected committed 'two' to be visible\n{joined}"
);
// The last three (three,four,five) remain in the live ring, not committed here.
}
#[test]
fn live_002_pre_scroll_and_commit() {
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend).unwrap();
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
// Viewport not at bottom: y=3
let area = Rect::new(0, 3, 20, 1);
term.set_viewport_area(area);
@@ -78,7 +90,12 @@ fn live_002_pre_scroll_and_commit() {
let s = String::from_utf8_lossy(&buf);
// Expect a SetScrollRegion to [area.top()+1 .. screen_height] and a cursor move to top of that region.
assert!(s.contains("\u{1b}[4;6r"), "expected pre-scroll region 4..6, got: {s:?}");
assert!(s.contains("\u{1b}[4;1H"), "expected cursor CUP 4;1H, got: {s:?}");
assert!(
s.contains("\u{1b}[4;6r"),
"expected pre-scroll region 4..6, got: {s:?}"
);
assert!(
s.contains("\u{1b}[4;1H"),
"expected cursor CUP 4;1H, got: {s:?}"
);
}