From 0b190f81653b6e763baead32de6b3518acd83587 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 29 May 2026 21:31:54 -0400 Subject: [PATCH 1/3] refactor(ui): address code-review findings on the notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the review of PR #231: - #1 (run.rs): the cap notice's new_messages.push is a legitimate part of run_agent_loop's returned message list, but the comment overclaimed that headless/subagent collectors and resumed models read it — they drive display from the LoopEvent stream and discard the return value. Comment corrected to describe it as a return-value contract nicety, not the display mechanism. - #2 (provider/mod.rs): headless run_print dropped SystemNotice into its catch-all, so a --print run hitting the turn cap reported a clean success with no truncation signal. It now prints the notice to stderr. - #3 (text_output.rs): documented the deliberate divergence between the live /warning-color notice and persisted /system-color session history, cross-referencing render_session. - #4 + altitude (text_output.rs): extracted write_prefixed_lines() shared by write_user_lines and write_system_lines, removing the copy-paste and making blank/empty-line handling identical by construction. --- src/agent/agent_loop/run.rs | 11 +++-- src/provider/mod.rs | 11 +++++ src/ui/text_output.rs | 98 +++++++++++++++++-------------------- 3 files changed, 62 insertions(+), 58 deletions(-) diff --git a/src/agent/agent_loop/run.rs b/src/agent/agent_loop/run.rs index d6665743..028fe3de 100644 --- a/src/agent/agent_loop/run.rs +++ b/src/agent/agent_loop/run.rs @@ -1211,10 +1211,13 @@ pub async fn run_loop( content: notice.clone(), }) .await; - // Still record it in the returned transcript so headless / - // subagent collectors (and a resumed model) see that the - // run was truncated. This is the model-facing transcript, - // not the user's on-screen echo. + // Also include it in `run_agent_loop`'s returned message + // list so a caller that inspects the produced messages can + // see the run was truncated. NOTE: the interactive and + // headless paths drive display from the LoopEvent stream + // (the SystemNotice above), not from this return value — + // today's production callers discard it — so this is a + // contract nicety, not the display mechanism. new_messages.push(LoopMessage::User(super::message::UserMessage { content: notice, })); diff --git a/src/provider/mod.rs b/src/provider/mod.rs index d82f6ffa..0398af44 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -1055,6 +1055,17 @@ impl AnyAgent { AgentEvent::TurnEnd { .. } => { num_turns += 1; } + AgentEvent::SystemNotice { content } => { + // dirge-originated runtime notice (e.g. the + // max-agent-turns cap). Headless drives output from + // events, so surface it to stderr — otherwise a + // truncated run looks like a clean success to a + // `--print` consumer. + if had_output { + println!(); + } + eprintln!("{}", content); + } // Plugin-driven model swap after last run puts the // request in the mgr; caller drains via // take_pending_next_model(). diff --git a/src/ui/text_output.rs b/src/ui/text_output.rs index f64db6a8..46cd1958 100644 --- a/src/ui/text_output.rs +++ b/src/ui/text_output.rs @@ -68,77 +68,67 @@ mod strip_system_reminder_tests { } } -/// Print a (possibly multi-line) user-typed message to the chat log -/// as a single visual message: the first line gets the ` ` -/// prefix, continuation lines are indented to align under it, and -/// blank lines stay blank (so an expanded paste doesn't produce a -/// column of empty `` markers, as reported by users pasting -/// multi-paragraph text). `sanitize_output` is applied per line to -/// strip control bytes — the paste-placeholder SOH markers in -/// particular must not leak to the terminal. -pub(crate) fn write_user_lines(renderer: &mut Renderer, text: &str) -> std::io::Result<()> { - const PREFIX: &str = " "; - // Visible width of `PREFIX` — 6 cells. Used as the continuation - // indent so wrapped lines line up under the first character of - // the message body. - const CONT_INDENT: &str = " "; +/// Print a (possibly multi-line) prefixed message to the chat log as a +/// single visual block: the first line gets `prefix`, continuation lines +/// are indented to align under it, blank lines stay blank (so an expanded +/// paste / multi-line notice doesn't produce a column of empty prefix +/// markers), and an entirely-empty `text` still emits one prefix line so +/// the submission is acknowledged. Every line (including blanks and the +/// fallback) renders in `color`; `sanitize_output` strips control bytes +/// per line so paste-placeholder SOH markers and ANSI escapes can't leak +/// to the terminal. +/// +/// Shared by `write_user_lines` (``) and `write_system_lines` +/// (``) so their wrap/blank/empty handling can't drift. +fn write_prefixed_lines( + renderer: &mut Renderer, + prefix: &str, + color: crossterm::style::Color, + text: &str, +) -> std::io::Result<()> { + // Continuation indent = the prefix's visible width, so wrapped lines + // line up under the first character of the body. + let cont_indent = " ".repeat(prefix.chars().count()); let mut prefix_emitted = false; for line in text.lines() { let safe = sanitize_output(line); if safe.is_empty() { - // Preserve blank lines as actual blank rows — no prefix, - // no indent — so paragraphs stay paragraphs in the log. - renderer.write_line("", theme::user())?; + renderer.write_line("", color)?; continue; } let formatted = if !prefix_emitted { prefix_emitted = true; - format!("{}{}", PREFIX, safe) + format!("{}{}", prefix, safe) } else { - format!("{}{}", CONT_INDENT, safe) + format!("{}{}", cont_indent, safe) }; - renderer.write_line(&formatted, theme::user())?; + renderer.write_line(&formatted, color)?; } - // If `text` was entirely empty (no `lines()` iterations) emit a - // single `` line so the user still sees their (empty) - // submission acknowledged. if !prefix_emitted { - renderer.write_line(PREFIX, theme::user())?; + renderer.write_line(prefix, color)?; } Ok(()) } -/// Print a dirge-originated log/notice (e.g. the max-agent-turns cap -/// message) to the chat log: the first line gets a ` ` prefix, -/// continuation lines align under it, and the whole thing renders in the -/// warning color so it reads as a runtime notice rather than something -/// the user typed (which uses `` + the user color via -/// `write_user_lines`). `sanitize_output` strips control bytes per line. +/// Print a (possibly multi-line) user-typed message to the chat log: the +/// first line gets the ` ` prefix in the user color, continuation +/// lines align under it. See `write_prefixed_lines`. +pub(crate) fn write_user_lines(renderer: &mut Renderer, text: &str) -> std::io::Result<()> { + write_prefixed_lines(renderer, " ", theme::user(), text) +} + +/// Print a dirge-originated log/notice (e.g. the max-agent-turns cap) to +/// the chat log with a ` ` prefix in the *warning* color so it +/// reads as a transient runtime notice that stands out from the user's +/// own messages and agent output. +/// +/// Note the deliberate divergence from persisted session-history system +/// messages (`MessageRole::System`), which render as `` in +/// `theme::system()` — see `render_session` in `ui/events.rs`. Those are +/// durable context (summaries, bootstrap); this is an attention-grabbing +/// live log line, hence the louder warning color and distinct label. pub(crate) fn write_system_lines(renderer: &mut Renderer, text: &str) -> std::io::Result<()> { - const PREFIX: &str = " "; - // Visible width of `PREFIX` — 9 cells — used as the continuation - // indent so wrapped lines line up under the message body. - const CONT_INDENT: &str = " "; - let color = theme::warn(); - let mut prefix_emitted = false; - for line in text.lines() { - let safe = sanitize_output(line); - if safe.is_empty() { - renderer.write_line("", color)?; - continue; - } - let formatted = if !prefix_emitted { - prefix_emitted = true; - format!("{}{}", PREFIX, safe) - } else { - format!("{}{}", CONT_INDENT, safe) - }; - renderer.write_line(&formatted, color)?; - } - if !prefix_emitted { - renderer.write_line(PREFIX, color)?; - } - Ok(()) + write_prefixed_lines(renderer, " ", theme::warn(), text) } /// Flatten a multi-line / control-char-bearing string into one safe line From ec30e7f6df3f4746dcbc68521dbd355818e885cf Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 29 May 2026 21:32:03 -0400 Subject: [PATCH 2/3] feat(ui): show active background-agent count in the status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `agents:N` segment to the bottom status line, shown only when N>0 (like the compaction/mode badges) so the bar stays quiet during normal single-agent work. N is BackgroundStore::running_count() — the number of in-flight background `task` subagents — read at each render and threaded into StatusLine::render via the bg_store already in scope at every call site. (Scoped to agents only: dirge has no background-shell registry — bash runs synchronously within a tool call — so there is no persistent 'shells' count to display.) --- src/ui/mod.rs | 84 +++++++++++++++++++++++++----------------------- src/ui/status.rs | 41 ++++++++++++++++++++++- 2 files changed, 84 insertions(+), 41 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a9a0887a..1364cd6f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -456,6 +456,7 @@ pub async fn run_interactive( None, context.current_prompt_name.as_deref(), perm_mode().as_deref(), + bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0), ), interjection_queue.lock().unwrap().len(), ), @@ -713,7 +714,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -747,7 +748,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -761,7 +762,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -770,7 +771,7 @@ pub async fn run_interactive( input.handle_paste(&text); renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -783,7 +784,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -799,7 +800,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -809,7 +810,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -884,7 +885,7 @@ pub async fn run_interactive( )?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; } else { @@ -930,7 +931,7 @@ pub async fn run_interactive( renderer.write_line(msg, c_error())?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -947,7 +948,7 @@ pub async fn run_interactive( } renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; if rewind_picker.active { @@ -965,7 +966,7 @@ pub async fn run_interactive( rewind_picker.draw()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -974,7 +975,7 @@ pub async fn run_interactive( renderer.write_line("Press Esc again to rewind...", theme::dim())?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -994,7 +995,7 @@ pub async fn run_interactive( )?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1065,7 +1066,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1118,7 +1119,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1131,7 +1132,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1141,7 +1142,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1151,7 +1152,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1160,7 +1161,7 @@ pub async fn run_interactive( renderer.scroll_to_bottom()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1173,7 +1174,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; if let Some(ref picker) = input.picker { @@ -1210,7 +1211,7 @@ pub async fn run_interactive( } renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1248,7 +1249,7 @@ pub async fn run_interactive( )?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1269,7 +1270,7 @@ pub async fn run_interactive( )?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1324,7 +1325,7 @@ pub async fn run_interactive( } renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1366,7 +1367,7 @@ pub async fn run_interactive( )?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -1704,7 +1705,7 @@ pub async fn run_interactive( } renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; if let Some(ref picker) = input.picker { @@ -2426,7 +2427,7 @@ pub async fn run_interactive( } renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; if let Some(ref picker) = input.picker { @@ -2551,6 +2552,7 @@ pub async fn run_interactive( loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), + bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0), ), interjection_queue.lock().unwrap().len(), ), @@ -2631,6 +2633,7 @@ pub async fn run_interactive( loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), + bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0), ), interjection_queue.lock().unwrap().len(), ), @@ -2653,7 +2656,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -2902,7 +2905,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; if let Some(ref picker) = input.picker { @@ -2986,6 +2989,7 @@ pub async fn run_interactive( loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), + bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0), ), interjection_queue.lock().unwrap().len(), ), @@ -3049,7 +3053,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; } @@ -3278,7 +3282,7 @@ pub async fn run_interactive( is_running = true; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; } @@ -3431,7 +3435,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; @@ -3506,7 +3510,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; let ev = user_rx.recv().await; @@ -3596,7 +3600,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; if let Some(ref picker) = input.picker { @@ -3660,7 +3664,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -3730,7 +3734,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -3776,7 +3780,7 @@ pub async fn run_interactive( } renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; } @@ -3816,7 +3820,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; continue; @@ -3889,7 +3893,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; if let Some(ref picker) = input.picker { @@ -3899,7 +3903,7 @@ pub async fn run_interactive( _ = tokio::time::sleep(tokio::time::Duration::from_millis(200)), if is_running => { renderer.draw_bottom( &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.lock().unwrap().len()), + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref(), bg_store.as_ref().map(|s| s.running_count()).unwrap_or(0)), interjection_queue.lock().unwrap().len()), is_running, )?; if let Some(ref picker) = input.picker { diff --git a/src/ui/status.rs b/src/ui/status.rs index 9a9b1723..7f27b73f 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -56,6 +56,7 @@ impl StatusLine { loop_label: Option<&str>, prompt_name: Option<&str>, perm_mode: Option<&str>, + active_agents: usize, ) -> String { let state = if is_running { "running" } else { "ready" }; let wd_path = Path::new(&session.working_dir); @@ -105,8 +106,17 @@ impl StatusLine { _ => String::new(), }; + // Active background subagents (task tool). Shown only when any are + // running, like the other conditional badges, so the bar stays + // quiet during normal single-agent work. + let agents_badge = if active_agents > 0 { + format!(" | agents:{}", active_agents) + } else { + String::new() + }; + format!( - "{}{} | {}{} | {}/{} ({}%) | {}msgs | {}{}{}{}", + "{}{} | {}{} | {}/{} ({}%) | {}msgs | {}{}{}{}{}", project_label, cost_str, session.model, @@ -119,6 +129,35 @@ impl StatusLine { compact_badge, prompt_badge, perm_badge, + agents_badge, ) } } + +#[cfg(test)] +mod tests { + use super::StatusLine; + use crate::session::Session; + + fn render_with_agents(n: usize) -> String { + let session = Session::new("openrouter", "test-model", 100_000); + StatusLine::render(&session, false, 0, None, None, None, n) + } + + #[test] + fn agents_badge_hidden_when_none_active() { + assert!( + !render_with_agents(0).contains("agents:"), + "no agents badge expected when zero background subagents are running" + ); + } + + #[test] + fn agents_badge_shown_with_count_when_active() { + let line = render_with_agents(3); + assert!( + line.contains("agents:3"), + "expected `agents:3` segment in status line, got: {line}" + ); + } +} From d16ef86d1ac726785b7261ab6b226816c70a5341 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 29 May 2026 21:32:04 -0400 Subject: [PATCH 3/3] docs(ui): correct keyboard-shortcut listings in /help and docs/tui.md Update the /help keybinding list (cmd_misc.rs), the session help (cmd_session.rs), and docs/tui.md to reflect the current subagent chat-window shortcuts: Ctrl+K kills the focused subagent, Ctrl+X closes the active chat window, Ctrl+N/Ctrl+P switch between subagent chat windows, and Ctrl+O expands a collapsed tool result. --- docs/tui.md | 6 ++++-- src/ui/slash/cmd_misc.rs | 18 +++++++++++------- src/ui/slash/cmd_session.rs | 6 ++++-- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index 6f3fb5f8..58fa87a3 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -35,9 +35,11 @@ slash-command list, see the top-level [README](../README.md#slash-commands). |-----|--------| | Ctrl+C / Ctrl+D / Esc | Interrupt running agent (also clears queued interjections) | | Type while running | Queues your message; runs after the current turn finishes. The runner also stops at the next tool-result boundary so the message is picked up quickly instead of waiting for the whole multi-turn run. Status line shows `q:N` for pending count. | -| Ctrl+X | Drop the most-recently-queued interjection | +| Ctrl+K | Kill subagent on focused chat tab | +| Ctrl+X | Close active chat window | +| Ctrl+N / Ctrl+P | Switch to next/previous chat window (when multiple subagent chats exist) | | Esc-Esc (idle) | Open rewind picker (truncate history) | -| Ctrl+F | Search chat buffer | +| Ctrl+O | Expand collapsed tool result | | Ctrl+R | Toggle reasoning visibility | | PgUp/PgDn | Scroll chat history | | Home/End | Jump to top/bottom | diff --git a/src/ui/slash/cmd_misc.rs b/src/ui/slash/cmd_misc.rs index 4a3cb9cc..f633785a 100644 --- a/src/ui/slash/cmd_misc.rs +++ b/src/ui/slash/cmd_misc.rs @@ -666,29 +666,33 @@ pub(super) async fn cmd_help(ctx: &mut SlashCtx<'_>) -> anyhow::Result<()> { " drag select text; mouse-up copies to clipboard", c_result(), )?; + renderer.write_line(" Ctrl+R toggle reasoning", c_result())?; + renderer.write_line(" Ctrl+C / Ctrl+D / Esc interrupt/quit", c_result())?; renderer.write_line( - " y / Esc (while selecting) copy selection / clear without copy", + " Ctrl+N / Ctrl+P next / previous chat (subagent windows)", c_result(), )?; - renderer.write_line(" Ctrl+R toggle reasoning", c_result())?; - renderer.write_line(" Ctrl+C / Ctrl+D interrupt/quit", c_result())?; + renderer.write_line(" Ctrl+X close chat window", c_result())?; renderer.write_line( - " Ctrl+N / Ctrl+P next / previous chat (subagent windows)", + " Ctrl+K kill subagent on focused tab", c_result(), )?; renderer.write_line( - " Ctrl+X / /tasks cycle through chat windows", + " Ctrl+O expand collapsed tool result", c_result(), )?; renderer.write_line( - " Alt+X drop last queued interjection", + " Esc-Esc (idle) open rewind picker (truncate history)", + c_result(), + )?; + renderer.write_line( + " ! / !! cmd run shell command (visible / invisible)", c_result(), )?; renderer.write_line( " (type while agent runs to queue a follow-up message)", c_result(), )?; - renderer.write_line(" PgUp/PgDn / wheel scroll chat history", c_result())?; #[cfg(feature = "plugin")] if let Some(pm_arc) = crate::plugin::hook::global() { diff --git a/src/ui/slash/cmd_session.rs b/src/ui/slash/cmd_session.rs index 1860df57..debf2e60 100644 --- a/src/ui/slash/cmd_session.rs +++ b/src/ui/slash/cmd_session.rs @@ -211,8 +211,10 @@ pub(super) async fn cmd_tasks(ctx: &mut SlashCtx<'_>) -> anyhow::Result<()> { ctx.renderer .write_line(&format!(" {} [{}] {}", marker, i, name), c_result())?; } - ctx.renderer - .write_line(" (Ctrl-N / Ctrl-P / Ctrl-X to switch)", theme::dim())?; + ctx.renderer.write_line( + " (Ctrl-N / Ctrl-P to cycle, Ctrl+X to close)", + theme::dim(), + )?; } Ok(()) }