Skip to content

Commit fe8f9b1

Browse files
BunsDevclaude
andcommitted
feat(tui): surface build & turn completion via toast + bell
Long-running bash tasks (`notify_on_complete: true`) now push a green/red toast into the TUI when they finish, and assistant turns that ran ≥8s do the same — so you can step away during a build or a long agent turn and notice the moment it's done. - core: add `completionToast` (Option<bool>, default true) and `bellOnComplete` (default false) settings, merged hierarchically. - tools: replace `CompletionNotifier(Fn(String))` with a structured `BgTaskCompletion` payload (task_id, command, success, exit_info, output_tail, duration_secs) so consumers can fan-out to system- message injection, TUI toasts, and a terminal bell from one source of truth. - cli: drain a new `bg_completion_rx` channel each main-loop tick and feed `App::signal_bg_task_completion`, which pushes a Success/Error notification (sticky on failure, 8s on success) and rings `\x07` when the bell flag is on. - tui: same toast+bell on `QueryEvent::TurnComplete` for turns past `completion_toast_min_secs` (8s). Brighten the idle "✽ Worked for…" status line into a bold green ✓ so it reads as "done" at a glance instead of dim gray. Four new unit tests cover the toast routing and duration formatting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 26a98c5 commit fe8f9b1

6 files changed

Lines changed: 260 additions & 29 deletions

File tree

src-rust/crates/cli/src/main.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,6 +1715,8 @@ async fn run_interactive(
17151715
app.provider_registry = base_query_config.provider_registry.clone();
17161716
app.refresh_context_window_size();
17171717
app.auto_compact_enabled = live_config.auto_compact;
1718+
app.completion_toast_enabled = settings.completion_toast_enabled();
1719+
app.bell_on_complete = settings.bell_on_complete;
17181720

17191721
// Background: refresh the model registry from models.dev.
17201722
// The fetched JSON is saved as a cache file; the App will reload it from
@@ -1911,6 +1913,10 @@ async fn run_interactive(
19111913
// Current cancel token (replaced each turn)
19121914
let mut cancel: Option<CancellationToken> = None;
19131915
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<QueryEvent>();
1916+
// Background bash tasks with notify_on_complete send their terminal-state
1917+
// info here, where the main loop fans-out to a TUI toast + optional bell.
1918+
let (bg_completion_tx, mut bg_completion_rx) =
1919+
mpsc::unbounded_channel::<claurst_tools::BgTaskCompletion>();
19141920
type MessagesArc = Arc<tokio::sync::Mutex<Vec<claurst_core::types::Message>>>;
19151921
let mut current_query: Option<(tokio::task::JoinHandle<QueryOutcome>, MessagesArc)> = None;
19161922
// Active effort level (None = use model default / High).
@@ -2673,13 +2679,22 @@ async fn run_interactive(
26732679
qcfg.effort_level = Some(level);
26742680
}
26752681
// Wire completion_notifier if a command queue is available.
2682+
// The closure fans-out the structured completion info to:
2683+
// 1. The command queue (system-message injection for the agent)
2684+
// 2. The TUI channel (toast + optional terminal bell)
26762685
if let Some(ref cq) = qcfg.command_queue {
26772686
let cq = cq.clone();
2678-
ctx_clone.completion_notifier = Some(claurst_tools::CompletionNotifier::new(move |msg| {
2687+
let aux_tx = bg_completion_tx.clone();
2688+
ctx_clone.completion_notifier = Some(claurst_tools::CompletionNotifier::new(move |info: claurst_tools::BgTaskCompletion| {
2689+
let msg = format!(
2690+
"[Monitor] Background task {} completed ({}).\nCommand: {}\nOutput (last 2000 chars):\n{}",
2691+
info.task_id, info.exit_info, info.command, info.output_tail
2692+
);
26792693
cq.push(
26802694
claurst_query::QueuedCommand::InjectSystemMessage(msg),
26812695
claurst_query::CommandPriority::Normal,
26822696
);
2697+
let _ = aux_tx.send(info);
26832698
}));
26842699
}
26852700
let tracker = cost_tracker.clone();
@@ -2973,6 +2988,17 @@ async fn run_interactive(
29732988
app.handle_query_event(evt);
29742989
}
29752990

2991+
// Drain background-task completion events: surface a toast and
2992+
// (optionally) ring the terminal bell so the user can leave their
2993+
// attention away from the chat and still notice long builds finishing.
2994+
while let Ok(info) = bg_completion_rx.try_recv() {
2995+
app.signal_bg_task_completion(
2996+
&info,
2997+
settings.completion_toast_enabled(),
2998+
settings.bell_on_complete,
2999+
);
3000+
}
3001+
29763002
// Auto-compact: when context usage hits 99% and no query is running,
29773003
// automatically submit a compact request.
29783004
if app.context_window_size > 0
@@ -3702,11 +3728,17 @@ async fn run_interactive(
37023728
}
37033729
if let Some(ref cq) = qcfg.command_queue {
37043730
let cq = cq.clone();
3705-
ctx_clone.completion_notifier = Some(claurst_tools::CompletionNotifier::new(move |msg| {
3731+
let aux_tx = bg_completion_tx.clone();
3732+
ctx_clone.completion_notifier = Some(claurst_tools::CompletionNotifier::new(move |info: claurst_tools::BgTaskCompletion| {
3733+
let msg = format!(
3734+
"[Monitor] Background task {} completed ({}).\nCommand: {}\nOutput (last 2000 chars):\n{}",
3735+
info.task_id, info.exit_info, info.command, info.output_tail
3736+
);
37063737
cq.push(
37073738
claurst_query::QueuedCommand::InjectSystemMessage(msg),
37083739
claurst_query::CommandPriority::Normal,
37093740
);
3741+
let _ = aux_tx.send(info);
37103742
}));
37113743
}
37123744
let tracker = cost_tracker.clone();

src-rust/crates/core/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,20 @@ pub mod config {
11621162
/// Note: @include in CLAUDE.md/AGENTS.md always injects regardless of this limit.
11631163
#[serde(default = "default_file_injection_max_size", rename = "fileInjectionMaxSize")]
11641164
pub file_injection_max_size: usize,
1165+
/// Show a toast when a background bash task or assistant turn finishes.
1166+
/// `None` (default) → enabled. `Some(false)` → explicitly disabled.
1167+
#[serde(default, rename = "completionToast", skip_serializing_if = "Option::is_none")]
1168+
pub completion_toast: Option<bool>,
1169+
/// Ring the terminal bell (\x07) when a background bash task or assistant turn finishes. Defaults to false.
1170+
#[serde(default, rename = "bellOnComplete")]
1171+
pub bell_on_complete: bool,
1172+
}
1173+
1174+
impl Settings {
1175+
/// Whether to show completion toasts (default: enabled).
1176+
pub fn completion_toast_enabled(&self) -> bool {
1177+
self.completion_toast.unwrap_or(true)
1178+
}
11651179
}
11661180

11671181
/// A user-defined slash command template.
@@ -1729,6 +1743,8 @@ pub mod config {
17291743
file_autocomplete_show_hidden_files: over.file_autocomplete_show_hidden_files || base.file_autocomplete_show_hidden_files,
17301744
file_injection_enabled: over.file_injection_enabled || base.file_injection_enabled,
17311745
file_injection_max_size: if over.file_injection_max_size != 0 { over.file_injection_max_size } else { base.file_injection_max_size },
1746+
completion_toast: over.completion_toast.or(base.completion_toast),
1747+
bell_on_complete: over.bell_on_complete || base.bell_on_complete,
17321748
}
17331749
}
17341750
}

src-rust/crates/tools/src/bash.rs

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -251,11 +251,13 @@ async fn run_in_background(
251251

252252
// If notify_on_complete is requested and a notifier is available, spawn a
253253
// watcher task that polls the registry until the task reaches a terminal
254-
// state, then injects a completion message into the agent's next turn.
254+
// state, then dispatches a structured completion event. Consumers fan-out
255+
// to system-message injection, TUI toasts, terminal bell, etc.
255256
if notify_on_complete {
256257
if let Some(notifier) = completion_notifier {
257258
let watcher_task_id = task_id.clone();
258259
let watcher_command = command.clone();
260+
let started_at = std::time::Instant::now();
259261
tokio::spawn(async move {
260262
loop {
261263
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
@@ -267,27 +269,32 @@ async fn run_in_background(
267269
| claurst_core::tasks::TaskStatus::Failed(_)
268270
| claurst_core::tasks::TaskStatus::Cancelled
269271
) => {
270-
let exit_info = match &t.status {
271-
claurst_core::tasks::TaskStatus::Completed => "exit 0".to_string(),
272+
let (success, exit_info) = match &t.status {
273+
claurst_core::tasks::TaskStatus::Completed => {
274+
(true, "exit 0".to_string())
275+
}
272276
claurst_core::tasks::TaskStatus::Failed(msg) => {
273-
format!("failed: {}", msg)
277+
(false, format!("failed: {}", msg))
274278
}
275279
claurst_core::tasks::TaskStatus::Cancelled => {
276-
"cancelled".to_string()
280+
(false, "cancelled".to_string())
277281
}
278282
_ => unreachable!(),
279283
};
280284
let output = t.output.join("\n");
281285
let output_tail = if output.len() > 2000 {
282-
&output[output.len() - 2000..]
286+
output[output.len() - 2000..].to_string()
283287
} else {
284-
&output
288+
output
285289
};
286-
let msg = format!(
287-
"[Monitor] Background task {} completed ({}).\nCommand: {}\nOutput (last 2000 chars):\n{}",
288-
watcher_task_id, exit_info, watcher_command, output_tail
289-
);
290-
notifier.notify(msg);
290+
notifier.notify(crate::BgTaskCompletion {
291+
task_id: watcher_task_id.clone(),
292+
command: watcher_command.clone(),
293+
success,
294+
exit_info,
295+
output_tail,
296+
duration_secs: started_at.elapsed().as_secs(),
297+
});
291298
break;
292299
}
293300
None => break, // Task disappeared from registry

src-rust/crates/tools/src/lib.rs

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -231,17 +231,38 @@ pub fn clear_session_shadow(working_dir: &std::path::Path) {
231231
}
232232

233233

234-
/// A cloneable handle for injecting notification messages into the next agent turn.
235-
/// Used by background tasks with `notify_on_complete` to signal completion without polling.
234+
/// Structured payload describing a background task's terminal state.
235+
/// Carried by [`CompletionNotifier`] so consumers can build their own UI
236+
/// (system-message injection, TUI toasts, terminal bell, etc.) without
237+
/// re-parsing a string.
238+
#[derive(Debug, Clone)]
239+
pub struct BgTaskCompletion {
240+
pub task_id: String,
241+
pub command: String,
242+
/// `true` if the task exited 0; `false` for non-zero, cancellation, or timeout.
243+
pub success: bool,
244+
/// Short human-readable status: `"exit 0"`, `"failed: ..."`, `"cancelled"`.
245+
pub exit_info: String,
246+
/// Last ~2000 characters of stdout/stderr (for system-message injection).
247+
pub output_tail: String,
248+
/// How long the task ran before reaching the terminal state.
249+
pub duration_secs: u64,
250+
}
251+
252+
/// A cloneable handle invoked when a background bash task reaches a terminal state.
253+
/// Used by `notify_on_complete` to signal completion without polling.
254+
///
255+
/// The closure receives a structured [`BgTaskCompletion`] so it can fan-out to
256+
/// multiple sinks (e.g., inject a system message AND show a TUI toast).
236257
#[derive(Clone)]
237-
pub struct CompletionNotifier(Arc<dyn Fn(String) + Send + Sync>);
258+
pub struct CompletionNotifier(Arc<dyn Fn(BgTaskCompletion) + Send + Sync>);
238259

239260
impl CompletionNotifier {
240-
pub fn new(f: impl Fn(String) + Send + Sync + 'static) -> Self {
261+
pub fn new(f: impl Fn(BgTaskCompletion) + Send + Sync + 'static) -> Self {
241262
Self(Arc::new(f))
242263
}
243-
pub fn notify(&self, msg: String) {
244-
(self.0)(msg);
264+
pub fn notify(&self, info: BgTaskCompletion) {
265+
(self.0)(info);
245266
}
246267
}
247268

0 commit comments

Comments
 (0)