Synced from monorepo
Changes: - Stop hooks for session lifecycle - Add x.ai/session/state and x.ai/session/import ACP methods - Deny-and-continue for auto-mode classifier blocks with denial limits - Drop codebase-upload from dhat soak test - scheduler_create upsert via task_id; retire one-shot tasks - Clipboard: copy file fallback + honest toasts for SSH/Apple Terminal - Polarity-safe syntax colors in minimal mode - Auto mode classifies unvetted env prefixes instead of hard-prompting - Add GROK_CLIPBOARD_NO_OSC52 kill switch to force OSC 52 off
This commit is contained in:
parent
7cfcb20d2b
commit
ba76b0a683
143 changed files with 9465 additions and 3419 deletions
|
|
@ -313,18 +313,25 @@ pub(super) fn handle_scheduled_task_created(
|
|||
.scheduled_tasks
|
||||
.retain(|k, _| !k.starts_with("provisional-"));
|
||||
|
||||
agent
|
||||
.session
|
||||
.scheduled_tasks
|
||||
.entry(task_id.clone())
|
||||
.or_insert_with(|| crate::app::agent::ScheduledTaskInfo {
|
||||
task_id,
|
||||
prompt,
|
||||
human_schedule,
|
||||
created_at: std::time::Instant::now(),
|
||||
next_fire_at,
|
||||
tag: "loop".into(),
|
||||
});
|
||||
match agent.session.scheduled_tasks.entry(task_id.clone()) {
|
||||
Entry::Occupied(mut e) => {
|
||||
let info = e.get_mut();
|
||||
info.prompt = prompt;
|
||||
info.human_schedule = human_schedule;
|
||||
info.next_fire_at = next_fire_at;
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
e.insert(crate::app::agent::ScheduledTaskInfo {
|
||||
task_id,
|
||||
prompt,
|
||||
human_schedule,
|
||||
created_at: std::time::Instant::now(),
|
||||
next_fire_at,
|
||||
tag: "loop".into(),
|
||||
last_subagent_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
is_active
|
||||
}
|
||||
|
|
@ -333,13 +340,14 @@ pub(super) fn handle_scheduled_task_fired(notif: &acp::ExtNotification, app: &mu
|
|||
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
|
||||
return false;
|
||||
};
|
||||
let (task_id, prompt, human_schedule, next_fire_at) = match session_notif.update {
|
||||
let (task_id, prompt, human_schedule, next_fire_at, subagent_id) = match session_notif.update {
|
||||
XaiSessionUpdate::ScheduledTaskFired {
|
||||
task_id,
|
||||
prompt,
|
||||
human_schedule,
|
||||
next_fire_at,
|
||||
} => (task_id, prompt, human_schedule, next_fire_at),
|
||||
subagent_id,
|
||||
} => (task_id, prompt, human_schedule, next_fire_at, subagent_id),
|
||||
_ => return false,
|
||||
};
|
||||
let matched = match find_session_match(app, &session_notif.session_id) {
|
||||
|
|
@ -358,12 +366,13 @@ pub(super) fn handle_scheduled_task_fired(notif: &acp::ExtNotification, app: &mu
|
|||
// payload so the tasks pane still shows the loop.
|
||||
match agent.session.scheduled_tasks.entry(task_id) {
|
||||
Entry::Occupied(mut e) => {
|
||||
e.get_mut().next_fire_at = next_fire_at;
|
||||
let info = e.get_mut();
|
||||
info.next_fire_at = next_fire_at;
|
||||
if subagent_id.is_some() {
|
||||
info.last_subagent_id = subagent_id;
|
||||
}
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
// next_fire_at: None marks a missed-one-shot fire from
|
||||
// handle_missed_tasks(); a ScheduledTaskRemoved follows
|
||||
// immediately. Skip the insert to avoid a one-frame flicker.
|
||||
if next_fire_at.is_none() {
|
||||
return is_active;
|
||||
}
|
||||
|
|
@ -375,6 +384,7 @@ pub(super) fn handle_scheduled_task_fired(notif: &acp::ExtNotification, app: &mu
|
|||
created_at: std::time::Instant::now(),
|
||||
next_fire_at,
|
||||
tag: "loop".into(),
|
||||
last_subagent_id: subagent_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -627,6 +627,15 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
|||
xai_grok_shell::extensions::notification::HookRunStatusDto::Failed {
|
||||
error,
|
||||
elapsed_ms,
|
||||
blocked: true,
|
||||
} => HookRunStatus::Blocked {
|
||||
detail: error,
|
||||
elapsed: std::time::Duration::from_millis(elapsed_ms),
|
||||
},
|
||||
xai_grok_shell::extensions::notification::HookRunStatusDto::Failed {
|
||||
error,
|
||||
elapsed_ms,
|
||||
blocked: false,
|
||||
} => HookRunStatus::Failed {
|
||||
error,
|
||||
elapsed: std::time::Duration::from_millis(elapsed_ms),
|
||||
|
|
|
|||
|
|
@ -511,6 +511,26 @@ pub(super) fn make_fired_notif(
|
|||
prompt: prompt.into(),
|
||||
human_schedule: human_schedule.into(),
|
||||
next_fire_at: next_fire_at.map(str::to_string),
|
||||
subagent_id: None,
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let raw = serde_json::value::to_raw_value(¬if).unwrap();
|
||||
acp::ExtNotification::new("x.ai/scheduled_task_fired", std::sync::Arc::from(raw))
|
||||
}
|
||||
pub(super) fn make_fired_notif_with_subagent(
|
||||
session_id: &str,
|
||||
task_id: &str,
|
||||
subagent_id: &str,
|
||||
) -> acp::ExtNotification {
|
||||
let notif = SessionNotification {
|
||||
session_id: acp::SessionId::new(session_id),
|
||||
update: XaiSessionUpdate::ScheduledTaskFired {
|
||||
task_id: task_id.into(),
|
||||
prompt: "p".into(),
|
||||
human_schedule: "every 1 minute".into(),
|
||||
next_fire_at: Some("2026-02-02T02:02:02Z".into()),
|
||||
subagent_id: Some(subagent_id.into()),
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
|
|
@ -941,16 +961,31 @@ pub(super) fn xai_hook_execution_notif_for_prompt(
|
|||
is_replay: bool,
|
||||
) -> acp::ExtNotification {
|
||||
use xai_grok_shell::extensions::notification::{HookRunEntryDto, HookRunStatusDto};
|
||||
xai_hook_execution_notif_with_runs(
|
||||
session_id,
|
||||
event_name,
|
||||
prompt_id,
|
||||
is_replay,
|
||||
vec![
|
||||
HookRunEntryDto { name : "global/notify".into(), status :
|
||||
HookRunStatusDto::Success { elapsed_ms : 12 }, output : None, }
|
||||
],
|
||||
)
|
||||
}
|
||||
pub(super) fn xai_hook_execution_notif_with_runs(
|
||||
session_id: &str,
|
||||
event_name: &str,
|
||||
prompt_id: Option<&str>,
|
||||
is_replay: bool,
|
||||
runs: Vec<xai_grok_shell::extensions::notification::HookRunEntryDto>,
|
||||
) -> acp::ExtNotification {
|
||||
let payload = SessionNotification {
|
||||
session_id: acp::SessionId::new(session_id),
|
||||
update: XaiSessionUpdate::HookExecution {
|
||||
event_name: event_name.into(),
|
||||
tool_name: None,
|
||||
prompt_id: prompt_id.map(str::to_string),
|
||||
runs: vec![
|
||||
HookRunEntryDto { name : "global/notify".into(), status :
|
||||
HookRunStatusDto::Success { elapsed_ms : 12 }, output : None, }
|
||||
],
|
||||
runs,
|
||||
},
|
||||
meta: Some(serde_json::json!({ "isReplay" : is_replay })),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@
|
|||
created_at: original_created_at,
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
last_subagent_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -365,6 +366,7 @@
|
|||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
last_subagent_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -391,6 +393,7 @@
|
|||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
last_subagent_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -428,6 +431,85 @@
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fired_with_subagent_id_links_chip_and_survives_foreground_fire() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
let notif = make_fired_notif_with_subagent("sess-1", "task-bg", "sub-abc");
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
{
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.session.scheduled_tasks.get("task-bg").unwrap();
|
||||
assert_eq!(info.last_subagent_id.as_deref(), Some("sub-abc"));
|
||||
}
|
||||
|
||||
let notif = make_fired_notif_with_subagent("sess-1", "task-bg", "sub-def");
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
{
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.session.scheduled_tasks.get("task-bg").unwrap();
|
||||
assert_eq!(info.last_subagent_id.as_deref(), Some("sub-def"));
|
||||
}
|
||||
|
||||
let notif = make_fired_notif(
|
||||
"sess-1",
|
||||
"task-bg",
|
||||
"p",
|
||||
"every 1 minute",
|
||||
Some("2026-03-03T03:03:03Z"),
|
||||
);
|
||||
assert!(handle_scheduled_task_fired(¬if, &mut app));
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent.session.scheduled_tasks.get("task-bg").unwrap();
|
||||
assert_eq!(info.last_subagent_id.as_deref(), Some("sub-def"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_upserts_existing_chip_preserving_identity_and_linkage() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let original_created_at = Instant::now() - std::time::Duration::from_secs(60);
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.scheduled_tasks.insert(
|
||||
"task-up".into(),
|
||||
crate::app::agent::ScheduledTaskInfo {
|
||||
task_id: "task-up".into(),
|
||||
prompt: "old prompt".into(),
|
||||
human_schedule: "every 5 minutes".into(),
|
||||
created_at: original_created_at,
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
last_subagent_id: Some("sub-abc".into()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let notif = make_created_ext_notif(
|
||||
"sess-1",
|
||||
"task-up",
|
||||
"new prompt",
|
||||
"every 10 minutes",
|
||||
Some("2026-02-02T02:02:02Z"),
|
||||
);
|
||||
assert!(handle_scheduled_task_created(¬if, &mut app));
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.session.scheduled_tasks.len(), 1, "no duplicate chip");
|
||||
let info = agent.session.scheduled_tasks.get("task-up").unwrap();
|
||||
assert_eq!(info.prompt, "new prompt");
|
||||
assert_eq!(info.human_schedule, "every 10 minutes");
|
||||
assert_eq!(info.next_fire_at.as_deref(), Some("2026-02-02T02:02:02Z"));
|
||||
assert_eq!(
|
||||
info.created_at, original_created_at,
|
||||
"chip identity (countdown anchor) preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
info.last_subagent_id.as_deref(),
|
||||
Some("sub-abc"),
|
||||
"click-through linkage preserved across an update"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_updates_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
|
|
@ -472,6 +554,7 @@
|
|||
created_at: Instant::now(),
|
||||
next_fire_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
tag: "loop".into(),
|
||||
last_subagent_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -529,6 +529,69 @@
|
|||
assert!(agent.pending_stop_hooks.is_none());
|
||||
}
|
||||
|
||||
/// The wire `blocked` flag splits a failed run: a stop-gate block maps to
|
||||
/// `HookRunStatus::Blocked` (a decision, not a failure), a plain failure stays `Failed`.
|
||||
#[test]
|
||||
fn blocked_wire_flag_maps_to_blocked_status() {
|
||||
use crate::scrollback::blocks::tool::HookRunStatus;
|
||||
use xai_grok_shell::extensions::notification::{HookRunEntryDto, HookRunStatusDto};
|
||||
|
||||
let mut app = make_app_with_agent("sess-blocked");
|
||||
{
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.start_turn(&mut agent.scrollback);
|
||||
agent.session.current_prompt_id = Some("pid-1".into());
|
||||
}
|
||||
|
||||
let _ = handle_ext_notification(
|
||||
&xai_hook_execution_notif_with_runs(
|
||||
"sess-blocked",
|
||||
"stop",
|
||||
Some("pid-1"),
|
||||
false,
|
||||
vec![
|
||||
HookRunEntryDto {
|
||||
name: "gate".into(),
|
||||
status: HookRunStatusDto::Failed {
|
||||
error: "blocked stop: run the tests".into(),
|
||||
elapsed_ms: 7,
|
||||
blocked: true,
|
||||
},
|
||||
output: None,
|
||||
},
|
||||
HookRunEntryDto {
|
||||
name: "broken".into(),
|
||||
status: HookRunStatusDto::Failed {
|
||||
error: "exit code 1".into(),
|
||||
elapsed_ms: 3,
|
||||
blocked: false,
|
||||
},
|
||||
output: None,
|
||||
},
|
||||
],
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let pending = agent
|
||||
.pending_stop_hooks
|
||||
.as_ref()
|
||||
.expect("stop hooks must be stashed for the marker");
|
||||
let runs = &pending.groups[0].1;
|
||||
assert!(
|
||||
matches!(&runs[0].status, HookRunStatus::Blocked { detail, .. }
|
||||
if detail == "blocked stop: run the tests"),
|
||||
"blocked: true must map to Blocked, got {:?}",
|
||||
runs[0].status
|
||||
);
|
||||
assert!(
|
||||
matches!(&runs[1].status, HookRunStatus::Failed { .. }),
|
||||
"blocked: false must stay Failed, got {:?}",
|
||||
runs[1].status
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_turn_stop_hooks_never_stash_under_running_turn() {
|
||||
// A delayed batch from an ended turn (pid-old) lands while a later
|
||||
|
|
|
|||
|
|
@ -307,9 +307,11 @@ pub enum Action {
|
|||
ShowDebugStatus,
|
||||
/// Copy selected block's content to clipboard.
|
||||
CopyBlockContent,
|
||||
/// Copy the Nth most recent assistant message to clipboard (1 = latest).
|
||||
/// Copy the Nth most recent assistant message (1 = latest).
|
||||
/// `None` => clipboard (with file fallback on failure); `Some(p)` => write UTF-8 file.
|
||||
CopyAssistantMessage {
|
||||
n: usize,
|
||||
file_path: Option<std::path::PathBuf>,
|
||||
},
|
||||
/// Export the active (sub)agent's conversation transcript as Markdown.
|
||||
/// `None` => copy to clipboard (with route-aware toast + stats); `Some(p)` => write UTF-8 file
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ pub struct ScheduledTaskInfo {
|
|||
pub next_fire_at: Option<String>,
|
||||
/// Tag shown in the tasks pane (e.g. "loop", "check").
|
||||
pub tag: String,
|
||||
pub last_subagent_id: Option<String>,
|
||||
}
|
||||
/// Parsed goal status from `GoalUpdated` session notifications.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -533,7 +533,7 @@ impl AgentView {
|
|||
use crate::scrollback::blocks::mermaid_content::AffordanceKind;
|
||||
match kind {
|
||||
AffordanceKind::CopySource => {
|
||||
if self.copy_to_clipboard(&source).is_failed() {
|
||||
if !self.copy_to_clipboard(&source).success() {
|
||||
crate::unified_log::error(
|
||||
"mermaid.copy_source.failed",
|
||||
self.session.session_id.as_ref().map(|s| s.0.as_ref()),
|
||||
|
|
|
|||
|
|
@ -269,11 +269,18 @@ impl AgentView {
|
|||
false
|
||||
}
|
||||
|
||||
/// Copy text to clipboard and show the result toast.
|
||||
pub fn copy_to_clipboard(&mut self, text: &str) -> crate::clipboard::ClipboardDelivery {
|
||||
let result = crate::clipboard::copy_text(text);
|
||||
self.show_toast_ticks(result.message, result.ticks);
|
||||
result.delivery
|
||||
/// Copy text to clipboard (a backup file is always written too — see
|
||||
/// `copy_text_or_file`) and show the result toast.
|
||||
///
|
||||
/// When every trusted clipboard backend fails (common on Apple Terminal
|
||||
/// over SSH), the toast points at the backup file
|
||||
/// (`~/.grok/last-copy.txt`, or `GROK_COPY_FILE`) instead. The returned
|
||||
/// [`CopyDelivery`](crate::clipboard::CopyDelivery) tells callers where
|
||||
/// the copy actually landed (clipboard, backup file, or nowhere).
|
||||
pub fn copy_to_clipboard(&mut self, text: &str) -> crate::clipboard::CopyDelivery {
|
||||
let delivery = crate::clipboard::copy_text_or_file(text);
|
||||
self.show_toast_ticks(delivery.toast_message().as_ref(), delivery.toast_ticks());
|
||||
delivery
|
||||
}
|
||||
|
||||
/// Like [`copy_to_clipboard`] but debounces the toast to prevent
|
||||
|
|
@ -284,8 +291,8 @@ impl AgentView {
|
|||
.last_clipboard_toast_at
|
||||
.is_some_and(|t| now.duration_since(t).as_millis() < CLIPBOARD_TOAST_DEBOUNCE_MS);
|
||||
if too_soon {
|
||||
// Still copy, just skip the toast.
|
||||
let _ = crate::clipboard::copy_text(text);
|
||||
// Still deliver (clipboard or file fallback), just skip the toast.
|
||||
let _ = crate::clipboard::copy_text_or_file(text);
|
||||
return;
|
||||
}
|
||||
self.last_clipboard_toast_at = Some(now);
|
||||
|
|
|
|||
|
|
@ -1617,6 +1617,7 @@ pub(super) mod paste_key_tests {
|
|||
assert!(
|
||||
toast.starts_with("Copied")
|
||||
|| toast.starts_with("Copy sent")
|
||||
|| toast.starts_with("Clipboard unreachable")
|
||||
|| toast.starts_with("Copy failed"),
|
||||
"copy-source emits a clipboard toast, got {toast:?}",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1732,25 +1732,26 @@ impl AgentView {
|
|||
}
|
||||
if let Some(msg) = self.active_toast_message() {
|
||||
let sb = layout.scrollback;
|
||||
let toast_text = format!(" {msg} ");
|
||||
let w = toast_text.chars().count() as u16;
|
||||
if sb.height > 0 && sb.width > w + 2 {
|
||||
let x = sb.right().saturating_sub(w + 1);
|
||||
let y = sb.bottom().saturating_sub(1);
|
||||
for (i, ch) in toast_text.chars().enumerate() {
|
||||
if let Some(cell) = buf.cell_mut((x + i as u16, y)) {
|
||||
cell.set_char(ch);
|
||||
cell.fg = theme.accent_user;
|
||||
cell.bg = theme.bg_base;
|
||||
cell.modifier = ratatui::prelude::Modifier::BOLD;
|
||||
if let Some(toast_text) = fit_toast_text(msg, sb.width) {
|
||||
let w = toast_text.chars().count() as u16;
|
||||
if sb.height > 0 {
|
||||
let x = sb.right().saturating_sub(w + 1);
|
||||
let y = sb.bottom().saturating_sub(1);
|
||||
for (i, ch) in toast_text.chars().enumerate() {
|
||||
if let Some(cell) = buf.cell_mut((x + i as u16, y)) {
|
||||
cell.set_char(ch);
|
||||
cell.fg = theme.accent_user;
|
||||
cell.bg = theme.bg_base;
|
||||
cell.modifier = ratatui::prelude::Modifier::BOLD;
|
||||
}
|
||||
}
|
||||
self.frame_occluder_rects.push(Rect {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: 1,
|
||||
});
|
||||
}
|
||||
self.frame_occluder_rects.push(Rect {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
if tasks_height > 0 {
|
||||
|
|
@ -3775,19 +3776,19 @@ impl AgentView {
|
|||
buf.set_string(content_x, status_y, &status, status_style);
|
||||
}
|
||||
}
|
||||
if let Some(ref msg) = block_viewer_toast {
|
||||
let toast_text = format!(" {msg} ");
|
||||
let w = toast_text.len() as u16;
|
||||
if popup_area.height > 2 && popup_area.width > w + 2 {
|
||||
let tx = popup_area.right().saturating_sub(w + 2);
|
||||
let ty = popup_area.bottom().saturating_sub(2);
|
||||
for (i, ch) in toast_text.chars().enumerate() {
|
||||
if let Some(cell) = buf.cell_mut((tx + i as u16, ty)) {
|
||||
cell.set_char(ch);
|
||||
cell.fg = theme.accent_user;
|
||||
cell.bg = theme.bg_base;
|
||||
cell.modifier = ratatui::prelude::Modifier::BOLD;
|
||||
}
|
||||
if let Some(ref msg) = block_viewer_toast
|
||||
&& popup_area.height > 2
|
||||
&& let Some(toast_text) = fit_toast_text(msg, popup_area.width.saturating_sub(1))
|
||||
{
|
||||
let w = toast_text.chars().count() as u16;
|
||||
let tx = popup_area.right().saturating_sub(w + 2);
|
||||
let ty = popup_area.bottom().saturating_sub(2);
|
||||
for (i, ch) in toast_text.chars().enumerate() {
|
||||
if let Some(cell) = buf.cell_mut((tx + i as u16, ty)) {
|
||||
cell.set_char(ch);
|
||||
cell.fg = theme.accent_user;
|
||||
cell.bg = theme.bg_base;
|
||||
cell.modifier = ratatui::prelude::Modifier::BOLD;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4212,6 +4213,43 @@ impl AgentView {
|
|||
(cursor, prompt_post_flush)
|
||||
}
|
||||
}
|
||||
/// Pad `msg` for the toast slot, truncating with a trailing ellipsis when it
|
||||
/// cannot fit in `avail_width` columns (long clipboard toasts embed backup
|
||||
/// file paths — dropping the whole toast would hide the copy feedback
|
||||
/// entirely). Returns `None` only when the slot is too narrow for any text.
|
||||
fn fit_toast_text(msg: &str, avail_width: u16) -> Option<String> {
|
||||
let max_msg_chars = (avail_width as usize).saturating_sub(4);
|
||||
if max_msg_chars == 0 {
|
||||
return None;
|
||||
}
|
||||
let msg_chars = msg.chars().count();
|
||||
if msg_chars <= max_msg_chars {
|
||||
return Some(format!(" {msg} "));
|
||||
}
|
||||
let truncated: String = msg.chars().take(max_msg_chars.saturating_sub(1)).collect();
|
||||
Some(format!(" {}… ", truncated.trim_end()))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod toast_fit_tests {
|
||||
use super::fit_toast_text;
|
||||
#[test]
|
||||
fn short_message_is_padded_untouched() {
|
||||
assert_eq!(fit_toast_text("Copied!", 40).as_deref(), Some(" Copied! "));
|
||||
}
|
||||
#[test]
|
||||
fn long_message_truncates_with_ellipsis_instead_of_vanishing() {
|
||||
let msg = "Copied via OSC 52 — also saved to /tmp/grok-0/last-copy.txt. If paste fails, hold Shift (or Fn) and drag to select & copy natively.";
|
||||
let fitted = fit_toast_text(msg, 60).expect("must render truncated");
|
||||
assert!(fitted.chars().count() <= 58);
|
||||
assert!(fitted.ends_with("… "));
|
||||
assert!(fitted.contains("also saved to"));
|
||||
}
|
||||
#[test]
|
||||
fn zero_width_slot_yields_none() {
|
||||
assert_eq!(fit_toast_text("Copied!", 4), None);
|
||||
assert_eq!(fit_toast_text("Copied!", 0), None);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod selection_state_tests {
|
||||
use super::super::test_fixtures::make_agent;
|
||||
|
|
|
|||
|
|
@ -593,6 +593,7 @@ pub(super) fn dispatch_send_prompt_inner(
|
|||
created_at: std::time::Instant::now(),
|
||||
next_fire_at: preview.next_fire_at,
|
||||
tag: preview.tag,
|
||||
last_subagent_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -550,8 +550,8 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
|||
dispatch_copy_block_content(app);
|
||||
vec![]
|
||||
}
|
||||
Action::CopyAssistantMessage { n } => {
|
||||
dispatch_copy_assistant_message(app, n);
|
||||
Action::CopyAssistantMessage { n, file_path } => {
|
||||
dispatch_copy_assistant_message(app, n, file_path);
|
||||
vec![]
|
||||
}
|
||||
Action::ExportConversation { file_path } => {
|
||||
|
|
|
|||
|
|
@ -475,8 +475,8 @@ pub(super) fn dispatch_copy_session_id(app: &mut AppView, index: usize) -> Vec<E
|
|||
.map(|e| e.id.clone())
|
||||
});
|
||||
if let Some(id) = id {
|
||||
let r = crate::clipboard::copy_text(&id);
|
||||
app.show_toast(r.message);
|
||||
let delivery = crate::clipboard::copy_text_or_file(&id);
|
||||
app.show_toast(delivery.toast_message().as_ref());
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1785,6 +1785,7 @@ fn show_tasks_lists_a_scheduled_task() {
|
|||
created_at: std::time::Instant::now(),
|
||||
next_fire_at: None,
|
||||
tag: "loop".to_string(),
|
||||
last_subagent_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,8 +51,12 @@ pub(super) fn dispatch_copy_block_content(app: &mut AppView) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Copy the Nth most recent assistant message to the clipboard.
|
||||
pub(super) fn dispatch_copy_assistant_message(app: &mut AppView, n: usize) {
|
||||
/// Copy the Nth most recent assistant message to the clipboard, or to `file_path`.
|
||||
pub(super) fn dispatch_copy_assistant_message(
|
||||
app: &mut AppView,
|
||||
n: usize,
|
||||
file_path: Option<std::path::PathBuf>,
|
||||
) {
|
||||
with_active_agent(app, |agent| {
|
||||
// Collect agent messages in reverse order (most recent first).
|
||||
let mut agent_messages: Vec<String> = Vec::new();
|
||||
|
|
@ -93,10 +97,49 @@ pub(super) fn dispatch_copy_assistant_message(app: &mut AppView, n: usize) {
|
|||
}
|
||||
|
||||
let stats = crate::clipboard::clipboard_stats_suffix(text);
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system(format!("Copied to clipboard{stats}")));
|
||||
agent.copy_to_clipboard(text);
|
||||
|
||||
if let Some(p) = file_path {
|
||||
match crate::clipboard::write_text_to_copy_file(text, &p) {
|
||||
Ok(path) => {
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"Copied to {}{stats}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system(format!("Failed to write file: {e}")));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let delivery = crate::clipboard::copy_text_or_file(text);
|
||||
match &delivery {
|
||||
crate::clipboard::CopyDelivery::Clipboard { file, .. } => {
|
||||
let block_msg = match file {
|
||||
Some(path) => format!(
|
||||
"Copied to clipboard (also saved to {}){stats}",
|
||||
crate::clipboard::display_copy_path(path)
|
||||
),
|
||||
None => format!("Copied to clipboard{stats}"),
|
||||
};
|
||||
agent.scrollback.push_block(RenderBlock::system(block_msg));
|
||||
}
|
||||
crate::clipboard::CopyDelivery::File { path } => {
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"Clipboard unreachable — wrote {}{stats}",
|
||||
crate::clipboard::display_copy_path(path)
|
||||
)));
|
||||
}
|
||||
crate::clipboard::CopyDelivery::Failed { .. } => {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system(format!("Copy failed{stats}")));
|
||||
}
|
||||
}
|
||||
agent.show_toast_ticks(delivery.toast_message().as_ref(), delivery.toast_ticks());
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -152,11 +195,28 @@ pub(super) fn dispatch_export_conversation(
|
|||
} else {
|
||||
// Clipboard path: stats block (like assistant copy) + route-aware toast
|
||||
// (like block content copy / selection). Good UX for a potentially large transcript.
|
||||
// The scrollback line reflects where the copy actually landed —
|
||||
// same pattern as /copy N — instead of claiming clipboard success
|
||||
// when the delivery fell back to the backup file.
|
||||
let stats = crate::clipboard::clipboard_stats_suffix(&md);
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"Conversation copied to clipboard{stats}"
|
||||
)));
|
||||
agent.copy_to_clipboard(&md);
|
||||
let delivery = agent.copy_to_clipboard(&md);
|
||||
let block_msg = match &delivery {
|
||||
crate::clipboard::CopyDelivery::Clipboard { file, .. } => match file {
|
||||
Some(path) => format!(
|
||||
"Conversation copied to clipboard (also saved to {}){stats}",
|
||||
crate::clipboard::display_copy_path(path)
|
||||
),
|
||||
None => format!("Conversation copied to clipboard{stats}"),
|
||||
},
|
||||
crate::clipboard::CopyDelivery::File { path } => format!(
|
||||
"Clipboard unreachable — conversation written to {}{stats}",
|
||||
crate::clipboard::display_copy_path(path)
|
||||
),
|
||||
crate::clipboard::CopyDelivery::Failed { .. } => {
|
||||
format!("Conversation copy failed{stats}")
|
||||
}
|
||||
};
|
||||
agent.scrollback.push_block(RenderBlock::system(block_msg));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1058,9 +1058,9 @@ impl AgentView {
|
|||
fn complete_mermaid_action(&mut self, action: MermaidClickAction, path: &Path) {
|
||||
let ok = match action {
|
||||
MermaidClickAction::Open => self.open_media_natively(path),
|
||||
MermaidClickAction::CopyPath => !self
|
||||
MermaidClickAction::CopyPath => self
|
||||
.copy_to_clipboard(&path.display().to_string())
|
||||
.is_failed(),
|
||||
.success(),
|
||||
};
|
||||
if !ok {
|
||||
crate::unified_log::error(
|
||||
|
|
@ -2220,6 +2220,7 @@ mod tests {
|
|||
assert!(
|
||||
toast.starts_with("Copied")
|
||||
|| toast.starts_with("Copy sent")
|
||||
|| toast.starts_with("Clipboard unreachable")
|
||||
|| toast.starts_with("Copy failed"),
|
||||
"a disk hit runs the copy action immediately, got {toast:?}",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -541,7 +541,25 @@ impl AgentView {
|
|||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
TaskEntryId::Scheduled(_) => {}
|
||||
TaskEntryId::Scheduled(tid) => {
|
||||
if let Some(sid) = self
|
||||
.session
|
||||
.scheduled_tasks
|
||||
.get(tid)
|
||||
.and_then(|info| info.last_subagent_id.clone())
|
||||
&& let Some(child_sid) = self
|
||||
.subagent_sessions
|
||||
.iter()
|
||||
.find(|(_, info)| {
|
||||
info.subagent_id.as_ref() == sid.as_str()
|
||||
})
|
||||
.map(|(k, _)| k.clone())
|
||||
&& self.subagent_views.contains_key(&child_sid)
|
||||
{
|
||||
self.open_subagent_fullscreen(child_sid);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,19 @@ use crate::theme::Theme;
|
|||
/// Status of a single hook execution within a batch.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HookRunStatus {
|
||||
Success { elapsed: Duration },
|
||||
Success {
|
||||
elapsed: Duration,
|
||||
},
|
||||
Skipped,
|
||||
Failed { error: String, elapsed: Duration },
|
||||
/// The hook ran and blocked (a stop-gate decision, not a failure).
|
||||
Blocked {
|
||||
detail: String,
|
||||
elapsed: Duration,
|
||||
},
|
||||
Failed {
|
||||
error: String,
|
||||
elapsed: Duration,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single hook run entry for display.
|
||||
|
|
@ -74,7 +84,7 @@ fn count_hooks(entries: &[&[HookRunEntry]]) -> (usize, usize) {
|
|||
for runs in entries {
|
||||
for r in *runs {
|
||||
match r.status {
|
||||
HookRunStatus::Success { .. } => success += 1,
|
||||
HookRunStatus::Success { .. } | HookRunStatus::Blocked { .. } => success += 1,
|
||||
HookRunStatus::Failed { .. } => failed += 1,
|
||||
HookRunStatus::Skipped => {}
|
||||
}
|
||||
|
|
@ -231,6 +241,27 @@ fn render_hooks_expanded_inner(runs: &[HookRunEntry]) -> Vec<BlockLine> {
|
|||
.into(),
|
||||
);
|
||||
}
|
||||
HookRunStatus::Blocked { detail, elapsed } => {
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{} ", INDENT), theme.muted()),
|
||||
Span::styled("\u{21a9} ", theme.fg(theme.accent_running)),
|
||||
Span::styled(run.name.clone(), theme.muted()),
|
||||
Span::styled(format!(" ({}ms)", elapsed.as_millis()), theme.muted()),
|
||||
])
|
||||
.into(),
|
||||
);
|
||||
let detail_text = crate::render::line_utils::truncate_str(detail, 120);
|
||||
for detail_line in detail_text.lines().take(3) {
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{} ", INDENT), theme.muted()),
|
||||
Span::styled(detail_line.to_string(), theme.fg(theme.accent_running)),
|
||||
])
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
HookRunStatus::Failed { error, elapsed } => {
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
|
|
|
|||
|
|
@ -269,7 +269,9 @@ impl ReadToolCallBlock {
|
|||
let gutter_width = digit_count(base_line + raw_lines.len().saturating_sub(1));
|
||||
let content_width = width.saturating_sub(gutter_width + 2).max(20);
|
||||
|
||||
let gutter_style = Style::default().fg(theme.gray_dim);
|
||||
// Use Theme::dim/primary so terminal-native (minimal) maps grays to
|
||||
// SGR dim / default fg instead of raw gray_dim slots.
|
||||
let gutter_style = theme.dim();
|
||||
let text_style = theme.primary();
|
||||
|
||||
let syntect = get_syntect();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
//! `/copy` -- copy the last (or Nth) assistant message to the clipboard.
|
||||
//!
|
||||
//! Optional file path writes instead of (or when) the clipboard is unreachable:
|
||||
//! - `/copy` — latest → clipboard (file fallback on failure)
|
||||
//! - `/copy 2` — 2nd-latest → clipboard
|
||||
//! - `/copy out.txt` — latest → file
|
||||
//! - `/copy 2 out.txt` — 2nd-latest → file
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
/// Copy an assistant message to the clipboard.
|
||||
/// Copy an assistant message to the clipboard (or an optional file).
|
||||
pub struct CopyCommand;
|
||||
|
||||
impl SlashCommand for CopyCommand {
|
||||
|
|
@ -12,7 +20,7 @@ impl SlashCommand for CopyCommand {
|
|||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Copy last response to clipboard (/copy N for Nth-latest)"
|
||||
"Copy last response to clipboard or file (/copy [N] [file])"
|
||||
}
|
||||
|
||||
fn session_scoped(&self) -> bool {
|
||||
|
|
@ -20,7 +28,7 @@ impl SlashCommand for CopyCommand {
|
|||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/copy [N]"
|
||||
"/copy [N] [file]"
|
||||
}
|
||||
|
||||
fn takes_args(&self) -> bool {
|
||||
|
|
@ -28,29 +36,42 @@ impl SlashCommand for CopyCommand {
|
|||
}
|
||||
|
||||
fn arg_placeholder(&self) -> Option<&str> {
|
||||
Some("[N]")
|
||||
Some("[N] [file]")
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
|
||||
let trimmed = args.trim();
|
||||
let n = if trimmed.is_empty() {
|
||||
1
|
||||
} else {
|
||||
match trimmed.parse::<usize>() {
|
||||
Ok(0) => {
|
||||
return CommandResult::Error(
|
||||
"Usage: /copy [N] where N is 1 (latest), 2, 3, ...".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return CommandResult::Error(format!(
|
||||
"/copy {trimmed} (invalid number)\nUsage: /copy [N] where N is 1 (latest), 2, 3, ..."
|
||||
));
|
||||
}
|
||||
match parse_copy_args(args) {
|
||||
Ok((n, file_path)) => {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path })
|
||||
}
|
||||
};
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n })
|
||||
Err(msg) => CommandResult::Error(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `/copy` args into `(n, optional_file_path)`.
|
||||
///
|
||||
/// - empty → `(1, None)`
|
||||
/// - `2` → `(2, None)`
|
||||
/// - `out.txt` → `(1, Some(out.txt))`
|
||||
/// - `2 out.txt` → `(2, Some(out.txt))` (rest of line is the path, spaces ok)
|
||||
fn parse_copy_args(args: &str) -> Result<(usize, Option<PathBuf>), String> {
|
||||
let trimmed = args.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok((1, None));
|
||||
}
|
||||
|
||||
let mut parts = trimmed.splitn(2, char::is_whitespace);
|
||||
let first = parts.next().unwrap_or("");
|
||||
let rest = parts.next().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
match first.parse::<usize>() {
|
||||
Ok(0) => Err("Usage: /copy [N] [file] where N is 1 (latest), 2, 3, ...".to_string()),
|
||||
Ok(n) => Ok((n, rest.map(PathBuf::from))),
|
||||
Err(_) => {
|
||||
// Non-numeric first token: treat the whole args string as a path.
|
||||
Ok((1, Some(PathBuf::from(trimmed))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +109,10 @@ mod tests {
|
|||
let mut ctx = make_ctx(&models);
|
||||
let cmd = CopyCommand;
|
||||
match cmd.run(&mut ctx, "") {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1),
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => {
|
||||
assert_eq!(n, 1);
|
||||
assert!(file_path.is_none());
|
||||
}
|
||||
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -99,7 +123,10 @@ mod tests {
|
|||
let mut ctx = make_ctx(&models);
|
||||
let cmd = CopyCommand;
|
||||
match cmd.run(&mut ctx, "1") {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1),
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => {
|
||||
assert_eq!(n, 1);
|
||||
assert!(file_path.is_none());
|
||||
}
|
||||
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -110,7 +137,10 @@ mod tests {
|
|||
let mut ctx = make_ctx(&models);
|
||||
let cmd = CopyCommand;
|
||||
match cmd.run(&mut ctx, "3") {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 3),
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => {
|
||||
assert_eq!(n, 3);
|
||||
assert!(file_path.is_none());
|
||||
}
|
||||
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -124,23 +154,37 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn non_numeric_returns_error() {
|
||||
fn path_only_writes_latest_to_file() {
|
||||
let models = ModelState::default();
|
||||
let mut ctx = make_ctx(&models);
|
||||
let cmd = CopyCommand;
|
||||
match cmd.run(&mut ctx, "abc") {
|
||||
CommandResult::Error(msg) => assert!(msg.contains("invalid number")),
|
||||
other => panic!("expected Error, got {other:?}"),
|
||||
match cmd.run(&mut ctx, "out.txt") {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => {
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(file_path.as_deref(), Some(std::path::Path::new("out.txt")));
|
||||
}
|
||||
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_and_path_with_spaces() {
|
||||
assert_eq!(
|
||||
parse_copy_args("2 ~/exports/my note.txt").unwrap(),
|
||||
(2, Some(PathBuf::from("~/exports/my note.txt")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_copies_latest() {
|
||||
let models = ModelState::default();
|
||||
let mut ctx = make_ctx(&models);
|
||||
let cmd = CopyCommand;
|
||||
match cmd.run(&mut ctx, " ") {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1),
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => {
|
||||
assert_eq!(n, 1);
|
||||
assert!(file_path.is_none());
|
||||
}
|
||||
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1885,6 +1885,7 @@ mod tests {
|
|||
created_at: std::time::Instant::now(),
|
||||
next_fire_at: None,
|
||||
tag: "loop".into(),
|
||||
last_subagent_id: None,
|
||||
}
|
||||
}
|
||||
/// A turn-idle agent with a RUNNING background task is `Working`, not
|
||||
|
|
|
|||
|
|
@ -1146,7 +1146,13 @@ fn highlight_to_ratatui_line(
|
|||
if piece.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fg = syntect_to_ratatui_color(style.foreground);
|
||||
// Shared path: polarity-safe under the terminal-native lock, else
|
||||
// normal theme quantize (see xai_grok_pager_render::syntax).
|
||||
let fg = crate::syntax::syntect_rgb_to_fg(
|
||||
style.foreground.r,
|
||||
style.foreground.g,
|
||||
style.foreground.b,
|
||||
);
|
||||
spans.push(Span::styled(piece, Style::default().fg(fg)));
|
||||
}
|
||||
|
||||
|
|
@ -1156,13 +1162,6 @@ fn highlight_to_ratatui_line(
|
|||
Line::from(spans)
|
||||
}
|
||||
|
||||
/// Convert a syntect RGBA color to a ratatui Color.
|
||||
///
|
||||
/// Quantizes the RGB value to the terminal's supported color level.
|
||||
fn syntect_to_ratatui_color(c: syntect::highlighting::Color) -> ratatui::style::Color {
|
||||
crate::theme::quantize(ratatui::style::Color::Rgb(c.r, c.g, c.b))
|
||||
}
|
||||
|
||||
/// Count digits in a number (for line number padding).
|
||||
fn digit_count(n: usize) -> usize {
|
||||
if n == 0 {
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ pub enum TaskEntry {
|
|||
label: String,
|
||||
styled: Line<'static>,
|
||||
started_at: Instant,
|
||||
linked_subagent: Option<String>,
|
||||
},
|
||||
/// Collapsible group header row (e.g. `▾ Subagents 2`). Not a task —
|
||||
/// selecting it and pressing Enter (or clicking it) toggles the group's
|
||||
|
|
@ -448,7 +449,9 @@ impl TaskEntry {
|
|||
info: &ScheduledTaskInfo,
|
||||
current_cron: Option<&str>,
|
||||
is_queued: bool,
|
||||
linked: Option<(String, bool)>,
|
||||
) -> Self {
|
||||
let linked_running = linked.as_ref().is_some_and(|(_, running)| *running);
|
||||
let theme = Theme::current();
|
||||
let prompt_preview = if info.prompt.chars().count() > 60 {
|
||||
info.prompt.chars().take(57).collect::<String>() + "..."
|
||||
|
|
@ -469,7 +472,7 @@ impl TaskEntry {
|
|||
}
|
||||
};
|
||||
let is_provisional = info.task_id.starts_with("provisional-");
|
||||
let suffix = if current_cron == Some(&info.task_id) {
|
||||
let suffix = if current_cron == Some(&info.task_id) || linked_running {
|
||||
" (running)".to_string()
|
||||
} else if is_queued {
|
||||
" (queued)".to_string()
|
||||
|
|
@ -501,7 +504,7 @@ impl TaskEntry {
|
|||
}
|
||||
};
|
||||
let label = format!(
|
||||
"{} {} {}{}",
|
||||
"{} {} \u{b7} {}{}",
|
||||
tag_display, info.human_schedule, &prompt_preview, &suffix
|
||||
);
|
||||
|
||||
|
|
@ -510,11 +513,11 @@ impl TaskEntry {
|
|||
// neutral secondary text color so the row reads calmly with a single
|
||||
// point of color. No surrounding `[ ]` brackets: the color alone
|
||||
// sets the tag apart from the schedule that follows it.
|
||||
let schedule_style = format!("{} ", info.human_schedule);
|
||||
let schedule_style = format!("{} \u{b7} ", info.human_schedule);
|
||||
let neutral = Style::default().fg(theme.text_secondary);
|
||||
let styled = Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{} ", tag_display),
|
||||
format!("{} ", tag_display),
|
||||
Style::default().fg(theme.accent_system),
|
||||
),
|
||||
Span::styled(schedule_style, neutral),
|
||||
|
|
@ -537,6 +540,7 @@ impl TaskEntry {
|
|||
label,
|
||||
styled,
|
||||
started_at: info.created_at,
|
||||
linked_subagent: linked.map(|(sid, _)| sid),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -660,7 +664,7 @@ impl ListItem for TaskEntry {
|
|||
enum OverlayEntryData {
|
||||
BgTask(String),
|
||||
Agent(String, String),
|
||||
Scheduled(String),
|
||||
Scheduled(String, Option<String>),
|
||||
}
|
||||
|
||||
const MAX_TASKS_HEIGHT: u16 = 8;
|
||||
|
|
@ -834,10 +838,17 @@ impl TasksPane {
|
|||
|
||||
// Add scheduled task items (always "running")
|
||||
for info in scheduled.values() {
|
||||
let linked = info.last_subagent_id.as_deref().and_then(|sid| {
|
||||
subagents
|
||||
.values()
|
||||
.find(|s| s.subagent_id.as_ref() == sid)
|
||||
.map(|s| (sid.to_string(), s.is_running()))
|
||||
});
|
||||
self.items.push(TaskEntry::from_scheduled(
|
||||
info,
|
||||
current_cron_task_id,
|
||||
queued_cron_ids.contains(info.task_id.as_str()),
|
||||
linked,
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -1307,9 +1318,11 @@ impl TasksPane {
|
|||
child_session_id,
|
||||
..
|
||||
} => OverlayEntryData::Agent(subagent_id.clone(), child_session_id.clone()),
|
||||
TaskEntry::Scheduled { task_id, .. } => {
|
||||
OverlayEntryData::Scheduled(task_id.clone())
|
||||
}
|
||||
TaskEntry::Scheduled {
|
||||
task_id,
|
||||
linked_subagent,
|
||||
..
|
||||
} => OverlayEntryData::Scheduled(task_id.clone(), linked_subagent.clone()),
|
||||
// Group headers have no kill/view buttons; they still
|
||||
// occupy a row (vis_row is enumerated before this filter),
|
||||
// so the y offsets for following items stay correct.
|
||||
|
|
@ -1333,8 +1346,15 @@ impl TasksPane {
|
|||
};
|
||||
self.render_agent_overlay(area, buf, y, subagent_id, info, &theme);
|
||||
}
|
||||
OverlayEntryData::Scheduled(ref task_id) => {
|
||||
self.render_scheduled_overlay(area, buf, y, task_id, &theme);
|
||||
OverlayEntryData::Scheduled(ref task_id, ref linked_subagent) => {
|
||||
self.render_scheduled_overlay(
|
||||
area,
|
||||
buf,
|
||||
y,
|
||||
task_id,
|
||||
linked_subagent.as_deref(),
|
||||
&theme,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1643,6 +1663,7 @@ impl TasksPane {
|
|||
buf: &mut Buffer,
|
||||
y: u16,
|
||||
task_id: &str,
|
||||
linked_subagent: Option<&str>,
|
||||
theme: &Theme,
|
||||
) {
|
||||
let frames = crate::glyphs::dot_spinner_frames();
|
||||
|
|
@ -1654,8 +1675,8 @@ impl TasksPane {
|
|||
2,
|
||||
);
|
||||
|
||||
// Clear overlay area (kill button + separator = 4 cols).
|
||||
clear_overlay_area(buf, area, y, 4);
|
||||
let overlay_cols = if linked_subagent.is_some() { 7 } else { 4 };
|
||||
clear_overlay_area(buf, area, y, overlay_cols);
|
||||
|
||||
let mut rx = area.x + area.width;
|
||||
|
||||
|
|
@ -1681,6 +1702,29 @@ impl TasksPane {
|
|||
Rect::new(rx, y, 3, 1),
|
||||
));
|
||||
|
||||
if linked_subagent.is_some() {
|
||||
rx = rx.saturating_sub(3);
|
||||
let is_view_hovered = matches!(
|
||||
&self.hovered_view,
|
||||
Some(TaskEntryId::Scheduled(tid)) if tid == task_id
|
||||
);
|
||||
let view_style = if is_view_hovered {
|
||||
Style::default().fg(theme.text_primary)
|
||||
} else {
|
||||
Style::default().fg(theme.gray)
|
||||
};
|
||||
buf.set_span(
|
||||
rx,
|
||||
y,
|
||||
&Span::styled(crate::glyphs::enlarge_button(), view_style),
|
||||
3,
|
||||
);
|
||||
self.view_button_rects.push((
|
||||
TaskEntryId::Scheduled(task_id.to_string()),
|
||||
Rect::new(rx, y, 3, 1),
|
||||
));
|
||||
}
|
||||
|
||||
if rx > area.x {
|
||||
buf.set_span(rx - 1, y, &Span::raw(" "), 1);
|
||||
}
|
||||
|
|
@ -2899,6 +2943,7 @@ mod tests {
|
|||
created_at: std::time::Instant::now(),
|
||||
next_fire_at: next.map(|s| s.to_string()),
|
||||
tag: "loop".into(),
|
||||
last_subagent_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue