This commit is contained in:
Ahmed Ibrahim
2026-01-07 23:49:27 -08:00
parent 187924d761
commit f2e64e9fa5
10 changed files with 473 additions and 43 deletions
+2
View File
@@ -17,6 +17,8 @@ path = "src/lib.rs"
vt100-tests = []
# Gate verbose debug logging inside the TUI implementation.
debug-logs = []
# Enable entertainment-only shimmer behavior in the status indicator.
entertainment = []
[lints]
workspace = true
+23 -6
View File
@@ -340,6 +340,8 @@ pub(crate) struct ChatWidget {
reasoning_buffer: String,
// Accumulates full reasoning content for transcript-only recording
full_reasoning_buffer: String,
// Tracks whether the current reasoning section header has been emitted to history.
reasoning_header_emitted: bool,
// Current status header shown in the status indicator.
current_status_header: String,
// Previous status header to restore after a transient stream retry.
@@ -523,18 +525,28 @@ impl ChatWidget {
fn on_agent_reasoning_delta(&mut self, delta: String) {
// For reasoning deltas, do not stream to history. Accumulate the
// current reasoning block and extract the first bold element
// (between **/**) as the chunk header. Show this header as status.
// (between **/**) as the chunk header. Emit this header as a history entry.
self.reasoning_buffer.push_str(&delta);
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
// Update the shimmer header to the extracted reasoning chunk header.
self.set_status_header(header);
} else {
// Fallback while we don't yet have a bold header: leave existing header as-is.
if !self.reasoning_header_emitted {
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
self.emit_reasoning_header(header);
self.reasoning_header_emitted = true;
}
}
self.request_redraw();
}
fn emit_reasoning_header(&mut self, header: String) {
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
append_markdown(&format!("**{header}**"), None, &mut rendered);
if rendered.is_empty() {
return;
}
let cell = AgentMessageCell::new(rendered, true);
self.add_boxed_history(Box::new(cell));
}
fn on_agent_reasoning_final(&mut self) {
// At the end of a reasoning block, record transcript-only content.
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
@@ -545,6 +557,7 @@ impl ChatWidget {
}
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.reasoning_header_emitted = false;
self.request_redraw();
}
@@ -553,6 +566,7 @@ impl ChatWidget {
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
self.full_reasoning_buffer.push_str("\n\n");
self.reasoning_buffer.clear();
self.reasoning_header_emitted = false;
}
// Raw reasoning uses the same flow as summarized reasoning
@@ -565,6 +579,7 @@ impl ChatWidget {
self.set_status_header(String::from("Working"));
self.full_reasoning_buffer.clear();
self.reasoning_buffer.clear();
self.reasoning_header_emitted = false;
self.request_redraw();
}
@@ -1461,6 +1476,7 @@ impl ChatWidget {
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
reasoning_header_emitted: false,
current_status_header: String::from("Working"),
retry_status_header: None,
thread_id: None,
@@ -1547,6 +1563,7 @@ impl ChatWidget {
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
reasoning_header_emitted: false,
current_status_header: String::from("Working"),
retry_status_header: None,
thread_id: None,
@@ -2,6 +2,31 @@
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• I’m going to search the repo for where “Change Approved” is rendered to update
that view.
@@ -9,7 +34,9 @@ expression: term.backend().vt100().screen().contents()
└ Search Change Approved
Read diff_render.rs
• Investigating rendering code (0s • esc to interrupt)
• Investigating rendering code
• Working (0s • esc to interrupt)
› Summarize recent commits
@@ -2,6 +2,7 @@
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
↳ Hello, world! 0
↳ Hello, world! 1
@@ -1,10 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1577
expression: terminal.backend()
---
" "
"• Analyzing (0s • esc to interrupt) "
"• Working (0s • esc to interrupt) "
" "
" "
"› Ask Codex to do anything "
+3 -22
View File
@@ -394,6 +394,7 @@ async fn make_chatwidget_manual(
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
reasoning_header_emitted: false,
current_status_header: String::from("Working"),
retry_status_header: None,
thread_id: None,
@@ -2713,12 +2714,6 @@ async fn ui_snapshots_small_heights_task_running() {
model_context_window: None,
}),
});
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "**Thinking**".into(),
}),
});
for h in [1u16, 2, 3] {
let name = format!("chat_small_running_h{h}");
let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal");
@@ -2744,13 +2739,6 @@ async fn status_widget_and_approval_modal_snapshot() {
model_context_window: None,
}),
});
// Provide a deterministic header for the status line.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "**Analyzing**".into(),
}),
});
// Now show an approval modal (e.g. exec approval).
let ev = ExecApprovalRequestEvent {
@@ -2796,13 +2784,6 @@ async fn status_widget_active_snapshot() {
model_context_window: None,
}),
});
// Provide a deterministic header via a bold reasoning chunk.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "**Analyzing**".into(),
}),
});
// Render and snapshot.
let height = chat.desired_height(80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
@@ -3343,7 +3324,7 @@ async fn stream_error_updates_status_indicator() {
.bottom_pane
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), msg);
assert_eq!(status.header(), msg.to_string());
assert_eq!(status.details(), Some(details));
}
@@ -3396,7 +3377,7 @@ async fn stream_recovery_restores_previous_status_header() {
.bottom_pane
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), "Working");
assert_eq!(status.header(), "Working".to_string());
assert_eq!(status.details(), None);
assert!(chat.retry_status_header.is_none());
}
+3
View File
@@ -68,8 +68,11 @@ mod resume_picker;
mod selection_list;
mod session_log;
mod shimmer;
#[cfg(feature = "entertainment")]
mod shimmer_text;
mod slash_command;
mod status;
mod status_indicator_shimmer;
mod status_indicator_widget;
mod streaming;
mod style;
+251
View File
@@ -0,0 +1,251 @@
use rand::Rng;
use rand::SeedableRng;
use rand::rngs::StdRng;
use std::time::Duration;
const DEFINITION_ARCS: &[&[&str]] = &[
&[
"And now, the moment.",
"I am doing the thing.",
"On that stubborn page.",
"To calm the spinner.",
"With one better check.",
"And one sweeter line.",
"Here we go again.",
"For real this time.",
],
&[
"No more looping.",
"No more coping.",
"Promise.",
"Pinky swear.",
"Cross my heart.",
"If it loops, I'll cry.",
"If it works, I'll fly.",
"Ok, focus.",
],
&[
"Starting vibes...",
"Starting logic...",
"Starting regret...",
"Spinning politely.",
"Caching bravely.",
"Fetching gently.",
"Retrying softly.",
"Still retrying.",
],
&[
"This is fine.",
"This is code.",
"This is hope.",
"This is rope.",
"Tugging the thread.",
"Oops, it's dread.",
"Kidding. Mostly.",
],
&[
"Compiling courage.",
"Linking feelings.",
"Bundling dreams.",
"Shipping screams.",
"Hydrating hopes.",
"Revalidating jokes.",
],
&[
"Negotiating with React.",
"Begging the router.",
"Asking state nicely.",
"State said \"no.\"",
"State said \"lol.\"",
"Ok that's rude.",
],
&[
"Back to build.",
"Build is life.",
"Build is love.",
"Build is joy.",
],
&[
"No more looping.",
"No more snooping.",
"No more duping.",
"Serious promise.",
"Serious-serious.",
"Double pinky.",
"Triple pinky.",
"Tap the keyboard.",
"Seal the commit.",
"Ok I'm calm.",
"I'm not calm.",
"I'm calm again.",
],
&[
"Optimism loaded.",
"Optimism unloaded.",
"Joy is async.",
"Sadness is sync.",
"Hope is pending.",
"Dread is trending.",
"It passed locally.",
"Eventually.",
"I trust the tests.",
"The tests hate me.",
"Ok that got dark.",
"Ok that got funny.",
],
&[
"Back to coding.",
"Coding is light.",
"Coding is life.",
"Coding is joy.",
],
];
const FACE_SEQUENCES: &[&[&str]] = &[
&["._.", "^_^", "^-^"],
&["^-^", "^_^", "^o^"],
&["^_^", "o_o", "O_O"],
&["o_o", "O_o", "o_O"],
&["o_O", "@_@", "x_x"],
&["x_x", "-_-", "._."],
&["._.", "-_-", ">_>"],
&[">_>", "<_<", ">_<"],
&[">_<", "^_^", "-_-"],
&["#_#", "^_^", "._."],
&["$_$", "o_O", "._."],
&["._.", "._.", "^_^"],
&["#_#", "^_^", "^.^"],
&["^.^", "^_^", "^-^"],
&["^_^", "T_T", "^_^"],
&["^_^", "@_@", "^_^"],
&["0_0", "o_o", "O_O"],
&["O_O", "o_o", "._."],
&["^_^", "^-^", "^o^"],
&["O_O", "^w^", "^_^"],
&["._.", "!_!", "^_^"],
&["-_-", "T_T", "._."],
&["@_@", "0_0", "o_o"],
&[">_>", "._.", "^-^"],
&["o_o", "._.", "^_^"],
];
#[derive(Debug, Clone)]
pub(crate) struct ShimmerStep {
pub(crate) face: String,
pub(crate) text: String,
}
#[derive(Debug)]
pub(crate) struct ShimmerText {
definition_arc_index: usize,
definition_item_index: usize,
face_arc_index: usize,
face_item_index: usize,
rng: StdRng,
}
impl Default for ShimmerText {
fn default() -> Self {
Self::new()
}
}
impl ShimmerText {
pub(crate) fn new() -> Self {
let mut rng = Self::seeded_rng();
let definition_arc_index = Self::pick_arc(&mut rng, None, DEFINITION_ARCS.len());
let face_arc_index = Self::pick_arc(&mut rng, None, FACE_SEQUENCES.len());
Self {
definition_arc_index,
definition_item_index: 0,
face_arc_index,
face_item_index: 0,
rng,
}
}
pub(crate) fn get_next(&mut self) -> ShimmerStep {
let text_arc = DEFINITION_ARCS[self.definition_arc_index];
let face_arc = FACE_SEQUENCES[self.face_arc_index];
let text = text_arc[self.definition_item_index];
let face = face_arc[self.face_item_index];
self.face_item_index += 1;
if self.face_item_index >= face_arc.len() {
self.face_item_index = 0;
self.definition_item_index += 1;
self.face_arc_index = Self::pick_arc(
&mut self.rng,
Some(self.face_arc_index),
FACE_SEQUENCES.len(),
);
if self.definition_item_index >= text_arc.len() {
self.definition_item_index = 0;
self.definition_arc_index = Self::pick_arc(
&mut self.rng,
Some(self.definition_arc_index),
DEFINITION_ARCS.len(),
);
}
}
ShimmerStep {
face: face.to_string(),
text: text.to_string(),
}
}
pub(crate) fn reset_and_get_next(&mut self) -> ShimmerStep {
self.definition_arc_index = Self::pick_arc(
&mut self.rng,
Some(self.definition_arc_index),
DEFINITION_ARCS.len(),
);
self.face_arc_index = Self::pick_arc(
&mut self.rng,
Some(self.face_arc_index),
FACE_SEQUENCES.len(),
);
self.definition_item_index = 0;
self.face_item_index = 0;
self.get_next()
}
pub(crate) fn is_default_label(&self, text: &str) -> bool {
text == "Working"
}
pub(crate) fn next_interval(&mut self, base: Duration) -> Duration {
let multiplier = self.rng.random_range(0.5..=1.25);
Duration::from_secs_f64(base.as_secs_f64() * multiplier)
}
fn pick_arc(rng: &mut StdRng, current: Option<usize>, count: usize) -> usize {
if count <= 1 {
return 0;
}
if let Some(current) = current {
loop {
let next = rng.random_range(0..count);
if next != current {
return next;
}
}
}
rng.random_range(0..count)
}
#[cfg(test)]
fn seeded_rng() -> StdRng {
StdRng::seed_from_u64(1)
}
#[cfg(not(test))]
fn seeded_rng() -> StdRng {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
StdRng::seed_from_u64(nanos as u64)
}
}
@@ -0,0 +1,139 @@
#[cfg(feature = "entertainment")]
use std::time::Duration;
use std::time::Instant;
#[cfg(feature = "entertainment")]
use std::cell::Cell;
#[cfg(feature = "entertainment")]
use std::cell::RefCell;
#[cfg(feature = "entertainment")]
use crate::shimmer_text::ShimmerText;
#[cfg(feature = "entertainment")]
const SHIMMER_TEXT_INTERVAL: Duration = Duration::from_secs(1);
pub(crate) struct RenderHeader {
pub(crate) face: Option<String>,
pub(crate) text: String,
}
#[cfg(feature = "entertainment")]
pub(crate) struct StatusShimmer {
header: String,
use_shimmer_text: bool,
shimmer_text: RefCell<ShimmerText>,
shimmer_face_cache: RefCell<String>,
shimmer_text_cache: RefCell<String>,
last_shimmer_update: Cell<Instant>,
shimmer_interval: Cell<Duration>,
}
#[cfg(feature = "entertainment")]
impl StatusShimmer {
pub(crate) fn new(now: Instant) -> Self {
let mut shimmer_text = ShimmerText::new();
let shimmer_step = shimmer_text.get_next();
let shimmer_interval = shimmer_text.next_interval(SHIMMER_TEXT_INTERVAL);
Self {
header: shimmer_step.text.clone(),
use_shimmer_text: true,
shimmer_text: RefCell::new(shimmer_text),
shimmer_face_cache: RefCell::new(shimmer_step.face),
shimmer_text_cache: RefCell::new(shimmer_step.text),
last_shimmer_update: Cell::new(now),
shimmer_interval: Cell::new(shimmer_interval),
}
}
pub(crate) fn update_header(&mut self, header: String) {
let was_shimmer = self.use_shimmer_text;
let use_shimmer = self.shimmer_text.borrow().is_default_label(&header);
self.use_shimmer_text = use_shimmer;
if use_shimmer {
self.header = header.clone();
if !was_shimmer {
let next = self.shimmer_text.borrow_mut().reset_and_get_next();
self.set_shimmer_step(next);
let next_interval = self
.shimmer_text
.borrow_mut()
.next_interval(SHIMMER_TEXT_INTERVAL);
self.shimmer_interval.set(next_interval);
}
self.last_shimmer_update.set(Instant::now());
} else {
self.header = header;
}
}
#[cfg(test)]
pub(crate) fn header_for_test(&self) -> String {
if self.use_shimmer_text {
self.shimmer_text_cache.borrow().clone()
} else {
self.header.clone()
}
}
pub(crate) fn render_header(&self, now: Instant) -> RenderHeader {
if !self.use_shimmer_text {
return RenderHeader {
face: Some(self.shimmer_face_cache.borrow().clone()),
text: self.header.clone(),
};
}
let elapsed = now.saturating_duration_since(self.last_shimmer_update.get());
if elapsed >= self.shimmer_interval.get() {
let next = self.shimmer_text.borrow_mut().get_next();
self.set_shimmer_step(next);
self.last_shimmer_update.set(now);
let next_interval = self
.shimmer_text
.borrow_mut()
.next_interval(SHIMMER_TEXT_INTERVAL);
self.shimmer_interval.set(next_interval);
}
RenderHeader {
face: Some(self.shimmer_face_cache.borrow().clone()),
text: self.shimmer_text_cache.borrow().clone(),
}
}
fn set_shimmer_step(&self, step: crate::shimmer_text::ShimmerStep) {
*self.shimmer_face_cache.borrow_mut() = step.face;
*self.shimmer_text_cache.borrow_mut() = step.text;
}
}
#[cfg(not(feature = "entertainment"))]
pub(crate) struct StatusShimmer {
header: String,
}
#[cfg(not(feature = "entertainment"))]
impl StatusShimmer {
pub(crate) fn new(_now: Instant) -> Self {
Self {
header: String::from("Working"),
}
}
pub(crate) fn update_header(&mut self, header: String) {
self.header = header;
}
#[cfg(test)]
pub(crate) fn header_for_test(&self) -> String {
self.header.clone()
}
pub(crate) fn render_header(&self, _now: Instant) -> RenderHeader {
RenderHeader {
face: None,
text: self.header.clone(),
}
}
}
+22 -12
View File
@@ -22,6 +22,8 @@ use crate::exec_cell::spinner;
use crate::key_hint;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::status_indicator_shimmer::RenderHeader;
use crate::status_indicator_shimmer::StatusShimmer;
use crate::text_formatting::capitalize_first;
use crate::tui::FrameRequester;
use crate::wrapping::RtOptions;
@@ -31,8 +33,7 @@ const DETAILS_MAX_LINES: usize = 3;
const DETAILS_PREFIX: &str = " └ ";
pub(crate) struct StatusIndicatorWidget {
/// Animated header text (defaults to "Working").
header: String,
shimmer: StatusShimmer,
details: Option<String>,
show_interrupt_hint: bool,
@@ -67,12 +68,13 @@ impl StatusIndicatorWidget {
frame_requester: FrameRequester,
animations_enabled: bool,
) -> Self {
let now = Instant::now();
Self {
header: String::from("Working"),
shimmer: StatusShimmer::new(now),
details: None,
show_interrupt_hint: true,
elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(),
last_resume_at: now,
is_paused: false,
app_event_tx,
@@ -87,7 +89,7 @@ impl StatusIndicatorWidget {
/// Update the animated header label (left of the brackets).
pub(crate) fn update_header(&mut self, header: String) {
self.header = header;
self.shimmer.update_header(header);
}
/// Update the details text shown below the header.
@@ -98,8 +100,8 @@ impl StatusIndicatorWidget {
}
#[cfg(test)]
pub(crate) fn header(&self) -> &str {
&self.header
pub(crate) fn header(&self) -> String {
self.shimmer.header_for_test()
}
#[cfg(test)]
@@ -188,6 +190,10 @@ impl StatusIndicatorWidget {
out
}
fn shimmer_header(&self, now: Instant) -> RenderHeader {
self.shimmer.render_header(now)
}
}
impl Renderable for StatusIndicatorWidget {
@@ -206,14 +212,18 @@ impl Renderable for StatusIndicatorWidget {
let now = Instant::now();
let elapsed_duration = self.elapsed_duration_at(now);
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
let header = self.shimmer_header(now);
let mut spans = Vec::with_capacity(5);
spans.push(spinner(Some(self.last_resume_at), self.animations_enabled));
if let Some(face) = header.face {
spans.push(face.into());
} else {
spans.push(spinner(Some(self.last_resume_at), self.animations_enabled));
}
spans.push(" ".into());
if self.animations_enabled {
spans.extend(shimmer_spans(&self.header));
} else if !self.header.is_empty() {
spans.push(self.header.clone().into());
spans.extend(shimmer_spans(&header.text));
} else if !header.text.is_empty() {
spans.push(header.text.into());
}
spans.push(" ".into());
if self.show_interrupt_hint {