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

@ -1,6 +1,6 @@
[package]
name = "xai-grok-pager"
version = "0.2.105"
version = "0.2.106"
edition.workspace = true
license = "Apache-2.0"
authors = ["xAI"]

View file

@ -84,10 +84,10 @@ Each `.json` file can define multiple hooks:
Key fields:
- **Event name** (top-level key): `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `Notification`, `SessionEnd`, etc.
- **matcher** (optional): Regex that must match the tool name. Only applies to `PreToolUse`/`PostToolUse`. Empty = match everything.
- **matcher** (optional): Regex tested against the event's match value — the tool name on tool events, and per-event values elsewhere (see the user guide's Hooks chapter). Empty = match everything.
- **type**: `"command"` (run a script or shell one-liner) or `"http"` (POST the event to a URL).
- **command**: Path to executable (relative to the JSON file) or inline shell command.
- **timeout**: Seconds before killing the hook (default: 5). Hooks fail open on timeout.
- **timeout**: Seconds before killing the hook (default: 5, or 600 for `Stop`/`SubagentStop` gates). Hooks fail open on timeout.
**Tool name aliases**: Claude-style names like `Bash`, `Edit`, `Read` automatically match Grok's internal names (`run_terminal_cmd`, `search_replace`, `read_file`).
@ -116,7 +116,7 @@ Write JSON to **stdout**:
**Exit codes** (behavior differs by hook type):
- `0` — success / allow (for blocking hooks)
- `2` — explicit deny (blocking hooks only)
- `2` — explicit deny (`PreToolUse`) or block-stop with stderr as feedback (`Stop`/`SubagentStop`; see Stop Decision Control in the user guide)
- Any other (including timeout/crash/missing env var) — **fail-open**: the failure is logged and shown in the hook scrollback, but the tool call is not blocked. To block a tool call, return JSON `{"decision":"deny","reason":"..."}` on stdout.
### Passive hooks

View file

@ -77,11 +77,14 @@ Rewind the conversation to an earlier turn, discarding everything after it.
### `/copy`
Copy the most recent response to the clipboard. Pass a number to copy the Nth-latest response.
Copy the most recent response to the clipboard. Pass a number to copy the Nth-latest response. Pass a file path to write instead of using the clipboard (useful over SSH when the local clipboard is unreachable).
Every copy is also written to a backup file (`~/.grok/last-copy.txt` by default, or `GROK_COPY_FILE` if set), and the toast names that path so you always know where to retrieve the text — including when the clipboard cannot be reached (for example Apple Terminal over SSH) or the copy went out as an OSC 52 escape that this terminal couldn't be verified to apply.
```
/copy
/copy 2
/copy out.txt
/copy 2 ~/exports/last-reply.md
```
### `/export`

View file

@ -22,6 +22,8 @@ Theme names are case-insensitive. The `auto` option (alias `system`) is document
**Minimal mode** (`--minimal`) always renders with a single fixed terminal-native palette and ignores the `theme` settings entirely (they still apply to the full TUI). Minimal draws directly on your terminal's own background, so it uses your terminal's default foreground/background plus its 16-color ANSI palette — the same colors `git` or `ls` use — which stays readable on any light or dark terminal profile without detection or configuration. `/theme` and the theme rows in `/settings` are unavailable in minimal mode.
Syntax highlighting in minimal mode does **not** switch between light and dark theme files (polarity detection is intentionally avoided). Near-gray tokens inherit the terminal default foreground; chromatic tokens use base ANSI accents (red/green/yellow/blue/magenta/cyan) so read-file output and fenced code stay legible on both light and dark profiles.
---
## Switching Themes

View file

@ -9,6 +9,7 @@ Hooks let you run a script or send an HTTP request at key moments in a Grok sess
A hook is a shell command or HTTP endpoint that Grok calls when a specific lifecycle event occurs. Hooks can:
- **Block actions** -- A `PreToolUse` hook can deny a dangerous command before it runs.
- **Keep the agent working** -- A `Stop` hook can block the agent from finishing its turn until a condition holds (e.g. the test suite passes) and feed the reason back to the model.
- **React to events** -- A `PostToolUse` hook can log every tool execution to a file.
- **Set up context** -- A `SessionStart` hook can export environment variables or run setup scripts.
@ -87,16 +88,16 @@ Because hooks are unified under folder-trust, a `--trust` / `/hooks-trust` grant
| `PostToolUse` | A tool completes successfully. | No |
| `PostToolUseFailure` | A tool fails. | No |
| `PermissionDenied` | The permission system denies a tool call. | No |
| `Stop` | An agent turn ends (completed, cancelled, or error). | No |
| `Stop` | An agent turn ends on a genuine completion (not on a user interrupt). | Yes — can block the stop |
| `StopFailure` | A turn ends because of an API error. | No |
| `Notification` | The agent sends a notification. | No |
| `SubagentStart` | A subagent starts. | No |
| `SubagentStop` | A subagent finishes. | No |
| `SubagentStop` | A subagent's turn ends (fires once, in the subagent, with stop decision control). | Yes — can block the stop |
| `PreCompact` | Conversation compaction is about to run. | No |
| `PostCompact` | Conversation compaction completes. | No |
| `SessionEnd` | The session ends. | No |
`SubagentEnd` is accepted as an alias for `SubagentStop`. Only `PreToolUse` can block a tool call; every other event is passive.
`SubagentEnd` is accepted as an alias for `SubagentStop`. `PreToolUse` can block a tool call, and `Stop`/`SubagentStop` can block the agent from stopping (see [Stop Decision Control](#stop-decision-control)); every other event is passive.
### Cursor Hook Compatibility
@ -146,10 +147,10 @@ Each `.json` file can define hooks for multiple events:
### Key Fields
- **Event name** (top-level key): any event listed in [Hook Events](#hook-events). Grok skips unrecognized event names so a shared Claude or Cursor settings file still loads.
- **matcher** (optional): A regular expression that selects which invocations trigger the hook. It applies to the tool events — `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, and `PermissionDenied` — where it tests the tool name, and to `Notification`, where it tests the notification type. The lifecycle events (`SessionStart`, `SessionEnd`, `Stop`, `UserPromptSubmit`) reject a matcher; other events ignore it. An empty or omitted matcher matches everything. The matcher tests the real tool name; MCP calls routed through the internal `use_tool` dispatcher appear as the qualified `server__tool` name (e.g. `linear__save_issue`), so match on that, not the dispatcher name.
- **matcher** (optional): A regular expression that selects which invocations trigger the hook. What it tests depends on the event: the tool name on tool events (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionDenied`), the notification type on `Notification`, the subagent type on `SubagentStart`/`SubagentStop` (e.g. `explore`), the start source on `SessionStart` (`startup`, `resume`, …), the end reason on `SessionEnd`, the compaction trigger on `PreCompact`/`PostCompact` (`manual` or `auto`), and the error type on `StopFailure` (`rate_limit`, `authentication_failed`, `invalid_request`, `server_error`, `max_output_tokens`, or `unknown`). A matcher on `Stop` or `UserPromptSubmit` is ignored with a warning (those events always fire). An empty or omitted matcher matches everything. The matcher tests the real tool name; MCP calls routed through the internal `use_tool` dispatcher appear as the qualified `server__tool` name (e.g. `linear__save_issue`), so match on that, not the dispatcher name.
- **type**: `"command"` (run a script or shell one-liner) or `"http"` (POST the event to a URL).
- **command**: Path to executable (relative to the JSON file) or inline shell command.
- **timeout**: Seconds before killing the hook (default: 5). All hook failures (timeouts, crashes, malformed output, missing required env vars) are fail-open: the failure is recorded for the UI scrollback but the tool call is not blocked. Only an explicit `deny` decision returned by the hook blocks a tool call.
- **timeout**: Seconds before killing the hook (default: 5, or 600 for `Stop`/`SubagentStop` gates, matching Claude Code). All hook failures (timeouts, crashes, malformed output, missing required env vars) are fail-open: the failure is recorded for the UI scrollback but the tool call is not blocked. Only an explicit `deny` decision returned by the hook blocks a tool call.
### Tool Name Aliases
@ -179,12 +180,15 @@ The event is sent as JSON on **stdin** (for example, a `PreToolUse` event; the p
"sessionId": "abc-123",
"cwd": "/Users/you/project",
"workspaceRoot": "/Users/you/project",
"permissionMode": "default",
"toolName": "run_terminal_command",
"toolInput": { "command": "npm test" },
"timestamp": "2026-04-14T12:00:00Z"
}
```
Every event carries the same common fields: `hookEventName`, `sessionId`, `cwd`, `workspaceRoot`, `timestamp`, and `permissionMode` (`default`, `auto`, `plan`, or `bypassPermissions`), plus event-specific fields like `toolName` above.
### Output (Blocking Hooks)
For `PreToolUse` hooks, write JSON to **stdout**:
@ -197,8 +201,59 @@ For `PreToolUse` hooks, write JSON to **stdout**:
| Exit Code | Meaning |
|-----------|---------|
| `0` | Success / allow (for blocking hooks) |
| `2` | Explicit deny (blocking hooks only) |
| Other | Fail-open — the failure is recorded but the tool call is not blocked. To block a call, emit a `deny` decision in stdout JSON (honored regardless of exit code). |
| `2` | Explicit deny (`PreToolUse`) or block-stop with stderr as feedback (`Stop`/`SubagentStop`) |
| Other | Fail-open — the failure is recorded but nothing is blocked. For `PreToolUse`, a `deny` decision in stdout JSON is honored regardless of exit code. For `Stop`/`SubagentStop`, a valid decision JSON on stdout wins over the exit code (matching Claude Code); the exit code decides only when stdout has no usable JSON, in which case exit 2 blocks with stderr as the feedback. |
### Stop Decision Control
`Stop` and `SubagentStop` hooks run when the agent is about to finish its turn and can keep it working (Claude Code-compatible). Write JSON to **stdout**:
- **Block the stop**: `{"decision": "block", "reason": "The test suite hasn't been run yet"}`. The reason is fed back to the model as a user message and the agent runs another round in the same turn.
- **Non-error feedback**: `{"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": "Run the linter before finishing"}}`. Also keeps the agent working, but is surfaced as hook feedback rather than a hook error.
- **Force stop**: `{"continue": false, "stopReason": "Budget exhausted"}`. Ends the turn, overriding any blocks.
- **Allow the stop**: exit 0 with no output (or any non-JSON output).
Exiting with code `2` also blocks the stop, with **stderr** as the feedback.
The hook input includes `stopHookActive` and `lastAssistantMessage`. `stopHookActive` is true when the agent is already continuing due to a previous stop-hook block this turn; check it, or the transcript, to avoid blocking on a condition that will never resolve. `lastAssistantMessage` carries the text of the agent's final response this turn, so hooks can act on it without parsing the transcript. After **8 continuations** (blocks or non-error feedback) in one turn the gate is overridden and the turn ends; hooks are not consulted for that final, forced stop. The counter is per turn: the next user prompt starts fresh, so a long-running goal can span turns. Hook failures fail open: the agent stops normally.
`Stop` and `SubagentStop` hooks default to a 600-second timeout (matching Claude Code) because gates commonly run builds or test suites, and a timed-out hook fails open, so the agent stops anyway. Other events keep the 5-second default. Set `timeout` explicitly when a gate needs more: `{ "type": "command", "command": "bin/verify.sh", "timeout": 1200 }`.
The gate runs only for genuine completions. Interrupted (Esc / Ctrl+C), refused, and max-turns turns skip Stop hooks entirely, and API-error turns fire `StopFailure` instead. A separate Stop also fires at session end (`reason: "channel_closed"` or `"shutdown"`); its decision output is parsed but ignored, since there is no turn left to continue. A script that counts or gates on Stop fires should check `reason == "end_turn"` so the session-end fire doesn't skew it.
`StopFailure` is observation-only (use it to log failures or send alerts; output and exit code are ignored). Its input carries `error` (the classified type the matcher tests, in Claude Code's vocabulary: `rate_limit`, `authentication_failed`, `invalid_request`, `server_error`, `max_output_tokens`, or `unknown` for anything the runtime cannot distinguish; capacity errors fold into `rate_limit` and there is no signal for `billing_error`), `errorDetails` (the raw error detail, when available), and `lastAssistantMessage` (the rendered error text shown in the conversation; for this event it is the error string, not assistant output).
`Stop` input also carries `backgroundTasks` and `sessionCrons`, so a hook can distinguish "session is done" from "session is paused waiting for background work to wake it back up". Both arrays are empty when nothing is in flight or scheduled. Each `backgroundTasks` entry describes one in-flight task: `id`, `type` (`shell`, `monitor`, or `subagent`), `status`, and (depending on the type) `command` (shell tasks only), `description` (a monitor's watched command line, or a subagent's task description), and `agentType` (subagents). Each `sessionCrons` entry describes one scheduled wakeup (`scheduler_create` or `/loop`): `id`, `schedule`, `recurring`, and `prompt`. The `schedule` value is a human-readable interval such as `every 5 minutes`; grok schedules are intervals, not cron expressions. Free-text entry fields are capped at 1000 characters with an in-string `… [+N chars]` marker.
Inside a subagent, the gate fires as `SubagentStop` (agent-frontmatter `Stop` hooks are automatically remapped). A `Stop` hook only gates the main agent.
`SubagentStop` fires once per subagent, at the subagent's own turn end, matching Claude Code. Its input carries a `phase` field (currently always `"gate"`) reserved for forward compatibility.
**Porting Claude Code stop hooks**: the output vocabulary (`decision`, `reason`, `continue`, `stopReason`, `additionalContext`) works unchanged. Check this list for what does not match Claude:
- **camelCase input**: grok's stdin envelope uses camelCase keys throughout where Claude uses snake_case. A script reading `.stop_hook_active`, `.hook_event_name`, or `.background_tasks[].agent_type` must switch to `.stopHookActive`, `.hookEventName`, and `.backgroundTasks[].agentType` (the event value is `"stop"`). Hooks registered through the grok-agent-sdk convert both the top-level keys and the `backgroundTasks`/`sessionCrons` entry keys to snake_case, so the wire's `.backgroundTasks[].agentType` reads as `.background_tasks[].agent_type` in the SDK.
- **`toolResult` field**: the `PostToolUse` tool output is `toolResult` (SDK: `tool_result`), not Claude's `tool_response`; a hook reading `.tool_response` must switch to `.toolResult`.
- **Session-end fire**: an extra observe-only Stop fires at session end; filter on `reason == "end_turn"` (see above).
- **Interval schedules**: `sessionCrons[].schedule` is a human-readable interval, never a cron expression.
- **Task types**: `backgroundTasks[].type` is only `shell`, `monitor`, or `subagent`; Claude's other labels (`workflow`, `teammate`, …) are not emitted.
- **StopFailure classes**: the emitted set is Claude Code's vocabulary — `rate_limit`, `authentication_failed`, `invalid_request`, `server_error`, `max_output_tokens`, `unknown`. grok emits a subset: capacity errors (503/529) fold into `rate_limit` as in Claude, and `billing_error` is never emitted (no signal), so a `billing_error` matcher will not fire.
- **permission_mode values**: grok emits `default`, `auto`, `plan`, or `bypassPermissions`. Claude's `acceptEdits`/`dontAsk` have no grok equivalent (grok's `auto` is the nearest), so a check like `permission_mode === "acceptEdits"` never matches.
- **Client (SDK) gate timeouts**: SDK `Stop`/`SubagentStop` gates default to 600 seconds like file hooks; `PreToolUse` client gates default to 30 seconds (the interactive hot path). Either can be overridden per matcher group via `timeoutS`, capped at 600.
- **`/goal`**: grok's goal loop is a separate feature that runs before the stop gate; it is not a prompt-type Stop hook.
A complete keep-working policy in one script:
```bash
#!/bin/bash
input=$(cat)
# Gate only genuine turn ends, not the session-end observe fire.
if [ "$(echo "$input" | jq -r '.reason')" != "end_turn" ]; then exit 0; fi
if ! bin/verify.sh >/dev/null 2>&1; then
echo '{"decision": "block", "reason": "verify.sh failed; fix the failures before finishing"}'
fi
```
registered as `{ "type": "command", "command": "bin/stop-gate.sh", "timeout": 300 }` with `timeout` sized for the verify step. The hook fires again after each continuation, and the built-in cap ends the turn after 8; check `stopHookActive` to give up earlier on feedback the agent evidently cannot act on.
### Passive Hooks

View file

@ -92,6 +92,8 @@ Grok writes to the clipboard through up to three routes, shown in the **Clipboar
**Linux Wayland**: on compositors that support the data-control protocol (GNOME 48+, KDE, Sway, Hyprland — the **Clipboard** section shows `data-control on`; the line is omitted off Wayland) copies work even if the terminal loses focus mid-copy. On older compositors (GNOME 46/47), keep the terminal focused until the copy toast confirms, and install the `wl-clipboard` package (provides `wl-copy`) for the most reliable route — Grok shows a startup warning when this applies. If data-control misbehaves on your compositor, set `GROK_CLIPBOARD_NO_DATA_CONTROL=1` to stop Grok from speaking that protocol entirely — copies then go through the CLI tools (`wl-copy`/`xclip`).
**OSC 52 kill switch**: Grok emits OSC 52 on every Linux copy (and over SSH/tmux/containers). Terminals that do not implement OSC 52 may paint the base64 payload as visible garbage (for example some VNC/X11 clients such as OpenText Exceed). Set `GROK_CLIPBOARD_NO_OSC52=1` before starting Grok to force the OSC 52 leg off; `/terminal-setup` then shows `osc 52 off`. Native and tmux clipboard legs are unchanged.
**Linux X11 selections**: X11 **PRIMARY** and **CLIPBOARD** are separate. Selecting text usually fills PRIMARY; an explicit Copy action fills CLIPBOARD. In Grok:
- An unmodified middle click reads PRIMARY only when `DISPLAY` is non-empty. Pure X11 can fall back to the native arboard reader. XWayland must have `xclip` or `xsel` on `PATH`; Grok deliberately disables the arboard fallback there so it cannot substitute Wayland PRIMARY.
@ -103,9 +105,9 @@ Grok writes to the clipboard through up to three routes, shown in the **Clipboar
**Unknown terminals over SSH**: when Grok cannot identify the outer terminal, it sends the copy but reports delivery as unverified. If paste fails, reconnect with `grok wrap <ssh command>` or use `/minimal`.
**Known limitation — Apple Terminal + SSH**:
Apple Terminal ignores OSC 52, so copying from a Grok session over SSH can't reach your local clipboard. Use the workaround below.
Apple Terminal ignores OSC 52, so copying from a Grok session over SSH can't reach your local clipboard. Grok writes every in-app copy to a backup file (`~/.grok/last-copy.txt`, override with `GROK_COPY_FILE`) and the toast names the path — so you can `cat`/`scp` it. You can also target a file explicitly with `/copy out.txt` or `/copy 2 ~/reply.md`. For native drag-select copy (terminal selection → local clipboard), turn mouse capture off with `/toggle-mouse-reporting` (opt-in feature) or run `grok --minimal`.
**Temporary workaround**: Use `grok wrap ssh` instead of plain `ssh` (for example, `grok wrap ssh user@host`). It runs the command in a local PTY that intercepts OSC 52 sequences, including tmux-wrapped ones, and writes their contents to your local clipboard. The same command wraps anything else whose clipboard can't reach you — for example `grok wrap docker exec -it <container> bash` or `grok wrap kubectl exec -it <pod> -- bash`.
**Optional workaround for live clipboard**: Use `grok wrap ssh` instead of plain `ssh` (for example, `grok wrap ssh user@host`). It runs the command in a local PTY that intercepts OSC 52 sequences, including tmux-wrapped ones, and writes their contents to your local clipboard. The same command wraps anything else whose clipboard can't reach you — for example `grok wrap docker exec -it <container> bash` or `grok wrap kubectl exec -it <pod> -- bash`.
`grok wrap` also protects your local terminal from dirty disconnects: if the wrapped command dies while a remote TUI has mouse reporting, the alternate screen, or similar modes enabled (for example the SSH connection drops mid-session), wrap resets those modes on exit instead of leaving the terminal spraying mouse escape codes.

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

View file

@ -30,6 +30,13 @@ fn grok_home_override_path_helpers() {
"$GROK_HOME/memory/MEMORY.md"
);
// Copy-toast paths follow the same abbreviation convention, so a custom
// $GROK_HOME outside $HOME still displays short.
assert_eq!(
xai_grok_pager::clipboard::display_copy_path(&grok_home.join("last-copy.txt")),
"$GROK_HOME/last-copy.txt"
);
assert!(xai_grok_pager::util::is_under_user_grok_home(&memory_path));
assert!(!xai_grok_pager::util::is_under_user_grok_home(
PathBuf::from("/tmp/other").as_path()

View file

@ -401,10 +401,13 @@ async fn cancel_before_task_completion_defers_auto_wake_until_user_prompt() {
harness
.wait_for_full_text("Task completed in", Duration::from_secs(15))
.expect("background completion chip");
// hold must be strictly less than timeout: wait_until_stable stamps
// true_since after the deadline is fixed, so timeout == hold flakes under
// load even when the condition is always true (remote --runs_per_test).
harness
.wait_until_stable(
"no auto-wake response after background completion",
Duration::from_secs(2),
Duration::from_secs(5),
Duration::from_secs(2),
|h| !h.contains_full_text(UNWANTED_AUTO_WAKE_SENTINEL),
)
@ -443,7 +446,7 @@ async fn cancel_before_task_completion_defers_auto_wake_until_user_prompt() {
harness
.wait_until_stable(
"no second completion request after the user turn",
Duration::from_secs(2),
Duration::from_secs(5),
Duration::from_secs(2),
|_| {
content