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:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
|
|
@ -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(),
|
||||
®istry,
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -4440,6 +4440,7 @@ fn dashboard_action_for_id(
|
|||
| ActionId::OpenPrevLink
|
||||
| ActionId::ToggleTodos
|
||||
| ActionId::ToggleTasks
|
||||
| ActionId::EditPromptExternal
|
||||
| ActionId::ToggleQueue
|
||||
| ActionId::OpenSessions
|
||||
| ActionId::OpenExtensions
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -50,3 +50,4 @@ pub mod timeline;
|
|||
pub mod todo_pane;
|
||||
pub mod turn_status;
|
||||
pub mod welcome;
|
||||
pub mod workflows;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -1024,6 +1024,7 @@ mod tests {
|
|||
last_editor: None,
|
||||
kind: "prompt".into(),
|
||||
text: text.into(),
|
||||
combined_texts: None,
|
||||
position: pos,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]>,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -1681,10 +1681,10 @@ mod tests {
|
|||
let entries = build_entries(&all_contexts(), ®istry, 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, ®istry, 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");
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
1536
crates/codegen/xai-grok-pager/src/views/workflows.rs
Normal file
1536
crates/codegen/xai-grok-pager/src/views/workflows.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue