fix(tui): a shell card waiting on approval says so instead of "running (Ns)"

The engine emits ToolCallStarted before it asks for approval, so the
shell card was created with started_at = now and rendered "running" with
a braille spinner and a climbing "(Ns)" badge for the whole time the
approval prompt sat open. Nothing had run. Observed in a real TUI against
the cw7087 mock: "⣦ ▶ run running (84s) · echo later-01" above the
approval card (CW/artifacts/cw6993-idle-20261018/ab-after-1-screen.txt).

ExecCell gains awaiting_approval. When the approval prompt is raised
(the Prompt disposition only; auto-approve and auto-deny paths never show
a card), set_exec_card_awaiting_approval marks the card and clears
started_at. note_human_decision_delivered, which every delivered human
decision passes through (the modal, the web mirror, a timed-out denial),
clears the mark and starts the clock, so the elapsed badge measures the
command, not the wait. While marked, the header reads "waiting for
approval" with the static low-motion glyph, and has_live_motion is false
for that cell. The activity detail duration reads 0 while waiting for the
same reason.

Verified in a real TUI (debug build, mock provider, isolated HOME;
CW/artifacts/approval-card-20261018/):
- waiting, 12 s and 20 s: "● ▶ run waiting for approval · echo later-01"
- after y: "✓ ▶ run done · echo later-01", next card waiting
- after n: "✕ ▶ run failed · echo later-01", next card waiting

Checks:
- cargo test -p codewhale-tui --lib -- tui::history tui::tool_routing
  tui::transcript tui::active_cell approval exec_cell shell_card:
  609 passed; 0 failed; 2 ignored
- cargo clippy -p codewhale-tui --all-targets --all-features --locked with
  CI's flags: clean; cargo fmt --check: clean

Known limits: only shell (Exec) cards carry the mark; other tool cards
that need approval have no elapsed badge and still say "running". The
label is English, like the neighbouring "running" and "failed" labels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-18 09:36:44 -07:00
1 parent eeb3f46471
commit a83205733e
11 files changed
+58 -8

No files matched your search

+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- A shell card whose command is waiting on your approval now says
"waiting for approval" with a still mark, instead of "running (Ns)" with
a spinner. Its elapsed time starts when you approve and the command
actually runs.
- `/color` gives a chat's composer box its own color. Bare `/color` cycles
through red, orange, amber, green, cyan, blue, purple and pink; `/color
<name>` picks one and `/color off` returns to the theme. The color is
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- A shell card whose command is waiting on your approval now says
"waiting for approval" with a still mark, instead of "running (Ns)" with
a spinner. Its elapsed time starts when you approve and the command
actually runs.
- `/color` gives a chat's composer box its own color. Bare `/color` cycles
through red, orange, amber, green, cyan, blue, purple and pink; `/color
<name>` picks one and `/color off` returns to the theme. The color is
+1
View File
@@ -327,6 +327,7 @@ mod tests {
started_at: Some(Instant::now()),
duration_ms: None,
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
+10 -8
View File
@@ -300,6 +300,7 @@ impl HistoryCell {
{
false
}
HistoryCell::Tool(ToolCell::Exec(exec)) if exec.awaiting_approval => false,
HistoryCell::Tool(tool) => tool.is_running(),
HistoryCell::User { .. }
| HistoryCell::System { .. }
@@ -1053,6 +1054,7 @@ pub struct ExecCell {
pub started_at: Option<Instant>,
pub duration_ms: Option<u64>,
pub stale_elapsed_since_output_ms: Option<u64>,
pub awaiting_approval: bool,
pub source: ExecSource,
pub interaction: Option<String>,
/// Cached output summary — avoids re-parsing JSON every frame.
@@ -1099,26 +1101,26 @@ impl ExecCell {
.interaction
.as_deref()
.or(Some(command_summary.as_str()));
let stale_status = self
.stale_elapsed_since_output_ms
.map(stale_shell_status_label);
let held_status = if self.awaiting_approval && self.status == ToolStatus::Running {
Some("waiting for approval".to_string())
} else {
self.stale_elapsed_since_output_ms
.map(stale_shell_status_label)
};
let receipt = tool_receipt_label(
crate::tui::widgets::tool_card::ToolFamily::Run,
self.status,
self.output.as_deref(),
locale,
);
let status_text = stale_status
.as_deref()
.map(Cow::Borrowed)
.unwrap_or(receipt);
let status_text = held_status.as_deref().map(Cow::Borrowed).unwrap_or(receipt);
lines.push(render_tool_header_with_summary(
"Shell",
header_summary,
status_text.as_ref(),
self.status,
self.started_at,
low_motion || stale_status.is_some(),
low_motion || held_status.is_some(),
));
if let Some(description) = self.description.as_deref() {
+1
View File
@@ -107,6 +107,7 @@ fn exec_tool(command: &str, status: ToolStatus) -> ExecCell {
started_at: None,
duration_ms: None,
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
+21
View File
@@ -112,6 +112,7 @@ pub(super) fn handle_tool_call_started(
started_at: Some(Instant::now()),
duration_ms: None,
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source,
description: None,
interaction: Some(summary.clone()),
@@ -150,6 +151,7 @@ pub(super) fn handle_tool_call_started(
started_at: Some(Instant::now()),
duration_ms: None,
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source,
description: input
.get("description")
@@ -327,6 +329,24 @@ fn push_active_tool_cell(
app.mark_history_updated();
}
pub(crate) fn set_exec_card_awaiting_approval(app: &mut App, tool_id: &str, awaiting: bool) {
let Some(&index) = app.tool_cells.get(tool_id) else {
return;
};
let is_change = matches!(
app.cell_at_virtual_index(index),
Some(HistoryCell::Tool(ToolCell::Exec(exec))) if exec.awaiting_approval != awaiting
);
if !is_change {
return;
}
if let Some(HistoryCell::Tool(ToolCell::Exec(exec))) = app.cell_at_virtual_index_mut(index) {
exec.awaiting_approval = awaiting;
exec.started_at = (!awaiting).then(Instant::now);
app.needs_redraw = true;
}
}
fn register_tool_cell(
app: &mut App,
tool_id: &str,
@@ -2771,6 +2791,7 @@ mod tests {
started_at: None,
duration_ms: Some(120),
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
+2
View File
@@ -197,6 +197,7 @@ fn exec_tool_cell_with_output(command: &str, output: String) -> HistoryCell {
started_at: None,
duration_ms: None,
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
@@ -216,6 +217,7 @@ fn exec_tool_cell(command: &str) -> HistoryCell {
started_at: None,
duration_ms: None,
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
+1
View File
@@ -3232,6 +3232,7 @@ pub(crate) fn observe_human_request_settlement(app: &mut App, event: &EngineEven
}
pub(crate) fn note_human_decision_delivered(app: &mut App, tool_id: &str) {
crate::tui::tool_routing::set_exec_card_awaiting_approval(app, tool_id, false);
if (app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")))
&& !app.suppress_stream_events_until_turn_complete
&& !crate::tui::pending_requests::is_foreign_child_request(app, tool_id)
+1
View File
@@ -8595,6 +8595,7 @@ pub(super) async fn handle_approval_required_event(
config.approval_default_selection(),
config.approval_timeout(),
);
crate::tui::tool_routing::set_exec_card_awaiting_approval(app, &id, true);
if let Some(agent_id) = crate::tui::pending_requests::child_agent_id(&id) {
crate::tui::pending_requests::record(
app,
+12
View File
@@ -6667,6 +6667,7 @@ fn running_exec_cell() -> HistoryCell {
started_at: None,
duration_ms: Some(20),
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
@@ -10038,6 +10039,7 @@ fn refresh_shell_exec_live_output_finalizes_restored_missing_shell_job() {
started_at: None,
duration_ms: Some(1_234),
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
@@ -10074,6 +10076,7 @@ fn shell_live_output_update_matches_exact_task_id_only() {
started_at: None,
duration_ms: Some(111),
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
@@ -10090,6 +10093,7 @@ fn shell_live_output_update_matches_exact_task_id_only() {
started_at: None,
duration_ms: None,
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
@@ -10186,6 +10190,7 @@ fn shell_live_output_update_marks_stale_running_job_static() {
started_at: Some(Instant::now() - Duration::from_secs(90)),
duration_ms: Some(90_000),
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
@@ -10269,6 +10274,7 @@ fn shell_live_output_update_finalizes_background_exec_output() {
started_at: None,
duration_ms: None,
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
@@ -10360,6 +10366,7 @@ fn shell_live_output_update_skips_finalized_exec_cell() {
started_at: None,
duration_ms: Some(10),
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
@@ -21878,6 +21885,7 @@ fn spillover_pager_uses_session_artifact_for_specialized_bash_cell() {
started_at: None,
duration_ms: None,
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
@@ -21945,6 +21953,7 @@ fn terminal_pause_has_live_owner_only_for_running_exec_cells() {
started_at: Some(Instant::now()),
duration_ms: None,
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: Some("interactive".to_string()),
@@ -26013,6 +26022,7 @@ fn turn_inspector_renders_overview_sections_for_active_turn() {
started_at: None,
duration_ms: Some(2400),
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
@@ -26174,6 +26184,7 @@ fn turn_inspector_timeline_numbers_semantic_entries_and_checkpoint_actions() {
started_at: None,
duration_ms: Some(1250),
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
@@ -26481,6 +26492,7 @@ fn turn_handoff_markdown_renders_compact_sections_for_active_turn() {
started_at: None,
duration_ms: Some(2400),
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::Assistant,
description: None,
interaction: None,
+1
View File
@@ -3909,6 +3909,7 @@ mod tests {
started_at: None,
duration_ms: None,
stale_elapsed_since_output_ms: None,
awaiting_approval: false,
source: ExecSource::User,
description: None,
interaction: None,