Synced from monorepo

Synced from monorepo

Changes:
- Refresh tool search when the managed MCP catalog is re-fetched
- Prevent duplicate leader process spawn and startup hang from stale leaders
- Document marketplaces, plugins, and organization controls
- Stamp session ID on image generation direct-to-API requests
- Fix auto mode blocked documentation
- Auto mode considers recent user intent
- Expose deploy archive, taken-down, limit, and in-progress reasons on the chat API
- Fail-closed auth refresh contract for shell clients
- Emit a chat-supplied per-session turn index in turn hooks
- Show bash mode chrome in minimal mode
- Add metrics for true-noop and stationarity stops
- Include voice interim text on prompt submit
- Silently end turn on true-noop thrash
- Quiet copy toast when clipboard delivery is confirmed
- Fix session fork truncating at the wrong prompt in rewound sessions
- Make the idle "still running" watcher cue clickable to open the tasks pane
- Default web search model to grok-4.5
- Let plugin subagents inherit parent MCP servers
- Gate no-op end-turn reminder on system reminders
- Add gateway bridge lifecycle telemetry
- Allow editing finalized text while voice is open
- Relocate token carrier to turn-commit events and plumb per-turn origin context
- Raise workflow scratch quotas and make failed runs resumable
- Workflows overlay: auto-progress phases, live agent status, and drop budget meter

Source-Revision: 9b8d35b46d959c042ea9aa31cbbebbd1f0c5c527
This commit is contained in:
grokkybara[bot] 2026-07-24 16:59:42 +00:00
commit 6e38642082
103 changed files with 4964 additions and 1261 deletions

View file

@ -868,12 +868,9 @@ pub fn render_peek_panel(
image_preview: false,
..PromptStyle::default()
};
// Stream the interim transcript into the reply box (and hide the caret)
// while dictating, so voice on the dashboard is visible even with a row's
// peek panel open — it stands in for the dispatch box's voice overlay.
// Interim STT into the reply box so voice stays visible with a peek open.
let voice_overlay = (voice_listening || voice_interim.is_some()).then_some(
crate::views::prompt_widget::VoicePromptOverlay {
listening: voice_listening,
interim: voice_interim,
color: theme.accent_running,
},

View file

@ -2981,13 +2981,10 @@ fn render_dispatch(
let prefix = "\u{276F} ";
let prefix_w = UnicodeWidthStr::width(prefix) as u16;
// Voice overlay: stream the interim transcript into the box and hide the
// caret while listening. When active we render through `PromptWidget::draw`
// (below) even on an empty buffer, so the manual empty-state branch is
// skipped in that case.
// When voice is active, draw through PromptWidget even on an empty buffer
// so the manual empty-state branch is skipped.
let voice_overlay = (state.voice_listening || state.voice_interim.is_some()).then_some(
crate::views::prompt_widget::VoicePromptOverlay {
listening: state.voice_listening,
interim: state.voice_interim.as_deref(),
color: theme.accent_running,
},

View file

@ -293,22 +293,17 @@ pub struct PromptInfo<'a> {
pub usage_warning_critical: bool,
}
/// Live voice-capture overlay state for the prompt.
/// Live voice-capture overlay for the prompt.
///
/// When voice capture is active the interim STT transcript streams
/// directly into the prompt body (in [`color`](Self::color)) so the user
/// sees their words land in the input box instead of a status-bar indicator.
/// The prompt prefix stays the normal `` chevron; the recording state is
/// signalled by a pulsating record indicator rendered above the prompt box.
/// Interim STT paints as muted italic ghost text (not in the textarea).
/// Finalized STT is real prompt content and stays editable while the mic is open.
/// Overlay presence (even with no interim) marks voice active for callers that
/// skip empty-state placeholders while capturing.
#[derive(Debug, Clone, Copy)]
pub struct VoicePromptOverlay<'a> {
/// Whether the mic is currently capturing (suppresses the caret while the
/// interim transcript stands in for it).
pub listening: bool,
/// Latest interim transcript to stream into the prompt body, if any.
/// Latest interim transcript, if any.
pub interim: Option<&'a str>,
/// Accent color used for both the mic prefix and the streamed text so
/// voice input is visually distinct from typed text.
/// Theme accent associated with this overlay.
pub color: ratatui::style::Color,
}
@ -3094,8 +3089,8 @@ impl PromptWidget {
(snap.active, snap.inline_ghost.is_some())
};
// Voice interim transcript rendered in muted text_secondary so
// in-progress words are visually distinct from finalized text.
// Interim STT: muted italic overlay (not in the textarea). Finalized
// text remains the real, editable draft.
let voice_interim_shown = if let Some(v) = voice
&& let Some(interim) = v.interim.filter(|t| !t.trim().is_empty())
&& ta_area.width > 0
@ -3103,7 +3098,10 @@ impl PromptWidget {
{
let interim_fg = crate::render::color::blend_color(bg, theme.text_secondary, 0.7)
.unwrap_or(theme.gray);
let interim_style = Style::default().fg(interim_fg).bg(bg);
let interim_style = Style::default()
.fg(interim_fg)
.bg(bg)
.add_modifier(Modifier::ITALIC);
if self.textarea.text().is_empty() {
let lines =
wrap_voice_interim(interim, ta_area.width as usize, ta_area.height as usize);
@ -3111,11 +3109,11 @@ impl PromptWidget {
buf.set_string(ta_area.x, ta_area.y + i as u16, line, interim_style);
}
} else {
// Append interim as ghost-text suffix after finalized text.
let cursor = self.textarea.text().len();
// Ghost suffix after the finalized draft (not at the caret).
let end = self.textarea.text().len();
if let Some((start_x, row_y)) =
self.textarea
.screen_position_of(cursor, ta_area, self.textarea_state)
.screen_position_of(end, ta_area, self.textarea_state)
{
let display = format!(" {interim}");
let avail = (ta_area.x + ta_area.width).saturating_sub(start_x) as usize;
@ -3208,44 +3206,41 @@ impl PromptWidget {
crate::render::color::blend_area(buf, dim_area, Some((bg, 0.66)), None);
}
// Hide the cursor while voice capture is active — the streamed
// transcript stands in for the caret, so a blinking cursor over it
// is noise.
let voice_listening = voice.is_some_and(|v| v.listening);
let cursor_pos = if style.focused && !voice_listening {
// Finalized draft stays editable during voice; hide the caret only when
// the box is empty and interim is standing in for it.
let hide_caret_for_empty_interim = self.textarea.text().is_empty()
&& voice.is_some_and(|v| v.interim.is_some_and(|t| !t.trim().is_empty()));
let cursor_pos = if style.focused && !hide_caret_for_empty_interim {
self.textarea
.cursor_pos_with_state(ta_area, self.textarea_state)
} else {
None
};
// Shell command ghost text: render suggestion suffix after cursor.
if let Some(ghost) = self.suggestions.ghost_text()
&& self.textarea.cursor() == self.textarea.text().len()
&& !slash_active
&& !slash_has_inline_ghost
&& let Some((cx, cy)) = cursor_pos
{
let avail = (ta_area.x + ta_area.width).saturating_sub(cx) as usize;
if avail > 0 {
let truncated = crate::render::line_utils::truncate_str(ghost, avail);
buf.set_string(cx, cy, &truncated, theme.ghost_text_style().bg(bg));
// Ghost suffixes (shell completion / predicted prompt). Voice interim
// owns the end-of-text cells when shown, so skip both ghosts then.
if !voice_interim_shown {
if let Some(ghost) = self.suggestions.ghost_text()
&& self.textarea.cursor() == self.textarea.text().len()
&& !slash_active
&& !slash_has_inline_ghost
&& let Some((cx, cy)) = cursor_pos
{
let avail = (ta_area.x + ta_area.width).saturating_sub(cx) as usize;
if avail > 0 {
let truncated = crate::render::line_utils::truncate_str(ghost, avail);
buf.set_string(cx, cy, &truncated, theme.ghost_text_style().bg(bg));
}
}
}
// Predicted-next-prompt ghost (tab autocomplete): render the remainder
// of the suggestion after the cursor. `prompt_suggestion_ghost()`
// owns all gating (per-frame active flag, no competing completion UI,
// cursor at end-of-text); voice interim already occupies the row when
// shown, so it wins.
if !voice_interim_shown
&& let Some(ghost) = self.prompt_suggestion_ghost()
&& let Some((cx, cy)) = cursor_pos
{
let avail = (ta_area.x + ta_area.width).saturating_sub(cx) as usize;
if avail > 0 {
let truncated = crate::render::line_utils::truncate_str(ghost, avail);
buf.set_string(cx, cy, &truncated, theme.ghost_text_style().bg(bg));
if let Some(ghost) = self.prompt_suggestion_ghost()
&& let Some((cx, cy)) = cursor_pos
{
let avail = (ta_area.x + ta_area.width).saturating_sub(cx) as usize;
if avail > 0 {
let truncated = crate::render::line_utils::truncate_str(ghost, avail);
buf.set_string(cx, cy, &truncated, theme.ghost_text_style().bg(bg));
}
}
}

View file

@ -3486,6 +3486,7 @@ mod tests {
model: None,
state: "running".into(),
tokens_used: 0,
duration_ms: 0,
},
crate::views::workflows::WorkflowAgentRowView {
agent_id: "a2".into(),
@ -3494,6 +3495,7 @@ mod tests {
model: None,
state: "done".into(),
tokens_used: 0,
duration_ms: 0,
},
];
let entry = TaskEntry::from_workflow_run(&run);

View file

@ -74,20 +74,22 @@ pub struct TurnStatusOutput {
pub cancel_button: Option<Rect>,
/// Hit area for the background-demote button, if rendered.
pub bg_button: Option<Rect>,
/// Hit area for the still-running watcher cue (click opens the tasks
/// pane). `None` on keyboard-only hosts.
pub watching_cue: Option<Rect>,
}
/// Mouse-clickable affordances on the turn-status row — the `[stop]` cancel and
/// `[↓]` send-to-background buttons — with their current hover state. Passing
/// `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+B` instead.
/// Hover state for the turn-status row's mouse affordances (`[stop]`, `[↓]`,
/// the still-running watcher cue). `Some(_)` renders them; `None` marks a
/// keyboard-only host (minimal mode — no mouse capture) and suppresses all.
#[derive(Debug, Clone, Copy, Default)]
pub struct MouseButtons {
/// Whether the mouse is over the `[stop]` cancel button.
pub cancel_hovered: bool,
/// Whether the mouse is over the `[↓]` send-to-background button.
pub bg_hovered: bool,
/// Whether the mouse is over the still-running watcher cue.
pub watching_hovered: bool,
}
/// Counts of idle-surviving "watcher" work — background jobs that can wake
@ -191,49 +193,64 @@ pub fn is_sendable_wait(activity: &Option<TurnActivity>) -> bool {
)
}
/// Inputs to [`render_turn_status`] — one frame's worth of turn state.
#[derive(Debug)]
pub struct TurnStatusArgs<'a> {
pub state: &'a AgentState,
pub activity: &'a Option<TurnActivity>,
pub turn_elapsed: Option<Duration>,
pub activity_started_at: Option<Instant>,
pub tick: u64,
pub drain_blocked: bool,
/// Mouse affordances + hover state; `None` for keyboard-only hosts.
pub buttons: Option<MouseButtons>,
pub has_running_execute: bool,
/// Context-window tokens used, shown as `⇣Nk`.
pub total_tokens: Option<u64>,
pub mcp_init_progress: Option<&'a McpInitProgress>,
pub is_bash_turn: bool,
pub is_pending_user_input: bool,
pub goal_verifying: bool,
pub watchers: Watchers,
/// Parked on a sendable wait (`AgentView::renders_parked`): suppress the
/// running-turn chrome and render only the still-running cue.
pub parked: bool,
/// Transparent right-side background so the row blends with the
/// terminal's own background (minimal mode).
pub flat_background: bool,
pub held_queue: usize,
pub held_queue_top_sendable: bool,
}
/// Render the turn status line into the given area.
///
/// The caller is responsible for only allocating a 1-row area when
/// `should_show()` returns true (and 0 rows when false).
///
/// # Parameters
/// - `buttons`: `Some(MouseButtons { .. })` to render the mouse-clickable
/// `[stop]` / `[↓]` buttons with their hover state; `None` for a keyboard-only
/// host (minimal mode — no mouse capture), which suppresses both buttons.
/// - `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 "… 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
/// with the terminal's own background (minimal mode).
///
/// # Returns
/// A [`TurnStatusOutput`] containing the cancel button hit area (if rendered).
#[allow(clippy::too_many_arguments)]
pub fn render_turn_status(
buf: &mut Buffer,
area: Rect,
state: &AgentState,
activity: &Option<TurnActivity>,
turn_elapsed: Option<Duration>,
activity_started_at: Option<Instant>,
tick: u64,
drain_blocked: bool,
buttons: Option<MouseButtons>,
has_running_execute: bool,
total_tokens: Option<u64>,
mcp_init_progress: Option<&McpInitProgress>,
is_bash_turn: bool,
is_pending_user_input: bool,
goal_verifying: bool,
watchers: Watchers,
parked: bool,
flat_background: bool,
held_queue: usize,
held_queue_top_sendable: bool,
args: TurnStatusArgs<'_>,
) -> TurnStatusOutput {
let TurnStatusArgs {
state,
activity,
turn_elapsed,
activity_started_at,
tick,
drain_blocked,
buttons,
has_running_execute,
total_tokens,
mcp_init_progress,
is_bash_turn,
is_pending_user_input,
goal_verifying,
watchers,
parked,
flat_background,
held_queue,
held_queue_top_sendable,
} = args;
// Resolve the mouse affordances: a keyboard-only host (`None`) suppresses
// both buttons and reports no hover.
let show_buttons = buttons.is_some();
@ -289,15 +306,22 @@ pub fn render_turn_status(
// 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 icon = format!("{} ", frames[frame_idx]);
let label_fg = if buttons.is_some_and(|b| b.watching_hovered) {
theme.text_primary
} else {
theme.gray
};
let cue_width = (icon.width() + cue.width()).min(area.width as usize) as u16;
let spans = vec![
Span::styled(
format!("{} ", frames[frame_idx]),
Style::default().fg(theme.accent_system),
),
Span::styled(cue, Style::default().fg(theme.gray)),
Span::styled(icon, Style::default().fg(theme.accent_system)),
Span::styled(cue, Style::default().fg(label_fg)),
];
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
return TurnStatusOutput::default();
return TurnStatusOutput {
watching_cue: show_buttons.then(|| Rect::new(area.x, area.y, cue_width, 1)),
..TurnStatusOutput::default()
};
}
// Parked with no watchers left: render nothing. The stopped look must
@ -603,6 +627,7 @@ pub fn render_turn_status(
TurnStatusOutput {
cancel_button: cancel_button_rect,
bg_button: bg_button_rect,
watching_cue: None,
}
}
@ -1123,33 +1148,49 @@ mod tests {
.join("\n")
}
/// Baseline render args: idle agent on a mouse host with the given watchers.
fn idle_args<'a>(watchers: Watchers) -> TurnStatusArgs<'a> {
TurnStatusArgs {
state: &AgentState::Idle,
activity: &None,
turn_elapsed: None,
activity_started_at: None,
tick: 0,
drain_blocked: false,
buttons: Some(MouseButtons::default()),
has_running_execute: false,
total_tokens: None,
mcp_init_progress: None,
is_bash_turn: false,
is_pending_user_input: false,
goal_verifying: false,
watchers,
parked: false,
flat_background: false,
held_queue: 0,
held_queue_top_sendable: false,
}
}
/// Render `args` into a `width`×1 row.
fn render_row(args: TurnStatusArgs<'_>, width: u16) -> (TurnStatusOutput, Buffer) {
let area = Rect::new(0, 0, width, 1);
let mut buf = Buffer::empty(area);
let output = render_turn_status(&mut buf, area, args);
(output, buf)
}
/// Render `args` into a `width`×1 row, returning the visible text.
fn render_row_text(args: TurnStatusArgs<'_>, width: u16) -> String {
let (_, buf) = render_row(args, width);
buffer_text(&buf, buf.area)
}
/// Invoke `render_turn_status` for an idle agent with the given MCP seed.
fn render_idle_with_mcp(progress: &McpInitProgress) -> String {
let area = Rect::new(0, 0, 60, 1);
let mut buf = Buffer::empty(area);
render_turn_status(
&mut buf,
area,
&AgentState::Idle,
&None,
None,
None,
0,
false,
Some(MouseButtons::default()),
false,
None,
Some(progress),
false,
false,
false,
Watchers::default(),
false,
false,
0,
false,
);
buffer_text(&buf, area)
let mut args = idle_args(Watchers::default());
args.mcp_init_progress = Some(progress);
render_row_text(args, 60)
}
/// Invoke `render_turn_status` for an idle agent with the given watcher
@ -1160,61 +1201,21 @@ mod tests {
/// [`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,
area,
&AgentState::Idle,
&None,
None,
None,
tick,
false,
Some(MouseButtons::default()),
false,
None,
None,
false,
false,
false,
watchers,
false,
false,
0,
false,
);
buffer_text(&buf, area)
let mut args = idle_args(watchers);
args.tick = tick;
render_row_text(args, width)
}
/// 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, 72, 1);
let mut buf = Buffer::empty(area);
render_turn_status(
&mut buf,
area,
&AgentState::TurnRunning,
&Some(TurnActivity::Waiting(WaitingReason::TasksComplete)),
Some(Duration::from_secs(5)),
None,
0,
false,
Some(MouseButtons::default()),
false,
None,
None,
false,
false,
false,
watchers,
true,
false,
0,
false,
);
buffer_text(&buf, area)
let activity = Some(TurnActivity::Waiting(WaitingReason::TasksComplete));
let mut args = idle_args(watchers);
args.state = &AgentState::TurnRunning;
args.activity = &activity;
args.turn_elapsed = Some(Duration::from_secs(5));
args.parked = true;
render_row_text(args, 72)
}
/// Invoke `render_turn_status` for an idle agent with the given watcher
@ -1268,6 +1269,38 @@ mod tests {
);
}
/// Mouse hosts get a hit rect hugging exactly the rendered cue text, and
/// hover brightens the label; keyboard-only hosts get neither.
#[test]
fn watching_cue_is_clickable_on_mouse_hosts_only() {
let theme = Theme::current();
let watchers = Watchers {
monitors: 1,
..Watchers::default()
};
// First label cell (after the 2-col icon).
let label_fg = |buf: &Buffer| buf.cell((2, 0)).map(|c| c.fg);
let (output, buf) = render_row(idle_args(watchers), 60);
let rect = output.watching_cue.expect("mouse host must get a hit rect");
let rendered_width = buffer_text(&buf, buf.area).trim_end().width() as u16;
assert_eq!(rect, Rect::new(0, 0, rendered_width, 1));
assert_eq!(label_fg(&buf), Some(theme.gray));
let mut args = idle_args(watchers);
args.buttons = Some(MouseButtons {
watching_hovered: true,
..MouseButtons::default()
});
let (_, buf) = render_row(args, 60);
assert_eq!(label_fg(&buf), Some(theme.text_primary));
let mut args = idle_args(watchers);
args.buttons = None;
let (output, _) = render_row(args, 60);
assert!(output.watching_cue.is_none());
}
#[test]
fn idle_with_loops_renders_still_running_cue() {
let text = render_idle_with_watchers(Watchers {
@ -1439,31 +1472,14 @@ mod tests {
#[test]
fn queued_hint_renders_after_phase_timer() {
let area = Rect::new(0, 0, 80, 1);
let mut buf = Buffer::empty(area);
render_turn_status(
&mut buf,
area,
&AgentState::TurnRunning,
&Some(TurnActivity::Waiting(WaitingReason::Subagent)),
None,
Some(Instant::now() - Duration::from_secs(359)),
0,
false,
Some(MouseButtons::default()),
false,
None,
None,
false,
false,
false,
Watchers::default(),
false,
false,
1,
true,
);
let text = buffer_text(&buf, area);
let activity = Some(TurnActivity::Waiting(WaitingReason::Subagent));
let mut args = idle_args(Watchers::default());
args.state = &AgentState::TurnRunning;
args.activity = &activity;
args.activity_started_at = Some(Instant::now() - Duration::from_secs(359));
args.held_queue = 1;
args.held_queue_top_sendable = true;
let text = render_row_text(args, 80);
assert!(
text.contains("Waiting on subagent… 5m59s · 1 queued — Enter to send now"),
"phase timer must sit between the wait label and the queued hint, got: {text:?}"

View file

@ -16,8 +16,18 @@ pub struct WorkflowAgentRowView {
pub model: Option<String>,
pub state: String,
pub tokens_used: u64,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Default)]
pub struct WorkflowAgentLiveStatus {
pub activity: Option<String>,
pub tokens_used: Option<u64>,
pub elapsed_ms: Option<u64>,
}
pub type WorkflowAgentLiveMap = std::collections::HashMap<String, WorkflowAgentLiveStatus>;
#[derive(Debug, Clone)]
pub struct WorkflowRunSnapshot {
pub run_id: String,
@ -63,7 +73,12 @@ impl WorkflowRunSnapshot {
}
matches!(
self.status.as_str(),
"user_paused" | "back_off_paused" | "no_progress_paused" | "infra_paused" | "blocked"
"user_paused"
| "back_off_paused"
| "no_progress_paused"
| "infra_paused"
| "blocked"
| "failed"
)
}
@ -99,6 +114,21 @@ impl WorkflowRunSnapshot {
}
}
pub fn phase_has_running_agents(&self, phase: &str) -> bool {
self.agents
.iter()
.any(|a| a.state == "running" && a.phase.as_deref() == Some(phase))
}
pub fn effective_active_phase(&self) -> Option<String> {
phase_rail(self)
.iter()
.rev()
.find(|(title, _)| self.phase_has_running_agents(title))
.map(|(title, _)| title.clone())
.or_else(|| self.current_phase.clone())
}
fn done_agents(&self) -> usize {
self.agents.iter().filter(|a| a.state != "running").count()
}
@ -114,6 +144,7 @@ pub struct WorkflowsViewState {
pub selected_phase_name: Option<String>,
pub phase_viewport: usize,
pub phase_pinned: bool,
pub pin_active_phase: Option<String>,
pub window: crate::views::modal_window::ModalWindowState,
pub run_hits: Vec<(Rect, String)>,
pub phase_hits: Vec<(Rect, String)>,
@ -264,6 +295,10 @@ impl WorkflowsViewState {
self.selected_run = idx;
}
let rail = phase_rail(run);
if self.phase_pinned && run.effective_active_phase() != self.pin_active_phase {
self.phase_pinned = false;
self.pin_active_phase = None;
}
if self.phase_pinned {
if let Some(name) = self.selected_phase_name.as_deref()
&& let Some(idx) = rail.iter().position(|(title, _)| title == name)
@ -312,6 +347,7 @@ impl WorkflowsViewState {
.get(self.selected_phase)
.map(|(title, _)| title.clone());
self.phase_pinned = true;
self.pin_active_phase = run.effective_active_phase();
}
pub fn ensure_run_visible(&mut self, visible_rows: usize, total_rows: usize) {
@ -424,9 +460,8 @@ pub fn phase_rail(run: &WorkflowRunSnapshot) -> Vec<(String, String)> {
fn default_phase_index(run: &WorkflowRunSnapshot) -> usize {
let rail = phase_rail(run);
run.current_phase
.as_deref()
.and_then(|current| rail.iter().position(|(title, _)| title == current))
run.effective_active_phase()
.and_then(|current| rail.iter().position(|(title, _)| title == &current))
.or_else(|| rail.iter().position(|(_, state)| state == "active"))
.unwrap_or_else(|| {
if rail.iter().all(|(_, state)| state == "done") {
@ -497,6 +532,7 @@ pub fn render_workflows(
runs: &[&WorkflowRunSnapshot],
state: &mut WorkflowsViewState,
tick: usize,
live: &WorkflowAgentLiveMap,
) -> Option<Rect> {
use crate::views::modal_window::{ModalWindowConfig, render_modal_window};
@ -523,7 +559,7 @@ pub fn render_workflows(
let inner = content.content;
match state.detail_run(runs) {
Some(run) => render_detail(buf, inner, run, state, tick, &theme),
Some(run) => render_detail(buf, inner, run, state, tick, &theme, live),
None => render_list(buf, inner, runs, state, &theme),
}
state.window.popup_area
@ -582,23 +618,11 @@ fn render_list(
if run.phases.len() == 1 { "" } else { "s" }
)
};
let agents = run
.agent_budget
.map(|total| {
format!(
" · agents {}/{} ({} left)",
run.agents_used,
total,
run.agents_remaining.unwrap_or(0)
)
})
.unwrap_or_default();
let meta = format!(
"{phase_part} · {}/{} agent{}{} · {}",
"{phase_part} · {}/{} agent{} · {}",
run.done_agents(),
run.agents.len(),
if run.agents.len() == 1 { "" } else { "s" },
agents,
format_elapsed(run.live_elapsed_ms()),
);
let label = format!(
@ -650,6 +674,7 @@ fn render_detail(
state: &mut WorkflowsViewState,
tick: usize,
theme: &Theme,
live: &WorkflowAgentLiveMap,
) {
let name = strip_control(&run.name);
let (glyph, glyph_style) = status_glyph_and_style(&run.status, theme);
@ -659,26 +684,11 @@ fn render_detail(
} else {
format!("{glyph} ")
};
let agent_budget = run.agent_budget.map(|total| {
let remaining = run.agents_remaining.unwrap_or(0);
format!(
" · agents {}/{} ({} left{})",
run.agents_used,
total,
remaining,
if run.agent_usage_incomplete {
", incomplete"
} else {
""
}
)
});
let meta = format!(
"{}/{} agent{}{} · {}",
"{}/{} agent{} · {}",
run.done_agents(),
run.agents.len(),
if run.agents.len() == 1 { "" } else { "s" },
agent_budget.unwrap_or_default(),
format_elapsed(run.live_elapsed_ms()),
);
let meta_w = unicode_width::UnicodeWidthStr::width(meta.as_str()) as u16;
@ -750,7 +760,7 @@ fn render_detail(
))
} else if run.status == "failed" {
Some((
"failed — see scrollback for details".to_string(),
"failed — see scrollback for details; r resumes from the journal".to_string(),
Style::default().fg(theme.accent_error),
))
} else {
@ -849,8 +859,18 @@ fn render_detail(
.filter(|agent| agent.state != "running")
.count()
};
let running_in = if all_agents_phase {
run.active_agent_count() > 0
} else {
run.phase_has_running_agents(title)
};
let effective_state = if running_in {
"active"
} else {
phase_state.as_str()
};
let marker = if selected { "" } else { " " };
let num_style = match phase_state.as_str() {
let num_style = match effective_state {
"done" => Style::default().fg(theme.accent_success),
"active" => Style::default().fg(theme.accent_plan),
_ => Style::default().fg(theme.gray_dim),
@ -859,16 +879,23 @@ fn render_detail(
Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD)
} else if phase_state == "pending" {
} else if effective_state == "pending" {
Style::default().fg(theme.gray_dim)
} else {
Style::default().fg(theme.gray_bright)
};
let count = if agents_in > 0 {
let count = if running_in {
format!("{done_in}/{agents_in}")
} else if agents_in > 0 {
format!("{done_in}/{agents_in}")
} else {
String::new()
};
let count_style = if running_in {
Style::default().fg(theme.accent_plan)
} else {
Style::default().fg(theme.gray_dim)
};
let count_w = unicode_width::UnicodeWidthStr::width(count.as_str()) as u16;
let count_x = rail_inner.right().saturating_sub(count_w);
@ -889,14 +916,7 @@ fn render_detail(
title_style,
count_x,
);
span_at(
buf,
count_x,
y,
&count,
Style::default().fg(theme.gray_dim),
rail_inner.right(),
);
span_at(buf, count_x, y, &count, count_style, rail_inner.right());
state.phase_hits.push((
Rect::new(rail_inner.x, y, rail_inner.width, 1),
title.clone(),
@ -970,8 +990,34 @@ fn render_detail(
if y >= roster_inner.bottom() {
break;
}
let (glyph, glyph_style) = agent_glyph_and_style(&agent.state, theme);
let tokens = fmt_tokens(agent.tokens_used);
let running = agent.state == "running";
let (glyph, glyph_style) = if running {
let frames = crate::glyphs::dot_spinner_frames();
(
frames[(tick / 4) % frames.len()],
Style::default().fg(theme.accent_plan),
)
} else {
agent_glyph_and_style(&agent.state, theme)
};
let live_status = running.then(|| live.get(&agent.agent_id)).flatten();
let tokens_val = live_status
.and_then(|l| l.tokens_used)
.unwrap_or(agent.tokens_used);
let elapsed_ms = if running {
live_status.and_then(|l| l.elapsed_ms).unwrap_or(0)
} else {
agent.duration_ms
};
let mut meta_parts: Vec<String> = Vec::new();
let tokens_txt = fmt_tokens(tokens_val);
if !tokens_txt.is_empty() {
meta_parts.push(tokens_txt);
}
if elapsed_ms > 0 {
meta_parts.push(format_elapsed(elapsed_ms));
}
let tokens = meta_parts.join(" · ");
let tokens_w = unicode_width::UnicodeWidthStr::width(tokens.as_str()) as u16;
let tokens_x = roster_inner.right().saturating_sub(tokens_w + 1);
@ -996,16 +1042,32 @@ fn render_detail(
tokens_x,
);
let label_w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
let model_x = roster_inner.x + 2 + label_w + 2;
let mut trail_x = roster_inner.x + 2 + label_w + 2;
if let Some(model) = agent.model.as_deref() {
let model_txt = truncate_to_width(model, tokens_x.saturating_sub(trail_x + 1) as usize);
span_at(
buf,
model_x,
trail_x,
y,
&truncate_to_width(model, tokens_x.saturating_sub(model_x + 1) as usize),
&model_txt,
Style::default().fg(theme.gray),
tokens_x,
);
trail_x += unicode_width::UnicodeWidthStr::width(model_txt.as_str()) as u16 + 2;
}
if let Some(activity) = live_status.and_then(|l| l.activity.as_deref()) {
let activity_txt = truncate_to_width(
&format!("{}", strip_control(activity)),
tokens_x.saturating_sub(trail_x + 1) as usize,
);
span_at(
buf,
trail_x,
y,
&activity_txt,
Style::default().fg(theme.gray_dim),
tokens_x,
);
}
span_at(
buf,
@ -1048,6 +1110,7 @@ mod tests {
model: None,
state: "done".into(),
tokens_used: 12_300,
duration_ms: 0,
},
WorkflowAgentRowView {
agent_id: "a2".into(),
@ -1056,6 +1119,7 @@ mod tests {
model: Some("grok-4.5".into()),
state: "running".into(),
tokens_used: 0,
duration_ms: 0,
},
],
agent_budget: Some(128),
@ -1086,7 +1150,14 @@ mod tests {
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
let mut state = state.clone();
render_workflows(&mut buf, area, runs, &mut state, 0);
render_workflows(
&mut buf,
area,
runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
buf_text(&buf, area)
}
@ -1146,7 +1217,14 @@ mod tests {
state.normalize(&runs);
let area = Rect::new(0, 0, 140, 30);
let mut buf = Buffer::empty(area);
render_workflows(&mut buf, area, &runs, &mut state, 0);
render_workflows(
&mut buf,
area,
&runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
let text = buf_text(&buf, area);
assert!(text.contains("raise agent budget above 2"), "{text}");
assert!(text.contains("bare resume disabled"), "{text}");
@ -1162,12 +1240,36 @@ mod tests {
state.normalize(&runs);
let narrow = Rect::new(0, 0, 84, 30);
let mut buf = Buffer::empty(narrow);
render_workflows(&mut buf, narrow, &runs, &mut state, 0);
render_workflows(
&mut buf,
narrow,
&runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
let text = buf_text(&buf, narrow);
assert!(text.contains("bare resume disabled"), "{text}");
assert!(text.contains("raise agent budget"), "{text}");
}
#[test]
fn failed_run_offers_resume_but_not_stop() {
let run = make_run("wf_1", "deep-research", "failed");
assert!(
run.can_resume(),
"failed runs resume via journal replay of completed agents"
);
assert!(!run.can_stop(), "failed is terminal");
let labels = footer_shortcuts(true, false, Some(&run))
.into_iter()
.map(|shortcut| shortcut.label)
.collect::<Vec<_>>();
assert!(labels.contains(&"r resume"));
assert!(!labels.contains(&"x stop"));
}
#[test]
fn narrow_detail_layout_is_panic_free() {
let run = make_run("wf_1", "deep-research", "active");
@ -1176,7 +1278,14 @@ mod tests {
let mut buf = Buffer::empty(area);
let mut state = WorkflowsViewState::default();
state.normalize(&runs);
render_workflows(&mut buf, area, &runs, &mut state, 0);
render_workflows(
&mut buf,
area,
&runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
}
#[test]
@ -1190,11 +1299,19 @@ mod tests {
let area = Rect::new(0, 0, 180, 30);
let mut buf = Buffer::empty(area);
render_workflows(&mut buf, area, &runs, &mut state, 0);
render_workflows(
&mut buf,
area,
&runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
let text = buf_text(&buf, area);
assert!(text.contains("deep-research"), "{text}");
assert!(text.contains("count-v2"), "{text}");
assert!(text.contains("agents 2/128 (126 left)"), "{text}");
assert!(text.contains("1/2 agents"), "{text}");
assert!(!text.contains("128"), "budget cap is not shown: {text}");
assert!(!text.contains(" · out "), "{text}");
assert!(text.contains("enter open"), "{text}");
}
@ -1257,6 +1374,7 @@ mod tests {
model: None,
state: "running".to_owned(),
tokens_used: 0,
duration_ms: 0,
}];
let runs = vec![&run];
let mut state = WorkflowsViewState::default();
@ -1301,7 +1419,14 @@ mod tests {
let mut state = WorkflowsViewState::default();
state.normalize(&runs);
let mut buf = Buffer::empty(area);
render_workflows(&mut buf, area, &runs, &mut state, 0);
render_workflows(
&mut buf,
area,
&runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
assert_eq!(
state
.run_hits
@ -1318,7 +1443,14 @@ mod tests {
state.normalize(&runs);
assert_eq!(state.selected_phase, 1);
let mut buf = Buffer::empty(area);
render_workflows(&mut buf, area, &runs, &mut state, 0);
render_workflows(
&mut buf,
area,
&runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
assert!(state.run_hits.is_empty());
assert!(state.list_area.is_none());
assert_eq!(
@ -1341,7 +1473,14 @@ mod tests {
assert!(rect.width > 0 && rect.height == 1);
let tiny = Rect::new(0, 0, 4, 2);
let mut buf = Buffer::empty(tiny);
render_workflows(&mut buf, tiny, &runs, &mut state, 0);
render_workflows(
&mut buf,
tiny,
&runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
assert!(state.agent_hits.is_empty());
assert!(state.phase_hits.is_empty());
assert!(state.rail_area.is_none() && state.roster_area.is_none());
@ -1419,6 +1558,7 @@ mod tests {
model: None,
state: "done".into(),
tokens_used: 0,
duration_ms: 0,
})
.collect();
let runs = vec![&run];
@ -1427,7 +1567,14 @@ mod tests {
state.normalize(&runs);
let mut buf = Buffer::empty(area);
render_workflows(&mut buf, area, &runs, &mut state, 0);
render_workflows(
&mut buf,
area,
&runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
let visible = state.agent_hits.len();
assert!(visible > 0 && visible < 30, "fixture must overflow");
let newest_first_visible = format!("a{:02}", 30 - visible);
@ -1435,7 +1582,14 @@ mod tests {
state.roster_scroll = 5;
let mut buf = Buffer::empty(area);
render_workflows(&mut buf, area, &runs, &mut state, 0);
render_workflows(
&mut buf,
area,
&runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
assert_eq!(state.agent_hits[0].1, format!("a{:02}", 30 - visible - 5));
let text = buf_text(&buf, area);
assert!(text.contains("↑5"), "{text}");
@ -1448,17 +1602,32 @@ mod tests {
model: None,
state: "running".to_owned(),
tokens_used: 0,
duration_ms: 0,
});
let runs = vec![&run];
let mut buf = Buffer::empty(area);
render_workflows(&mut buf, area, &runs, &mut state, 0);
render_workflows(
&mut buf,
area,
&runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
assert_eq!(state.agent_hits[0].1, anchored_top);
assert_eq!(state.roster_scroll, 6);
state.roster_scroll = 10_000;
state.roster_top_agent_id = None;
let mut buf = Buffer::empty(area);
render_workflows(&mut buf, area, &runs, &mut state, 0);
render_workflows(
&mut buf,
area,
&runs,
&mut state,
0,
&WorkflowAgentLiveMap::default(),
);
assert_eq!(state.roster_scroll, 31 - visible);
assert_eq!(state.agent_hits[0].1, "a00");
@ -1474,14 +1643,115 @@ mod tests {
}
#[test]
fn detail_renders_agent_budget_breakdown() {
fn detail_header_omits_agent_budget() {
let run = make_run("wf_1", "deep-research", "active");
let runs = vec![&run];
let mut state = WorkflowsViewState::default();
state.normalize(&runs);
let text = render_to_text(&runs, &state);
assert!(text.contains("agents 2/128"), "{text}");
assert!(text.contains("126 left"), "{text}");
assert!(text.contains("1/2 agents"), "{text}");
assert!(!text.contains("128"), "budget cap is not shown: {text}");
assert!(!text.contains("left"), "{text}");
}
fn run_with_lagging_current_phase() -> WorkflowRunSnapshot {
let mut run = make_run("wf_lag", "morefixes-quality-audit", "active");
run.phases = vec![
("Export".to_owned(), "done".to_owned()),
("Audit".to_owned(), "active".to_owned()),
("Synthesize".to_owned(), "pending".to_owned()),
];
run.current_phase = Some("Audit".to_owned());
run.agents = vec![
WorkflowAgentRowView {
agent_id: "a1".into(),
label: "audit-batch-0".into(),
phase: Some("Audit".into()),
model: None,
state: "done".into(),
tokens_used: 1_000,
duration_ms: 0,
},
WorkflowAgentRowView {
agent_id: "a2".into(),
label: "synthesizer".into(),
phase: Some("Synthesize".into()),
model: None,
state: "running".into(),
tokens_used: 0,
duration_ms: 0,
},
];
run
}
#[test]
fn default_selection_follows_phase_with_running_agents() {
let run = run_with_lagging_current_phase();
assert_eq!(run.effective_active_phase().as_deref(), Some("Synthesize"));
let runs = vec![&run];
let mut state = WorkflowsViewState::default();
state.normalize(&runs);
assert_eq!(state.selected_phase_name.as_deref(), Some("Synthesize"));
}
#[test]
fn pinned_phase_unpins_when_run_progresses() {
let mut run = make_run("wf_1", "deep-research", "active");
run.agents[1].state = "running".to_owned();
let runs = vec![&run];
let mut state = WorkflowsViewState::default();
state.normalize(&runs);
state.select_phase(0, &run);
state.normalize(&runs);
assert!(state.phase_pinned);
assert_eq!(state.selected_phase_name.as_deref(), Some("Plan"));
run.agents[1].state = "done".to_owned();
run.agents.push(WorkflowAgentRowView {
agent_id: "a3".into(),
label: "synthesizer".into(),
phase: Some("Synthesize".into()),
model: None,
state: "running".into(),
tokens_used: 0,
duration_ms: 0,
});
let runs = vec![&run];
state.normalize(&runs);
assert!(!state.phase_pinned);
assert_eq!(state.selected_phase_name.as_deref(), Some("Synthesize"));
}
#[test]
fn rail_marks_running_phase_and_roster_streams_live_status() {
let run = run_with_lagging_current_phase();
let runs = vec![&run];
let mut state = WorkflowsViewState::default();
state.normalize(&runs);
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
let mut live = WorkflowAgentLiveMap::default();
live.insert(
"a2".to_owned(),
WorkflowAgentLiveStatus {
activity: Some("Running: rg -n needle /data".to_owned()),
tokens_used: Some(42_000),
elapsed_ms: Some(75_000),
},
);
render_workflows(&mut buf, area, &runs, &mut state, 0, &live);
let text = buf_text(&buf, area);
assert!(
text.contains("● 0/1"),
"running phase gets a ● marker: {text}"
);
assert!(text.contains("— Running: rg -n needle"), "{text}");
assert!(
text.contains("42k tok · 1m15s"),
"live tokens + elapsed match the header meta style: {text}"
);
}
#[test]
@ -1527,6 +1797,7 @@ mod tests {
selected_phase: 9,
selected_phase_name: Some("missing".to_owned()),
phase_pinned: true,
pin_active_phase: Some("Research".to_owned()),
..Default::default()
};
state.normalize(&runs);