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

@ -158,6 +158,7 @@ pub(super) fn ingest_workflow_update(agent: &mut AgentView, update: XaiSessionUp
model: a.model.clone(),
state: a.state.clone(),
tokens_used: a.tokens_used,
duration_ms: a.duration_ms,
})
.collect(),
agent_budget,

View file

@ -733,6 +733,38 @@ mod link_click_tests {
"click where stop used to be must not cancel the turn under a dropdown"
);
}
/// Clicking the still-running watcher cue toggles the tasks pane like
/// Ctrl+G; only the first click that reveals the pane shows the one-time
/// shortcut toast.
#[test]
fn watching_cue_click_opens_tasks_pane_with_one_time_shortcut_toast() {
let reg = ActionRegistry::defaults();
let mut agent = make_agent();
agent.last_terminal_size = (80, 30);
super::test_fixtures::add_running_bg_task(&mut agent);
draw_banner_frame(&mut agent, &reg, &[], 0);
let rect = agent.hit_watching_cue.rect.expect("cue rect must be armed");
let click = Event::Mouse(mouse_down(rect.x + 1, rect.y));
let _ = agent.handle_input(&click, &reg);
assert!(agent.tasks.overlay.focused);
assert!(agent.toast.is_none(), "focus-only click must not toast");
agent.tasks.overlay.hide();
agent.tasks.on_state_change();
draw_banner_frame(&mut agent, &reg, &[], 0);
let _ = agent.handle_input(&click, &reg);
assert!(agent.tasks.overlay.visible && agent.tasks.overlay.focused);
assert_eq!(agent.active_pane, AgentPane::Tasks);
let toast = agent.toast.clone().map(|(msg, _)| msg);
assert_eq!(toast.as_deref(), Some("Tip: Ctrl+G toggles the tasks pane"));
agent.toast = None;
draw_banner_frame(&mut agent, &reg, &[], 0);
let _ = agent.handle_input(&click, &reg);
assert!(!agent.tasks.overlay.visible);
draw_banner_frame(&mut agent, &reg, &[], 0);
let _ = agent.handle_input(&click, &reg);
assert!(agent.tasks.overlay.visible);
assert!(agent.toast.is_none(), "toast fires only once per session");
}
/// Bg twin: the `[↓]` demote button rides the same turn-status row, so its
/// rect must drop under an open dropdown too — a dropdown click must never
/// background the running execute tool.

View file

@ -1108,6 +1108,11 @@ pub struct AgentView {
pub hit_cwd: HitArea,
/// Cancel button in turn status line (`[stop]`).
pub hit_cancel_button: HitArea,
/// Still-running watcher cue on the turn-status row (click opens the
/// tasks pane, same as `Ctrl+G`).
pub hit_watching_cue: HitArea,
/// One-time Ctrl+G toast already fired for a watching-cue click.
pub(crate) watching_cue_toast_shown: bool,
/// `[hide]` button on the announcement banner (click == `/announcements hide`).
pub hit_announcement_hide: HitArea,
/// `[label]` CTA button on the promo banner row (click opens its link).

View file

@ -2005,6 +2005,7 @@ impl AgentView {
));
self.hit_cancel_button.rect = None;
self.hit_bg_button.rect = None;
self.hit_watching_cue.rect = None;
} else {
let has_running_execute = !self.is_subagent_view
&& self
@ -2023,36 +2024,42 @@ impl AgentView {
let turn_output = turn_status::render_turn_status(
buf,
turn_area,
&self.session.state,
&activity,
self.turn_elapsed(),
self.activity_started_at,
tick,
drain_blocked,
Some(turn_status::MouseButtons {
cancel_hovered: self.hit_cancel_button.hovered,
bg_hovered: self.hit_bg_button.hovered,
}),
has_running_execute,
self.context_state.as_ref().map(|c| c.used),
self.mcp_init_progress.as_ref(),
self.bash_turn,
is_pending_user_input,
goal_verifying,
watchers,
parked,
false,
held_queue,
held_queue_top_sendable,
turn_status::TurnStatusArgs {
state: &self.session.state,
activity: &activity,
turn_elapsed: self.turn_elapsed(),
activity_started_at: self.activity_started_at,
tick,
drain_blocked,
buttons: Some(turn_status::MouseButtons {
cancel_hovered: self.hit_cancel_button.hovered,
bg_hovered: self.hit_bg_button.hovered,
watching_hovered: self.hit_watching_cue.hovered,
}),
has_running_execute,
total_tokens: self.context_state.as_ref().map(|c| c.used),
mcp_init_progress: self.mcp_init_progress.as_ref(),
is_bash_turn: self.bash_turn,
is_pending_user_input,
goal_verifying,
watchers,
parked,
flat_background: false,
held_queue,
held_queue_top_sendable,
},
);
self.hit_cancel_button
.set_unless_dropdown(turn_output.cancel_button, dropdown_open);
self.hit_bg_button
.set_unless_dropdown(turn_output.bg_button, dropdown_open);
self.hit_watching_cue
.set_unless_dropdown(turn_output.watching_cue, dropdown_open);
}
} else {
self.hit_cancel_button.clear();
self.hit_bg_button.clear();
self.hit_watching_cue.clear();
self.hit_plan_approval_status.clear();
}
let privacy_banner_owns_slot = privacy_banner && layout.banner.height >= 2;
@ -2735,7 +2742,6 @@ impl AgentView {
};
let voice_overlay = if voice_available && (voice_listening || voice_interim.is_some()) {
Some(crate::views::prompt_widget::VoicePromptOverlay {
listening: voice_listening,
interim: voice_interim,
color: theme.accent_running,
})
@ -4208,8 +4214,23 @@ impl AgentView {
let mut view = self.workflows_view.clone();
view.normalize(&runs);
let tick = self.tasks.tick_count() as usize;
let live: crate::views::workflows::WorkflowAgentLiveMap = self
.subagent_sessions
.iter()
.filter(|(_, info)| info.workflow_run_id.is_some() && info.is_running())
.map(|(id, info)| {
(
id.clone(),
crate::views::workflows::WorkflowAgentLiveStatus {
activity: info.activity_label.clone(),
tokens_used: info.tokens_used,
elapsed_ms: Some(info.display_elapsed().as_millis() as u64),
},
)
})
.collect();
let popup =
crate::views::workflows::render_workflows(buf, area, &runs, &mut view, tick);
crate::views::workflows::render_workflows(buf, area, &runs, &mut view, tick, &live);
self.workflows_view = view;
if let Some(popup) = popup {
self.frame_occluder_rects.push(popup);

View file

@ -196,6 +196,8 @@ impl AgentView {
hit_follow_indicator: Default::default(),
hit_cwd: Default::default(),
hit_cancel_button: Default::default(),
hit_watching_cue: Default::default(),
watching_cue_toast_shown: false,
hit_announcement_hide: Default::default(),
hit_announcement_cta: Default::default(),
privacy_banner: Default::default(),

View file

@ -464,6 +464,7 @@ mod workflows_overlay_key_tests {
model: None,
state: "done".to_owned(),
tokens_used: 0,
duration_ms: 0,
},
crate::views::workflows::WorkflowAgentRowView {
agent_id: "child-running".to_owned(),
@ -472,6 +473,7 @@ mod workflows_overlay_key_tests {
model: None,
state: "running".to_owned(),
tokens_used: 0,
duration_ms: 0,
},
];
agent
@ -511,7 +513,7 @@ mod workflows_overlay_key_tests {
}
#[test]
fn only_explicitly_paused_background_runs_are_resumable() {
fn paused_budget_limited_and_failed_runs_are_resumable_others_fail_closed() {
let mut agent = workflows_agent(&["wf_run"]);
let reg = ActionRegistry::defaults();
agent.workflow_runs[0].status = "user_paused".to_string();
@ -540,8 +542,24 @@ mod workflows_overlay_key_tests {
agent.show_workflows = true;
agent.workflow_runs[0].status = "failed".to_string();
let out = agent.handle_input(&key(KeyCode::Char('r')), &reg);
assert!(
matches!(
out,
InputOutcome::Action(Action::SendSlashCommandPreservingDraft(ref command))
if command == "/workflow resume deep-research"
),
"failed runs resume via journal replay"
);
assert!(
!agent.show_workflows,
"failed r dispatches a resume and closes the overlay"
);
agent.show_workflows = true;
agent.workflow_runs[0].status = "complete".to_string();
let out = agent.handle_input(&key(KeyCode::Char('r')), &reg);
assert!(matches!(out, InputOutcome::Changed));
assert!(agent.show_workflows, "failed runs must not be resumed");
assert!(agent.show_workflows, "completed runs must not be resumed");
agent.workflow_runs[0].status = "user_paused".to_string();
agent.workflow_runs[0].management_available = false;

View file

@ -1881,6 +1881,27 @@ impl AppView {
|| self.voice_listening()
|| self.voice_state.pending_cold_start()
}
/// Commit interim on real send keys only (not multiline bare Enter).
fn maybe_commit_voice_interim_before_submit_key(&mut self, key: &crossterm::event::KeyEvent) {
if self.registry.matches_id(ActionId::InterjectPrompt, key) {
let _ = crate::voice::commit_interim_into_prompt(self);
return;
}
let multiline = match self.active_view {
ActiveView::Agent(id) => self.agents.get(&id).is_some_and(|a| a.multiline_mode),
ActiveView::AgentDashboard => self.dashboard.as_ref().is_some_and(|d| d.multiline_mode),
_ => false,
};
let is_send = if multiline {
crate::input::is_mod_enter(key)
} else {
matches!(key.code, KeyCode::Enter)
|| self.registry.matches_id(ActionId::SendPrompt, key)
};
if is_send {
let _ = crate::voice::commit_interim_into_prompt(self);
}
}
/// The active agent's view, when an agent tab is focused.
///
/// Always the root agent, even when a subagent view is focused within the
@ -2623,6 +2644,11 @@ impl AppView {
if let Some(outcome) = self.voice_esc_outcome(key_event) {
return outcome;
}
if let Event::Key(key) = ev
&& key.kind != KeyEventKind::Release
{
self.maybe_commit_voice_interim_before_submit_key(key);
}
if self.screen_mode.is_minimal()
&& let Event::Key(key) = ev
&& key.kind != KeyEventKind::Release
@ -2669,6 +2695,11 @@ impl AppView {
if let Some(outcome) = self.voice_esc_outcome(key_event) {
return outcome;
}
if let Event::Key(key) = ev
&& key.kind != KeyEventKind::Release
{
self.maybe_commit_voice_interim_before_submit_key(key);
}
let attached_raw = self.dashboard.as_ref().and_then(|d| d.attached_agent);
let attached = attached_raw.filter(|id| self.agents.contains_key(id));
if attached_raw.is_some()
@ -9919,6 +9950,7 @@ pub(crate) mod tests {
model: None,
state: "running".to_owned(),
tokens_used: 0,
duration_ms: 0,
}],
agent_budget: None,
agents_used: 0,

View file

@ -15,7 +15,7 @@ use super::session::load::dispatch_load_session;
use super::session::load::focus_if_session_already_open;
use super::session::modal::dispatch_sessions_confirm_close;
use super::turn::dispatch_cancel_turn;
use super::voice::voice_stop_on_submit;
use super::voice::{merge_prompt_with_voice_interim, voice_stop_on_submit};
use crate::app::actions::{Action, Effect};
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
@ -667,9 +667,7 @@ fn open_dashboard_worktree_dialog(
/// Mirrors `dispatch_dashboard_dispatch`'s new-session arm with `attach=true`,
/// minus the prompt enqueue.
pub(super) fn dispatch_dashboard_create_new_agent_with_detail(app: &mut AppView) -> Vec<Effect> {
// Creating/switching consumes the dispatch surface — stop voice and drop the
// target so a late final can't refill the box after the view switch.
voice_stop_on_submit(app);
let _ = voice_stop_on_submit(app);
// Worktree mode armed + git repo: open the label dialog (which spawns the
// agent in a fresh worktree on confirm) instead of a plain session. The
// button opens the detail view, so confirm attaches (`attach = true`).
@ -1101,10 +1099,7 @@ pub(super) fn dispatch_dashboard_dispatch(
text: String,
attach: bool,
) -> Vec<Effect> {
// Enter is a submit attempt — stop voice and drop the target up front (as the
// agent path does), so even a rejected send (empty / over-cap) can't leave a
// hot mic or let a late final refill the box.
voice_stop_on_submit(app);
let text = merge_prompt_with_voice_interim(text, voice_stop_on_submit(app));
// Paste-then-immediate-send: a Cmd+V image probe is still off-thread. Stash
// this send and re-issue it once the probe completes so the image is never
// dropped from the dispatched prompt's content blocks.
@ -1284,8 +1279,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
use crate::slash::command::{CommandExecCtx, CommandResult};
use crate::slash::parse_invocation;
// Enter is a submit attempt — stop voice and drop the target up front.
voice_stop_on_submit(app);
let text = merge_prompt_with_voice_interim(text, voice_stop_on_submit(app));
let trimmed = text.trim().to_string();
if trimmed.is_empty() || !trimmed.starts_with('/') {
return vec![];
@ -1680,9 +1674,7 @@ pub(super) fn dispatch_dashboard_peek_reply(
) -> Vec<Effect> {
use crate::views::dashboard::DashboardRowId;
// Enter is a submit attempt — stop voice and drop the target up front so a
// rejected reply can't leave a hot mic or let a late final refill the box.
voice_stop_on_submit(app);
let text = merge_prompt_with_voice_interim(text, voice_stop_on_submit(app));
// Paste-then-immediate-send: a Cmd+V image probe is still off-thread. Stash
// this reply and re-issue it once the probe completes so the image is never

View file

@ -2,6 +2,7 @@
//! `x.ai/interject` effect, and prompt-history recording. Split out of
//! `dispatch.rs` verbatim (pure code motion).
use super::voice::voice_stop_on_submit;
use crate::app::actions::Effect;
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView};
@ -23,6 +24,8 @@ pub(super) fn dispatch_interject(
text: String,
images: Vec<crate::prompt_images::PastedImage>,
) -> Vec<Effect> {
// Hard-reset only — `text` may not be from the composer.
let _ = voice_stop_on_submit(app);
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
@ -90,6 +93,8 @@ pub(super) fn dispatch_send_prompt_now(
text: String,
images: Vec<crate::prompt_images::PastedImage>,
) -> Vec<Effect> {
// Hard-reset only — `text` may be a queue row, not the composer.
let _ = voice_stop_on_submit(app);
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};

View file

@ -12,7 +12,7 @@ use super::queue::{
use super::router::dispatch;
use super::session::fork::open_project_question;
use super::session::lifecycle::skip_picker_and_create_session;
use super::voice::voice_stop_on_submit;
use super::voice::{merge_prompt_with_voice_interim, voice_stop_on_submit};
use crate::app::actions::{Action, DoctorFixTarget, Effect};
use crate::app::agent::{AgentId, AgentState};
use crate::app::agent_view::AgentView;
@ -441,9 +441,13 @@ pub(super) fn dispatch_send_prompt_inner(
// the common funnel so every submit path is covered, before any early-return
// guard below.
app.pending_action = None;
// Releases the mic and drops the recording target so a late in-flight final
// can't refill the prompt the user just sent.
voice_stop_on_submit(app);
// Promote interim + hard-reset; merge only when consuming the composer.
let interim = voice_stop_on_submit(app);
let text = if consume_input {
merge_prompt_with_voice_interim(text, interim)
} else {
text
};
if app.reconnect_pending {
app.show_toast("Reconnecting, please wait...");

View file

@ -67,7 +67,9 @@ fn voice_final_appends_to_prompt_with_single_space() {
target: VoiceTarget::Agent(id),
interim: None,
};
app.agents.get_mut(&id).unwrap().prompt.set_text("hello");
let p = &mut app.agents.get_mut(&id).unwrap().prompt;
p.set_text("hello");
p.set_cursor(5);
let redraw = crate::voice::handle_voice_event(
&mut app,
xai_grok_voice::VoiceEvent::UtteranceFinal {
@ -75,7 +77,36 @@ fn voice_final_appends_to_prompt_with_single_space() {
},
);
assert!(redraw);
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "hello world");
let p = &app.agents.get(&id).unwrap().prompt;
assert_eq!(p.text(), "hello world");
assert_eq!(p.cursor(), "hello world".len());
}
#[test]
fn voice_final_preserves_mid_text_cursor() {
let mut app = test_app_with_agent();
let id = AgentId(0);
app.voice_state = VoiceState::Recording {
hold: false,
target: VoiceTarget::Agent(id),
interim: Some("partial".into()),
};
let p = &mut app.agents.get_mut(&id).unwrap().prompt;
p.set_text("hello world");
p.set_cursor(5);
crate::voice::handle_voice_event(
&mut app,
xai_grok_voice::VoiceEvent::UtteranceFinal {
text: "again".into(),
},
);
let p = &app.agents.get(&id).unwrap().prompt;
assert_eq!(p.text(), "hello world again");
assert_eq!(p.cursor(), 5);
assert!(app.voice_listening());
assert!(app.voice_interim().is_none());
}
#[test]
@ -92,7 +123,29 @@ fn voice_final_into_empty_prompt_has_no_leading_space() {
text: "hi there".into(),
},
);
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "hi there");
let p = &app.agents.get(&id).unwrap().prompt;
assert_eq!(p.text(), "hi there");
assert_eq!(p.cursor(), "hi there".len());
}
#[test]
fn voice_final_replaces_whitespace_only_draft() {
let mut app = test_app_with_agent();
let id = AgentId(0);
app.voice_state = VoiceState::Stopping {
target: VoiceTarget::Agent(id),
interim: None,
};
let p = &mut app.agents.get_mut(&id).unwrap().prompt;
p.set_text(" \n");
p.set_cursor(0);
crate::voice::handle_voice_event(
&mut app,
xai_grok_voice::VoiceEvent::UtteranceFinal { text: "hi".into() },
);
let p = &app.agents.get(&id).unwrap().prompt;
assert_eq!(p.text(), "hi");
assert_eq!(p.cursor(), 2);
}
#[test]
@ -733,3 +786,66 @@ fn voice_stt_language_auto_stored_unresolved() {
assert_eq!(app.voice_config.language, "auto");
assert_eq!(app.current_ui.voice_stt_language.as_deref(), Some("auto"));
}
#[test]
fn voice_submit_includes_interim() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
app.voice_cmd_tx = Some(tx);
app.agents.get_mut(&id).unwrap().prompt.set_text("hello");
app.voice_state = VoiceState::Recording {
hold: false,
target: VoiceTarget::Agent(id),
interim: Some("world".into()),
};
let effects = dispatch(Action::SendPrompt("hello".into()), &mut app);
let Effect::SendPrompt { text, .. } = &effects[0] else {
panic!("expected SendPrompt, got {effects:?}");
};
assert_eq!(text, "hello world");
assert!(!app.voice_listening());
assert!(matches!(
rx.try_recv(),
Ok(xai_grok_voice::VoiceCommand::PttRelease)
));
}
#[test]
fn voice_submit_interim_only() {
let mut app = test_app_with_agent();
let id = AgentId(0);
app.voice_state = VoiceState::Recording {
hold: false,
target: VoiceTarget::Agent(id),
interim: Some("ghost only".into()),
};
let effects = dispatch(Action::SendPrompt(String::new()), &mut app);
let Effect::SendPrompt { text, .. } = &effects[0] else {
panic!("expected SendPrompt, got {effects:?}");
};
assert_eq!(text, "ghost only");
assert!(!app.voice_listening());
}
#[test]
fn voice_submit_follow_up_keeps_chip_literal() {
let mut app = test_app_with_agent();
let id = AgentId(0);
app.agents.get_mut(&id).unwrap().prompt.set_text("draft");
app.voice_state = VoiceState::Recording {
hold: false,
target: VoiceTarget::Agent(id),
interim: Some("dictated".into()),
};
let effects = dispatch(Action::SubmitFollowUp("chip text".into()), &mut app);
let Effect::SendPrompt { text, .. } = &effects[0] else {
panic!("expected SendPrompt, got {effects:?}");
};
assert_eq!(text, "chip text");
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "draft dictated");
assert!(!app.voice_listening());
}

View file

@ -4,13 +4,20 @@ use super::session::lifecycle::dispatch_new_session;
use crate::app::actions::Effect;
use crate::app::app_view::{ActiveView, AppView, VoiceState, VoiceTarget};
/// Tear down voice when a prompt box is **submitted** (Enter / send): release
/// the mic and forget the session entirely (no trailing final) so a late
/// in-flight final can't refill the box the user just sent, and a queued
/// cold-start can't open the mic afterwards. Used by the agent prompt and every
/// dashboard submit path (dispatch / peek reply / new-agent / slash).
pub(super) fn voice_stop_on_submit(app: &mut AppView) {
/// Promote live interim into the bound prompt, then hard-reset (no trailing
/// final). Returns the fragment for callers that captured text earlier.
pub(super) fn voice_stop_on_submit(app: &mut AppView) -> Option<String> {
let interim = crate::voice::commit_interim_into_prompt(app);
app.voice_reset();
interim
}
/// Merge interim into a payload captured before [`voice_stop_on_submit`].
pub(super) fn merge_prompt_with_voice_interim(existing: String, interim: Option<String>) -> String {
match interim {
Some(interim) => crate::voice::combine_prompt_with_voice_text(&existing, &interim),
None => existing,
}
}
/// The prompt box dictation should target for the current surface: a top-level

View file

@ -151,6 +151,23 @@ impl AgentView {
crate::views::privacy_banner::PRIVACY_BANNER_LEGAL_URL.to_string(),
));
}
if self.hit_watching_cue.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
let was_visible = self.tasks.overlay.visible;
self.tasks.overlay.toggle();
self.tasks.on_state_change();
if self.tasks.overlay.focused {
self.set_active_pane(AgentPane::Tasks, false);
} else if self.active_pane == AgentPane::Tasks {
self.set_active_pane(AgentPane::Scrollback, false);
}
if !was_visible && !self.watching_cue_toast_shown {
self.watching_cue_toast_shown = true;
self.show_toast("Tip: Ctrl+G toggles the tasks pane");
}
return InputOutcome::Changed;
}
if self.hit_announcement_hide.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
@ -1068,6 +1085,7 @@ impl AgentView {
.update_hover(mouse.column, mouse.row);
changed |= self.hit_cancel_button.update_hover(mouse.column, mouse.row);
changed |= self.hit_bg_button.update_hover(mouse.column, mouse.row);
changed |= self.hit_watching_cue.update_hover(mouse.column, mouse.row);
changed |= self
.hit_announcement_hide
.update_hover(mouse.column, mouse.row);

View file

@ -154,7 +154,7 @@ pub static USER_GUIDE: &[Doc] = &[
guide!(
"22-permissions-and-safety.md",
"Permissions and Safety",
"Tool approval, sandbox, security"
"Modes, authorization order, allow/ask/deny rules, matching, and hooks"
),
];

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);

View file

@ -3,42 +3,48 @@
use xai_grok_voice::VoiceEvent;
use crate::app::app_view::{AppView, VoiceTarget};
use crate::views::prompt_widget::PromptWidget;
/// Append finalized text to whichever prompt started capture
/// (`voice_recording_target`) — the agent prompt or the dashboard dispatch input
/// — not necessarily the active view, so a late final after a view switch still
/// lands in the right place. Inserts a single separating space unless the prompt
/// is empty or already ends in whitespace (preserves trailing newlines).
/// Join committed prompt text with a voice fragment. Space-separated unless the
/// prompt is empty or already ends in whitespace (keeps trailing newlines).
pub(crate) fn combine_prompt_with_voice_text(existing: &str, text: &str) -> String {
if existing.trim().is_empty() {
text.to_string()
} else if existing.ends_with(char::is_whitespace) {
format!("{existing}{text}")
} else {
format!("{existing} {text}")
}
}
/// Append `text` to the prompt bound at capture start (agent or dashboard).
///
/// Finals always append at end (or replace a blank draft). The caret follows
/// when it was at end; mid-text edits keep their place.
fn append_voice_text_to_prompt(app: &mut AppView, text: &str) {
let combine = |existing: &str| -> String {
if existing.trim().is_empty() {
text.to_string()
} else if existing.ends_with(char::is_whitespace) {
format!("{existing}{text}")
} else {
format!("{existing} {text}")
}
let append = |prompt: &mut PromptWidget| {
let existing = prompt.text();
let cursor = prompt.cursor();
let blank = existing.trim().is_empty();
// Blank draft is a full replace — park the caret at the new end.
// Otherwise append at end; only follow the caret if it was already there.
let follow_end = blank || cursor >= existing.len();
let combined = combine_prompt_with_voice_text(existing, text);
prompt.set_text(&combined);
prompt.set_cursor(if follow_end { combined.len() } else { cursor });
};
match app.voice_recording_target() {
Some(VoiceTarget::Agent(id)) => {
let Some(agent) = app.agents.get_mut(&id) else {
return;
};
let combined = combine(agent.prompt.text());
agent.prompt.set_text(&combined);
agent.prompt.set_cursor(combined.len());
append(&mut agent.prompt);
}
Some(target @ (VoiceTarget::DashboardDispatch | VoiceTarget::DashboardPeekReply(_))) => {
let Some(dashboard) = app.dashboard.as_mut() else {
return;
};
// Route to the box bound at capture start. The dispatch box is stable,
// but the peek reply widget is *shared* across rows and reassigned when
// the peeked row changes. While listening `enforce_voice_session_bound`
// stops capture on a row change, but after an explicit stop the target
// is kept for the trailing final and that guard no longer runs — so
// re-check the bound row here, or a final would land in (and send from)
// another agent's reply.
// Peek reply is shared across rows: only land if still on the bound row.
let prompt = match target {
VoiceTarget::DashboardPeekReply(rec) => {
let peeked = match dashboard.peek.as_ref().map(|p| &p.row) {
@ -52,14 +58,25 @@ fn append_voice_text_to_prompt(app: &mut AppView, text: &str) {
}
_ => &mut dashboard.dispatch,
};
let combined = combine(prompt.text());
prompt.set_text(&combined);
prompt.set_cursor(combined.len());
append(prompt);
}
None => {}
}
}
/// Move non-empty interim into the bound prompt and clear the overlay.
/// Does not stop the mic. Returns the promoted fragment.
pub(crate) fn commit_interim_into_prompt(app: &mut AppView) -> Option<String> {
let interim = app
.voice_interim()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_owned)?;
append_voice_text_to_prompt(app, &interim);
app.voice_clear_interim();
Some(interim)
}
/// Apply a voice event to app state. Returns whether the frame should redraw.
pub fn handle_voice_event(app: &mut AppView, event: VoiceEvent) -> bool {
match event {

View file

@ -19,12 +19,14 @@
//! dashboard's dispatch (new-agent) input, captured at start via
//! [`crate::app::app_view::VoiceTarget`] — while capture stays open across
//! speech pauses. The user always submits with Enter; nothing is auto-sent.
//! Submit promotes any remaining interim into the bound prompt, then hard-resets.
mod auth;
mod handle;
pub use auth::build_voice_auth;
pub use handle::handle_voice_event;
pub(crate) use handle::{combine_prompt_with_voice_text, commit_interim_into_prompt};
// Hidden `__mic-capture` helper intercept (macOS out-of-process capture),
// re-exported for the composition-root binary, which links the pager library
// rather than the voice crate. Called at the very top of `main`.