Files
codex/codex-rs/tui/src/chatwidget/hook_lifecycle.rs
T
Mitsuhiro Kotake 44403dd3dd tui: clear stale hook row after turn completion (#27619)
Fixes #27210.

## Why
When the app server reports a visible `HookStarted` event for a
`PostToolUse` hook but the turn reaches `TurnCompleted` before a
matching hook completion event arrives, the TUI can leave the transient
`Running PostToolUse hook` row visible after the agent is done.
Interrupted and failed turn cleanup already drops transient live hook
rows; the normal completion path did not.

## What Changed
- Added `ChatWidget::clear_active_hook_cell()` for dropping transient
live hook status without writing it to history.
- Call that cleanup from normal task completion, while reusing it for
the existing start/finalize cleanup paths.
- Added `completed_turn_clears_visible_running_hook` snapshot coverage
for the reported `PostToolUse` case.

## Tests
- `just test -p codex-tui completed_turn_clears_visible_running_hook`
- `just test -p codex-tui` (fails on current `main` in unrelated
guardian tests:
`update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default`
and
`update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history`)
2026-06-12 09:15:04 +09:00

141 lines
4.5 KiB
Rust

//! Hook run lifecycle handling for `ChatWidget`.
//!
//! This module keeps active hook cells, hook timers, and 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) {
if self.active_hook_cell.take().is_some() {
self.bump_active_cell_revision();
}
}
pub(super) fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) {
self.record_visible_turn_activity();
self.flush_answer_stream_with_separator();
self.flush_completed_hook_output();
match self.active_hook_cell.as_mut() {
Some(cell) => {
cell.start_run(run);
self.bump_active_cell_revision();
}
None => {
self.active_hook_cell = Some(history_cell::new_active_hook_cell(
run,
self.config.animations,
));
self.bump_active_cell_revision();
}
}
self.request_redraw();
}
pub(super) fn on_hook_completed(
&mut self,
completed: codex_app_server_protocol::HookRunSummary,
) {
let completed_existing_run = self
.active_hook_cell
.as_mut()
.map(|cell| cell.complete_run(completed.clone()))
.unwrap_or(false);
if completed_existing_run {
self.bump_active_cell_revision();
} else {
match self.active_hook_cell.as_mut() {
Some(cell) => {
cell.add_completed_run(completed);
self.bump_active_cell_revision();
}
None => {
let cell =
history_cell::new_completed_hook_cell(completed, self.config.animations);
if !cell.is_empty() {
self.active_hook_cell = Some(cell);
self.bump_active_cell_revision();
}
}
}
}
self.flush_completed_hook_output();
self.finish_active_hook_cell_if_idle();
self.request_redraw();
}
pub(super) fn flush_completed_hook_output(&mut self) {
let Some(completed_cell) = self
.active_hook_cell
.as_mut()
.and_then(HookCell::take_completed_persistent_runs)
else {
return;
};
let active_cell_is_empty = self
.active_hook_cell
.as_ref()
.is_some_and(HookCell::is_empty);
if active_cell_is_empty {
self.active_hook_cell = None;
}
self.bump_active_cell_revision();
self.transcript.needs_final_message_separator = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(completed_cell)));
}
pub(super) fn finish_active_hook_cell_if_idle(&mut self) {
let Some(cell) = self.active_hook_cell.as_ref() else {
return;
};
if cell.is_empty() {
self.active_hook_cell = None;
self.bump_active_cell_revision();
return;
}
if cell.should_flush()
&& let Some(cell) = self.active_hook_cell.take()
{
self.bump_active_cell_revision();
self.transcript.needs_final_message_separator = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
}
}
pub(super) fn update_due_hook_visibility(&mut self) {
let Some(cell) = self.active_hook_cell.as_mut() else {
return;
};
let now = Instant::now();
if cell.advance_time(now) {
self.bump_active_cell_revision();
}
self.finish_active_hook_cell_if_idle();
}
pub(super) fn schedule_hook_timer_if_needed(&self) {
if self.config.animations
&& self
.active_hook_cell
.as_ref()
.is_some_and(HookCell::has_visible_running_run)
{
self.frame_requester
.schedule_frame_in(Duration::from_millis(50));
}
let Some(deadline) = self
.active_hook_cell
.as_ref()
.and_then(HookCell::next_timer_deadline)
else {
return;
};
let delay = deadline.saturating_duration_since(Instant::now());
self.frame_requester.schedule_frame_in(delay);
}
}