Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1715,6 +1715,8 @@ async fn run_interactive(
app.provider_registry = base_query_config.provider_registry.clone();
app.refresh_context_window_size();
app.auto_compact_enabled = live_config.auto_compact;
app.completion_toast_enabled = settings.completion_toast_enabled();
app.bell_on_complete = settings.bell_on_complete;

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

// Drain background-task completion events: surface a toast and
// (optionally) ring the terminal bell so the user can leave their
// attention away from the chat and still notice long builds finishing.
while let Ok(info) = bg_completion_rx.try_recv() {
app.signal_bg_task_completion(
&info,
settings.completion_toast_enabled(),
settings.bell_on_complete,
);
}

// Auto-compact: when context usage hits 99% and no query is running,
// automatically submit a compact request.
if app.context_window_size > 0
Expand Down Expand Up @@ -3702,11 +3728,17 @@ async fn run_interactive(
}
if let Some(ref cq) = qcfg.command_queue {
let cq = cq.clone();
ctx_clone.completion_notifier = Some(claurst_tools::CompletionNotifier::new(move |msg| {
let aux_tx = bg_completion_tx.clone();
ctx_clone.completion_notifier = Some(claurst_tools::CompletionNotifier::new(move |info: claurst_tools::BgTaskCompletion| {
let msg = format!(
"[Monitor] Background task {} completed ({}).\nCommand: {}\nOutput (last 2000 chars):\n{}",
info.task_id, info.exit_info, info.command, info.output_tail
);
cq.push(
claurst_query::QueuedCommand::InjectSystemMessage(msg),
claurst_query::CommandPriority::Normal,
);
let _ = aux_tx.send(info);
}));
}
let tracker = cost_tracker.clone();
Expand Down
16 changes: 16 additions & 0 deletions src-rust/crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,20 @@ pub mod config {
/// Note: @include in CLAUDE.md/AGENTS.md always injects regardless of this limit.
#[serde(default = "default_file_injection_max_size", rename = "fileInjectionMaxSize")]
pub file_injection_max_size: usize,
/// Show a toast when a background bash task or assistant turn finishes.
/// `None` (default) → enabled. `Some(false)` → explicitly disabled.
#[serde(default, rename = "completionToast", skip_serializing_if = "Option::is_none")]
pub completion_toast: Option<bool>,
/// Ring the terminal bell (\x07) when a background bash task or assistant turn finishes. Defaults to false.
#[serde(default, rename = "bellOnComplete")]
pub bell_on_complete: bool,
}

impl Settings {
/// Whether to show completion toasts (default: enabled).
pub fn completion_toast_enabled(&self) -> bool {
self.completion_toast.unwrap_or(true)
}
}

/// A user-defined slash command template.
Expand Down Expand Up @@ -1729,6 +1743,8 @@ pub mod config {
file_autocomplete_show_hidden_files: over.file_autocomplete_show_hidden_files || base.file_autocomplete_show_hidden_files,
file_injection_enabled: over.file_injection_enabled || base.file_injection_enabled,
file_injection_max_size: if over.file_injection_max_size != 0 { over.file_injection_max_size } else { base.file_injection_max_size },
completion_toast: over.completion_toast.or(base.completion_toast),
bell_on_complete: over.bell_on_complete || base.bell_on_complete,
}
}
}
Expand Down
31 changes: 19 additions & 12 deletions src-rust/crates/tools/src/bash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,13 @@ async fn run_in_background(

// If notify_on_complete is requested and a notifier is available, spawn a
// watcher task that polls the registry until the task reaches a terminal
// state, then injects a completion message into the agent's next turn.
// state, then dispatches a structured completion event. Consumers fan-out
// to system-message injection, TUI toasts, terminal bell, etc.
if notify_on_complete {
if let Some(notifier) = completion_notifier {
let watcher_task_id = task_id.clone();
let watcher_command = command.clone();
let started_at = std::time::Instant::now();
tokio::spawn(async move {
loop {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
Expand All @@ -267,27 +269,32 @@ async fn run_in_background(
| claurst_core::tasks::TaskStatus::Failed(_)
| claurst_core::tasks::TaskStatus::Cancelled
) => {
let exit_info = match &t.status {
claurst_core::tasks::TaskStatus::Completed => "exit 0".to_string(),
let (success, exit_info) = match &t.status {
claurst_core::tasks::TaskStatus::Completed => {
(true, "exit 0".to_string())
}
claurst_core::tasks::TaskStatus::Failed(msg) => {
format!("failed: {}", msg)
(false, format!("failed: {}", msg))
}
claurst_core::tasks::TaskStatus::Cancelled => {
"cancelled".to_string()
(false, "cancelled".to_string())
}
_ => unreachable!(),
};
let output = t.output.join("\n");
let output_tail = if output.len() > 2000 {
&output[output.len() - 2000..]
output[output.len() - 2000..].to_string()
} else {
&output
output
};
let msg = format!(
"[Monitor] Background task {} completed ({}).\nCommand: {}\nOutput (last 2000 chars):\n{}",
watcher_task_id, exit_info, watcher_command, output_tail
);
notifier.notify(msg);
notifier.notify(crate::BgTaskCompletion {
task_id: watcher_task_id.clone(),
command: watcher_command.clone(),
success,
exit_info,
output_tail,
duration_secs: started_at.elapsed().as_secs(),
});
break;
}
None => break, // Task disappeared from registry
Expand Down
33 changes: 27 additions & 6 deletions src-rust/crates/tools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,17 +231,38 @@ pub fn clear_session_shadow(working_dir: &std::path::Path) {
}


/// A cloneable handle for injecting notification messages into the next agent turn.
/// Used by background tasks with `notify_on_complete` to signal completion without polling.
/// Structured payload describing a background task's terminal state.
/// Carried by [`CompletionNotifier`] so consumers can build their own UI
/// (system-message injection, TUI toasts, terminal bell, etc.) without
/// re-parsing a string.
#[derive(Debug, Clone)]
pub struct BgTaskCompletion {
pub task_id: String,
pub command: String,
/// `true` if the task exited 0; `false` for non-zero, cancellation, or timeout.
pub success: bool,
/// Short human-readable status: `"exit 0"`, `"failed: ..."`, `"cancelled"`.
pub exit_info: String,
/// Last ~2000 characters of stdout/stderr (for system-message injection).
pub output_tail: String,
/// How long the task ran before reaching the terminal state.
pub duration_secs: u64,
}

/// A cloneable handle invoked when a background bash task reaches a terminal state.
/// Used by `notify_on_complete` to signal completion without polling.
///
/// The closure receives a structured [`BgTaskCompletion`] so it can fan-out to
/// multiple sinks (e.g., inject a system message AND show a TUI toast).
#[derive(Clone)]
pub struct CompletionNotifier(Arc<dyn Fn(String) + Send + Sync>);
pub struct CompletionNotifier(Arc<dyn Fn(BgTaskCompletion) + Send + Sync>);

impl CompletionNotifier {
pub fn new(f: impl Fn(String) + Send + Sync + 'static) -> Self {
pub fn new(f: impl Fn(BgTaskCompletion) + Send + Sync + 'static) -> Self {
Self(Arc::new(f))
}
pub fn notify(&self, msg: String) {
(self.0)(msg);
pub fn notify(&self, info: BgTaskCompletion) {
(self.0)(info);
}
}

Expand Down
Loading
Loading