Skip to content

Commit 3eff990

Browse files
CSResselnori-agent
andauthored
feat(tui): add terminal title throbber for tab activity (#396)
## Summary - Adds animated braille spinner in terminal tab title when Nori is actively working (task running, MCP startup) - Shows project name (from cwd) as idle title; spinner prefix when busy - Gated on `config.animations`; title cleared on exit via Drop ## Implementation - New `terminal_title.rs` module: OSC 0 escape sequences, title sanitization (control chars, bidi, truncation), spinner frame computation - `ChatWidget` integration: `refresh_terminal_title()` called on lifecycle events (`on_task_started`, `on_task_complete`, `on_session_configured`, `on_mcp_startup_complete`) - Demand-driven animation: self-schedules next frame via `FrameRequester::schedule_frame_in()` during `pre_draw_tick()` - Write deduplication via cached `last_terminal_title` to avoid redundant OSC writes ## Test plan - [x] 10 unit tests covering sanitization, spinner cycling, title composition - [x] All 1045 nori-tui tests pass - [x] Clippy clean (`just fix -p nori-tui`) - [x] Manual verification: title updates visible in terminal emulator tabs Co-authored-by: Nori <contact@tilework.tech>
1 parent 13554f3 commit 3eff990

9 files changed

Lines changed: 335 additions & 0 deletions

File tree

codex-rs/tui/docs.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,18 @@ When the user selects an agent (or resumes a session), the TUI shows a "Connecti
604604

605605
When the agent begins processing a task, the `StatusIndicatorWidget` displays an animated header with a randomly selected tongue-in-cheek message (e.g., "Thinking really hard", "Hallucinating responsibly") drawn from the `WHIMSICAL_STATUS_MESSAGES` pool via `random_status_message()`. A new random message is selected each time `on_task_started()` fires in `chatwidget/event_handlers.rs`. During streaming, reasoning chunk headers (extracted from bold markdown text) dynamically replace this initial message via `update_status_header()`.
606606

607+
**Terminal Title Management (`terminal_title.rs`, `chatwidget/helpers.rs`):**
608+
609+
The TUI sets the terminal window/tab title via OSC 0 escape sequences so users can see whether Nori is idle or working at a glance, even when the tab is not focused. The title is written directly to stdout via crossterm's `execute!` macro with a custom `SetWindowTitle` command implementation -- this bypasses the ratatui draw buffer entirely.
610+
611+
When the agent is working (`mcp_startup_status` is present or `bottom_pane.is_task_running()` is true), an animated braille dot-spinner (`SPINNER_FRAMES`, 10 frames at 100ms intervals) appears before the project name in the title bar. When idle, only the project name (derived from `config.cwd`) is shown. The animation is gated on `config.animations` -- when disabled, the spinner is suppressed but the project name still appears.
612+
613+
The animation is demand-driven rather than timer-based: each `refresh_terminal_title()` call schedules the next frame via `FrameRequester::schedule_frame_in(100ms)`, and `pre_draw_tick()` (called before every frame in the `TuiEvent::Draw` handler in `app/event_handling.rs`) advances the spinner only when progress is active. This creates a self-stopping loop -- when progress ends, no further frames are scheduled. Title writes are deduplicated via a `last_terminal_title: Option<String>` cache to avoid redundant OSC writes.
614+
615+
`refresh_terminal_title()` is hooked into `on_session_configured()`, `on_task_started()`, `on_task_complete()`, and `on_mcp_startup_complete()` in `chatwidget/event_handlers.rs`. The title is cleared (set to empty string) on `ChatWidget` drop. The module does not attempt to save or restore the terminal's previous title because that is not portable across terminals.
616+
617+
Title content is sanitized by `sanitize_terminal_title()` which strips control characters, bidi overrides, zero-width characters, and collapses whitespace, with a 240-character cap.
618+
607619
**Exit Path When Backend Is Dead:**
608620

609621
Every error/timeout/shutdown arm in the `tokio::select!` explicitly calls `drop(codex_op_rx)` before returning. This closes the receiver end of the channel so that `codex_op_tx` (held by `ChatWidget`) has no listener. If the user then attempts to exit (via `/exit`, `/quit`, or Ctrl-C), `submit_op(Op::Shutdown)` detects the dead channel (the `send()` returns `Err`) and falls back to sending `AppEvent::ExitRequest` directly via `app_event_tx`. This ensures the TUI can always exit cleanly even when no backend is running.

codex-rs/tui/src/app/event_handling.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ impl App {
2222
self.chat_widget.handle_paste(pasted);
2323
}
2424
TuiEvent::Draw => {
25+
self.chat_widget.pre_draw_tick();
2526
self.chat_widget.maybe_post_pending_notification(tui);
2627
if self
2728
.chat_widget

codex-rs/tui/src/chatwidget/constructors.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ impl ChatWidget {
9898
turn_finished: false,
9999
plan_drawer_mode: PlanDrawerMode::Off,
100100
pinned_plan: None,
101+
terminal_title_animation_origin: std::time::Instant::now(),
102+
last_terminal_title: None,
101103
};
102104

103105
widget.prefetch_rate_limits();
@@ -201,6 +203,8 @@ impl ChatWidget {
201203
turn_finished: false,
202204
plan_drawer_mode: PlanDrawerMode::Off,
203205
pinned_plan: None,
206+
terminal_title_animation_origin: std::time::Instant::now(),
207+
last_terminal_title: None,
204208
};
205209

206210
widget.prefetch_rate_limits();

codex-rs/tui/src/chatwidget/event_handlers.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ impl ChatWidget {
5252
if !self.suppress_session_configured_redraw {
5353
self.request_redraw();
5454
}
55+
self.refresh_terminal_title();
5556
}
5657

5758
pub(super) fn on_agent_message(&mut self, message: String) {
@@ -183,6 +184,7 @@ impl ChatWidget {
183184
self.reasoning_buffer.clear();
184185
self.turn_finished = false;
185186
self.request_redraw();
187+
self.refresh_terminal_title();
186188
}
187189

188190
pub(super) fn on_task_complete(&mut self, last_agent_message: Option<String>) {
@@ -213,6 +215,7 @@ impl ChatWidget {
213215
self.suppressed_exec_calls.clear();
214216
self.last_unified_wait = None;
215217
self.request_redraw();
218+
self.refresh_terminal_title();
216219

217220
// Refresh system info (including git branch) on task completion.
218221
// This catches any branch changes that occurred during the agent's turn.
@@ -398,6 +401,7 @@ impl ChatWidget {
398401
self.bottom_pane.set_task_running(false);
399402
self.maybe_send_next_queued_input();
400403
self.request_redraw();
404+
self.refresh_terminal_title();
401405
}
402406

403407
/// Handle a turn aborted due to user interrupt (Esc).

codex-rs/tui/src/chatwidget/helpers.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,4 +259,79 @@ impl ChatWidget {
259259
);
260260
RenderableItem::Owned(Box::new(flex))
261261
}
262+
263+
// --- Terminal title management ---
264+
265+
/// Returns the project name derived from the working directory.
266+
fn project_name(&self) -> String {
267+
self.config
268+
.cwd
269+
.file_name()
270+
.map(|name| name.to_string_lossy().to_string())
271+
.unwrap_or_default()
272+
}
273+
274+
/// Whether the terminal title spinner should animate right now.
275+
pub(crate) fn should_animate_terminal_title_spinner(&self) -> bool {
276+
self.config.animations && self.terminal_title_has_active_progress()
277+
}
278+
279+
/// Whether there is active progress that warrants showing the spinner.
280+
fn terminal_title_has_active_progress(&self) -> bool {
281+
self.mcp_startup_status.is_some() || self.bottom_pane.is_task_running()
282+
}
283+
284+
/// Recompute and write the terminal title. Schedules the next animation
285+
/// frame if the spinner is active.
286+
pub(crate) fn refresh_terminal_title(&mut self) {
287+
let now = Instant::now();
288+
let spinner_frame = if self.should_animate_terminal_title_spinner() {
289+
Some(crate::terminal_title::spinner_frame_at(
290+
self.terminal_title_animation_origin,
291+
now,
292+
))
293+
} else {
294+
None
295+
};
296+
297+
let project = self.project_name();
298+
if project.is_empty() {
299+
return;
300+
}
301+
302+
let title = crate::terminal_title::compose_title(&project, spinner_frame);
303+
304+
// Skip redundant writes.
305+
if self.last_terminal_title.as_deref() == Some(&title) {
306+
// Still schedule the next frame so the animation continues.
307+
if spinner_frame.is_some() {
308+
self.frame_requester
309+
.schedule_frame_in(crate::terminal_title::SPINNER_INTERVAL);
310+
}
311+
return;
312+
}
313+
314+
if let Err(err) = crate::terminal_title::set_terminal_title(&title) {
315+
tracing::debug!(error = %err, "failed to set terminal title");
316+
}
317+
self.last_terminal_title = Some(title);
318+
319+
if spinner_frame.is_some() {
320+
self.frame_requester
321+
.schedule_frame_in(crate::terminal_title::SPINNER_INTERVAL);
322+
}
323+
}
324+
325+
/// Clear the managed terminal title and reset the cache.
326+
pub(crate) fn clear_managed_terminal_title(&mut self) -> std::io::Result<()> {
327+
self.last_terminal_title = None;
328+
crate::terminal_title::clear_terminal_title()
329+
}
330+
331+
/// Called before every frame draw to advance the terminal title spinner.
332+
pub(crate) fn pre_draw_tick(&mut self) {
333+
if self.should_animate_terminal_title_spinner() {
334+
self.refresh_terminal_title();
335+
}
336+
}
262337
}

codex-rs/tui/src/chatwidget/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::path::PathBuf;
55
use std::sync::Arc;
66
#[allow(unused_imports)]
77
use std::time::Duration;
8+
use std::time::Instant;
89

910
#[allow(unused_imports)]
1011
use codex_app_server_protocol::AuthMode;
@@ -426,6 +427,11 @@ pub(crate) struct ChatWidget {
426427
/// pinned plan drawer when enabled; retained when disabled so toggling
427428
/// the drawer on shows the most recent plan immediately.
428429
pinned_plan: Option<UpdatePlanArgs>,
430+
431+
// Terminal title state: baseline instant for computing spinner frame index.
432+
terminal_title_animation_origin: Instant,
433+
// Terminal title state: cache to avoid redundant OSC writes.
434+
last_terminal_title: Option<String>,
429435
}
430436

431437
/// Information about a pending agent switch in ChatWidget.
@@ -471,6 +477,9 @@ impl ChatWidget {}
471477
impl Drop for ChatWidget {
472478
fn drop(&mut self) {
473479
self.stop_rate_limit_poller();
480+
if let Err(err) = self.clear_managed_terminal_title() {
481+
tracing::debug!(error = %err, "failed to clear terminal title on drop");
482+
}
474483
}
475484
}
476485

codex-rs/tui/src/chatwidget/tests/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ pub(crate) fn make_chatwidget_manual() -> (
312312
turn_finished: false,
313313
plan_drawer_mode: PlanDrawerMode::Off,
314314
pinned_plan: None,
315+
terminal_title_animation_origin: std::time::Instant::now(),
316+
last_terminal_title: None,
315317
};
316318
(widget, rx, op_rx)
317319
}

codex-rs/tui/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ mod streaming;
7777
mod style;
7878
mod system_info;
7979
mod terminal_palette;
80+
mod terminal_title;
8081
mod text_formatting;
8182
mod tui;
8283
mod ui_consts;

0 commit comments

Comments
 (0)