This commit is contained in:
Ahmed Ibrahim
2026-01-08 00:02:08 -08:00
parent f2e64e9fa5
commit b9e443f0c4
9 changed files with 108 additions and 92 deletions
+8
View File
@@ -87,6 +87,8 @@ pub enum Feature {
ShellSnapshot,
/// Experimental TUI v2 (viewport) implementation.
Tui2,
/// Experimental entertainment mode for the status shimmer.
Entertainment,
/// Enforce UTF8 output in Powershell.
PowershellUtf8,
}
@@ -380,4 +382,10 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Entertainment,
key: "entertainment",
stage: Stage::Experimental,
default_enabled: false,
},
];
-2
View File
@@ -17,8 +17,6 @@ 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
+13
View File
@@ -78,6 +78,7 @@ pub(crate) struct BottomPane {
ctrl_c_quit_hint: bool,
esc_backtrack_hint: bool,
animations_enabled: bool,
entertainment_enabled: bool,
/// Inline status indicator shown above the composer while a task is running.
status: Option<StatusIndicatorWidget>,
@@ -97,6 +98,7 @@ pub(crate) struct BottomPaneParams {
pub(crate) placeholder_text: String,
pub(crate) disable_paste_burst: bool,
pub(crate) animations_enabled: bool,
pub(crate) entertainment_enabled: bool,
pub(crate) skills: Option<Vec<SkillMetadata>>,
}
@@ -110,6 +112,7 @@ impl BottomPane {
placeholder_text,
disable_paste_burst,
animations_enabled,
entertainment_enabled,
skills,
} = params;
let mut composer = ChatComposer::new(
@@ -134,6 +137,7 @@ impl BottomPane {
queued_user_messages: QueuedUserMessages::new(),
esc_backtrack_hint: false,
animations_enabled,
entertainment_enabled,
context_window_percent: None,
context_window_used_tokens: None,
}
@@ -353,6 +357,7 @@ impl BottomPane {
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
self.entertainment_enabled,
));
}
if let Some(status) = self.status.as_mut() {
@@ -379,6 +384,7 @@ impl BottomPane {
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
self.entertainment_enabled,
));
self.request_redraw();
}
@@ -636,6 +642,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
entertainment_enabled: false,
skills: Some(Vec::new()),
});
pane.push_approval_request(exec_request(), &features);
@@ -659,6 +666,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
entertainment_enabled: false,
skills: Some(Vec::new()),
});
@@ -693,6 +701,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
entertainment_enabled: false,
skills: Some(Vec::new()),
});
@@ -760,6 +769,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
entertainment_enabled: false,
skills: Some(Vec::new()),
});
@@ -787,6 +797,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
entertainment_enabled: false,
skills: Some(Vec::new()),
});
@@ -818,6 +829,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
entertainment_enabled: false,
skills: Some(Vec::new()),
});
@@ -846,6 +858,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
entertainment_enabled: false,
skills: Some(Vec::new()),
});
+2
View File
@@ -1448,6 +1448,7 @@ impl ChatWidget {
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
entertainment_enabled: config.features.enabled(Feature::Entertainment),
skills: None,
}),
active_cell: None,
@@ -1535,6 +1536,7 @@ impl ChatWidget {
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
entertainment_enabled: config.features.enabled(Feature::Entertainment),
skills: None,
}),
active_cell: None,
+1
View File
@@ -363,6 +363,7 @@ async fn make_chatwidget_manual(
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: cfg.animations,
entertainment_enabled: cfg.features.enabled(Feature::Entertainment),
skills: None,
});
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
-1
View File
@@ -68,7 +68,6 @@ mod resume_picker;
mod selection_list;
mod session_log;
mod shimmer;
#[cfg(feature = "entertainment")]
mod shimmer_text;
mod slash_command;
mod status;
+1 -1
View File
@@ -216,7 +216,7 @@ impl ShimmerText {
}
pub(crate) fn next_interval(&mut self, base: Duration) -> Duration {
let multiplier = self.rng.random_range(0.5..=1.25);
let multiplier = self.rng.random_range(0.4..=1.0);
Duration::from_secs_f64(base.as_secs_f64() * multiplier)
}
+72 -82
View File
@@ -1,16 +1,10 @@
#[cfg(feature = "entertainment")]
use std::cell::Cell;
use std::cell::RefCell;
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 {
@@ -18,10 +12,14 @@ pub(crate) struct RenderHeader {
pub(crate) text: String,
}
#[cfg(feature = "entertainment")]
pub(crate) struct StatusShimmer {
entertainment_enabled: bool,
header: String,
use_shimmer_text: bool,
entertainment: Option<EntertainmentState>,
}
struct EntertainmentState {
use_shimmer_text: Cell<bool>,
shimmer_text: RefCell<ShimmerText>,
shimmer_face_cache: RefCell<String>,
shimmer_text_cache: RefCell<String>,
@@ -29,111 +27,103 @@ pub(crate) struct StatusShimmer {
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 new(now: Instant, entertainment_enabled: bool) -> Self {
if entertainment_enabled {
let mut shimmer_text = ShimmerText::new();
let shimmer_step = shimmer_text.get_next();
let shimmer_interval = shimmer_text.next_interval(SHIMMER_TEXT_INTERVAL);
let entertainment = EntertainmentState {
use_shimmer_text: Cell::new(true),
shimmer_text: RefCell::new(shimmer_text),
shimmer_face_cache: RefCell::new(shimmer_step.face),
shimmer_text_cache: RefCell::new(shimmer_step.text.clone()),
last_shimmer_update: Cell::new(now),
shimmer_interval: Cell::new(shimmer_interval),
};
Self {
entertainment_enabled,
header: shimmer_step.text,
entertainment: Some(entertainment),
}
} else {
Self {
entertainment_enabled,
header: String::from("Working"),
entertainment: None,
}
}
}
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;
self.header = header.clone();
if !self.entertainment_enabled {
return;
}
let Some(state) = self.entertainment.as_ref() else {
return;
};
let was_shimmer = state.use_shimmer_text.get();
let use_shimmer = state.shimmer_text.borrow().is_default_label(&header);
state.use_shimmer_text.set(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
let next = state.shimmer_text.borrow_mut().reset_and_get_next();
self.set_shimmer_step(state, next);
let next_interval = state
.shimmer_text
.borrow_mut()
.next_interval(SHIMMER_TEXT_INTERVAL);
self.shimmer_interval.set(next_interval);
state.shimmer_interval.set(next_interval);
}
self.last_shimmer_update.set(Instant::now());
} else {
self.header = header;
state.last_shimmer_update.set(Instant::now());
}
}
#[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()
if let Some(state) = self.entertainment.as_ref()
&& state.use_shimmer_text.get()
{
return state.shimmer_text_cache.borrow().clone();
}
self.header.clone()
}
pub(crate) fn render_header(&self, now: Instant) -> RenderHeader {
if !self.use_shimmer_text {
let Some(state) = self.entertainment.as_ref() else {
return RenderHeader {
face: Some(self.shimmer_face_cache.borrow().clone()),
face: None,
text: self.header.clone(),
};
};
if !state.use_shimmer_text.get() {
return RenderHeader {
face: Some(state.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
let elapsed = now.saturating_duration_since(state.last_shimmer_update.get());
if elapsed >= state.shimmer_interval.get() {
let next = state.shimmer_text.borrow_mut().get_next();
self.set_shimmer_step(state, next);
state.last_shimmer_update.set(now);
let next_interval = state
.shimmer_text
.borrow_mut()
.next_interval(SHIMMER_TEXT_INTERVAL);
self.shimmer_interval.set(next_interval);
state.shimmer_interval.set(next_interval);
}
RenderHeader {
face: Some(self.shimmer_face_cache.borrow().clone()),
text: self.shimmer_text_cache.borrow().clone(),
face: Some(state.shimmer_face_cache.borrow().clone()),
text: state.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(),
}
fn set_shimmer_step(&self, state: &EntertainmentState, step: crate::shimmer_text::ShimmerStep) {
*state.shimmer_face_cache.borrow_mut() = step.face;
*state.shimmer_text_cache.borrow_mut() = step.text;
}
}
+11 -6
View File
@@ -67,10 +67,11 @@ impl StatusIndicatorWidget {
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
animations_enabled: bool,
entertainment_enabled: bool,
) -> Self {
let now = Instant::now();
Self {
shimmer: StatusShimmer::new(now),
shimmer: StatusShimmer::new(now, entertainment_enabled),
details: None,
show_interrupt_hint: true,
elapsed_running: Duration::ZERO,
@@ -280,7 +281,8 @@ mod tests {
fn renders_with_working_header() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let w =
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, false);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
@@ -294,7 +296,8 @@ mod tests {
fn renders_truncated() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let w =
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, false);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
@@ -308,7 +311,8 @@ mod tests {
fn renders_wrapped_details_panama_two_lines() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false);
let mut w =
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false, false);
w.update_details(Some("A man a plan a canal panama".to_string()));
w.set_interrupt_hint_visible(false);
@@ -330,7 +334,7 @@ mod tests {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut widget =
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, false);
let baseline = Instant::now();
widget.last_resume_at = baseline;
@@ -351,7 +355,8 @@ mod tests {
fn details_overflow_adds_ellipsis() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let mut w =
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, false);
w.update_details(Some("abcd abcd abcd abcd".to_string()));
let lines = w.wrapped_details_lines(6);