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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, ®, &[], 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, ®);
|
||||
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, ®, &[], 0);
|
||||
let _ = agent.handle_input(&click, ®);
|
||||
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, ®, &[], 0);
|
||||
let _ = agent.handle_input(&click, ®);
|
||||
assert!(!agent.tasks.overlay.visible);
|
||||
draw_banner_frame(&mut agent, ®, &[], 0);
|
||||
let _ = agent.handle_input(&click, ®);
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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')), ®);
|
||||
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')), ®);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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![];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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...");
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Reference in a new issue