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:
parent
69f0ba880a
commit
6e38642082
103 changed files with 4964 additions and 1261 deletions
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
|
|
|
|||
|
|
@ -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 == ¤t))
|
||||
.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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue