Synced from monorepo

Synced from monorepo

Changes:
- Shell: accept target response id on rewind execute
- Shell: stamp response id on chat user message chunks
- Worktree: optional rebuild and stale git registration cleanup in auto-GC
- Worktree: kind-aware auto-GC TTLs and config knobs
- Worktree: macOS process CWD scan and Unix PID liveness for GC guards
- Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only)
- Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups
- Shell: stop overwriting user skills
- Tools: read markdown in `skills/` directories untruncated
- `/usage` shows per-session token and dollar usage in the TUI
- Security: prompt on environment-dumping `ps` variants
- Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission
- Tools: make scheduler deletion durable
- Shell: add relocation storage primitives
- Shell: give side model calls their own conversation ids
- Fix five workflow-runtime bugs (budget, pause, cancel, reconnect)
- Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask)
- Pager: expose doctor in the TUI
- Security: block unauthorized RCE via abused safe commands
- Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent"
- Security: block `rg --pre` arbitrary code execution in auto-mode
- Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section
- App builder deployer: `allow_forking` and `show_built_with_grok`
- Pager: stop stacking duplicate "Worked for" markers on parked turns
- Shell: support `max` as a distinct reasoning effort tier
- Tools: serialize background `/loop` fires on the whole work unit
- Shell: add working-directory relocation state primitives
- Proto: `ClientToolResult` and `ChatConfig` client-side tools
- Shell: model providers
- Chat: select App Builder product on the Build path
- Shell: attach author identity to feedback when the deployment opts in
- Doctor: fix for SSH wrap setup
- Workflow authoring skills: create-workflow and import-claude-workflow docs
- Add read-only grok doctor
- Sandbox: apply Landlock without a controlling TTY
- Pager: recover image paste over grok wrap on headless remotes
- Pager: make actions screen-mode aware
- Shell: resume sessions when the working directory moves
- Pager: centralize terminal diagnostics
- Workspace: gate inline shell file access
- Pager: centralize terminal probes
- Pager: edit minimal prompts in an external editor
- Pager: standardize backgrounding on Ctrl+B
- Shell: recap rides the parent turn's prompt cache
- Tools: add scheduler lifecycle version clock

Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View file

@ -1198,8 +1198,11 @@ pub fn build_hints(
{
hints.push(def.hint());
}
if can_demote {
hints.push(HintItem::new(crate::key!('g', CONTROL), "send to bg"));
if can_demote
&& !is_subagent_view
&& let Some(key) = registry.key_for(ActionId::SendToBackground)
{
hints.push(HintItem::new(key, "send to bg"));
}
hints
}
@ -1267,6 +1270,40 @@ mod tests {
hints.iter().take(2).map(|h| h.label.as_ref()).collect()
}
#[test]
fn demotion_hint_uses_registered_ctrl_b_binding() {
let registry = ActionRegistry::defaults();
let hints = build_hints(
ActivePane::Scrollback,
&PromptWidget::default(),
&registry,
false,
None,
None,
"expand thinking",
false,
false,
None,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
None,
);
let hint = hints
.iter()
.find(|hint| hint.label == "send to bg")
.expect("running Execute should advertise demotion");
assert_eq!(hint.keys, vec![crate::key!('b', CONTROL)]);
}
#[test]
fn group_header_shows_enter_toggle_hint_instead_of_open_and_fold() {
let registry = ActionRegistry::defaults();
let hints = build_hints(

View file

@ -175,6 +175,8 @@ fn goal_phase_label(goal: &GoalDisplayState) -> String {
| GoalDisplayStatus::NoProgressPaused
| GoalDisplayStatus::InfraPaused
| GoalDisplayStatus::Blocked => goal.status.pause_label().into(),
GoalDisplayStatus::Failed => "Failed".into(),
GoalDisplayStatus::Interrupted => "Interrupted".into(),
GoalDisplayStatus::BudgetLimited => "Budget".into(),
GoalDisplayStatus::Complete => "Done".into(),
GoalDisplayStatus::Active => active_phase_label(goal),
@ -254,6 +256,11 @@ pub fn goal_status_line(
// visually matches the modal's `theme.warning` status row.
let mut label_style = if goal.status.is_paused() {
Style::default().fg(theme.bg_base).bg(theme.warning)
} else if matches!(
goal.status,
GoalDisplayStatus::Failed | GoalDisplayStatus::Interrupted
) {
Style::default().fg(theme.bg_base).bg(theme.accent_error)
} else {
Style::default().fg(theme.accent_plan).bg(theme.bg_base)
};
@ -266,12 +273,13 @@ pub fn goal_status_line(
let is_active = matches!(goal.status, GoalDisplayStatus::Active);
let chip_name = "Goal";
let goal_text = if is_active {
let frames = crate::glyphs::dot_spinner_frames();
let frame = frames[(tick / 4) % frames.len()];
format!("{frame} Goal: {label}")
format!("{frame} {chip_name}: {label}")
} else {
format!("Goal: {label}")
format!("{chip_name}: {label}")
};
Line::from(vec![
@ -688,6 +696,18 @@ mod tests {
assert_ne!(label_span.style.bg, Some(t.warning));
}
#[test]
fn phase_label_failed() {
let g = make_goal(
GoalDisplayStatus::Failed,
GoalDisplayPhase::Idle,
None,
0,
0,
);
assert_eq!(goal_phase_label(&g), "Failed");
}
#[test]
fn phase_label_budget_limited() {
let g = make_goal(

View file

@ -51,6 +51,7 @@ pub struct PeekAllocation {
/// - `pin_user`: last user exists → budget one pin row (paint charges it too).
/// - Blank when body > 0 and room remains after pin+body (paint blanks only
/// when middle still has ≥2 rows after the blank so pin + body share).
///
/// Empty body reserves 1 row for the empty/hint line. Never exceeds
/// `max_content`; body is also capped by [`MAX_LIVE_TAIL_ROWS`].
pub fn peek_live_tail_desired_content(

View file

@ -1021,6 +1021,7 @@ pub fn extract_last_response_type(agent: &AgentView) -> String {
}
}
RenderBlock::Subagent(_) => return "Subagent".to_string(),
RenderBlock::Workflow(_) => return "Workflow".to_string(),
RenderBlock::BgTask(_) => return "Task".to_string(),
RenderBlock::Btw(_) => return "Btw".to_string(),
RenderBlock::ContextInfo(_) => return "Context".to_string(),
@ -1150,6 +1151,7 @@ fn block_short_text(block: &crate::scrollback::block::RenderBlock) -> Option<Str
RenderBlock::ToolCall(_) => Some("(tool call)".to_string()),
RenderBlock::BgTask(_) => Some("(background task)".to_string()),
RenderBlock::Subagent(_) => Some("(subagent)".to_string()),
RenderBlock::Workflow(_) => Some("(workflow)".to_string()),
RenderBlock::Btw(_) => Some("(btw)".to_string()),
RenderBlock::ContextInfo(_) => Some("(context info)".to_string()),
RenderBlock::CreditLimit(_) => Some("(credit limit)".to_string()),

View file

@ -6040,10 +6040,10 @@ mod tests {
);
assert!(
lines.iter().any(|l| matches!(
l,
DashboardLine::Header { state, count }
if *state == RowState::Working && *count == 2
)),
l,
DashboardLine::Header { state, count }
if *state == RowState::Working && *count == 2
)),
"collapsed Working header must still render with its true count",
);
let working_rows = lines
@ -6081,8 +6081,7 @@ mod tests {
assert!(
lines
.iter()
.any(|l| matches!(l, DashboardLine::PinnedHeader { count }
if *count == 1)),
.any(|l| matches!(l, DashboardLine::PinnedHeader { count } if *count == 1)),
"collapsed Pinned header must still render",
);
// The pinned row is hidden; the (non-pinned) Working row remains.
@ -6144,10 +6143,10 @@ if *count == 1)),
// Header still shows the TRUE total, not the visible count.
assert!(
lines.iter().any(|l| matches!(
l,
DashboardLine::Header { state, count }
if *state == RowState::Idle && *count == total as usize
)),
l,
DashboardLine::Header { state, count }
if *state == RowState::Idle && *count == total as usize
)),
"Idle header keeps the true total count",
);
}

View file

@ -196,7 +196,11 @@ fn build_local_rows(
if !include_subagents {
continue;
}
let mut subagents: Vec<&SubagentInfo> = agent.subagent_sessions.values().collect();
let mut subagents: Vec<&SubagentInfo> = agent
.subagent_sessions
.values()
.filter(|info| info.workflow_run_id.is_none())
.collect();
subagents.sort_by(|a, b| {
let a_running = !a.finished;
let b_running = !b.finished;
@ -400,47 +404,22 @@ pub fn has_background_work(agent: &AgentView) -> bool {
.any(|t| t.status == crate::app::agent::BgTaskStatus::Running)
|| !agent.session.scheduled_tasks.is_empty()
}
/// Compact `"watching · …"` label summarising a turn-idle agent's live
/// background work, listing only the non-zero kinds with singular/plural
/// nouns — e.g. `"watching · 1 monitor · 2 loops"` or
/// `"watching · 1 task"`. `None` when there's no background work (the
/// caller then falls back to a bare `"Working"`). Mirrors the agent
/// view's idle "watching" cue (`turn_status::watching_label`) so the
/// dashboard and the agent view speak the same language; the counting
/// matches [`has_background_work`].
/// Compact `"… still running"` label summarising a turn-idle agent's live
/// background work — e.g. `"1 monitor · 2 loops still running"` or
/// `"1 task still running"`. `None` when there's no background work (the
/// caller then falls back to a bare `"Working"`). Shares the format
/// mechanics with the agent view's idle cue
/// ([`crate::views::turn_status::format_still_running`]) but keeps the
/// dashboard's own nouns ("task", not "command") and omits subagents —
/// dashboard rows list those separately. Counts come from local state (not
/// backend content), so no sanitise.
fn background_work_label(agent: &AgentView) -> Option<String> {
use std::fmt::Write as _;
let mut monitors = 0usize;
let mut tasks = 0usize;
for t in agent.session.bg_tasks.values() {
if t.status != crate::app::agent::BgTaskStatus::Running {
continue;
}
if t.is_monitor {
monitors += 1;
} else {
tasks += 1;
}
}
let loops = agent.session.scheduled_tasks.len();
if monitors + tasks + loops == 0 {
return None;
}
let mut label = String::with_capacity(24);
label.push_str("watching");
if monitors > 0 {
let noun = if monitors == 1 { "monitor" } else { "monitors" };
let _ = write!(label, " \u{00b7} {monitors} {noun}");
}
if loops > 0 {
let noun = if loops == 1 { "loop" } else { "loops" };
let _ = write!(label, " \u{00b7} {loops} {noun}");
}
if tasks > 0 {
let noun = if tasks == 1 { "task" } else { "tasks" };
let _ = write!(label, " \u{00b7} {tasks} {noun}");
}
Some(label)
let w = agent.watchers();
crate::views::turn_status::format_still_running([
(w.monitors, "monitor"),
(w.loops, "loop"),
(w.commands, "task"),
])
}
/// Classify a subagent.
///
@ -1084,6 +1063,7 @@ mod tests {
context_source: None,
resumed_from: None,
capability_mode: None,
workflow_run_id: None,
context_normalized: false,
child_updates_replayed: false,
parent_prompt_id: None,
@ -1118,6 +1098,30 @@ mod tests {
assert_eq!(classify_subagent(&info), RowState::Working);
}
#[test]
fn full_tree_excludes_workflow_owned_subagent_rows() {
let mut agents = IndexMap::new();
let mut agent = crate::app::agent_view::test_fixtures::make_agent();
let mut workflow_child = make_subagent("workflow-child", false, None);
workflow_child.workflow_run_id = Some(Arc::from("wf_1"));
agent
.subagent_sessions
.insert("workflow-child".into(), workflow_child);
agents.insert(AgentId(0), agent);
let rows = build_rows(
&agents,
&Default::default(),
&[],
Some(AgentId(0)),
super::super::state::Grouping::State,
&Filter::default(),
None,
);
assert!(
rows.iter()
.all(|row| !matches!(row.id, DashboardRowId::Subagent { .. }))
);
}
#[test]
fn classify_subagent_completed() {
let info = make_subagent("a", true, Some("completed"));
assert_eq!(classify_subagent(&info), RowState::Completed);
@ -1921,7 +1925,7 @@ mod tests {
.insert("m1".into(), running_bg_task("m1", true));
assert_eq!(classify_top_level(&agent), RowState::Working);
let row = top_level_row(AgentId(0), &agent, false, false, None);
assert_eq!(row.activity.as_deref(), Some("watching · 1 monitor"));
assert_eq!(row.activity.as_deref(), Some("1 monitor still running"));
}
/// An active scheduled `/loop` keeps the agent `Working` even with a
/// fully idle turn, labelled as a loop.
@ -1934,7 +1938,7 @@ mod tests {
.insert("l1".into(), scheduled_loop("l1"));
assert_eq!(classify_top_level(&agent), RowState::Working);
let row = top_level_row(AgentId(0), &agent, false, false, None);
assert_eq!(row.activity.as_deref(), Some("watching · 1 loop"));
assert_eq!(row.activity.as_deref(), Some("1 loop still running"));
}
/// The background-work label lists every non-zero kind (monitors,
/// then loops, then plain tasks) with correct singular/plural nouns.
@ -1961,12 +1965,12 @@ mod tests {
let row = top_level_row(AgentId(0), &agent, false, false, None);
assert_eq!(
row.activity.as_deref(),
Some("watching · 1 monitor · 1 loop · 2 tasks"),
Some("1 monitor · 1 loop · 2 tasks still running"),
);
}
/// The background-work label is the LAST activity fallback: a more
/// specific Working signal (here, replay loading) still wins over
/// "watching · …", so a real turn is never masked by it.
/// "… still running", so a real turn is never masked by it.
#[test]
fn specific_working_activity_wins_over_background_label() {
let mut agent = make_idle_agent_with_model(None);

View file

@ -4440,6 +4440,7 @@ fn dashboard_action_for_id(
| ActionId::OpenPrevLink
| ActionId::ToggleTodos
| ActionId::ToggleTasks
| ActionId::EditPromptExternal
| ActionId::ToggleQueue
| ActionId::OpenSessions
| ActionId::OpenExtensions

View file

@ -1650,6 +1650,29 @@ pub enum TabDataState<T> {
Error(String),
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct WorkflowInfo {
pub name: String,
pub description: String,
pub when_to_use: Option<String>,
pub source: String,
pub path: Option<String>,
}
impl WorkflowInfo {
fn has_usable_command_name(&self) -> bool {
let name = self.name.as_str();
!name.is_empty()
&& name.len() <= 64
&& !name.starts_with('-')
&& !name.ends_with('-')
&& !name.contains("--")
&& name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
}
/// State for the hooks/plugins modal popup.
pub struct ExtensionsModalState {
/// Shared modal window chrome state (close button, tabs, footer
@ -1706,6 +1729,7 @@ pub struct ExtensionsModalState {
pub skills_data: TabDataState<Vec<SkillInfo>>,
pub skills_selected: usize,
pub skills_scroll: usize,
pub workflows_data: TabDataState<Vec<WorkflowInfo>>,
/// MCP servers tab state.
pub mcps_data: TabDataState<Vec<crate::views::mcps_modal::McpServerInfo>>,
/// Last selection that triggered auto-scroll. Prevents mouse scroll
@ -1792,6 +1816,7 @@ impl ExtensionsModalState {
skills_data: TabDataState::Loading,
skills_selected: 0,
skills_scroll: 0,
workflows_data: TabDataState::Loading,
mcps_data: TabDataState::Loading,
mcps_scroll_pinned_selection: None,
mcps_scroll: 0,
@ -2587,6 +2612,74 @@ pub fn render_extensions_modal(
entry_badge_text.push(String::new());
entry_badge_color.push(None);
}
match state.workflows_data {
TabDataState::Loaded(ref workflows) => {
let query_lower = state.picker_state.query().to_lowercase();
let visible: Vec<&WorkflowInfo> = workflows
.iter()
.filter(|workflow| workflow.has_usable_command_name())
.filter(|w| {
query_lower.is_empty()
|| w.name.to_lowercase().contains(&query_lower)
|| w.description.to_lowercase().contains(&query_lower)
})
.collect();
if !visible.is_empty() {
entry_labels.push("Workflows".to_string());
entry_right_labels.push(String::new());
entry_desc_lines.push(vec![]);
entry_summary_lines.push(vec![]);
entry_fields.push(vec![]);
entry_is_header.push(true);
entry_dimmed.push(false);
entry_indent.push(0);
entry_data_indices.push(None);
entry_group_keys.push(None);
entry_badge_text.push(String::new());
entry_badge_color.push(None);
for wf in visible {
entry_labels.push(wf.name.clone());
entry_right_labels.push(format!("({})", wf.source));
if wf.description.is_empty() {
entry_desc_lines.push(vec![]);
} else {
entry_desc_lines.push(vec![wf.description.clone()]);
}
entry_summary_lines.push(vec![]);
let mut fields = Vec::new();
if let Some(ref p) = wf.path {
fields.push(("path".to_string(), p.clone()));
}
if let Some(ref w) = wf.when_to_use {
fields.push(("when to use".to_string(), w.clone()));
}
entry_fields.push(fields);
entry_is_header.push(false);
entry_dimmed.push(false);
entry_indent.push(0);
entry_data_indices.push(None);
entry_group_keys.push(None);
entry_badge_text.push(String::new());
entry_badge_color.push(None);
}
}
}
TabDataState::Error(ref msg) => {
entry_labels.push(format!("workflows: {}", msg));
entry_right_labels.push(String::new());
entry_desc_lines.push(vec![]);
entry_summary_lines.push(vec![]);
entry_fields.push(vec![]);
entry_is_header.push(false);
entry_dimmed.push(true);
entry_indent.push(0);
entry_data_indices.push(None);
entry_group_keys.push(None);
entry_badge_text.push(String::new());
entry_badge_color.push(None);
}
TabDataState::Loading => {}
}
}
ExtensionsTab::Plugins => {
if let TabDataState::Loaded(ref response) = state.plugins_data {
@ -4737,6 +4830,68 @@ mod tests {
assert_eq!(selected, 0);
}
#[test]
fn skills_tab_renders_workflows_group() {
let mut state = ExtensionsModalState::new(ExtensionsTab::Skills);
state.skills_data = TabDataState::Loaded(vec![]);
state.workflows_data = TabDataState::Loaded(vec![WorkflowInfo {
name: "fix-ci".to_string(),
description: "Fix failing CI on the current PR".to_string(),
when_to_use: Some("when CI is red".to_string()),
source: "builtin".to_string(),
path: None,
}]);
let area = Rect::new(0, 0, 100, 40);
let mut buf = Buffer::empty(area);
render_extensions_modal(&mut buf, area, &mut state, None, false, 0);
assert!(
buffer_count(&buf, "Workflows") >= 1,
"the Workflows group header must render on the Skills tab"
);
assert_eq!(
buffer_count(&buf, "fix-ci"),
1,
"the workflow name must render as a row"
);
assert_eq!(
buffer_count(&buf, "(builtin)"),
1,
"the workflow source must render as the right label"
);
assert!(
state.entry_data_indices.iter().all(|d| d.is_none()),
"workflow rows (and the header) must not map to skill data indices"
);
}
#[test]
fn skills_tab_hides_unusable_workflow_names() {
let mut state = ExtensionsModalState::new(ExtensionsTab::Skills);
state.skills_data = TabDataState::Loaded(vec![]);
state.workflows_data = TabDataState::Loaded(vec![
WorkflowInfo {
name: "valid-workflow".into(),
description: "Valid".into(),
when_to_use: None,
source: "project".into(),
path: None,
},
WorkflowInfo {
name: "Not Launchable".into(),
description: "Invalid".into(),
when_to_use: None,
source: "project".into(),
path: None,
},
]);
let area = Rect::new(0, 0, 100, 40);
let mut buf = Buffer::empty(area);
render_extensions_modal(&mut buf, area, &mut state, None, false, 0);
assert_eq!(buffer_count(&buf, "valid-workflow"), 1);
assert_eq!(buffer_count(&buf, "Not Launchable"), 0);
}
// ── Plugin fixtures ─────────────────────────────────────────────
fn make_plugin(name: &str) -> xai_hooks_plugins_types::PluginInfo {

View file

@ -59,7 +59,7 @@ fn budget_color(pct: f32, theme: &Theme) -> Color {
/// Format elapsed milliseconds as a compact human-readable duration.
/// Same style as `goal_orchestrator::format_elapsed` — keep in sync.
fn format_elapsed(ms: u64) -> String {
pub(crate) fn format_elapsed(ms: u64) -> String {
let total_secs = ms / 1000;
let hours = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
@ -86,6 +86,8 @@ fn status_label(goal: &GoalDisplayState) -> (&'static str, Color, String) {
| GoalDisplayStatus::NoProgressPaused
| GoalDisplayStatus::InfraPaused
| GoalDisplayStatus::Blocked => (goal.status.pause_label(), theme.warning, String::new()),
GoalDisplayStatus::Failed => ("Failed", theme.accent_error, String::new()),
GoalDisplayStatus::Interrupted => ("Interrupted", theme.accent_error, String::new()),
GoalDisplayStatus::BudgetLimited => ("Budget Limited", theme.accent_error, String::new()),
GoalDisplayStatus::Complete => ("Complete", theme.accent_success, String::new()),
}
@ -194,7 +196,7 @@ fn wrap_pause_message_lines(text: &str, width: u16) -> Vec<String> {
/// ellipsis if truncated. Uses display width (not char count) so CJK
/// and emoji characters measure correctly — matches the
/// `wrap_pause_message_lines` pattern.
fn truncate_to_width(text: &str, budget: usize) -> String {
pub(crate) fn truncate_to_width(text: &str, budget: usize) -> String {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
if UnicodeWidthStr::width(text) <= budget {
@ -220,7 +222,7 @@ fn truncate_to_width(text: &str, budget: usize) -> String {
/// timestamp, pause reason) can't break a rendered row even if ratatui's own
/// filter regresses. `keep_newlines` preserves `\n` for the pause-reason
/// wrapper (which splits on it before render); single-row sinks pass `false`.
fn strip_control_chars(s: &str, keep_newlines: bool) -> String {
pub(crate) fn strip_control_chars(s: &str, keep_newlines: bool) -> String {
s.chars()
.map(|c| {
if c.is_control() && !(keep_newlines && c == '\n') {
@ -387,7 +389,15 @@ pub fn goal_detail_area(screen: Rect, goal: &GoalDisplayState, todos: &[TodoItem
// 1 commands hint
let has_budget = goal.token_budget.is_some_and(|b| b > 0);
let budget_bar = if has_budget { 1u16 } else { 0 };
let pause_hint = if goal.status.is_paused() { 1u16 } else { 0 };
let recovery_hint = if goal.status.is_paused()
|| matches!(
goal.status,
GoalDisplayStatus::Failed | GoalDisplayStatus::Interrupted
) {
1u16
} else {
0
};
// Reason block renders as `Reason: <pause_message>` wrapped to the
// inner column width. Prefix is part of the wrapped content so
// continuation rows just continue at column 0 without alignment
@ -395,7 +405,11 @@ pub fn goal_detail_area(screen: Rect, goal: &GoalDisplayState, todos: &[TodoItem
// `is_paused()` to stay in sync with the renderer — a future shell
// bug that leaks `pause_message` on a non-paused snapshot must not
// grow the modal box without also rendering content into it.
let reason_lines = if goal.status.is_paused() {
let reason_lines = if goal.status.is_paused()
|| matches!(
goal.status,
GoalDisplayStatus::Failed | GoalDisplayStatus::Interrupted
) {
goal.pause_message
.as_deref()
.map(|m| {
@ -452,7 +466,7 @@ pub fn goal_detail_area(screen: Rect, goal: &GoalDisplayState, todos: &[TodoItem
let content_h = 2
+ 1
+ reason_lines
+ pause_hint
+ recovery_hint
+ 1
+ budget_bar
+ 1
@ -608,7 +622,6 @@ pub fn render_goal_detail(
return Some(close_rect);
}
// ── Pause hint (only for any paused variant) ──
if goal.status.is_paused() {
let hint = format!(
"Status: {} \u{2014} type /goal resume to continue",
@ -621,19 +634,35 @@ pub fn render_goal_detail(
w,
);
y += 1;
} else if matches!(
goal.status,
GoalDisplayStatus::Failed | GoalDisplayStatus::Interrupted
) {
let label = if goal.status == GoalDisplayStatus::Interrupted {
"Interrupted"
} else {
"Failed"
};
let hint = format!("Status: {label} \u{2014} type /goal clear, then start a new goal");
buf.set_line_safe(
x,
y,
&Line::from(Span::styled(hint, Style::default().fg(theme.warning))),
w,
);
y += 1;
if y >= inner.y + inner.height {
return Some(close_rect);
}
}
// ── Reason block (only when paused AND pause_message is set) ──
//
// Double-gate on `is_paused()`: the shell clears `pause_message` on
// every transition out of a paused state, but defending against a
// stale value on the wire is cheap and means a future shell bug
// can't make the modal render `Reason:` next to a non-paused status.
if goal.status.is_paused()
if (goal.status.is_paused()
|| matches!(
goal.status,
GoalDisplayStatus::Failed | GoalDisplayStatus::Interrupted
))
&& let Some(msg) = goal.pause_message.as_deref()
{
let formatted = format_pause_reason(msg);
@ -1015,15 +1044,15 @@ pub fn render_goal_detail(
// ── Commands hint ──
if y < inner.y + inner.height {
let hint_style = Style::default().fg(theme.gray_dim);
buf.set_line_safe(
x,
y,
&Line::from(Span::styled(
"Esc: close /goal resume | pause | status | clear",
hint_style,
)),
w,
);
let hint = if matches!(
goal.status,
GoalDisplayStatus::Failed | GoalDisplayStatus::Interrupted
) {
"Esc: close /goal clear, then start a new goal"
} else {
"Esc: close /goal resume | pause | status | clear"
};
buf.set_line_safe(x, y, &Line::from(Span::styled(hint, hint_style)), w);
}
Some(close_rect)
@ -1642,6 +1671,11 @@ mod tests {
assert_eq!(p, "", "phase_text for {status:?}");
}
goal.status = GoalDisplayStatus::Failed;
let (s, color, _) = status_label(&goal);
assert_eq!(s, "Failed");
assert_eq!(color, theme.accent_error);
goal.status = GoalDisplayStatus::BudgetLimited;
let (s, _, _) = status_label(&goal);
assert_eq!(s, "Budget Limited");

View file

@ -595,8 +595,7 @@ fn render_file_list(buf: &mut Buffer, area: Rect, state: &mut MemoryModalState,
);
if is_selected
&& matches!(state.mode, MemoryModalMode::ConfirmingDelete { idx }
if idx == filt_idx)
&& matches!(state.mode, MemoryModalMode::ConfirmingDelete { idx } if idx == filt_idx)
{
let hint = " [x to confirm]";
let hint_w = hint.len() as u16;

View file

@ -50,3 +50,4 @@ pub mod timeline;
pub mod todo_pane;
pub mod turn_status;
pub mod welcome;
pub mod workflows;

View file

@ -343,8 +343,10 @@ pub enum PaletteCommand {
NewSessionInWorktree,
Home,
Quit,
/// Execute a slash command by inserting it into the prompt.
/// Execute a slash command through the palette's draft-preserving route.
SlashCommand(String),
/// Edit the minimal-mode composer draft without routing through slash text.
EditPromptExternal,
/// Non-selectable section header for visual grouping.
SectionHeader(String),
/// Open the how-to documentation picker.
@ -364,9 +366,12 @@ pub enum PaletteCommand {
}
/// Build the default set of palette entries with section grouping.
///
/// `sharing_enabled` controls whether the `/share` entry is included.
/// Pass `true` to preserve the default behavior (show `/share`).
pub fn default_palette_entries(sharing_enabled: bool) -> Vec<PaletteEntry> {
/// `sharing_enabled` controls whether `/share` is included. `screen_mode`
/// exposes the draft-preserving external-editor row only in minimal mode.
pub(crate) fn default_palette_entries(
sharing_enabled: bool,
screen_mode: crate::app::ScreenMode,
) -> Vec<PaletteEntry> {
let mut entries = vec![
PaletteEntry {
label: "Session".into(),
@ -463,6 +468,11 @@ pub fn default_palette_entries(sharing_enabled: bool) -> Vec<PaletteEntry> {
shortcut: "/multiline".into(),
command: PaletteCommand::SlashCommand("/multiline".into()),
},
PaletteEntry {
label: "Edit Prompt in External Editor".into(),
shortcut: "Ctrl+G".into(),
command: PaletteCommand::EditPromptExternal,
},
PaletteEntry {
label: "Tools".into(),
shortcut: String::new(),
@ -543,19 +553,27 @@ pub fn default_palette_entries(sharing_enabled: bool) -> Vec<PaletteEntry> {
command: PaletteCommand::Quit,
},
];
if !sharing_enabled {
entries.retain(|e| {
!matches!(
& e.command, PaletteCommand::SlashCommand(s) if s.trim() == "/share"
entries.retain(|entry| {
if !sharing_enabled
&& matches!(
& entry.command, PaletteCommand::SlashCommand(s) if s.trim() ==
"/share"
)
});
}
{
return false;
}
screen_mode.is_minimal() || !matches!(entry.command, PaletteCommand::EditPromptExternal)
});
entries
}
#[allow(clippy::collapsible_if)]
/// Filter palette entries for search, preserving section headers when any item in the section matches.
pub fn filter_palette_entries(query: &str, sharing_enabled: bool) -> Vec<PaletteEntry> {
let all = default_palette_entries(sharing_enabled);
pub(crate) fn filter_palette_entries(
query: &str,
sharing_enabled: bool,
screen_mode: crate::app::ScreenMode,
) -> Vec<PaletteEntry> {
let all = default_palette_entries(sharing_enabled, screen_mode);
let query_lower = query.to_lowercase();
if query_lower.is_empty() {
return all;
@ -1251,7 +1269,7 @@ mod palette_sharing_tests {
}
#[test]
fn default_palette_includes_share_when_enabled() {
let entries = default_palette_entries(true);
let entries = default_palette_entries(true, crate::app::ScreenMode::Fullscreen);
assert!(
has_share(&entries),
"/share should be present when sharing_enabled=true"
@ -1259,7 +1277,7 @@ mod palette_sharing_tests {
}
#[test]
fn default_palette_includes_dashboard() {
let entries = default_palette_entries(true);
let entries = default_palette_entries(true, crate::app::ScreenMode::Fullscreen);
let has_dashboard = entries.iter().any(|e| {
matches!(
& e.command, PaletteCommand::SlashCommand(s) if s.trim() ==
@ -1277,8 +1295,23 @@ mod palette_sharing_tests {
);
}
#[test]
fn edit_prompt_palette_entry_is_minimal_only() {
let minimal = default_palette_entries(true, crate::app::ScreenMode::Minimal);
assert!(
minimal
.iter()
.any(|entry| matches!(entry.command, PaletteCommand::EditPromptExternal))
);
let fullscreen = default_palette_entries(true, crate::app::ScreenMode::Fullscreen);
assert!(
!fullscreen
.iter()
.any(|entry| matches!(entry.command, PaletteCommand::EditPromptExternal))
);
}
#[test]
fn default_palette_omits_share_when_disabled() {
let entries = default_palette_entries(false);
let entries = default_palette_entries(false, crate::app::ScreenMode::Fullscreen);
assert!(
!has_share(&entries),
"/share must not appear in palette when sharing_enabled=false"
@ -1286,12 +1319,12 @@ mod palette_sharing_tests {
}
#[test]
fn filter_palette_omits_share_when_disabled() {
let entries = filter_palette_entries("", false);
let entries = filter_palette_entries("", false, crate::app::ScreenMode::Fullscreen);
assert!(
!has_share(&entries),
"/share must not appear in unfiltered palette when sharing_enabled=false"
);
let entries = filter_palette_entries("share", false);
let entries = filter_palette_entries("share", false, crate::app::ScreenMode::Fullscreen);
assert!(
!has_share(&entries),
"/share must not appear when filtering for 'share' with sharing_enabled=false"
@ -1299,7 +1332,7 @@ mod palette_sharing_tests {
}
#[test]
fn filter_palette_includes_share_when_enabled_and_matched() {
let entries = filter_palette_entries("share", true);
let entries = filter_palette_entries("share", true, crate::app::ScreenMode::Fullscreen);
assert!(
has_share(&entries),
"/share should match a 'share' query when sharing_enabled=true"
@ -1308,7 +1341,7 @@ mod palette_sharing_tests {
#[test]
fn palette_tools_section_routes_each_tab_to_itself() {
use crate::views::extensions_modal::ExtensionsTab;
let entries = default_palette_entries(true);
let entries = default_palette_entries(true, crate::app::ScreenMode::Fullscreen);
for (label, expected) in [
("Hooks", ExtensionsTab::Hooks),
("Plugins", ExtensionsTab::Plugins),

View file

@ -1024,6 +1024,7 @@ mod tests {
last_editor: None,
kind: "prompt".into(),
text: text.into(),
combined_texts: None,
position: pos,
}
}

View file

@ -551,6 +551,7 @@ pub(crate) fn session_picker_worktree_selection(
}
/// Rebuild backing-index expansion after a session query changes.
#[allow(clippy::too_many_arguments)]
pub(crate) fn sync_session_picker_query_expansion(
entries: Option<&[SessionPickerEntry]>,
content_results: Option<&[xai_grok_shell::extensions::session_search::SearchSessionHit]>,

View file

@ -275,20 +275,21 @@ impl SettingsModalState {
self.invalidate_filter();
if let Some(key) = subpane_key {
let still_visible = self.rows.iter().any(|r| {
matches!(r, RowEntry::Setting { key: k, .. }
if *k == key)
});
let still_visible = self
.rows
.iter()
.any(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key));
if !still_visible {
self.transition_to_browse();
}
}
if let Some(key) = prev_key {
if let Some(idx) = self.rows.iter().position(|r| {
matches!(r, RowEntry::Setting { key: k, .. }
if *k == key)
}) {
if let Some(idx) = self
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key))
{
self.selected = idx;
} else {
self.selected = self
@ -852,6 +853,7 @@ pub(super) fn action_for_bool(key: SettingKey, new: bool) -> Option<Action> {
"prompt_suggestions" => Some(Action::SetPromptSuggestions(new)),
"respect_manual_folds" => Some(Action::SetRespectManualFolds(new)),
"page_flip_on_send" => Some(Action::SetPageFlipOnSend(new)),
"combine_queued_prompts" => Some(Action::SetCombineQueuedPrompts(new)),
"invert_scroll" => Some(Action::SetInvertScroll(new)),
"show_tips" => Some(Action::SetShowTips(new)),
"auto_update" => Some(Action::SetAutoUpdate(new)),

View file

@ -36,17 +36,14 @@ fn contextual_hints_group_sub_sheet_flow() {
let group_idx = s
.rows
.iter()
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "contextual_hints")
})
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "contextual_hints"))
.expect("group row present");
assert!(
!s.rows.iter().any(|r| matches!(
r,
RowEntry::Setting { key, .. }
if key.starts_with("contextual_hints.")
)),
r,
RowEntry::Setting { key, .. }
if key.starts_with("contextual_hints.")
)),
"child rows must be hidden from the top-level list",
);
@ -652,6 +649,10 @@ fn rows_contain_categories_and_settings_through_pr_14() {
"scroll_lines",
"invert_scroll",
"keep_text_selection",
// SHARED-owned combine_queued_prompts (Editor category; read by
// both the pager drain and the shell promote. Registered before
// multiline_mode, so it renders first).
"combine_queued_prompts",
// PAGER-owned multiline (Editor category).
"multiline_mode",
// SHELL-owned prompt_suggestions (Editor; tab autocomplete
@ -4122,10 +4123,7 @@ fn advance_next_recovers_when_selection_is_hidden() {
let compact_idx = s
.rows
.iter()
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "compact_mode")
})
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "compact_mode"))
.unwrap();
s.selected = compact_idx;
// Advance: lands on the first visible setting (show_timestamps).
@ -4134,10 +4132,7 @@ if *key == "compact_mode")
let show_ts_idx = s
.rows
.iter()
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "show_timestamps")
})
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "show_timestamps"))
.unwrap();
assert_eq!(s.selected, show_ts_idx);
}
@ -4160,10 +4155,7 @@ fn advance_prev_recovers_when_selection_is_hidden() {
let compact_idx = s
.rows
.iter()
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "compact_mode")
})
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "compact_mode"))
.unwrap();
s.selected = compact_idx;
let moved = s.advance_prev();
@ -4171,10 +4163,7 @@ if *key == "compact_mode")
let simple_idx = s
.rows
.iter()
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "simple_mode")
})
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "simple_mode"))
.unwrap();
assert_eq!(s.selected, simple_idx);
}
@ -4274,10 +4263,10 @@ fn section_headers_have_blank_line_above_except_first() {
for cat in SettingCategory::ALL {
// Skip categories the default registry doesn't populate
// (e.g. Session — no settings registered).
let has_setting = s.rows.iter().any(|r| {
matches!(r, RowEntry::Header { category }
if category == cat)
});
let has_setting = s
.rows
.iter()
.any(|r| matches!(r, RowEntry::Header { category } if category == cat));
if !has_setting {
continue;
}
@ -4623,10 +4612,7 @@ fn two_line_row_hit_rect_spans_both_lines() {
let row_idx = s
.rows
.iter()
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "coding_data_sharing")
})
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing"))
.expect("coding_data_sharing must be registered");
// Render at a narrow width so coding_data_sharing forces a
// two-line layout.
@ -4691,10 +4677,7 @@ fn two_line_row_with_expansion_renders_three_segments() {
let row_idx = s
.rows
.iter()
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "coding_data_sharing")
})
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing"))
.expect("coding_data_sharing must be registered");
s.selected = row_idx;
s.expanded_keys.insert("coding_data_sharing");
@ -4752,10 +4735,7 @@ fn group_row_renders_expanded_description() {
let row_idx = s
.rows
.iter()
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "contextual_hints")
})
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "contextual_hints"))
.expect("contextual_hints group must be registered");
s.selected = row_idx;
s.expanded_keys.insert("contextual_hints");
@ -5794,10 +5774,7 @@ fn enter_picker_for(key: &'static str) -> SettingsModalState {
let row_idx = s
.rows
.iter()
.position(|r| {
matches!(r, RowEntry::Setting { key: k, .. }
if *k == key)
})
.position(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key))
.unwrap_or_else(|| panic!("no row for key `{key}` in default registry"));
assert!(s.select_at(row_idx), "select_at({row_idx})");
assert!(

View file

@ -1681,10 +1681,10 @@ mod tests {
let entries = build_entries(&all_contexts(), &registry, true);
let has_row = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "mouse reporting"
)
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "mouse reporting"
)
});
assert!(
!has_row,
@ -1770,6 +1770,72 @@ mod tests {
}
}
#[test]
fn build_entries_show_mode_correct_ctrl_g_and_shared_ctrl_b() {
for mode in [
crate::app::ScreenMode::Fullscreen,
crate::app::ScreenMode::Inline,
crate::app::ScreenMode::Minimal,
] {
let registry = ActionRegistry::defaults_for(mode);
let prompt_contexts = [When::PromptFocused, When::AgentScreen, When::Always];
let entries = build_entries(&prompt_contexts, &registry, true);
let row = |action: ActionId| {
entries.iter().find_map(|entry| match entry {
ShortcutsHelpEntry::Hint {
item,
action_id: Some(id),
..
} if *id == action => Some(item),
_ => None,
})
};
let background = row(ActionId::SendToBackground).expect("background row");
assert_eq!(background.keys, vec![crate::key!('b', CONTROL)]);
let agent_ctrl_g_rows: Vec<_> = entries
.iter()
.filter_map(|entry| match entry {
ShortcutsHelpEntry::Hint {
item,
action_id: Some(id),
..
}
if item.keys.contains(&crate::key!('g', CONTROL))
&& registry
.find(*id)
.is_some_and(|def| def.context == When::AgentScreen) =>
{
Some(*id)
}
_ => None,
})
.collect();
if mode.is_minimal() {
assert!(row(ActionId::FocusScrollback).is_none());
} else {
assert!(row(ActionId::FocusScrollback).is_some());
}
let expected = if mode.is_minimal() {
ActionId::EditPromptExternal
} else {
ActionId::ToggleTasks
};
assert_eq!(agent_ctrl_g_rows, vec![expected]);
assert!(row(expected).is_some());
assert!(
row(if mode.is_minimal() {
ActionId::ToggleTasks
} else {
ActionId::EditPromptExternal
})
.is_none()
);
}
}
#[test]
fn build_entries_includes_new_pane_actions() {
let registry = ActionRegistry::defaults();
@ -1777,24 +1843,24 @@ mod tests {
let has_todos = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "todos"
)
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "todos"
)
});
let has_sessions = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "sessions"
)
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "sessions"
)
});
let has_queue = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "queue"
)
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "queue"
)
});
assert!(has_todos, "should include toggle todos");
assert!(has_sessions, "should include open sessions");
@ -1839,14 +1905,14 @@ mod tests {
.iter()
.find(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
..
}
if item.label == "paste"
)
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
..
}
if item.label == "paste"
)
})
.expect("cheatsheet should list paste");
let ShortcutsHelpEntry::Hint {
@ -1927,10 +1993,10 @@ mod tests {
let nav_dimmed = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "nav"
)
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "nav"
)
});
assert!(
nav_dimmed,
@ -1939,19 +2005,19 @@ mod tests {
let quit_bright = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: false, .. }
if item.label == "quit"
)
e,
ShortcutsHelpEntry::Hint { item, dimmed: false, .. }
if item.label == "quit"
)
});
assert!(quit_bright, "quit should not be dimmed (When::Always)");
let cancel_bright = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: false, .. }
if item.label == "cancel"
)
e,
ShortcutsHelpEntry::Hint { item, dimmed: false, .. }
if item.label == "cancel"
)
});
assert!(
cancel_bright,
@ -1967,10 +2033,10 @@ mod tests {
let send_dimmed = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "send"
)
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "send"
)
});
assert!(
send_dimmed,
@ -1979,10 +2045,10 @@ mod tests {
let nav_dimmed = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "nav"
)
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "nav"
)
});
assert!(
nav_dimmed,
@ -2648,15 +2714,15 @@ mod tests {
.iter()
.position(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
long_help: Some(_),
..
}
if item.label == "paste"
)
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
long_help: Some(_),
..
}
if item.label == "paste"
)
})
.expect("paste pseudo-row with long_help");
assert_eq!(
@ -3035,10 +3101,10 @@ mod tests {
for label in ["top", "btm", "copy", "copy cmd"] {
let present = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == label
)
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == label
)
});
assert!(
!present,
@ -3327,15 +3393,15 @@ mod tests {
.iter()
.position(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
long_help: Some(_),
..
}
if item.label == "paste"
)
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
long_help: Some(_),
..
}
if item.label == "paste"
)
})
.expect("paste pseudo-row with long_help");
let key_id = ExpandKey::Pseudo("paste");

View file

@ -169,12 +169,14 @@ pub enum TaskEntryId {
BgTask(String),
Agent(String),
Scheduled(String),
Workflow(String),
}
/// Logical group a [`TaskEntry`] belongs to. Drives both the sort order (so
/// each kind is contiguous) and the collapsible group headers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GroupKind {
Workflows,
Subagents,
Tasks,
/// Recurring background processes: `monitor` tasks and `/loop` scheduled
@ -183,22 +185,25 @@ pub enum GroupKind {
Watchers,
}
const GROUP_KIND_COUNT: usize = 4;
impl GroupKind {
/// Display label shown in the group header.
fn label(self) -> &'static str {
match self {
GroupKind::Workflows => "Workflows",
GroupKind::Subagents => "Subagents",
GroupKind::Tasks => "Tasks",
GroupKind::Watchers => "Watchers",
}
}
/// Sort/render order: subagents → tasks → watchers (monitors + loops).
fn order(self) -> u8 {
match self {
GroupKind::Subagents => 0,
GroupKind::Tasks => 1,
GroupKind::Watchers => 2,
GroupKind::Workflows => 0,
GroupKind::Subagents => 1,
GroupKind::Tasks => 2,
GroupKind::Watchers => 3,
}
}
}
@ -240,6 +245,15 @@ pub enum TaskEntry {
started_at: Instant,
linked_subagent: Option<String>,
},
Workflow {
id: u64,
name: String,
label: String,
styled: Line<'static>,
running: bool,
stoppable: bool,
started_at: Instant,
},
/// Collapsible group header row (e.g. `▾ Subagents 2`). Not a task —
/// selecting it and pressing Enter (or clicking it) toggles the group's
/// collapse state.
@ -445,6 +459,82 @@ impl TaskEntry {
}
}
fn from_workflow_run(run: &crate::views::workflows::WorkflowRunSnapshot) -> Self {
let theme = Theme::current();
let running = run.is_active();
let raw_tag_color = if running {
theme.accent_running
} else if run.status == "complete" {
theme.accent_success
} else if run.is_terminal() {
theme.accent_error
} else {
theme.warning
};
let tag_color = if running {
raw_tag_color
} else {
crate::render::color::blend_color(theme.bg_base, raw_tag_color, 0.45)
.unwrap_or(raw_tag_color)
};
let name_style = if running {
Style::default().fg(theme.text_primary)
} else {
Style::default().fg(theme.gray_bright)
};
let suffix = if running {
let phase = run
.current_phase
.as_deref()
.map(str::trim)
.filter(|p| !p.is_empty());
let agents = match run.agents.iter().filter(|a| a.state == "running").count() {
0 => None,
1 => Some("1 agent".to_string()),
n => Some(format!("{n} agents")),
};
match (phase, agents) {
(Some(p), Some(a)) => format!("{p} · {a}"),
(Some(p), None) => p.to_string(),
(None, Some(a)) => a,
(None, None) => "running".to_string(),
}
} else {
run.status.replace('_', " ")
};
let mut spans = vec![
Span::styled("Workflow ".to_string(), Style::default().fg(tag_color)),
Span::styled(run.name.clone(), name_style),
];
if !suffix.is_empty() {
spans.push(Span::styled(
format!(" \u{2014} {suffix}"),
Style::default().fg(theme.gray),
));
}
let label = format!("Workflow {} {suffix}", run.name);
let mut hasher = std::collections::hash_map::DefaultHasher::new();
"workflow:".hash(&mut hasher);
run.run_id.hash(&mut hasher);
let id = hasher.finish();
TaskEntry::Workflow {
id,
name: run.name.clone(),
label,
styled: Line::from(spans),
running,
stoppable: run.can_stop(),
started_at: Instant::now()
.checked_sub(std::time::Duration::from_millis(run.live_elapsed_ms()))
.unwrap_or_else(Instant::now),
}
}
fn from_scheduled(
info: &ScheduledTaskInfo,
current_cron: Option<&str>,
@ -575,13 +665,16 @@ impl TaskEntry {
is_monitor: true, ..
} => GroupKind::Watchers,
TaskEntry::Scheduled { .. } => GroupKind::Watchers,
TaskEntry::Workflow { .. } => GroupKind::Workflows,
TaskEntry::Header { group, .. } => *group,
}
}
fn is_running(&self) -> bool {
match self {
TaskEntry::BgTask { running, .. } | TaskEntry::Agent { running, .. } => *running,
TaskEntry::BgTask { running, .. }
| TaskEntry::Agent { running, .. }
| TaskEntry::Workflow { running, .. } => *running,
TaskEntry::Scheduled { .. } => true,
TaskEntry::Header { .. } => false,
}
@ -594,14 +687,15 @@ impl TaskEntry {
/// loops within that section.
fn type_order(&self) -> u8 {
match self {
TaskEntry::Agent { .. } => 0,
TaskEntry::Workflow { .. } => 0,
TaskEntry::Agent { .. } => 1,
TaskEntry::BgTask {
is_monitor: false, ..
} => 1,
} => 2,
TaskEntry::BgTask {
is_monitor: true, ..
} => 2,
TaskEntry::Scheduled { .. } => 3,
} => 3,
TaskEntry::Scheduled { .. } => 4,
// Headers never appear in the sorted `items` list; fall back to
// the group's coarse order for completeness.
TaskEntry::Header { group, .. } => group.order(),
@ -615,6 +709,7 @@ impl ListItem for TaskEntry {
TaskEntry::BgTask { styled, .. }
| TaskEntry::Agent { styled, .. }
| TaskEntry::Scheduled { styled, .. }
| TaskEntry::Workflow { styled, .. }
| TaskEntry::Header { styled, .. } => styled,
}
}
@ -632,7 +727,8 @@ impl ListItem for TaskEntry {
match self {
TaskEntry::BgTask { id, .. }
| TaskEntry::Agent { id, .. }
| TaskEntry::Scheduled { id, .. } => *id,
| TaskEntry::Scheduled { id, .. }
| TaskEntry::Workflow { id, .. } => *id,
TaskEntry::Header { group, .. } => {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
"header:".hash(&mut hasher);
@ -650,7 +746,8 @@ impl ListItem for TaskEntry {
match self {
TaskEntry::BgTask { label, .. }
| TaskEntry::Agent { label, .. }
| TaskEntry::Scheduled { label, .. } => label,
| TaskEntry::Scheduled { label, .. }
| TaskEntry::Workflow { label, .. } => label,
TaskEntry::Header { group, .. } => group.label(),
}
}
@ -665,6 +762,7 @@ enum OverlayEntryData {
BgTask(String),
Agent(String, String),
Scheduled(String, Option<String>),
Workflow(String),
}
const MAX_TASKS_HEIGHT: u16 = 8;
@ -693,6 +791,7 @@ pub struct TasksPane {
opened_by_auto: bool,
highlight_cache: HashMap<String, Vec<Span<'static>>>,
last_theme: ThemeKind,
workflow_runs: Vec<crate::views::workflows::WorkflowRunSnapshot>,
}
impl Default for TasksPane {
@ -794,12 +893,12 @@ impl TasksPane {
opened_by_auto: false,
highlight_cache: HashMap::new(),
last_theme: Theme::current_kind(),
workflow_runs: Vec::new(),
}
}
// -- Data sync -----------------------------------------------------------
/// Sync entries from bg tasks, subagent sessions, and scheduled tasks.
pub fn sync(
&mut self,
bg_tasks: &std::collections::BTreeMap<String, BgTaskState>,
@ -807,6 +906,7 @@ impl TasksPane {
scheduled: &HashMap<String, ScheduledTaskInfo>,
current_cron_task_id: Option<&str>,
queued_cron_ids: &std::collections::HashSet<&str>,
workflow_runs: &[crate::views::workflows::WorkflowRunSnapshot],
) {
// Detect theme switch and refresh caches.
let current_theme = Theme::current_kind();
@ -829,8 +929,10 @@ impl TasksPane {
}
}
// Add subagent items
for info in subagents.values() {
if info.workflow_run_id.is_some() {
continue;
}
if self.show_done || info.is_running() {
self.items.push(TaskEntry::from_subagent(info));
}
@ -852,6 +954,13 @@ impl TasksPane {
));
}
self.workflow_runs = workflow_runs.to_vec();
for run in workflow_runs {
if self.show_done || !run.is_terminal() {
self.items.push(TaskEntry::from_workflow_run(run));
}
}
// Sort: group by type first (subagents → tasks → monitors →
// scheduled) so each kind is one contiguous block, then running
// before done within each group, then newest-first, then a stable
@ -889,6 +998,10 @@ impl TasksPane {
TaskEntry::BgTask { start_time: a, .. },
TaskEntry::BgTask { start_time: b, .. },
) => b.cmp(a),
(
TaskEntry::Workflow { started_at: a, .. },
TaskEntry::Workflow { started_at: b, .. },
) => b.cmp(a),
_ => std::cmp::Ordering::Equal,
})
// 4. Stable tiebreak so equal-timestamp rows don't reshuffle
@ -901,7 +1014,7 @@ impl TasksPane {
// after the group emptied) the group reappears expanded instead of
// hidden under a stale collapsed header.
if !self.collapsed_groups.is_empty() {
let mut present = [false; 3];
let mut present = [false; GROUP_KIND_COUNT];
for it in &self.items {
present[it.group_kind().order() as usize] = true;
}
@ -921,8 +1034,12 @@ impl TasksPane {
.values()
.filter(|t| t.status == BgTaskStatus::Running && !t.restored_from_replay)
.count()
+ subagents.values().filter(|s| s.is_running()).count()
+ scheduled.len();
+ subagents
.values()
.filter(|s| s.is_running() && s.workflow_run_id.is_none())
.count()
+ scheduled.len()
+ workflow_runs.iter().filter(|run| run.is_active()).count();
// Auto-show: running went from 0 to N
if running_count > 0 && self.prev_running_count == 0 {
@ -952,7 +1069,7 @@ impl TasksPane {
fn rebuild_entries(&mut self) {
self.entries.clear();
// Per-group item counts (indexed by `GroupKind::order`).
let mut counts: [usize; 3] = [0; 3];
let mut counts: [usize; GROUP_KIND_COUNT] = [0; GROUP_KIND_COUNT];
for it in &self.items {
counts[it.group_kind().order() as usize] += 1;
}
@ -992,10 +1109,11 @@ impl TasksPane {
};
if changed {
self.rebuild_entries();
if let Some(header) = self.entries.iter().find(|e| {
matches!(e, TaskEntry::Header { group: g, .. }
if *g == group)
}) {
if let Some(header) = self
.entries
.iter()
.find(|e| matches!(e, TaskEntry::Header { group: g, .. } if *g == group))
{
let id = header.stable_id();
self.list_state.select_by_id(id);
}
@ -1011,19 +1129,23 @@ if *g == group)
}
}
/// Number of running bg tasks + subagents + scheduled tasks.
pub fn running_count(
&self,
bg_tasks: &std::collections::BTreeMap<String, BgTaskState>,
subagents: &HashMap<String, SubagentInfo>,
scheduled: &HashMap<String, ScheduledTaskInfo>,
workflow_runs: &[crate::views::workflows::WorkflowRunSnapshot],
) -> usize {
bg_tasks
.values()
.filter(|t| t.status == BgTaskStatus::Running)
.count()
+ subagents.values().filter(|s| s.is_running()).count()
+ subagents
.values()
.filter(|s| s.is_running() && s.workflow_run_id.is_none())
.count()
+ scheduled.len()
+ workflow_runs.iter().filter(|run| run.is_active()).count()
}
// -- Visibility ----------------------------------------------------------
@ -1322,6 +1444,7 @@ if *g == group)
linked_subagent,
..
} => OverlayEntryData::Scheduled(task_id.clone(), linked_subagent.clone()),
TaskEntry::Workflow { name, .. } => OverlayEntryData::Workflow(name.clone()),
// Group headers have no kill/view buttons; they still
// occupy a row (vis_row is enumerated before this filter),
// so the y offsets for following items stay correct.
@ -1355,10 +1478,86 @@ if *g == group)
&theme,
);
}
OverlayEntryData::Workflow(ref name) => {
let Some(run) = self.workflow_runs.iter().find(|r| r.name == *name).cloned()
else {
continue;
};
self.render_workflow_overlay(area, buf, y, &run, &theme);
}
}
}
}
fn render_workflow_overlay(
&mut self,
area: Rect,
buf: &mut Buffer,
y: u16,
run: &crate::views::workflows::WorkflowRunSnapshot,
theme: &Theme,
) {
let running = run.is_active();
let elapsed = format_duration(std::time::Duration::from_millis(run.live_elapsed_ms()));
let (icon, icon_style) = if running {
let frames = crate::glyphs::dot_spinner_frames();
let frame_idx = (self.tick / SPINNER_DIVISOR) as usize % frames.len();
(frames[frame_idx], Style::default().fg(theme.accent_running))
} else if run.status == "complete" {
(
crate::glyphs::check_mark(),
Style::default().fg(theme.accent_success),
)
} else if run.is_terminal() {
(
crate::glyphs::ballot_x(),
Style::default().fg(theme.accent_error),
)
} else {
("", Style::default().fg(theme.warning))
};
let right_text = format!("{elapsed} ");
buf.set_span(area.x, y, &Span::styled(icon, icon_style), 2);
let right_text_w = right_text.width() as u16;
let kill_w: u16 = if running { 3 } else { 0 };
let overlay_w = kill_w + right_text_w + 1;
clear_overlay_area(buf, area, y, overlay_w);
let mut rx = area.x + area.width;
if running {
rx = rx.saturating_sub(3);
let is_hovered = matches!(
&self.hovered_kill,
Some(TaskEntryId::Workflow(n)) if n == &run.name
);
let kill_style = if is_hovered {
Style::default().fg(theme.accent_error)
} else {
Style::default().fg(theme.gray)
};
buf.set_span(
rx,
y,
&Span::styled(crate::glyphs::ballot_x_button(), kill_style),
3,
);
self.kill_button_rects.push((
TaskEntryId::Workflow(run.name.clone()),
Rect::new(rx, y, 3, 1),
));
}
rx = rx.saturating_sub(right_text_w);
buf.set_span(
rx,
y,
&Span::styled(right_text, Style::default().fg(theme.gray)),
right_text_w,
);
}
fn render_bg_task_overlay(
&mut self,
area: Rect,
@ -1749,6 +1948,7 @@ mod tests {
context_source: None,
resumed_from: None,
capability_mode: None,
workflow_run_id: None,
context_normalized: false,
parent_prompt_id: None,
started_at: Instant::now(),
@ -2068,6 +2268,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
// 12+ rows so `desired_height` is non-zero; wide enough that the
@ -2100,6 +2301,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
assert!(
!pane.is_visible(),
@ -2117,6 +2319,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
assert!(
pane.is_visible(),
@ -2142,6 +2345,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
let lines = render_pane_to_strings(&mut pane, &bg_tasks, 80, 16);
@ -2169,6 +2373,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
let lines = render_pane_to_strings(&mut pane, &bg_tasks, 80, 16);
@ -2208,6 +2413,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
// Press `/` to open the search bar.
@ -2267,6 +2473,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
// Tall enough that all entries fit without scrolling.
@ -2321,6 +2528,7 @@ mod tests {
&scheduled,
None,
&HashSet::new(),
&[],
);
// One header + one loop row in a tall pane ⇒ not scrollable.
@ -2380,6 +2588,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
// A short panel forces the list to overflow; at the top of the list a
@ -2411,6 +2620,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
// Establish the viewport, then scroll to the very bottom.
@ -2451,6 +2661,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
assert!(pane.items.len() >= 2);
@ -2478,6 +2689,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
assert_eq!(pane.items.len(), 2);
@ -2513,6 +2725,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
assert_eq!(pane.items.len(), 3);
@ -2566,6 +2779,7 @@ mod tests {
&scheduled,
None,
&HashSet::new(),
&[],
);
// items: monitor first, then loop.
@ -2620,6 +2834,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
assert_eq!(pane.items.len(), 1, "only running tasks shown by default");
@ -2640,6 +2855,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
// Display list interleaves a header before each group's items:
@ -2684,6 +2900,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
// Expanded: header + item.
@ -2720,6 +2937,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
assert_eq!(pane.entries.len(), 2);
@ -2753,6 +2971,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
pane.toggle_group(GroupKind::Subagents);
@ -2765,6 +2984,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
assert!(!pane.collapsed_groups.contains(&GroupKind::Subagents));
@ -2780,6 +3000,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
assert_eq!(pane.entries.len(), 2);
assert!(matches!(&pane.entries[1], TaskEntry::Agent { .. }));
@ -2806,6 +3027,7 @@ mod tests {
&HashMap::new(),
None,
&HashSet::new(),
&[],
);
// Ordered by agent type alphabetically: Explore before Plan.
@ -2962,6 +3184,7 @@ mod tests {
&scheduled,
None,
&HashSet::new(),
&[],
);
let label = match &pane.items[0] {
TaskEntry::Scheduled { label, .. } => label,
@ -2987,6 +3210,7 @@ mod tests {
&scheduled,
Some("cron1"),
&HashSet::new(),
&[],
);
let label = match &pane.items[0] {
TaskEntry::Scheduled { label, .. } => label,
@ -3012,6 +3236,7 @@ mod tests {
&scheduled,
None,
&HashSet::new(),
&[],
);
let label = match &pane.items[0] {
TaskEntry::Scheduled { label, .. } => label,
@ -3033,7 +3258,14 @@ mod tests {
);
let mut queued = HashSet::new();
queued.insert("q1");
pane.sync(&BTreeMap::new(), &HashMap::new(), &scheduled, None, &queued);
pane.sync(
&BTreeMap::new(),
&HashMap::new(),
&scheduled,
None,
&queued,
&[],
);
let label = match &pane.items[0] {
TaskEntry::Scheduled { label, .. } => label,
_ => panic!("expected Scheduled"),
@ -3059,6 +3291,7 @@ mod tests {
&scheduled,
None,
&HashSet::new(),
&[],
);
let label = match &pane.items[0] {
TaskEntry::Scheduled { label, .. } => label,
@ -3085,6 +3318,7 @@ mod tests {
&scheduled,
None,
&HashSet::new(),
&[],
);
let entry = &pane.items[0];
let label = match entry {
@ -3112,6 +3346,7 @@ mod tests {
&scheduled,
None,
&HashSet::new(),
&[],
);
let label = match &pane.items[0] {
TaskEntry::Scheduled { label, .. } => label,
@ -3141,6 +3376,7 @@ mod tests {
&scheduled,
None,
&HashSet::new(),
&[],
);
let label = match &pane.items[0] {
TaskEntry::Scheduled { label, .. } => label,
@ -3151,4 +3387,149 @@ mod tests {
"unknown schedule should have no status suffix: {label}"
);
}
fn make_workflow_run(name: &str, status: &str) -> crate::views::workflows::WorkflowRunSnapshot {
crate::views::workflows::WorkflowRunSnapshot {
run_id: format!("wf_{name}"),
name: name.to_string(),
objective: "obj".to_string(),
status: status.to_string(),
management_available: true,
builtin: false,
phases: Vec::new(),
current_phase: Some("Scan".to_string()),
agents: Vec::new(),
agent_budget: None,
agents_used: 0,
agents_reserved: 0,
agents_remaining: None,
agent_usage_incomplete: false,
active_agents: 0,
elapsed_ms: 5_000,
received_at: Instant::now(),
pause_message: None,
result_summary: None,
}
}
#[test]
fn workflows_section_lists_runs() {
let mut pane = TasksPane::new();
let runs = vec![
make_workflow_run("pii-purge", "active"),
make_workflow_run("old-scan", "complete"),
];
pane.sync(
&BTreeMap::new(),
&HashMap::new(),
&HashMap::new(),
None,
&HashSet::new(),
&runs,
);
let labels: Vec<&str> = pane.entries.iter().map(|e| e.search_text()).collect();
assert!(
labels.contains(&"Workflows"),
"missing Workflows header: {labels:?}"
);
assert!(labels.iter().any(|l| l.contains("pii-purge")), "{labels:?}");
assert!(
!labels.iter().any(|l| l.contains("old-scan")),
"settled run hidden while show_done is off: {labels:?}"
);
let row = labels
.iter()
.find(|l| l.contains("pii-purge"))
.unwrap()
.to_string();
assert!(row.starts_with("Workflow "), "{row}");
assert!(row.contains("Scan"), "live phase suffix missing: {row}");
}
#[test]
fn workflow_children_are_excluded_and_run_counts_once() {
let mut pane = TasksPane::new();
let mut child = make_info();
child.workflow_run_id = Some(Arc::from("wf_deep-research"));
let mut subagents = HashMap::new();
subagents.insert("cs-1".to_string(), child);
let runs = vec![make_workflow_run("deep-research", "active")];
pane.sync(
&BTreeMap::new(),
&subagents,
&HashMap::new(),
None,
&HashSet::new(),
&runs,
);
assert!(
pane.items
.iter()
.all(|e| !matches!(e, TaskEntry::Agent { .. }))
);
assert_eq!(
pane.running_count(&BTreeMap::new(), &subagents, &HashMap::new(), &runs),
1
);
}
#[test]
fn workflow_suffix_counts_only_running_roster_rows() {
let mut run = make_workflow_run("deep-research", "active");
run.agents = vec![
crate::views::workflows::WorkflowAgentRowView {
agent_id: "a1".into(),
label: "one".into(),
phase: None,
model: None,
state: "running".into(),
tokens_used: 0,
},
crate::views::workflows::WorkflowAgentRowView {
agent_id: "a2".into(),
label: "two".into(),
phase: None,
model: None,
state: "done".into(),
tokens_used: 0,
},
];
let entry = TaskEntry::from_workflow_run(&run);
assert!(entry.search_text().contains("1 agent"));
assert!(!entry.search_text().contains("2 agents"));
}
#[test]
fn workflow_row_stoppable_tracks_can_stop_not_is_active() {
fn stoppable_of(run: &crate::views::workflows::WorkflowRunSnapshot) -> bool {
match TaskEntry::from_workflow_run(run) {
TaskEntry::Workflow { stoppable, .. } => stoppable,
_ => panic!("expected a workflow entry"),
}
}
for status in ["active", "paused", "budget_limited"] {
let run = make_workflow_run("wf", status);
assert!(run.can_stop(), "{status} run should be stoppable");
assert!(stoppable_of(&run), "{status} row must be marked stoppable");
}
for status in ["complete", "failed", "cancelled", "interrupted"] {
let run = make_workflow_run("wf", status);
assert!(!run.can_stop(), "{status} run should not be stoppable");
assert!(!stoppable_of(&run), "{status} row must not be stoppable");
}
match TaskEntry::from_workflow_run(&make_workflow_run("wf", "paused")) {
TaskEntry::Workflow {
running, stoppable, ..
} => {
assert!(!running, "paused is not is_active()");
assert!(stoppable, "but paused IS can_stop()");
}
_ => panic!("expected a workflow entry"),
}
}
}

View file

@ -32,7 +32,7 @@ use crate::theme::Theme;
pub(crate) const SPINNER_DIVISOR: u64 = 4;
/// Show each monitor-pulse frame for this many animation ticks — twice the
/// [`SPINNER_DIVISOR`] dwell (~3.75 fps). The idle "watching" cue should
/// [`SPINNER_DIVISOR`] dwell (~3.75 fps). The idle still-running cue should
/// breathe calmly rather than read like the active turn spinner, so its
/// `○ ◎ ◉ ◎` cycle runs at roughly half the speed (~1.07s per loop).
pub(crate) const MONITOR_PULSE_DIVISOR: u64 = 8;
@ -81,7 +81,7 @@ pub struct TurnStatusOutput {
/// `Some(_)` to [`render_turn_status`] renders the buttons; passing `None`
/// marks a keyboard-only host (minimal mode has no mouse capture) and suppresses
/// both — that host cancels the turn via `Ctrl+C` and sends to background via
/// `Ctrl+G` instead.
/// `Ctrl+B` instead.
#[derive(Debug, Clone, Copy, Default)]
pub struct MouseButtons {
/// Whether the mouse is over the `[stop]` cancel button.
@ -93,7 +93,7 @@ pub struct MouseButtons {
/// Counts of idle-surviving "watcher" work — background jobs that can wake
/// the agent for a new turn while it sits idle (commands and monitors on
/// completion/events, `/loop` tasks on a timer, background subagents on
/// finish). They share one persistent "watching" cue above the prompt.
/// finish). They share one persistent still-running cue above the prompt.
/// Broader than the tasks-pane `Watchers` group (monitors + loops only).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Watchers {
@ -107,59 +107,63 @@ pub struct Watchers {
/// subagent is a background one — a foreground subagent would keep the
/// parent in `TurnRunning`.
pub subagents: usize,
pub workflows: usize,
}
impl Watchers {
/// Total watcher count across all kinds.
pub fn total(self) -> usize {
self.commands + self.monitors + self.loops + self.subagents
self.commands + self.monitors + self.loops + self.subagents + self.workflows
}
/// Awaitable in-flight work — the kinds a blocking `wait_tasks` /
/// `get_task_output` wait can resolve on (commands, monitors, subagents;
/// scheduled `/loop` tasks are timers, not awaitable work).
/// scheduled `/loop` tasks and workflows are not task waits).
pub fn awaitable_work(self) -> usize {
self.commands + self.monitors + self.subagents
}
}
/// Build the "watching · …" label for the idle watcher cue, listing only the
/// non-zero kinds with correct singular/plural nouns — e.g.
/// `"watching · 1 command · 2 monitors · 1 loop · 1 subagent"`. Assumes
/// `watchers.total() > 0`.
fn watching_label(watchers: Watchers) -> String {
/// Format a counts-first `"… still running"` cue from `(count, noun)` pairs,
/// listing only the non-zero kinds (plain-`s` plurals) — e.g.
/// `"1 command · 2 monitors still running"`. `None` when every count is
/// zero. Single owner of the format mechanics so the agent view's idle cue
/// and the dashboard's background-work label cannot drift.
pub(crate) fn format_still_running<'a>(
kinds: impl IntoIterator<Item = (usize, &'a str)>,
) -> Option<String> {
use std::fmt::Write as _;
let mut label = String::with_capacity(32);
label.push_str("watching");
if watchers.commands > 0 {
let noun = if watchers.commands == 1 {
"command"
} else {
"commands"
};
let _ = write!(label, " \u{00b7} {} {noun}", watchers.commands);
let mut label = String::with_capacity(48);
for (count, noun) in kinds {
if count == 0 {
continue;
}
if !label.is_empty() {
label.push_str(" \u{00b7} ");
}
let plural = if count == 1 { "" } else { "s" };
let _ = write!(label, "{count} {noun}{plural}");
}
if watchers.monitors > 0 {
let noun = if watchers.monitors == 1 {
"monitor"
} else {
"monitors"
};
let _ = write!(label, " \u{00b7} {} {noun}", watchers.monitors);
if label.is_empty() {
return None;
}
if watchers.loops > 0 {
let noun = if watchers.loops == 1 { "loop" } else { "loops" };
let _ = write!(label, " \u{00b7} {} {noun}", watchers.loops);
}
if watchers.subagents > 0 {
let noun = if watchers.subagents == 1 {
"subagent"
} else {
"subagents"
};
let _ = write!(label, " \u{00b7} {} {noun}", watchers.subagents);
}
label
label.push_str(" still running");
Some(label)
}
/// The idle watcher cue's label — e.g.
/// `"1 command · 2 monitors · 1 loop · 1 subagent still running"`. Leads
/// with the counts (not an ambient "watching") so a glance under a
/// "Worked for X" marker still reads as unfinished work. `None` when no
/// watchers are live.
fn still_running_label(watchers: Watchers) -> Option<String> {
format_still_running([
(watchers.commands, "command"),
(watchers.monitors, "monitor"),
(watchers.loops, "loop"),
(watchers.subagents, "subagent"),
(watchers.workflows, "workflow"),
])
}
/// Whether the turn is blocked in a wait the shell aborts as soon as the
@ -199,7 +203,7 @@ pub fn is_sendable_wait(activity: &Option<TurnActivity>) -> bool {
/// - `total_tokens`: Total tokens used (context window usage), shown as `⇣Nk`.
/// - `parked`: the turn is parked on a sendable wait and renders the stopped
/// look (`AgentView::renders_parked`). The running-turn chrome is suppressed;
/// only the "watching · …" cue renders (the parked turn is by definition
/// only the "… still running" cue renders (the parked turn is by definition
/// waiting on background work, so the cue explains the idle-looking chrome).
/// - `flat_background`: when `true`, right-side timer/buttons use a transparent
/// (`Color::Reset`) background instead of `theme.bg_base`, so the row blends
@ -274,13 +278,15 @@ pub fn render_turn_status(
return TurnStatusOutput::default();
}
// Idle or parked with watchers: persistent watching cue (not scrollback
// — it must never scroll away). Lower priority than the starting-session
// and drain-blocked cues above.
if (state.is_idle() || parked) && watchers.total() > 0 {
// Idle or parked with watchers: persistent still-running cue (not
// scrollback — it must never scroll away). Lower priority than the
// starting-session and drain-blocked cues above.
if (state.is_idle() || parked)
&& let Some(cue) = still_running_label(watchers)
{
// Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence:
// the agent is idle, so this "watching" breath runs slower than the
// active turn spinner (see MONITOR_PULSE_DIVISOR).
// the agent is idle, so this breath runs slower than the active
// turn spinner (see MONITOR_PULSE_DIVISOR).
let frames = crate::glyphs::monitor_icon_frames();
let frame_idx = (tick / MONITOR_PULSE_DIVISOR) as usize % frames.len();
let spans = vec![
@ -288,7 +294,7 @@ pub fn render_turn_status(
format!("{} ", frames[frame_idx]),
Style::default().fg(theme.accent_system),
),
Span::styled(watching_label(watchers), Style::default().fg(theme.gray)),
Span::styled(cue, Style::default().fg(theme.gray)),
];
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
return TurnStatusOutput::default();
@ -758,7 +764,7 @@ fn render_starting_session(
///
/// A parked turn (`parked` — the stopped look while blocked on a sendable
/// wait) suppresses the running-turn chrome entirely: the row shows only when
/// watchers exist, rendering the "watching · …" cue.
/// watchers exist, rendering the "… still running" cue.
///
/// Real MCP progress (`total > 0`) renders as a compact chip in the top status
/// bar instead, so it does not affect this row.
@ -1004,7 +1010,7 @@ mod tests {
#[test]
fn should_show_when_watchers_running() {
// Idle but a watcher (command, monitor, loop, or subagent) is still
// running → row stays visible so the persistent "watching · …" cue
// running → row stays visible so the persistent "… still running" cue
// can show.
for watchers in [
Watchers {
@ -1039,7 +1045,7 @@ mod tests {
#[test]
fn should_show_parked_only_with_watchers() {
// Parked (turn running but rendering the stopped look): the row shows
// only to carry the "watching · …" cue — never the running chrome.
// only to carry the "… still running" cue — never the running chrome.
assert!(should_show(
&AgentState::TurnRunning,
false,
@ -1149,7 +1155,12 @@ mod tests {
/// Invoke `render_turn_status` for an idle agent with the given watcher
/// counts at animation tick `tick`.
fn render_idle_with_watchers_at_tick(watchers: Watchers, tick: u64) -> String {
let area = Rect::new(0, 0, 60, 1);
render_idle_with_watchers_in_width(watchers, tick, 72)
}
/// [`render_idle_with_watchers_at_tick`] with an explicit row width.
fn render_idle_with_watchers_in_width(watchers: Watchers, tick: u64, width: u16) -> String {
let area = Rect::new(0, 0, width, 1);
let mut buf = Buffer::empty(area);
render_turn_status(
&mut buf,
@ -1179,7 +1190,7 @@ mod tests {
/// Invoke `render_turn_status` for a PARKED running turn (the stopped
/// look) with the given watcher counts.
fn render_parked_with_watchers(watchers: Watchers) -> String {
let area = Rect::new(0, 0, 60, 1);
let area = Rect::new(0, 0, 72, 1);
let mut buf = Buffer::empty(area);
render_turn_status(
&mut buf,
@ -1231,11 +1242,11 @@ mod tests {
}
#[test]
fn idle_with_monitors_renders_watching_line() {
fn idle_with_monitors_renders_still_running_cue() {
let text = render_idle_with_monitors(2);
assert!(
text.contains("watching") && text.contains("2 monitors"),
"idle with monitors must render the watching cue, got: {text:?}"
text.contains("2 monitors still running"),
"idle with monitors must render the still-running cue, got: {text:?}"
);
}
@ -1243,7 +1254,7 @@ mod tests {
fn idle_with_one_monitor_uses_singular() {
let text = render_idle_with_monitors(1);
assert!(
text.contains("watching \u{00b7} 1 monitor") && !text.contains("monitors"),
text.contains("1 monitor still running") && !text.contains("monitors"),
"single monitor must use the singular noun, got: {text:?}"
);
}
@ -1258,14 +1269,14 @@ mod tests {
}
#[test]
fn idle_with_loops_renders_watching_line() {
fn idle_with_loops_renders_still_running_cue() {
let text = render_idle_with_watchers(Watchers {
loops: 2,
..Watchers::default()
});
assert!(
text.contains("watching") && text.contains("2 loops"),
"idle with loops must render the watching cue, got: {text:?}"
text.contains("2 loops still running"),
"idle with loops must render the still-running cue, got: {text:?}"
);
}
@ -1276,20 +1287,20 @@ mod tests {
..Watchers::default()
});
assert!(
text.contains("watching \u{00b7} 1 loop") && !text.contains("loops"),
text.contains("1 loop still running") && !text.contains("loops"),
"single loop must use the singular noun, got: {text:?}"
);
}
#[test]
fn idle_with_subagents_renders_watching_line() {
fn idle_with_subagents_renders_still_running_cue() {
let text = render_idle_with_watchers(Watchers {
subagents: 2,
..Watchers::default()
});
assert!(
text.contains("watching") && text.contains("2 subagents"),
"idle with subagents must render the watching cue, got: {text:?}"
text.contains("2 subagents still running"),
"idle with subagents must render the still-running cue, got: {text:?}"
);
}
@ -1300,11 +1311,20 @@ mod tests {
..Watchers::default()
});
assert!(
text.contains("watching \u{00b7} 1 subagent") && !text.contains("subagents"),
text.contains("1 subagent still running") && !text.contains("subagents"),
"single subagent must use the singular noun, got: {text:?}"
);
}
#[test]
fn idle_with_one_workflow_counts_run_once() {
let text = render_idle_with_watchers(Watchers {
workflows: 1,
..Watchers::default()
});
assert!(text.contains("1 workflow still running"), "got: {text:?}");
}
#[test]
fn idle_with_monitors_and_loops_lists_both() {
// Both watcher kinds present → one cue lists monitors then loops,
@ -1315,7 +1335,7 @@ mod tests {
..Watchers::default()
});
assert!(
text.contains("watching \u{00b7} 1 monitor \u{00b7} 2 loops"),
text.contains("1 monitor \u{00b7} 2 loops still running"),
"both kinds must be listed in one cue, got: {text:?}"
);
}
@ -1329,17 +1349,37 @@ mod tests {
monitors: 2,
loops: 1,
subagents: 3,
workflows: 0,
});
assert!(
text.contains(
"watching \u{00b7} 1 command \u{00b7} 2 monitors \u{00b7} 1 loop \u{00b7} 3 subagents"
"1 command \u{00b7} 2 monitors \u{00b7} 1 loop \u{00b7} 3 subagents still running"
),
"all kinds must be listed in one cue, got: {text:?}"
);
}
#[test]
fn idle_with_commands_renders_watching_line() {
fn narrow_area_clips_cue_tail_keeping_counts() {
// 40 cols with three kinds: the row tail-clips with no ellipsis, so
// the leading counts survive and the trailing suffix is what gets
// cut. Pins the narrow-pane tradeoff of leading with the counts; a
// smarter compact fallback would be a behavior change.
let watchers = Watchers {
commands: 1,
monitors: 2,
loops: 1,
..Watchers::default()
};
let text = render_idle_with_watchers_in_width(watchers, 0, 40);
assert!(
text.contains("1 command \u{00b7} 2 monitors \u{00b7} 1 loop"),
"the counts must survive the clip, got: {text:?}"
);
}
#[test]
fn idle_with_commands_renders_still_running_cue() {
// Plain background commands (non-monitor bg tasks) count as watchers:
// they wake the agent with a task-completed turn, so the cue must show.
let text = render_idle_with_watchers(Watchers {
@ -1347,22 +1387,22 @@ mod tests {
..Watchers::default()
});
assert!(
text.contains("watching \u{00b7} 2 commands"),
"idle with bg commands must render the watching cue, got: {text:?}"
text.contains("2 commands still running"),
"idle with bg commands must render the still-running cue, got: {text:?}"
);
let text = render_idle_with_watchers(Watchers {
commands: 1,
..Watchers::default()
});
assert!(
text.contains("watching \u{00b7} 1 command") && !text.contains("commands"),
text.contains("1 command still running") && !text.contains("commands"),
"single command must use the singular noun, got: {text:?}"
);
}
#[test]
fn parked_with_watchers_renders_watching_not_running_chrome() {
// A parked running turn renders the watching cue — never the busy
fn parked_with_watchers_renders_cue_not_running_chrome() {
// A parked running turn renders the still-running cue — never the busy
// spinner/timers/[stop] chrome (the wait aborts as soon as the user
// types, so that chrome would lie).
let text = render_parked_with_watchers(Watchers {
@ -1370,8 +1410,8 @@ mod tests {
..Watchers::default()
});
assert!(
text.contains("watching \u{00b7} 2 commands"),
"parked with bg work must render the watching cue, got: {text:?}"
text.contains("2 commands still running"),
"parked with bg work must render the still-running cue, got: {text:?}"
);
assert!(
!text.contains("Waiting") && !text.contains("[stop]"),
@ -1431,52 +1471,57 @@ mod tests {
}
#[test]
fn watching_label_lists_only_nonzero_kinds() {
fn still_running_label_lists_only_nonzero_kinds() {
assert_eq!(
watching_label(Watchers {
still_running_label(Watchers {
commands: 2,
..Watchers::default()
}),
"watching \u{00b7} 2 commands"
Some("2 commands still running".into())
);
assert_eq!(
watching_label(Watchers {
still_running_label(Watchers {
monitors: 2,
..Watchers::default()
}),
"watching \u{00b7} 2 monitors"
Some("2 monitors still running".into())
);
assert_eq!(
watching_label(Watchers {
still_running_label(Watchers {
loops: 1,
..Watchers::default()
}),
"watching \u{00b7} 1 loop"
Some("1 loop still running".into())
);
assert_eq!(
watching_label(Watchers {
still_running_label(Watchers {
subagents: 1,
..Watchers::default()
}),
"watching \u{00b7} 1 subagent"
Some("1 subagent still running".into())
);
assert_eq!(
watching_label(Watchers {
still_running_label(Watchers {
monitors: 1,
loops: 2,
..Watchers::default()
}),
"watching \u{00b7} 1 monitor \u{00b7} 2 loops"
Some("1 monitor \u{00b7} 2 loops still running".into())
);
assert_eq!(
watching_label(Watchers {
still_running_label(Watchers {
commands: 1,
monitors: 1,
loops: 1,
subagents: 2,
workflows: 0,
}),
"watching \u{00b7} 1 command \u{00b7} 1 monitor \u{00b7} 1 loop \u{00b7} 2 subagents"
Some(
"1 command \u{00b7} 1 monitor \u{00b7} 1 loop \u{00b7} 2 subagents still running"
.into()
)
);
assert_eq!(still_running_label(Watchers::default()), None);
}
#[test]

View file

@ -635,7 +635,7 @@ pub struct WelcomeRenderParams<'a> {
pub credit_balance: Option<&'a crate::views::credit_bar::CreditBalance>,
/// Auto top-up rule paired with `credit_balance` for the welcome warning.
pub auto_topup: Option<&'a crate::views::credit_bar::AutoTopupInfo>,
/// Whether /usage is visible (false for team users — suppresses the warning).
/// Consumer billing surface (false for team / API-key — no credit warning).
pub usage_visible: bool,
/// Cached changelog bullets for the welcome screen (up to 3).
pub changelog_bullets: &'a [String],
@ -2468,9 +2468,9 @@ fn render_auth_input_box(
/// output — see `diagnostics::assemble_startup_warnings`), but only one is
/// rendered — the severity-aware pick from `startup::banner_warning`, so a
/// runtime-pushed Warning displaces an earlier Info entry; all of them point
/// at `/terminal-setup`, which lists every issue. One message line, one
/// optional action line, plus a buffer row for spacing. Severity controls
/// color (yellow for `Warning`, dim for `Info`).
/// at `/terminal-setup`, which remains an alias and lists every issue. One
/// message line, one optional action line, plus a buffer row for spacing.
/// Severity controls color (yellow for `Warning`, dim for `Info`).
fn render_startup_warnings(
area: Rect,
buf: &mut Buffer,
@ -2949,12 +2949,10 @@ mod tests {
// Verify headers
assert!(
matches!(&result[0], crate::views::picker::PickerEntry::Header { label }
if label == &"fw-1")
matches!(&result[0], crate::views::picker::PickerEntry::Header { label } if label == &"fw-1")
);
assert!(
matches!(&result[2], crate::views::picker::PickerEntry::Header { label }
if label == &"xai")
matches!(&result[2], crate::views::picker::PickerEntry::Header { label } if label == &"xai")
);
}
@ -2983,13 +2981,11 @@ if label == &"xai")
Some("zzz"),
);
assert!(
matches!(&result[0], crate::views::picker::PickerEntry::Header { label }
if label == &"zzz"),
matches!(&result[0], crate::views::picker::PickerEntry::Header { label } if label == &"zzz"),
"current repo group pinned first"
);
assert!(
matches!(&result[2], crate::views::picker::PickerEntry::Header { label }
if label == &"aaa"),
matches!(&result[2], crate::views::picker::PickerEntry::Header { label } if label == &"aaa"),
"remaining group follows alphabetically"
);
}

File diff suppressed because it is too large Load diff