Refine hook activity rendering in the TUI (#42118)

## What changed

- Show revealed running hooks in the bottom activity area instead of the conversation viewport or transcript overlay. Fold the hook message into the active status row when space permits and reflow it below the row without displacing background-process controls or status details.
- Keep fast hooks hidden, collapse concurrent hooks to a shared summary when needed, and support hook activity outside an active agent turn.
- Simplify durable hook output into user-facing messages and actionable failure, block, and stop states. Suppress model-facing context and quiet successful completions from all TUI history surfaces.

## Testing

- Add lifecycle, layout, narrow-width, multi-width Unicode, transcript, restart, and completion-output coverage for hook status and history rendering.

GitOrigin-RevId: 4c52b7a736e9e466e109107eb3c04a39d089597f
This commit is contained in:
Abhinav
2026-09-01 19:06:32 +00:00
committed by copyberry
parent ea7e0714da
commit bc39b0ed59
35 changed files with 903 additions and 945 deletions
@@ -0,0 +1,32 @@
//! Transient hook activity, independent of whether an agent turn owns a status row.
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::motion::MotionMode;
use crate::motion::shimmer_text;
use crate::render::renderable::Renderable;
use crate::text_formatting::capitalize_first;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
pub(super) struct HookStatus<'a> {
pub message: &'a str,
pub animations_enabled: bool,
}
impl Renderable for HookStatus<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut spans = vec!["• ".dim()];
spans.extend(shimmer_text(
&capitalize_first(self.message),
MotionMode::from_animations_enabled(self.animations_enabled),
));
truncate_line_with_ellipsis_if_overflow(Line::from(spans).dim(), usize::from(area.width))
.render(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
+35 -3
View File
@@ -61,6 +61,7 @@ mod actionable_banner;
mod app_link_view;
mod apply_patch_header;
mod approval_overlay;
mod hook_status;
mod mcp_server_elicitation;
mod multi_select_picker;
mod request_user_input;
@@ -248,6 +249,8 @@ pub(crate) struct BottomPane {
/// Inline status indicator shown above the composer while a task is running.
status: Option<StatusIndicatorWidget>,
/// Running-hook summary supplied by the lifecycle owner after its reveal delay.
hook_status_message: Option<String>,
inline_banner: Option<actionable_banner::InlineBanner>,
/// Streaming may drop the row without losing its elapsed time or modal pause.
status_timer: crate::status_indicator_widget::StatusTimer,
@@ -321,6 +324,7 @@ impl BottomPane {
disable_paste_burst,
is_task_running: false,
status: None,
hook_status_message: None,
inline_banner: None,
status_timer: crate::status_indicator_widget::StatusTimer::default(),
unified_exec_footer: UnifiedExecFooter::new(),
@@ -1434,13 +1438,29 @@ impl BottomPane {
}
}
/// Copy unified-exec summary text into the active status row, if any.
/// Update hook activity after the lifecycle reveal delay, even outside a turn.
pub(crate) fn set_hook_status_message(&mut self, message: Option<String>) {
if self.hook_status_message == message {
return;
}
self.hook_status_message = message;
if self.hook_status_message.is_some() && self.status.is_none() && self.is_task_running() {
self.ensure_status_indicator();
} else {
self.sync_status_inline_message();
self.request_redraw();
}
}
/// Copy background activity and hook text into the active status row, if any.
///
/// This keeps status-line inline text synchronized without forcing the
/// standalone unified-exec footer row to be visible.
fn sync_status_inline_message(&mut self) {
if let Some(status) = self.status.as_mut() {
status.update_inline_message(self.unified_exec_footer.summary_text());
status.update_hook_status_message(self.hook_status_message.clone());
}
}
@@ -1877,6 +1897,17 @@ impl BottomPane {
RenderableItem::Owned(Box::new(status.with_timer(&self.status_timer))),
);
}
if self.status.is_none()
&& let Some(message) = &self.hook_status_message
{
flex.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(hook_status::HookStatus {
message,
animations_enabled: self.animations_enabled,
})),
);
}
// Avoid double-surfacing the same summary and avoid adding an extra
// row while the status line is already visible.
if self.status_widget().is_none() && !self.unified_exec_footer.is_empty() {
@@ -1889,8 +1920,9 @@ impl BottomPane {
let has_pending_input = !self.pending_input_preview.queued_messages.is_empty()
|| !self.pending_input_preview.pending_steers.is_empty()
|| !self.pending_input_preview.rejected_steers.is_empty();
let has_status_or_footer =
self.status_widget().is_some() || !self.unified_exec_footer.is_empty();
let has_status_or_footer = self.status_widget().is_some()
|| self.hook_status_message.is_some()
|| !self.unified_exec_footer.is_empty();
let has_inline_previews = has_pending_thread_approvals || has_pending_input;
if has_inline_previews && has_status_or_footer {
flex.push(/*flex*/ 0, RenderableItem::Owned("".into()));
+3 -20
View File
@@ -1902,7 +1902,7 @@ impl ChatWidget {
/// Returns a cache key describing the current in-flight cells for the transcript overlay.
///
/// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the
/// current active, hook, and asynchronous usage cells, and the overlay caches that tail; this
/// current active and asynchronous usage cells, and the overlay caches that tail; this
/// key is what it uses to decide whether it must recompute. When there are no live cells, this
/// returns `None` so the overlay can drop the tail entirely.
///
@@ -1911,14 +1911,9 @@ impl ChatWidget {
/// the main viewport updates.
pub(crate) fn active_cell_transcript_key(&self) -> Option<ActiveCellTranscriptKey> {
let cell = self.transcript.active_cell.as_ref();
let hook_cell = self.active_hook_cell.as_ref();
let token_activity_cell = self.pending_token_activity_output();
let rate_limit_reset_hint = self.pending_rate_limit_reset_hint();
if cell.is_none()
&& hook_cell.is_none()
&& token_activity_cell.is_none()
&& rate_limit_reset_hint.is_none()
{
if cell.is_none() && token_activity_cell.is_none() && rate_limit_reset_hint.is_none() {
return None;
}
Some(ActiveCellTranscriptKey {
@@ -1926,11 +1921,7 @@ impl ChatWidget {
is_stream_continuation: cell
.map(|cell| cell.is_stream_continuation())
.unwrap_or(false),
animation_tick: cell
.and_then(|cell| cell.transcript_animation_tick())
.or_else(|| {
hook_cell.and_then(super::history_cell::HistoryCell::transcript_animation_tick)
}),
animation_tick: cell.and_then(|cell| cell.transcript_animation_tick()),
})
}
@@ -1948,14 +1939,6 @@ impl ChatWidget {
if let Some(cell) = self.transcript.active_cell.as_ref() {
lines.extend(cell.transcript_hyperlink_lines(width));
}
if let Some(hook_cell) = self.active_hook_cell.as_ref() {
// Compute hook lines first so hidden hooks do not add a separator.
let hook_lines = hook_cell.transcript_hyperlink_lines(width);
if !hook_lines.is_empty() && !lines.is_empty() {
lines.push(HyperlinkLine::from(""));
}
lines.extend(hook_lines);
}
if let Some(token_activity_cell) = self.pending_token_activity_output() {
let token_activity_lines = token_activity_cell.transcript_hyperlink_lines(width);
if !token_activity_lines.is_empty() && !lines.is_empty() {
+16 -8
View File
@@ -1,13 +1,14 @@
//! Hook run lifecycle handling for `ChatWidget`.
//!
//! This module keeps active hook cells, hook timers, and hook completion output
//! together.
//! This module keeps hook timers, compact activity-row progress, and durable
//! hook completion output together.
use super::*;
impl ChatWidget {
/// Drop transient live hook status without flushing it into history.
pub(super) fn clear_active_hook_cell(&mut self) {
self.bottom_pane.set_hook_status_message(/*message*/ None);
if self.active_hook_cell.take().is_some() {
self.bump_active_cell_revision();
self.request_pending_usage_output_insertion();
@@ -23,13 +24,11 @@ impl ChatWidget {
self.bump_active_cell_revision();
}
None => {
self.active_hook_cell = Some(history_cell::new_active_hook_cell(
run,
self.config.animations,
));
self.active_hook_cell = Some(history_cell::new_active_hook_cell(run));
self.bump_active_cell_revision();
}
}
self.sync_hook_status_message();
self.request_redraw();
}
@@ -51,8 +50,7 @@ impl ChatWidget {
self.bump_active_cell_revision();
}
None => {
let cell =
history_cell::new_completed_hook_cell(completed, self.config.animations);
let cell = history_cell::new_completed_hook_cell(completed);
if !cell.is_empty() {
self.active_hook_cell = Some(cell);
self.bump_active_cell_revision();
@@ -62,6 +60,7 @@ impl ChatWidget {
}
self.flush_completed_hook_output();
self.finish_active_hook_cell_if_idle();
self.sync_hook_status_message();
self.request_redraw();
}
@@ -118,6 +117,15 @@ impl ChatWidget {
self.bump_active_cell_revision();
}
self.finish_active_hook_cell_if_idle();
self.sync_hook_status_message();
}
fn sync_hook_status_message(&mut self) {
let status_message = self
.active_hook_cell
.as_ref()
.and_then(HookCell::running_status_summary);
self.bottom_pane.set_hook_status_message(status_message);
}
pub(super) fn schedule_hook_timer_if_needed(&self) {
-12
View File
@@ -44,20 +44,8 @@ impl ChatWidget {
})),
None => RenderableItem::Owned(Box::new(())),
};
let active_hook_cell_renderable = match &self.active_hook_cell {
Some(cell) if cell.should_render() => {
RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
child: cell,
top: 1,
right: active_cell_right_reserve,
persistent_layout: None,
}))
}
_ => RenderableItem::Owned(Box::new(())),
};
let mut flex = FlexRenderable::new();
flex.push(/*flex*/ 1, active_cell_renderable);
flex.push(/*flex*/ 0, active_hook_cell_renderable);
if let Some(cell) = self.pending_token_activity_output() {
flex.push(
/*flex*/ 1,
@@ -1,11 +1,8 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"active hooks:\\n{}history:\\n{history}\", active_hook_blob(&chat))"
expression: history
---
active hooks:
<empty>
history:
• PreToolUse hook (blocked)
feedback: command blocked by policy
• Blocked by hook
└ command blocked by policy
• The hook feedback was applied.
@@ -3,14 +3,24 @@ source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"{running_snapshot}\\n\\n{completed_snapshot}\")"
---
running
live hooks:
• Running PreToolUse hook: checking command
activity:
" "
"• Checking command "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
history:
<empty>
completed
live hooks:
<empty>
activity:
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
history:
• PreToolUse hook (blocked)
feedback: command blocked by policy
• Blocked by hook
└ command blocked by policy
@@ -1,9 +1,16 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"active hooks:\\n{}history:\\n{history}\", active_hook_blob(&chat))"
expression: "hook_status_and_history_snapshot(&chat, \"restarted\", &history)"
---
active hooks:
• Running Stop hook: checking stop condition
restarted
activity:
" "
"• Checking stop condition "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
history:
• Stop hook (stopped)
stop: continue with more context
• Hook stopped
└ continue with more context
@@ -1,8 +1,18 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"before completion:\\n{before_completion}after completion:\\n{}\",\nactive_hook_blob(&chat))"
expression: "format!(\"before completion:\\n{before_completion}\\nafter completion:\\n{}\",\nhook_status_frame(&chat, 80))"
---
before completion:
• Running PostToolUse hook
" "
"• Working (0s • esc to interrupt) · Running hook "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
after completion:
<empty>
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
@@ -2,8 +2,8 @@
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: rendered
---
• PreToolUse hook (blocked)
feedback: run tests before touching the fixture
• Blocked by hook
└ run tests before touching the fixture
• PostToolUse hook (failed)
error: hook exited with code 7
• Hook failed
└ hook exited with code 7
@@ -1,10 +0,0 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"started hidden:\\n{started_hidden_snapshot}\\nhistory:\\n{history}\")"
---
started hidden:
history:
• SessionStart hook (completed)
hook context: session context
second line
@@ -1,21 +0,0 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"{running_snapshot}\\n\\n{completed_lingering_snapshot}\\n\\n{completed_snapshot}\")"
---
running
live hooks:
• Running PostToolUse hook
history:
<empty>
completed lingering
live hooks:
• Running PostToolUse hook
history:
<empty>
completed after linger
live hooks:
<empty>
history:
<empty>
@@ -1,25 +1,37 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"exec running:\\n{exec_running}\\nexec and hook running:\\n{exec_and_hook_running}\\nhistory after exec:\\n{history_after_exec}\\nhook running after exec:\\n{hook_running_after_exec}\\nquiet hook completed lingering:\\n{quiet_hook_completed_lingering}\\nquiet hook completed:\\n{quiet_hook_completed}\")"
expression: "format!(\"exec running:\\n{exec_running}\\nexec and hook running:\\n{exec_and_hook_running}\\nhistory after exec:\\n{history_after_exec}\\nhook running after exec:\\n{hook_running_after_exec}\\nquiet hook completed:\\n{quiet_hook_completed}\")"
---
exec running:
• Running echo done
exec and hook running:
active exec:
• Running echo done
active hooks:
• Running PostToolUse hook: checking output policy
" "
"• Running echo done "
" "
"• Working (0s • esc to interrupt) · checking output policy "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
history after exec:
• Ran echo done
└ done
hook running after exec:
• Running PostToolUse hook: checking output policy
quiet hook completed lingering:
• Running PostToolUse hook: checking output policy
" "
"• Working (0s • esc to interrupt) · checking output policy "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
quiet hook completed:
<empty>
" "
"• Working (0s • esc to interrupt) "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
@@ -1,9 +0,0 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "hook_live_and_history_snapshot(&chat, \"running\", \"\")"
---
running
live hooks:
• Running 3 PreToolUse hooks: checking command policy
history:
<empty>
@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests/helpers.rs
expression: combined
---
• Interrupt (completed) says: Heads up from the hook
↳ Hook · Heads up from the hook
@@ -1,8 +1,18 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"before interrupt:\\n{before_interrupt}after interrupt:\\n{}\",\nactive_hook_blob(&chat))"
expression: "format!(\"before interrupt:\\n{before_interrupt}\\nafter interrupt:\\n{}\",\nhook_status_frame(&chat, 80))"
---
before interrupt:
• Running PreToolUse hook: checking command policy
" "
"• Working (0s • esc to interrupt) · checking command policy "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
after interrupt:
<empty>
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
@@ -1,10 +0,0 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: history
---
• SessionStart hook (stopped)
hook context: This hook context is intentionally long enough to wrap across
several terminal rows while keeping the complete value available in the
… +2 lines (ctrl + t to view transcript)
stop: The hook stopped this turn for an important reason.
This second line must remain visible in full.
@@ -0,0 +1,22 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"80 columns:\\n{}\\n120 columns:\\n{}\", hook_status_frame(&chat, 80),\nhook_status_frame(&chat, 120),)"
---
80 columns:
" "
"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view…"
" └ checking command policy for deployment commands, production credentials, an…"
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
120 columns:
" "
"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close "
" └ checking command policy for deployment commands, production credentials, and paths containing 日本語 before continu…" Hidden by multi-width symbols: [(99, " "), (101, " "), (103, " ")]
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
@@ -0,0 +1,21 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"80 columns:\\n{}\\n120 columns:\\n{}\", hook_status_frame(&chat, 80),\nhook_status_frame(&chat, 120),)"
---
80 columns:
" "
"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view…"
" └ Running hooks "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
120 columns:
" "
"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close · Running hooks "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
@@ -1,43 +1,51 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"{first_running_snapshot}\\n\\n{second_running_snapshot}\\n\\n{older_completed_snapshot}\\n\\n{older_completed_expired_snapshot}\\n\\n{all_completed_lingering_snapshot}\\n\\n{all_completed_snapshot}\")"
expression: "format!(\"{first_running_snapshot}\\n\\n{second_running_snapshot}\\n\\n{older_completed_snapshot}\\n\\n{all_completed_snapshot}\")"
---
pre running
live hooks:
• Running PreToolUse hook: checking command policy
activity:
" "
"• Thinking (0s • esc to interrupt) · checking command policy "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
history:
<empty>
post running
live hooks:
• Running PreToolUse hook: checking command policy
• Running PostToolUse hook: checking output policy
activity:
" "
"• Thinking (0s • esc to interrupt) · Running hooks "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
history:
<empty>
pre completed lingering
live hooks:
• Running PreToolUse hook: checking command policy
• Running PostToolUse hook: checking output policy
history:
<empty>
pre completed after linger
live hooks:
• Running PostToolUse hook: checking output policy
history:
<empty>
all completed lingering
live hooks:
• Running PostToolUse hook: checking output policy
pre completed
activity:
" "
"• Thinking (0s • esc to interrupt) · checking output policy "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
history:
<empty>
all completed
live hooks:
<empty>
activity:
" "
"• Thinking (0s • esc to interrupt) "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
history:
<empty>
@@ -2,5 +2,4 @@
source: tui/src/chatwidget/tests/helpers.rs
expression: combined
---
• PostToolUse (completed) says: Heads up from the hook
hook context: Remember the startup checklist.
↳ Hook · Heads up from the hook
@@ -2,5 +2,4 @@
source: tui/src/chatwidget/tests/helpers.rs
expression: combined
---
• PreToolUse (completed) says: Heads up from the hook
hook context: Remember the startup checklist.
↳ Hook · Heads up from the hook
@@ -0,0 +1,22 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"80 columns:\\n{}\\n120 columns:\\n{}\", hook_status_frame(&chat, 80),\nhook_status_frame(&chat, 120),)"
---
80 columns:
" "
"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view…"
" └ checking command policy "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
120 columns:
" "
"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close "
" └ checking command policy "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
@@ -0,0 +1,24 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"before reveal:\\n{hidden}\\nrunning:\\n{running}\\ncompleted:\\n{}\",\nhook_status_frame(&chat, 80),)"
---
before reveal:
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
running:
" "
"• Saving session summary "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
completed:
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
@@ -0,0 +1,24 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "format!(\"before reveal:\\n{hidden}\\nrunning:\\n{running}\\ncompleted:\\n{}\",\nhook_status_frame(&chat, 80),)"
---
before reveal:
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
running:
" "
"• Running hook "
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
completed:
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
@@ -2,5 +2,4 @@
source: tui/src/chatwidget/tests/helpers.rs
expression: combined
---
• SessionStart (completed) says: Heads up from the hook
hook context: Remember the startup checklist.
↳ Hook · Heads up from the hook
@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: history
---
• Hook stopped
└ The hook stopped this turn for an important reason.
This second line must remain visible in full.
@@ -2,5 +2,6 @@
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: combined
---
• UserPromptSubmit (stopped) says: go-workflow must start from PlanMode
stop: prompt blocked
• Hook stopped
└ go-workflow must start from PlanMode
└ prompt blocked
+13 -30
View File
@@ -1127,14 +1127,6 @@ pub(super) fn active_blob(chat: &ChatWidget) -> String {
lines_to_single_string(&lines)
}
pub(super) fn active_hook_blob(chat: &ChatWidget) -> String {
let Some(cell) = chat.active_hook_cell.as_ref() else {
return "<empty>\n".to_string();
};
let lines = cell.display_lines(/*width*/ 80);
lines_to_single_string(&lines)
}
pub(super) fn expire_quiet_hook_linger(chat: &mut ChatWidget) {
if let Some(cell) = chat.active_hook_cell.as_mut() {
cell.expire_quiet_runs_now_for_test();
@@ -1649,6 +1641,7 @@ pub(super) async fn assert_hook_events_snapshot(
snapshot_name: &str,
) {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
handle_hook_started(
&mut chat,
@@ -1665,12 +1658,19 @@ pub(super) async fn assert_hook_events_snapshot(
"hook start should update the live hook cell instead of writing history"
);
reveal_running_hooks(&mut chat);
let width = 100;
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
.expect("create terminal");
terminal
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
.expect("render hook activity status");
let running = normalized_backend_snapshot(terminal.backend());
assert!(
active_hook_blob(&chat).contains(&format!(
"Running {} hook: {status_message}",
hook_event_label(event_name)
)),
"hook start should render in the live hook cell"
running
.lines()
.any(|line| line.contains("Working") && line.contains(status_message)),
"hook start should render its status in the activity row: {running}"
);
let mut entries = vec![codex_app_server_protocol::HookOutputEntry {
@@ -1701,20 +1701,3 @@ pub(super) async fn assert_hook_events_snapshot(
.collect::<String>();
assert_chatwidget_snapshot!(snapshot_name, combined);
}
fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'static str {
match event_name {
codex_app_server_protocol::HookEventName::PreToolUse => "PreToolUse",
codex_app_server_protocol::HookEventName::PermissionRequest => "PermissionRequest",
codex_app_server_protocol::HookEventName::PostToolUse => "PostToolUse",
codex_app_server_protocol::HookEventName::PreCompact => "PreCompact",
codex_app_server_protocol::HookEventName::PostCompact => "PostCompact",
codex_app_server_protocol::HookEventName::SessionStart => "SessionStart",
codex_app_server_protocol::HookEventName::SessionEnd => "SessionEnd",
codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_app_server_protocol::HookEventName::SubagentStart => "SubagentStart",
codex_app_server_protocol::HookEventName::SubagentStop => "SubagentStop",
codex_app_server_protocol::HookEventName::Stop => "Stop",
codex_app_server_protocol::HookEventName::Interrupt => "Interrupt",
}
}
@@ -1648,7 +1648,6 @@ async fn completed_token_activity_refresh_waits_for_active_hook() {
),
);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput));
}
@@ -3786,6 +3786,7 @@ impl crate::workspace_command::WorkspaceCommandExecutor for NoopWorkspaceCommand
#[tokio::test]
async fn interrupted_turn_clears_visible_running_hook() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
handle_hook_started(
&mut chat,
@@ -3796,15 +3797,15 @@ async fn interrupted_turn_clears_visible_running_hook() {
),
);
reveal_running_hooks(&mut chat);
let before_interrupt = active_hook_blob(&chat);
let before_interrupt = hook_status_frame(&chat, /*width*/ 80);
handle_turn_interrupted(&mut chat, "turn-1");
assert_chatwidget_snapshot!(
"interrupted_turn_clears_visible_running_hook",
format!(
"before interrupt:\n{before_interrupt}after interrupt:\n{}",
active_hook_blob(&chat)
"before interrupt:\n{before_interrupt}\nafter interrupt:\n{}",
hook_status_frame(&chat, /*width*/ 80)
)
);
}
@@ -3812,6 +3813,7 @@ async fn interrupted_turn_clears_visible_running_hook() {
#[tokio::test]
async fn completed_turn_clears_visible_running_hook() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
handle_hook_started(
&mut chat,
@@ -3822,15 +3824,15 @@ async fn completed_turn_clears_visible_running_hook() {
),
);
reveal_running_hooks(&mut chat);
let before_completion = active_hook_blob(&chat);
let before_completion = hook_status_frame(&chat, /*width*/ 80);
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
assert_chatwidget_snapshot!(
"completed_turn_clears_visible_running_hook",
format!(
"before completion:\n{before_completion}after completion:\n{}",
active_hook_blob(&chat)
"before completion:\n{before_completion}\nafter completion:\n{}",
hook_status_frame(&chat, /*width*/ 80)
)
);
}
@@ -4857,74 +4859,43 @@ async fn post_tool_use_hook_events_render_snapshot() {
}
#[tokio::test]
async fn completed_hook_with_no_entries_stays_out_of_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
async fn quiet_hook_cleanup_starts_when_the_hook_is_revealed() {
for delayed_redraw in [false, true] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
),
);
if delayed_redraw {
reveal_running_hooks_after_delayed_redraw(&mut chat);
} else {
reveal_running_hooks(&mut chat);
}
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
/*status_message*/ None,
),
);
assert!(drain_insert_history(&mut rx).is_empty());
reveal_running_hooks(&mut chat);
let running_snapshot = hook_live_and_history_snapshot(&chat, "running", "");
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert!(drain_insert_history(&mut rx).is_empty());
let completed_lingering_snapshot =
hook_live_and_history_snapshot(&chat, "completed lingering", "");
expire_quiet_hook_linger(&mut chat);
let completed_snapshot = hook_live_and_history_snapshot(&chat, "completed after linger", "");
assert_chatwidget_snapshot!(
"hook_live_running_then_quiet_completed_snapshot",
format!("{running_snapshot}\n\n{completed_lingering_snapshot}\n\n{completed_snapshot}")
);
}
#[tokio::test]
async fn quiet_hook_linger_starts_when_delayed_redraw_reveals_hook() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
),
);
assert!(drain_insert_history(&mut rx).is_empty());
reveal_running_hooks_after_delayed_redraw(&mut chat);
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(
active_hook_blob(&chat).contains("Running PostToolUse hook"),
"quiet hook should linger after the row becomes visible"
);
expire_quiet_hook_linger(&mut chat);
assert_eq!(active_hook_blob(&chat), "<empty>\n");
let cell = chat
.active_hook_cell
.as_ref()
.expect("quiet cleanup should linger");
assert!(cell.is_active());
assert!(cell.next_timer_deadline().is_some());
assert_eq!(cell.running_status_summary(), None);
expire_quiet_hook_linger(&mut chat);
assert!(chat.active_hook_cell.is_none());
assert!(drain_insert_history(&mut rx).is_empty());
}
}
#[tokio::test]
@@ -4962,13 +4933,11 @@ async fn blocked_and_failed_hooks_render_feedback_and_errors() {
.collect::<String>();
assert_chatwidget_snapshot!("hook_blocked_failed_feedback_history_snapshot", rendered);
assert!(
rendered.contains(
"PreToolUse hook (blocked)\n feedback: run tests before touching the fixture"
),
rendered.contains("Blocked by hook\n └ run tests before touching the fixture"),
"expected blocked hook feedback: {rendered:?}"
);
assert!(
rendered.contains("PostToolUse hook (failed)\n error: hook exited with code 7"),
rendered.contains("Hook failed\n └ hook exited with code 7"),
"expected failed hook error: {rendered:?}"
);
}
@@ -4986,7 +4955,7 @@ async fn completed_hook_with_output_flushes_immediately() {
),
);
reveal_running_hooks(&mut chat);
let running_snapshot = hook_live_and_history_snapshot(&chat, "running", "");
let running_snapshot = hook_status_and_history_snapshot(&chat, "running", "");
handle_hook_completed(
&mut chat,
@@ -5004,7 +4973,7 @@ async fn completed_hook_with_output_flushes_immediately() {
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
let completed_snapshot = hook_live_and_history_snapshot(&chat, "completed", &history);
let completed_snapshot = hook_status_and_history_snapshot(&chat, "completed", &history);
assert_chatwidget_snapshot!(
"completed_hook_with_output_flushes_immediately_snapshot",
@@ -5052,13 +5021,10 @@ async fn completed_hook_output_precedes_following_assistant_message() {
.collect::<String>();
assert_chatwidget_snapshot!(
"completed_hook_output_precedes_following_assistant_message_snapshot",
format!(
"active hooks:\n{}history:\n{history}",
active_hook_blob(&chat)
)
history
);
let hook_index = history
.find("PreToolUse hook (blocked)")
.find("Blocked by hook")
.expect("hook feedback should be in history");
let assistant_index = history
.find("The hook feedback was applied.")
@@ -5111,45 +5077,125 @@ async fn completed_same_id_hook_output_survives_restart() {
.collect::<String>();
assert_chatwidget_snapshot!(
"completed_same_id_hook_output_survives_restart_snapshot",
format!(
"active hooks:\n{}history:\n{history}",
active_hook_blob(&chat)
)
hook_status_and_history_snapshot(&chat, "restarted", &history)
);
assert!(
history.contains("Stop hook (stopped)\n stop: continue with more context"),
history.contains("Hook stopped\n └ continue with more context"),
"first hook output should not be overwritten: {history:?}"
);
}
#[tokio::test]
async fn identical_parallel_running_hooks_collapse_to_count() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
async fn running_hooks_fit_around_background_activity_and_finish_without_history_snapshot() {
for (messages, snapshot) in [
(
vec!["checking command policy"; 3],
"running_hooks_share_background_activity_row",
),
(
vec![
"checking command policy",
"scanning for secrets",
"loading project context",
],
"mixed_running_hooks_share_background_activity_row",
),
(
vec![
"checking command policy for deployment commands, production credentials, and paths containing 日本語 before continuing",
],
"long_running_hook_below_background_activity_row",
),
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
begin_unified_exec_startup(&mut chat, "call-1", "proc-1", "sleep 2");
chat.bottom_pane.hide_status_indicator();
for tool_call_id in ["tool-call-1", "tool-call-2", "tool-call-3"] {
for (index, message) in messages.iter().enumerate() {
handle_hook_started(
&mut chat,
hook_started_run(
&format!("hook-{index}"),
codex_app_server_protocol::HookEventName::PreToolUse,
Some(message),
),
);
}
reveal_running_hooks(&mut chat);
assert_chatwidget_snapshot!(
snapshot,
format!(
"80 columns:\n{}\n120 columns:\n{}",
hook_status_frame(&chat, /*width*/ 80),
hook_status_frame(&chat, /*width*/ 120),
)
);
// Match production delivery: finish the batch before drawing another frame.
for index in 0..messages.len() {
handle_hook_completed(
&mut chat,
hook_completed_run(
&format!("hook-{index}"),
codex_app_server_protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
}
let rendered = hook_status_frame(&chat, /*width*/ 120);
assert!(rendered.contains("1 background terminal running"));
assert!(!rendered.contains("Running hooks"));
assert!(!rendered.contains("checking command policy"));
assert!(drain_insert_history(&mut rx).is_empty());
}
}
#[tokio::test]
async fn session_end_hook_has_standalone_activity_row() {
for (message, snapshot) in [
(Some("saving session summary"), "session_end_hook_activity"),
(None, "session_end_hook_activity_without_message"),
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_started(
&mut chat,
hook_started_run(
&format!("pre-tool-use:0:/tmp/hooks.json:{tool_call_id}"),
codex_app_server_protocol::HookEventName::PreToolUse,
Some("checking command policy"),
"session-end",
codex_app_server_protocol::HookEventName::SessionEnd,
message,
),
);
let hidden = hook_status_frame(&chat, /*width*/ 80);
reveal_running_hooks(&mut chat);
let running = hook_status_frame(&chat, /*width*/ 80);
handle_hook_completed(
&mut chat,
hook_completed_run(
"session-end",
codex_app_server_protocol::HookEventName::SessionEnd,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert_chatwidget_snapshot!(
snapshot,
format!(
"before reveal:\n{hidden}\nrunning:\n{running}\ncompleted:\n{}",
hook_status_frame(&chat, /*width*/ 80),
)
);
assert!(drain_insert_history(&mut rx).is_empty());
}
reveal_running_hooks(&mut chat);
assert_chatwidget_snapshot!(
"identical_parallel_running_hooks_collapse_to_count_snapshot",
hook_live_and_history_snapshot(&chat, "running", "")
);
}
#[tokio::test]
async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
chat.set_status_header("Thinking".to_string());
chat.bottom_pane.ensure_status_indicator();
handle_hook_started(
&mut chat,
@@ -5161,7 +5207,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
);
assert_eq!(chat.status_state.current_status.header, "Thinking");
reveal_running_hooks(&mut chat);
let first_running_snapshot = hook_live_and_history_snapshot(&chat, "pre running", "");
let first_running_snapshot = hook_status_and_history_snapshot(&chat, "pre running", "");
handle_hook_started(
&mut chat,
@@ -5173,7 +5219,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
);
assert_eq!(chat.status_state.current_status.header, "Thinking");
reveal_running_hooks(&mut chat);
let second_running_snapshot = hook_live_and_history_snapshot(&chat, "post running", "");
let second_running_snapshot = hook_status_and_history_snapshot(&chat, "post running", "");
handle_hook_completed(
&mut chat,
@@ -5185,11 +5231,15 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
),
);
assert_eq!(chat.status_state.current_status.header, "Thinking");
let older_completed_snapshot =
hook_live_and_history_snapshot(&chat, "pre completed lingering", "");
let older_completed_snapshot = hook_status_and_history_snapshot(&chat, "pre completed", "");
expire_quiet_hook_linger(&mut chat);
let older_completed_expired_snapshot =
hook_live_and_history_snapshot(&chat, "pre completed after linger", "");
assert_eq!(
chat.active_hook_cell
.as_ref()
.and_then(HookCell::running_status_summary)
.as_deref(),
Some("checking output policy"),
);
handle_hook_completed(
&mut chat,
@@ -5203,14 +5253,13 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
assert_eq!(chat.status_state.current_status.header, "Thinking");
assert!(chat.bottom_pane.status_indicator_visible());
assert!(drain_insert_history(&mut rx).is_empty());
let all_completed_lingering_snapshot =
hook_live_and_history_snapshot(&chat, "all completed lingering", "");
let all_completed_snapshot = hook_status_and_history_snapshot(&chat, "all completed", "");
expire_quiet_hook_linger(&mut chat);
let all_completed_snapshot = hook_live_and_history_snapshot(&chat, "all completed", "");
assert!(chat.active_hook_cell.is_none());
assert_chatwidget_snapshot!(
"overlapping_hook_live_cell_snapshot",
format!(
"{first_running_snapshot}\n\n{second_running_snapshot}\n\n{older_completed_snapshot}\n\n{older_completed_expired_snapshot}\n\n{all_completed_lingering_snapshot}\n\n{all_completed_snapshot}"
"{first_running_snapshot}\n\n{second_running_snapshot}\n\n{older_completed_snapshot}\n\n{all_completed_snapshot}"
)
);
}
@@ -5231,18 +5280,14 @@ async fn running_hook_does_not_displace_active_exec_cell() {
),
);
reveal_running_hooks(&mut chat);
let exec_and_hook_running = format!(
"active exec:\n{}active hooks:\n{}",
active_blob(&chat),
active_hook_blob(&chat)
);
let exec_and_hook_running = hook_status_frame(&chat, /*width*/ 80);
end_exec(&mut chat, begin, "done", "", /*exit_code*/ 0);
let history_after_exec = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
let hook_running_after_exec = active_hook_blob(&chat);
let hook_running_after_exec = hook_status_frame(&chat, /*width*/ 80);
handle_hook_completed(
&mut chat,
@@ -5254,20 +5299,20 @@ async fn running_hook_does_not_displace_active_exec_cell() {
),
);
assert!(drain_insert_history(&mut rx).is_empty());
let quiet_hook_completed_lingering = active_hook_blob(&chat);
let quiet_hook_completed = hook_status_frame(&chat, /*width*/ 80);
expire_quiet_hook_linger(&mut chat);
let quiet_hook_completed = active_hook_blob(&chat);
assert!(chat.active_hook_cell.is_none());
assert_chatwidget_snapshot!(
"hook_runs_while_exec_active_snapshot",
format!(
"exec running:\n{exec_running}\nexec and hook running:\n{exec_and_hook_running}\nhistory after exec:\n{history_after_exec}\nhook running after exec:\n{hook_running_after_exec}\nquiet hook completed lingering:\n{quiet_hook_completed_lingering}\nquiet hook completed:\n{quiet_hook_completed}"
"exec running:\n{exec_running}\nexec and hook running:\n{exec_and_hook_running}\nhistory after exec:\n{history_after_exec}\nhook running after exec:\n{hook_running_after_exec}\nquiet hook completed:\n{quiet_hook_completed}"
)
);
}
#[tokio::test]
async fn hidden_active_hook_does_not_add_transcript_separator() {
async fn running_hook_never_adds_active_transcript_lines() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
begin_exec(&mut chat, "call-1", "echo done");
@@ -5290,28 +5335,14 @@ async fn hidden_active_hook_does_not_add_transcript_separator() {
assert_eq!(hidden_hook_transcript.len(), exec_only_line_count);
reveal_running_hooks(&mut chat);
let visible_hook_lines = chat
.active_hook_cell
.as_ref()
.expect("active hook cell")
.transcript_lines(/*width*/ 80);
let visible_hook_transcript = chat
.active_cell_transcript_lines(/*width*/ 80)
.expect("active exec and hook transcript lines");
assert_eq!(
visible_hook_transcript.len(),
exec_only_line_count + 1 + visible_hook_lines.len()
);
assert_eq!(
lines_to_single_string(
&visible_hook_transcript[exec_only_line_count..exec_only_line_count + 1],
),
"\n"
);
.expect("active exec transcript lines");
assert_eq!(visible_hook_transcript.len(), exec_only_line_count);
}
#[tokio::test]
async fn hook_completed_before_reveal_renders_completed_without_running_flash() {
async fn context_only_hook_completed_before_reveal_stays_out_of_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_started(
@@ -5322,7 +5353,6 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash()
Some("warming the shell"),
),
);
let started_hidden_snapshot = active_hook_blob(&chat);
handle_hook_completed(
&mut chat,
@@ -5337,18 +5367,12 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash()
),
);
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"hook_completed_before_reveal_renders_completed_without_running_flash_snapshot",
format!("started hidden:\n{started_hidden_snapshot}\nhistory:\n{history}")
);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(chat.active_hook_cell.is_none());
}
#[tokio::test]
async fn long_hook_context_is_truncated_with_transcript_hint_snapshot() {
async fn stopped_hook_hides_model_context_and_preserves_stop_reason_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_completed(
@@ -5377,7 +5401,7 @@ async fn long_hook_context_is_truncated_with_transcript_hint_snapshot() {
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"long_hook_context_is_truncated_with_transcript_hint",
"stopped_hook_hides_model_context_and_preserves_stop_reason",
history
);
}
@@ -5443,18 +5467,32 @@ fn hook_run_summary(
}
}
fn hook_live_and_history_snapshot(chat: &ChatWidget, phase: &str, history: &str) -> String {
fn hook_status_and_history_snapshot(chat: &ChatWidget, phase: &str, history: &str) -> String {
let history = if history.is_empty() {
"<empty>"
} else {
history
};
format!(
"{phase}\nlive hooks:\n{}history:\n{history}",
active_hook_blob(chat),
"{phase}\nactivity:\n{}\nhistory:\n{history}",
hook_status_frame(chat, /*width*/ 80),
)
}
fn hook_status_frame(chat: &ChatWidget, width: u16) -> String {
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(TestBackend::new(width, height))
.expect("create terminal for hook activity");
terminal
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
.expect("render hook activity frame");
// Windows path normalization drops Display's final newline; make composed
// frames independent of that platform difference.
normalized_backend_snapshot(terminal.backend())
.trim_end()
.to_string()
}
// Combined visual snapshot using vt100 for history + direct buffer overlay for UI.
// This renders the final visual as seen in a terminal: history above, then a blank line,
// then the exec block, another blank line, the status line, a blank line, and the composer.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
---
source: tui/src/status_indicator_widget.rs
expression: "frames.join(\"\\n\")"
---
124 columns:
"Working (0s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close · checking 日本語 ガパ policy" Hidden by multi-width symbols: [(107, " "), (109, " "), (111, " "), (114, " "), (116, " ")]
" └ existing details "
123 columns:
"Working (0s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close "
" └ checking 日本語 ガパ policy " Hidden by multi-width symbols: [(14, " "), (16, " "), (18, " "), (21, " "), (23, " ")]
" └ existing details "
24 columns:
"Working (0s • esc to in…"
" └ checking 日本語 ガ… " Hidden by multi-width symbols: [(14, " "), (16, " "), (18, " "), (21, " ")]
" └ existing details "
124 columns:
"Working (0s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close · checking 日本語 ガパ policy" Hidden by multi-width symbols: [(107, " "), (109, " "), (111, " "), (114, " "), (116, " ")]
" └ existing details "
@@ -0,0 +1,21 @@
---
source: tui/src/status_indicator_widget.rs
expression: "frames.join(\"\\n\")"
---
61 columns:
"Working (0s • esc to interrupt) · checking 日本語 ガパ policy" Hidden by multi-width symbols: [(44, " "), (46, " "), (48, " "), (51, " "), (53, " ")]
" └ existing details "
60 columns:
"Working (0s • esc to interrupt) "
" └ checking 日本語 ガパ policy " Hidden by multi-width symbols: [(14, " "), (16, " "), (18, " "), (21, " "), (23, " ")]
" └ existing details "
24 columns:
"Working (0s • esc to in…"
" └ checking 日本語 ガ… " Hidden by multi-width symbols: [(14, " "), (16, " "), (18, " "), (21, " ")]
" └ existing details "
61 columns:
"Working (0s • esc to interrupt) · checking 日本語 ガパ policy" Hidden by multi-width symbols: [(44, " "), (46, " "), (48, " "), (51, " "), (53, " ")]
" └ existing details "
+97 -24
View File
@@ -3,6 +3,8 @@
//! The row renders a separately owned clock, the optional interrupt hint, and short inline
//! context (for example, the unified-exec background-process summary). Keeping
//! these pieces on one line avoids vertical layout churn in the bottom pane.
//! Hook activity uses the remaining space or its own line on overflow, so it
//! never displaces background-process controls.
use std::time::Duration;
use std::time::Instant;
@@ -21,6 +23,7 @@ use unicode_width::UnicodeWidthStr;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::key_hint::ShortcutHint;
use crate::line_truncation::line_width;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
@@ -29,6 +32,7 @@ use crate::motion::shimmer_text;
use crate::render::renderable::Renderable;
use crate::text_formatting::capitalize_first;
use crate::tui::FrameRequester;
use crate::width::display_width;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
@@ -52,6 +56,8 @@ pub(crate) struct StatusIndicatorWidget {
details_max_lines: usize,
/// Optional suffix rendered after the elapsed/interrupt segment.
inline_message: Option<String>,
/// Hook activity may move below the status row when it cannot fit in full.
hook_status_message: Option<String>,
show_interrupt_hint: bool,
interrupt_binding: Option<ShortcutHint>,
@@ -88,6 +94,7 @@ impl StatusIndicatorWidget {
details: None,
details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES,
inline_message: None,
hook_status_message: None,
show_interrupt_hint: true,
interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()),
app_event_tx,
@@ -135,6 +142,10 @@ impl StatusIndicatorWidget {
.filter(|message| !message.is_empty());
}
pub(crate) fn update_hook_status_message(&mut self, message: Option<String>) {
self.hook_status_message = message;
}
pub(crate) fn header(&self) -> &str {
&self.header
}
@@ -194,22 +205,11 @@ struct StatusIndicator<'a> {
timer: &'a StatusTimer,
}
impl Renderable for StatusIndicator<'_> {
fn desired_height(&self, width: u16) -> u16 {
1 + u16::try_from(self.row.wrapped_details_lines(width).len()).unwrap_or(0)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
impl StatusIndicator<'_> {
// Share width decisions between height measurement and rendering, including
// wide Unicode characters, remapped interrupt hints, and elapsed-time text.
fn lines(&self, width: u16) -> Vec<Line<'static>> {
let row = self.row;
if row.animations_enabled {
// Schedule next animation frame.
row.frame_requester
.schedule_frame_in(Duration::from_millis(32));
}
let now = Instant::now();
let elapsed_duration = self.timer.elapsed_at(now);
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
@@ -246,19 +246,46 @@ impl Renderable for StatusIndicator<'_> {
spans.push(message.clone().dim());
}
let mut header = Line::from(spans);
let mut hook_overflow = None;
if let Some(message) = &row.hook_status_message {
if line_width(&header) + display_width(" · ") + display_width(message)
<= usize::from(width)
{
header.spans.extend([" · ".dim(), message.clone().dim()]);
} else {
hook_overflow = Some(truncate_line_with_ellipsis_if_overflow(
Line::from(vec![DETAILS_PREFIX.dim(), message.clone().dim()]),
usize::from(width),
));
}
}
let mut lines = Vec::new();
lines.push(truncate_line_with_ellipsis_if_overflow(
Line::from(spans),
usize::from(area.width),
header,
usize::from(width),
));
if area.height > 1 {
// If there is enough space, add the details lines below the header.
let details = row.wrapped_details_lines(area.width);
let max_details = usize::from(area.height.saturating_sub(1));
lines.extend(details.into_iter().take(max_details));
}
lines.extend(hook_overflow);
lines.extend(row.wrapped_details_lines(width));
lines
}
}
Paragraph::new(Text::from(lines)).render(area, buf);
impl Renderable for StatusIndicator<'_> {
fn desired_height(&self, width: u16) -> u16 {
self.lines(width).len() as u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
if self.row.animations_enabled {
self.row
.frame_requester
.schedule_frame_in(Duration::from_millis(32));
}
Paragraph::new(Text::from(self.lines(area.width))).render(area, buf);
}
}
@@ -398,6 +425,52 @@ mod tests {
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn hook_status_reflows_without_displacing_controls_or_details() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let mut w = StatusIndicatorWidget::new(
AppEventSender::new(tx),
crate::tui::FrameRequester::test_dummy(),
/*animations_enabled*/ false,
);
let mut timer = StatusTimer::default();
timer.pause_at(timer.last_resume_at);
w.update_hook_status_message(Some("checking 日本語 ガパ policy".to_string()));
w.update_details(
Some("existing details".to_string()),
StatusDetailsCapitalization::Preserve,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
for (background, snapshot) in [
(None, "hook_status_reflows_without_background_activity"),
(
Some("1 background terminal running · /ps to view · /stop to close"),
"hook_status_reflows_with_background_activity",
),
] {
w.update_inline_message(background.map(str::to_string));
let mut expected = "Working (0s • esc to interrupt)".to_string();
if let Some(background) = background {
expected.push_str(&format!(" · {background}"));
}
expected.push_str(" · checking 日本語 ガパ policy");
let fit_width = display_width(&expected) as u16;
let mut frames = Vec::new();
for width in [fit_width, fit_width - 1, 24, fit_width] {
let height = w.with_timer(&timer).desired_height(width);
assert_eq!(height, if width >= fit_width { 2 } else { 3 });
let mut terminal =
Terminal::new(TestBackend::new(width, height)).expect("terminal");
terminal
.draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut()))
.expect("draw");
frames.push(format!("{width} columns:\n{}", terminal.backend()));
}
insta::assert_snapshot!(snapshot, frames.join("\n"));
}
}
#[test]
fn details_overflow_adds_ellipsis() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();