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:
grokkybara[bot] 2026-07-19 18:40:33 +01:00
commit ba76b0a683
143 changed files with 9465 additions and 3419 deletions

View file

@ -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,
});
}
}

View file

@ -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),

View file

@ -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(&notif).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 })),
};

View file

@ -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(&notif, &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(&notif, &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(&notif, &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(&notif, &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,
},
);
}

View file

@ -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

View file

@ -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

View 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.
///

View file

@ -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()),

View file

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

View file

@ -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:?}",
);

View file

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

View file

@ -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,
},
);
}

View file

@ -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 } => {

View file

@ -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![]
}

View file

@ -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,
},
);
}

View file

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

View file

@ -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:?}",
);

View file

@ -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;
}
}
}
}
}

View file

@ -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![

View file

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

View file

@ -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:?}"),
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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,
}
}