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
6 changes: 4 additions & 2 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 7 additions & 4 deletions src/agent/agent_loop/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
Expand Down
11 changes: 11 additions & 0 deletions src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down
84 changes: 44 additions & 40 deletions src/ui/mod.rs

Large diffs are not rendered by default.

18 changes: 11 additions & 7 deletions src/ui/slash/cmd_misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 4 additions & 2 deletions src/ui/slash/cmd_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
41 changes: 40 additions & 1 deletion src/ui/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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}"
);
}
}
98 changes: 44 additions & 54 deletions src/ui/text_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<you> `
/// 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 `<you>` 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 = "<you> ";
// 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` (`<you>`) and `write_system_lines`
/// (`<system>`) 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 `<you>` 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 `<system> ` 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 `<you>` + 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 `<you> ` 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, "<you> ", theme::user(), text)
}

/// Print a dirge-originated log/notice (e.g. the max-agent-turns cap) to
/// the chat log with a `<system> ` 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 `<sys>` 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 = "<system> ";
// 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, "<system> ", theme::warn(), text)
}

/// Flatten a multi-line / control-char-bearing string into one safe line
Expand Down
Loading