Synced from monorepo
Synced from monorepo Changes: - Cache growing transcripts on the messages backend - Tell the model when a wait was clamped instead of re-inviting it - Stop the stationarity nudge from claiming results are identical - Deliver the stationarity nudge after the tool result - Run auth provider commands through the platform shell (fixes Windows) - Keep monitor tool stdout short and prescriptive - Use UUIDs for analytics event insert IDs - Stop crashing at startup when the host runs out of threads - Delete the current session from within the session - Add project forking-settings toggle (backend and deploy-time control) - Reap a session’s bash and background commands when it closes - Reap a session’s hook child processes when it closes - Track coding-data consent decisions - Fail open the access gate to stop false CLI paywalls - Ship Agent Dashboard user guide - Enable doom-loop recovery by default - Kill agent children and the idle inhibitor when the parent process dies - Fix multi-process credential wipe and orphaned session log writers Source-Revision: 6372e41d828b8a6ee82c29e01a69e27ec895cca9
This commit is contained in:
parent
5da6962e4a
commit
500129c714
89 changed files with 3841 additions and 771 deletions
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "xai-grok-pager"
|
||||
version = "0.2.112"
|
||||
version = "0.2.114"
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
authors = ["xAI"]
|
||||
|
|
@ -161,8 +161,8 @@ core-foundation = { workspace = true }
|
|||
windows-sys = { version = "0.59", features = ["Win32_System_Console"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Enable the render crate's test-only helpers for the pager's test build.
|
||||
xai-grok-pager-render = { path = "../xai-grok-pager-render", features = ["test-support"] }
|
||||
xai-grok-shell = { workspace = true, features = ["test-support"] }
|
||||
pretty_assertions = { workspace = true }
|
||||
insta = { workspace = true }
|
||||
criterion = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ Actions available from any screen.
|
|||
| Key | Alt Key | Action | Confirmation |
|
||||
|-----|---------|--------|-------------|
|
||||
| `Ctrl+N` | | Create a new session (optionally in a git worktree) | Yes (double-press within 1000ms) |
|
||||
| `Ctrl+\` | | Open or toggle the [Agent Dashboard](23-dashboard.md) | No |
|
||||
| `Ctrl+Q` | `Ctrl+D` | Quit the application | Yes (double-press within 1000ms) |
|
||||
|
||||
**VS Code family terminal** (VS Code, Cursor, Windsurf, Zed integrated terminals): `Ctrl+Q` is captured by the host, so Grok makes **`Ctrl+D` the sole quit key** (`Ctrl+Q` is not bound). Half-page-down is rebound to bare **`Shift+D`**. Mid-turn interject uses **`Ctrl+L`** (no alternates) because `Ctrl+Enter` / `Ctrl+I` do not reliably reach the PTY; extensions are opened via `/plugins` instead of `Ctrl+L`.
|
||||
|
|
@ -239,6 +240,30 @@ Bindings that only fire on the welcome screen (before any agent session is open)
|
|||
|
||||
---
|
||||
|
||||
## Agent Dashboard
|
||||
|
||||
Bindings while the [Agent Dashboard](23-dashboard.md) is focused (`Ctrl+\` or `/dashboard`).
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `↑` / `↓`, `j` / `k` | Navigate agent rows (selecting a row opens peek) |
|
||||
| `Enter` | Open the selected agent, or send a typed peek reply / dispatch prompt |
|
||||
| `Ctrl+S` | Reply or dispatch **and** attach to that agent |
|
||||
| `Ctrl+/` | Toggle search / filter mode |
|
||||
| `Ctrl+R` | Rename the selected agent |
|
||||
| `Ctrl+T` | Pin / unpin |
|
||||
| `Ctrl+G` | Toggle grouping (state ↔ working directory) |
|
||||
| `Ctrl+X` | Stop a running turn, or press twice within 2s to close the session |
|
||||
| `Ctrl+O` | Toggle always-approve on the selected agent |
|
||||
| `Tab` | Toggle focus between the list and the dispatch / peek input |
|
||||
| `Esc` | Step back (cancel search → close peek → clear filter → unfocus → unselect → exit) |
|
||||
| `Ctrl+\` | Exit the dashboard (or return from an attached agent) |
|
||||
| `Ctrl+.` (alt: `?`) | Shortcuts cheatsheet |
|
||||
|
||||
Details (peek vs dispatch, search prefixes, persistence): [Agent Dashboard](23-dashboard.md).
|
||||
|
||||
---
|
||||
|
||||
## Command Palette
|
||||
|
||||
Press `Ctrl+P` or `?` to open the command palette -- a searchable list of actions. The palette shows:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ Start a fresh session and clear the current conversation. Alias: `/clear`.
|
|||
|
||||
Open the session picker to reload a previous session from disk.
|
||||
|
||||
### `/dashboard`
|
||||
|
||||
Open the [Agent Dashboard](23-dashboard.md): live roster of top-level sessions in this pager (peek, reply, dispatch, pin, rename, stop, attach). Aliases: `/agents-dashboard`, `/sessions`.
|
||||
|
||||
Not `/config-agents` (alias `/agents`), which manages agent *definitions* and personas. Hidden in minimal mode; disable with `GROK_AGENT_DASHBOARD=0` or `[dashboard].enabled = false`.
|
||||
|
||||
### `/compact [context]`
|
||||
|
||||
Compress conversation history to reclaim context-window space. Pass a note to tell Grok what to keep:
|
||||
|
|
@ -80,6 +86,12 @@ Quit the application. Alias: `/exit`.
|
|||
|
||||
Leave the current session and return to the welcome screen. Alias: `/welcome`.
|
||||
|
||||
### `/delete`
|
||||
|
||||
Delete the current session's history and return to the welcome screen. Confirms first.
|
||||
|
||||
To delete a session you are not in, open `/resume` and press `d` then `y`.
|
||||
|
||||
### `/rename`
|
||||
|
||||
Rename the current session. Alias: `/title`.
|
||||
|
|
@ -369,6 +381,8 @@ Open the Claude import modal to bring over `~/.claude` settings: permissions, en
|
|||
|
||||
Open the agents modal to view and manage agent definitions, set the default, and switch the active one. Alias: `/agents`.
|
||||
|
||||
Not the live multi-session [Agent Dashboard](23-dashboard.md) (`/dashboard` / `Ctrl+\`).
|
||||
|
||||
### `/personas`
|
||||
|
||||
Create, edit, and delete personas. A subagent can apply a persona to shape how it behaves.
|
||||
|
|
|
|||
|
|
@ -62,6 +62,14 @@ End the session and quit Grok:
|
|||
|
||||
Alias: `/exit`. To leave the current session but stay in Grok, use `/home` to return to the welcome screen.
|
||||
|
||||
### Delete the current session
|
||||
|
||||
```
|
||||
/delete
|
||||
```
|
||||
|
||||
Confirms, then permanently removes the session history and returns to the welcome screen. From `/resume`, press `d` then `y` on a row to delete a session you are not currently in.
|
||||
|
||||
---
|
||||
|
||||
## Resuming Sessions
|
||||
|
|
@ -78,7 +86,7 @@ This opens a session picker that lists recent sessions for the current workspace
|
|||
|
||||
Typing in the picker filters the list by title and also searches your conversation content as you type; content matches appear under an "Extended search results" heading. Press `Ctrl+/` to search immediately without the brief pause.
|
||||
|
||||
To switch between, rename, or close the sessions that are currently active (the parent session and any forks), use `/dashboard` (or its alias `/sessions`) instead.
|
||||
For the live top-level sessions in this pager (parent and forks) — switch, rename, peek, dispatch, or close — use the [Agent Dashboard](23-dashboard.md): `/dashboard` (aliases `/sessions`, `/agents-dashboard`) or `Ctrl+\`.
|
||||
|
||||
### From the Command Line
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,26 @@
|
|||
# Agent Dashboard
|
||||
|
||||
The Agent Dashboard is a centralised, agent-native overview of every
|
||||
top-level session you have in flight — your local sessions and forks
|
||||
— grouped by state, with peek, attach, and dispatch from one screen.
|
||||
Subagents are not listed here: they run under their parent session,
|
||||
which already shows when work is in flight.
|
||||
The Agent Dashboard lists every top-level session in this pager process —
|
||||
local sessions and forks — grouped by state. From one screen you can peek,
|
||||
reply, attach, pin, rename, stop, or dispatch a new agent. Subagents are not
|
||||
listed; they run under their parent, which already shows when work is in
|
||||
flight.
|
||||
|
||||
Not the agents modal (`/config-agents` / `/agents` — definitions and
|
||||
personas), the session picker (`/resume` / `Ctrl+S` — past conversations on
|
||||
disk), or the workflows run UI (`/workflows`).
|
||||
|
||||
---
|
||||
|
||||
## Opening the dashboard
|
||||
|
||||
Three entry points, all opening the same view:
|
||||
|
||||
- **`grok dashboard`** — launches the TUI directly into the dashboard.
|
||||
- **`grok dashboard`** — launch the TUI into the dashboard.
|
||||
- **`/dashboard`** (aliases **`/agents-dashboard`**, **`/sessions`**) — open
|
||||
from inside an active session.
|
||||
- **Ctrl+\\** — same as the slash command, two keystrokes. Configurable
|
||||
in `~/.grok/config.toml` under `[keybindings]` like every other shortcut.
|
||||
from inside a session.
|
||||
- **`Ctrl+\`** — same view as the slash command.
|
||||
|
||||
Hidden in minimal mode. Set `GROK_AGENT_DASHBOARD=0` or
|
||||
`[dashboard].enabled = false` to disable.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -35,46 +39,36 @@ Three entry points, all opening the same view:
|
|||
↑/↓ select (peek) · Enter open · Ctrl+R rename · Ctrl+T pin · Ctrl+X stop · ? help · Esc new
|
||||
```
|
||||
|
||||
Each row is a top-level agent (subagents aren't shown — they run under
|
||||
their parent). Rows are sorted by state (Needs input → Working → Idle →
|
||||
Inactive → Completed → Failed) so same-state rows sit adjacent, or by
|
||||
working directory (toggle with `Ctrl+G`). **Inactive** holds roster-only
|
||||
sessions — idle/dormant sessions owned by other pager processes that
|
||||
haven't been loaded in this one — so **Idle** stays focused on the
|
||||
sessions you're actively cycling between. Because it's background noise,
|
||||
**Inactive starts collapsed** (expand it with `→` / click — see below).
|
||||
Each row is a top-level agent. Sort by state (Needs input → Working → Idle →
|
||||
Inactive → Completed → Failed) so same-state rows sit together, or by working
|
||||
directory (`Ctrl+G` toggles). **Inactive** is roster-only sessions owned by
|
||||
other pager processes that this process has not loaded — background noise, so
|
||||
the section **starts collapsed** (expand with `→` / click).
|
||||
|
||||
To keep the **Idle** group scannable, only the most recent idle agents
|
||||
stay visible — the 8 freshest, plus any active within the last hour. The
|
||||
rest fold into a **"N more"** row (marked with a `+` / `-` toggle) at the
|
||||
bottom of the group; select it and press `Enter` / `→` (or click it) to
|
||||
reveal them all, and `←` to re-fold. The Idle header always shows the true total. Folding is
|
||||
suspended while a filter or search is active (so every match shows).
|
||||
To keep **Idle** scannable, only the most recent idle agents stay visible —
|
||||
the 8 freshest, plus any active within the last hour. The rest fold into a
|
||||
**"N more"** row at the bottom of the group; select it and press `Enter` /
|
||||
`→` (or click) to expand, `←` to re-fold. The Idle header always shows the
|
||||
true total. Folding is suspended while a filter or search is active.
|
||||
|
||||
The state icon matches Grok Build's sibling views (
|
||||
`tasks_pane`):
|
||||
State icons match other session lists in Grok Build:
|
||||
|
||||
- `⋅`/`:`/`⸬`/`⁙` — animated spinner for **Working** rows.
|
||||
- `⋅`/`:`/`⸬`/`⁙` — animated spinner for **Working**
|
||||
- `●` — filled circle for **Needs input**, **Completed**, **Failed**,
|
||||
**Blocked**. Colour communicates the state (yellow / green / red /
|
||||
amber).
|
||||
- `○` — hollow circle for **Idle** and **Inactive** rows.
|
||||
**Blocked** (color: yellow / green / red / amber)
|
||||
- `○` — hollow circle for **Idle** and **Inactive**
|
||||
|
||||
A row stays in **Working** while it has live background work even if its
|
||||
turn has finished — a running background task, a `monitor`, or an active
|
||||
scheduled `/loop`. The activity line says what's running (e.g.
|
||||
`1 monitor · 2 loops still running`), since each can wake the agent for a
|
||||
new turn.
|
||||
A row stays **Working** while it has live background work even if its turn
|
||||
has finished — a background task, a `monitor`, or an active scheduled
|
||||
`/loop`. The activity line says what is still running (for example
|
||||
`1 monitor · 2 loops still running`).
|
||||
|
||||
There are no inline group headers — the sort order keeps same-state
|
||||
rows adjacent and the per-row dot+colour communicates which group
|
||||
each row belongs to (matching other session lists).
|
||||
There are no inline group headers; sort order keeps same-state rows adjacent,
|
||||
and the per-row dot + color shows the group.
|
||||
|
||||
The dispatch input shares the same `PromptWidget` chrome as the
|
||||
agent view's prompt (rounded box, `❯` prefix, accent border, info
|
||||
line). Pressing `Ctrl+/` flips it into **search mode**: the `❯`
|
||||
prefix becomes a yellow `Search:` and whatever you type live-filters
|
||||
the row list instead of being dispatched.
|
||||
The dispatch input uses the same prompt chrome as the agent view. Press
|
||||
`Ctrl+/` to flip it into **search mode**: the `❯` prefix becomes a yellow
|
||||
`Search:` and typing live-filters the list instead of dispatching.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -82,248 +76,185 @@ the row list instead of being dispatched.
|
|||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `↑` / `↓`, `j` / `k` | Navigate rows AND section titles (selecting a row opens its peek panel) |
|
||||
| `→` / `←` (on a section title) | Expand / collapse the section (shows / hides its rows); `l` / `h` in vim mode |
|
||||
| `↑` / `↓`, `j` / `k` | Navigate rows and section titles (selecting a row opens peek) |
|
||||
| `→` / `←` (on a section title) | Expand / collapse the section (`l` / `h` in vim mode) |
|
||||
| `Enter` (on a section title) | Toggle the section collapsed / expanded |
|
||||
| `Enter` (empty reply) | Open the selected agent's conversation full-screen (details view) |
|
||||
| `Ctrl+S` | Send the peek reply AND open the agent (or dispatch + attach a new session) |
|
||||
| `Shift+Enter` / `Alt+Enter` | Insert a newline in the reply / dispatch input (multiline compose) |
|
||||
| `1`–`9` | Answer a pending permission / ask question (when the peek shows options) |
|
||||
| `Enter` (empty reply) | Open the selected agent full-screen (details view) |
|
||||
| `Ctrl+S` | Send the peek reply and open the agent (or dispatch and attach a new session) |
|
||||
| `Shift+Enter` / `Alt+Enter` | Newline in the reply / dispatch input |
|
||||
| `1`–`9` | Answer a pending permission / ask question when peek shows options |
|
||||
| `Enter` (typed reply) | Send / queue the reply to the selected agent |
|
||||
| `/` | Types a literal `/` into the prompt |
|
||||
| `Ctrl+/` | Toggle search mode (live-filter the rows) |
|
||||
| `/` | Literal `/` into the prompt |
|
||||
| `Ctrl+/` | Toggle search mode (live-filter rows) |
|
||||
| `Ctrl+R` | Rename selected row |
|
||||
| `Ctrl+T` | Pin / unpin |
|
||||
| `Ctrl+G` | Toggle grouping (state ↔ directory) |
|
||||
| `Ctrl+X` | Stop / kill (two presses within 2s to close a session) |
|
||||
| `Shift+↑` / `Shift+↓` | Reorder pinned rows |
|
||||
| `Esc` | Step back one level: cancel search → close peek (clear reply draft, then unselect) → clear filter → **unfocus the dispatch input** (so `↑`/`↓`, `j`/`k` navigate the list) → unselect row (→ `[+ New Agent]`) → exit dashboard. Esc never clears your typed dispatch draft — use `Ctrl+U` / `Ctrl+C` for that |
|
||||
| `Ctrl+\` | Return to the dashboard from the details view, or exit dashboard |
|
||||
| `Ctrl+.` (alt: `?`) | Open the keyboard shortcuts cheatsheet. Footer advertises `?` when `Ctrl+.` cannot be delivered. Bare `?` opens help when list-focused or the draft is empty (otherwise types); `Ctrl+X` remains stop |
|
||||
| `Esc` | Step back: cancel search → close peek → clear filter → unfocus dispatch → unselect row → exit. Never clears a typed dispatch draft (`Ctrl+U` / `Ctrl+C` for that) |
|
||||
| `Ctrl+\` | Return from details view, or exit dashboard |
|
||||
| `Ctrl+.` (alt: `?`) | Keyboard shortcuts cheatsheet. Footer shows `?` when `Ctrl+.` cannot be delivered. Bare `?` opens help when list-focused or the draft is empty |
|
||||
|
||||
When grouping by state, each group has a **section title** (e.g. `Working`,
|
||||
`Idle`) with a `▸`/`▾` disclosure marker. Section titles are part of the
|
||||
up/down navigation: select one and press `→` to expand it (showing its rows)
|
||||
or `←` to collapse it — `l` / `h` do the same when vim mode is on.
|
||||
**Clicking** a section title toggles it, and **hovering**
|
||||
brightens its text. Collapse state is remembered while the dashboard stays open.
|
||||
The **Inactive** section starts collapsed by default each time the pager
|
||||
starts; expanding it sticks until you quit.
|
||||
When grouping by state, each group has a **section title** (for example
|
||||
`Working`, `Idle`) with a `▸`/`▾` marker. Select a title and press `→` /
|
||||
`←` to expand or collapse (`l` / `h` in vim mode). Click toggles; hover
|
||||
brightens. Collapse state is remembered while the dashboard stays open.
|
||||
**Inactive** starts collapsed each time the pager starts; expanding it sticks
|
||||
until you quit.
|
||||
|
||||
Opening a row shows the agent's conversation in the **details view**:
|
||||
a single top header row (the agent name on the left, `{i}/{n} [‹][›]
|
||||
[Dashboard]` cycle/close affordances on the right) sits above the conversation,
|
||||
which renders **full-width** — no bordered modal frame — so the prompt
|
||||
position and overall padding match the dashboard list view. All key
|
||||
presses route to the attached agent; `Esc` / `Ctrl+\\` (or the `[Dashboard]`
|
||||
affordance) return to the dashboard, the `[‹]` / `[›]` chips cycle to
|
||||
the previous / next agent, and the agent's shortcuts bar shows a
|
||||
`Ctrl+\\: back to dashboard` hint. Quick gotcha — `Esc` only returns to
|
||||
the dashboard; typing `/exit` inside the agent actually closes the
|
||||
underlying session (returning to the dashboard with a "Session closed"
|
||||
toast).
|
||||
Opening a row shows the agent's conversation in the **details view**: a top
|
||||
header (agent name; `{i}/{n}` cycle chips and `[Dashboard]` on the right)
|
||||
above a full-width conversation — no bordered modal — so padding matches the
|
||||
list view. Keys go to the attached agent; `Esc` / `Ctrl+\` (or `[Dashboard]`)
|
||||
return to the dashboard; `[‹]` / `[›]` cycle agents. The shortcuts bar shows
|
||||
`Ctrl+\: back to dashboard`. Gotcha: `Esc` only returns; `/exit` inside the
|
||||
agent closes the session (dashboard toast: "Session closed").
|
||||
|
||||
`Ctrl+X` in the details view is state-dependent. While a **turn is
|
||||
running** it cancels the turn — the same behaviour as `Ctrl+C`,
|
||||
including the keep-subagents prompt — and never touches the session
|
||||
itself, so mashing it to stop a turn can't close anything. In any
|
||||
other state — **idle**, a slash command in flight (commands can't
|
||||
be cancelled yet), or a cancel still pending — `Ctrl+X` arms a
|
||||
confirmation: the shortcuts bar flips to "press Ctrl+x again to
|
||||
close this session", and a second press within 2 seconds closes the
|
||||
session and returns you to the dashboard. Pressing any other key
|
||||
cancels the confirmation, and a turn that starts inside the window
|
||||
downgrades the confirmed press to a cancel instead of closing.
|
||||
(On terminals where `Ctrl+X` doubles as the shortcuts-cheatsheet
|
||||
binding, the cheatsheet stays reachable via `Ctrl+.` inside the
|
||||
details view.)
|
||||
`Ctrl+X` in the details view is state-dependent. While a **turn is running**
|
||||
it cancels the turn (same as `Ctrl+C`, including the keep-subagents prompt)
|
||||
and never closes the session. Otherwise — **idle**, a slash command in
|
||||
flight, or a cancel still pending — `Ctrl+X` arms a confirmation: press again
|
||||
within 2 seconds to close the session and return to the dashboard. Any other
|
||||
key cancels the confirmation; a turn that starts inside the window turns the
|
||||
confirmed press into a cancel instead. (If `Ctrl+X` is also the cheatsheet
|
||||
binding on your terminal, use `Ctrl+.` inside the details view.)
|
||||
|
||||
For the full behavioural specification (including the registry-lookup
|
||||
rules and the mouse-event intercept matrix) see plan
|
||||
[§3.10](../../plan/agent-dashboard.md) "Keybindings (v1)" — the user
|
||||
guide here is intentionally short and cross-references the plan as
|
||||
the source of truth.
|
||||
See [Keyboard Shortcuts](03-keyboard-shortcuts.md#agent-dashboard).
|
||||
|
||||
All shortcuts are registered under `When::DashboardFocused` and can be
|
||||
rebound via `~/.grok/config.toml`.
|
||||
---
|
||||
|
||||
## Completing or closing a session
|
||||
|
||||
There is **no** “mark completed” command. Row state is derived from the agent:
|
||||
|
||||
- **Completed** / **Failed** when work ends on its own (turn finished and no
|
||||
background task / monitor / `/loop` still running).
|
||||
- **`Ctrl+X` once** while a turn is running cancels the turn.
|
||||
- **`Ctrl+X` twice** (within 2s) on an idle / stopped row **closes** the
|
||||
session and removes it from the live roster.
|
||||
- In the details view, `/exit` also closes the session (Esc only returns).
|
||||
|
||||
Use close/stop when you want a row gone; there is no manual complete flag.
|
||||
|
||||
---
|
||||
|
||||
## Dispatch input
|
||||
|
||||
The bottom textarea **always spawns a NEW session** — it is never a
|
||||
reply target. A selected row is the overview's navigation cursor, not a
|
||||
reply destination; to talk to an existing agent, open it (navigate +
|
||||
`Enter`, or click) and reply inside its own view.
|
||||
The bottom textarea **always spawns a new session**. A selected row is the
|
||||
navigation cursor, not a reply target — open an agent to talk to it.
|
||||
|
||||
Enter handler:
|
||||
- Free text → new top-level session seeded with the prompt. Text is never
|
||||
treated as a filter (even if it starts with `/`, `s:`, `a:`, or `#`);
|
||||
filtering is `Ctrl+/` search mode. A leading `/` runs a pager-global slash
|
||||
command.
|
||||
- Empty input → open the selected row, or create a new agent when
|
||||
`[+ New Agent]` is focused.
|
||||
|
||||
- Free text → creates a new top-level session, seeded with the prompt.
|
||||
Text is **never** reinterpreted as a filter — a prompt may start with
|
||||
`/`, `s:`, `a:`, or `#` and still dispatches verbatim (filtering is
|
||||
the explicit `Ctrl+/` search mode). A leading `/` runs a pager-global
|
||||
slash command.
|
||||
- Empty input → opens the selected row (`Attach`), or creates a new
|
||||
agent when the `[+ New Agent]` button is focused.
|
||||
`Ctrl+S` after typing dispatches **and** attaches; plain `Enter` stays on the
|
||||
dashboard so you can dispatch several sessions. `Shift+Enter` / `Alt+Enter`
|
||||
insert a newline; the box grows with the draft (up to a cap, then scrolls).
|
||||
|
||||
Press `Ctrl+S` after typing a prompt to dispatch AND attach
|
||||
(jump into the new session); plain `Enter` stays on the dashboard so
|
||||
you can dispatch several sessions in a row. `Shift+Enter` / `Alt+Enter`
|
||||
insert a newline for a multi-line prompt — the box **grows in height**
|
||||
as you add lines (up to a cap, after which it scrolls), so the whole
|
||||
draft stays visible.
|
||||
|
||||
The dispatch input accepts any non-empty prompt; an empty /
|
||||
whitespace-only prompt is ignored. Prompts above 64 KiB are rejected
|
||||
with a toast.
|
||||
Empty or whitespace-only prompts are ignored. Prompts above 64 KiB are
|
||||
rejected with a toast.
|
||||
|
||||
### Focus: input bar ↔ overview list (`Tab`)
|
||||
|
||||
The dashboard has two focus areas — the **dispatch input bar** (typing)
|
||||
and the **overview list** (navigating). `Tab` toggles between them; the
|
||||
inactive input dims its border and hides its caret.
|
||||
Two focus areas: the **dispatch input** and the **overview list**. `Tab`
|
||||
toggles between them; the inactive input dims its border and hides its caret.
|
||||
|
||||
On open, focus defaults to the **overview list** when at least one agent
|
||||
exists (so `↑`/`↓` / vim `j`/`k` navigate immediately). With **no**
|
||||
agents, focus stays on the **dispatch input** so you can type a first
|
||||
prompt right away. Either way, the `[+ New Agent]` button is the cursor
|
||||
target (no agent row is pre-selected).
|
||||
exists (so `↑`/`↓` / vim `j`/`k` navigate immediately). With **no** agents,
|
||||
focus stays on the **dispatch input**. Either way, the cursor starts on
|
||||
`[+ New Agent]` (no agent row pre-selected).
|
||||
|
||||
- **Input focused**: type to compose a new-session prompt. `↑`/`↓`
|
||||
navigate the row list when the prompt is empty (a convenience),
|
||||
otherwise move the caret. `Esc` unfocuses the input → overview list
|
||||
(your typed draft is kept) so you can navigate straight away.
|
||||
- **Overview focused**: `↑`/`↓` — and, in **vim mode**, `j`/`k` — move
|
||||
between agent rows. `Enter` opens the highlighted agent (on
|
||||
`[+ New Agent]`, it sends a typed draft, else creates a new session).
|
||||
`Esc` **stays on the list** and steps back — clearing an active filter,
|
||||
then unselecting the row (→ `[+ New Agent]`), then exiting the
|
||||
dashboard. `Tab` or `i` (vim) — or any other printable key — return to
|
||||
the input.
|
||||
- **Input focused**: type a new-session prompt. Empty prompt: `↑`/`↓`
|
||||
navigate rows; non-empty: move the caret. `Esc` unfocuses to the list
|
||||
(draft kept).
|
||||
- **Overview focused**: `↑`/`↓` (and vim `j`/`k`) move between rows. `Enter`
|
||||
opens the highlighted agent (on `[+ New Agent]`, sends a typed draft or
|
||||
creates a new session). `Esc` stays on the list and steps back — clear
|
||||
filter, then unselect (→ `[+ New Agent]`), then exit. `Tab`, `i` (vim), or
|
||||
any printable key returns to the input.
|
||||
|
||||
---
|
||||
|
||||
## Peek panel
|
||||
|
||||
The peek panel is shown **by default whenever an agent row is
|
||||
selected** — it **replaces** the new-session dispatch box. With no row
|
||||
selected (the `[+ New Agent]` button focused, or after `Esc`), the
|
||||
dispatch box returns for starting a new session. So selecting a row is
|
||||
how you talk to an existing agent; deselecting is how you start a new
|
||||
Selecting an agent row shows the **peek panel** in place of the dispatch box.
|
||||
With no row selected (`[+ New Agent]`, or after `Esc`), the dispatch box
|
||||
returns. Select a row to talk to an existing agent; deselect to start a new
|
||||
one.
|
||||
|
||||
The panel shows, top to bottom, a header (the **last response type** —
|
||||
`Thinking` / `Thought` / `Response` / `Edit` / `Read` / `Bash` / … — on
|
||||
the left, **time** on the far right), the most recent response
|
||||
(**word-wrapped** to fit, up to ~3 rows), and a live `❯ reply` input. A
|
||||
`…` marker appears on the last row only when there's more than fits.
|
||||
Top to bottom: header (**last response type** — `Thinking` / `Thought` /
|
||||
`Response` / `Edit` / `Read` / `Bash` / … — and **time**), the most recent
|
||||
response (word-wrapped, up to ~3 rows; `…` when truncated), and a live
|
||||
`❯ reply` input.
|
||||
|
||||
The selected agent's **model** and, when it's in always-approve (yolo)
|
||||
mode, an **`always-approve`** flag are shown on the panel's **bottom
|
||||
border** (bottom-right) — the same config-badge slot the new-session
|
||||
dispatch box uses. This holds in the question / approval modes too, so
|
||||
the model and approval mode are always in view while you answer. (The
|
||||
dashboard list rows no longer repeat the model or an always-approve badge,
|
||||
keeping the list compact.)
|
||||
The selected agent's **model** and, in always-approve (yolo) mode, an
|
||||
**`always-approve`** flag sit on the panel's bottom border (same badge slot as
|
||||
the dispatch box), including while answering questions. List rows no longer
|
||||
repeat model or always-approve badges.
|
||||
|
||||
**`Shift+Tab` cycles the peeked agent's mode** (Normal → Plan →
|
||||
Always-approve → Normal) — the same cycle as Shift+Tab inside that agent's
|
||||
chat view, applied to the **live** agent (the badge updates to match).
|
||||
This differs from the new-session dispatch box, where Shift+Tab only
|
||||
stages the mode for the *next* agent.
|
||||
Always-approve → Normal) on the **live** agent. On the dispatch box,
|
||||
Shift+Tab only stages mode for the *next* agent.
|
||||
|
||||
Unlike the dispatch box (which only ever spawns new sessions), the
|
||||
peek's reply **talks to the selected agent**:
|
||||
Unlike dispatch (new sessions only), peek reply **talks to the selected
|
||||
agent**:
|
||||
|
||||
- **Type into `❯ reply`, then `Enter`** to send. An **idle** agent
|
||||
starts the turn immediately; a **busy** agent **queues** the message
|
||||
so it sends after the current turn finishes (the same queue/drain
|
||||
behaviour as the agent view's own prompt). `Ctrl+S` replies AND
|
||||
opens the agent's detail view; `Shift+Enter` / `Alt+Enter` insert a
|
||||
newline (multiline compose) and the reply **grows in height** to fit
|
||||
the draft (up to a cap, then it scrolls).
|
||||
- With an **empty** reply, `Enter` opens the agent.
|
||||
- **`↑`/`↓` move the caret within the reply** once it has content (so you
|
||||
can edit a multi-line draft). While the reply is **empty** (or
|
||||
unfocused via `Tab`), `↑`/`↓` instead **switch the selected agent** —
|
||||
the panel follows the selection cursor and refreshes live, and the
|
||||
switch clears any half-typed draft so a reply can't land on the wrong
|
||||
agent. (`Tab` to the row list to navigate agents while a draft is in
|
||||
the reply.)
|
||||
- **`Esc` unselects**: it first clears a typed reply, then deselects the
|
||||
row and focuses the `[+ New Agent]` button (bringing back the
|
||||
new-session input).
|
||||
- **`Tab`** toggles focus between the reply input and the row list: an
|
||||
unfocused reply dims its border and hides the caret; a printable key
|
||||
re-focuses it and starts composing.
|
||||
- The reply is a **full prompt editor** (the same component as the
|
||||
dispatch box and the agent prompt): pasting multi-line text folds
|
||||
into a `[Pasted: N lines]` chip with the same preview overlay and
|
||||
expand affordances as the agent prompt (`Enter` / double-click /
|
||||
paste-again), mouse click / drag place the caret and select text,
|
||||
and the usual editing chords work (word navigation, `Ctrl+A`/`Ctrl+E`,
|
||||
`Alt+Backspace`, `Ctrl+W`/`Ctrl+U`/`Ctrl+K`, undo, Shift+arrow
|
||||
selection, `Ctrl+Shift+V` inline paste).
|
||||
Typing **`@`** opens the file-context picker rooted at the **peeked
|
||||
agent's** working directory (so `@path` resolves against the agent
|
||||
you're replying to); its dropdown floats **above** the panel and
|
||||
`↑`/`↓`/`Tab`/`Enter`/`Esc` drive it while it's open.
|
||||
Dashboard chords (`Ctrl+X` stop, `Ctrl+T` pin, `Shift+↑/↓` reorder,
|
||||
…) still win over the editor while the panel is open.
|
||||
- When a **permission / ask-tool question** is pending, the `❯ reply`
|
||||
row is hidden and the options are listed instead: **`↑`/`↓` move the
|
||||
highlighted option** (marked with `▸`) and **`Enter` answers** it.
|
||||
**`1`–`9`** still answer an option directly. (While answering, the
|
||||
arrows pick options rather than switching agents.)
|
||||
- The **free-text row** accepts an inline typed answer (just like the
|
||||
chat panel): the permission **"No" / reject** option ("No, reject
|
||||
(type to add feedback)") and the ask-tool **"Other"** row ("Other
|
||||
(type your own answer)"). Type on it and `Enter` sends the rejection +
|
||||
message / the free-text answer.
|
||||
- This also covers the agent's **Ask tool** (`AskUserQuestion`): its
|
||||
options + the "Other" row show in the peek, answered the same way.
|
||||
**Multi-question** forms are walked one question at a time — a `(i/N)`
|
||||
marker shows progress and each answer advances to the next, submitting
|
||||
on the last. (Forms with a **multi-select** question are left to the
|
||||
agent's own view — open the agent to answer those.)
|
||||
- **Type into `❯ reply`, then `Enter`** to send. Idle agents start immediately;
|
||||
busy agents **queue** the message (same as the agent view prompt). `Ctrl+S`
|
||||
replies and opens the detail view; `Shift+Enter` / `Alt+Enter` insert a
|
||||
newline (reply grows with the draft).
|
||||
- Empty reply + `Enter` opens the agent.
|
||||
- **`↑`/`↓` move the caret** once the reply has content. While empty (or
|
||||
unfocused via `Tab`), `↑`/`↓` **switch the selected agent** — the panel
|
||||
follows, and a half-typed draft is cleared so it cannot land on the wrong
|
||||
agent. (`Tab` to the list to navigate while a draft is in the reply.)
|
||||
- **`Esc` unselects**: clear a typed reply first, then deselect and focus
|
||||
`[+ New Agent]`.
|
||||
- **`Tab`** toggles focus between reply and row list; a printable key
|
||||
re-focuses the reply.
|
||||
- Full prompt editor (same as dispatch / agent prompt): multi-line paste
|
||||
chips, mouse select, word navigation, `Ctrl+A`/`Ctrl+E`, `Alt+Backspace`,
|
||||
`Ctrl+W`/`Ctrl+U`/`Ctrl+K`, undo, Shift+arrow selection, `Ctrl+Shift+V`
|
||||
inline paste. **`@`** opens the file picker rooted at the **peeked agent's**
|
||||
working directory; the dropdown floats above the panel. Dashboard chords
|
||||
(`Ctrl+X` stop, `Ctrl+T` pin, `Shift+↑/↓` reorder, …) still win while the
|
||||
panel is open.
|
||||
- Pending **permission / ask-tool** question: `❯ reply` hides; options list
|
||||
instead. **`↑`/`↓` highlight**, **`Enter` answers**, **`1`–`9`** answer
|
||||
directly. Free-text **No / reject** and ask-tool **Other** accept a typed
|
||||
answer on the free-text row. Multi-question Ask forms walk one at a time
|
||||
(`(i/N)`); multi-select forms need the agent's own view.
|
||||
|
||||
The panel only renders when the terminal is tall enough; on very short
|
||||
terminals the dispatch box shows even with a row selected.
|
||||
On very short terminals the panel may not fit; the dispatch box stays even
|
||||
with a row selected.
|
||||
|
||||
---
|
||||
|
||||
## Search / filter (`Ctrl+/`)
|
||||
|
||||
Filtering lives behind an explicit **search mode** so normal typing
|
||||
always dispatches. Press `Ctrl+/` to toggle it: the prompt prefix
|
||||
flips from `❯` to a yellow `Search:` and every keystroke live-filters
|
||||
the row list.
|
||||
`Ctrl+/` toggles search mode so normal typing always dispatches. Prefix
|
||||
flips from `❯` to yellow `Search:`; every keystroke live-filters the list.
|
||||
|
||||
Inside search mode:
|
||||
- `Enter` — confirm: keep the filter and return to the dispatch prompt.
|
||||
- `Esc` or `Ctrl+/` — cancel: clear the filter and exit search.
|
||||
- `↑` / `↓` — navigate filtered rows.
|
||||
|
||||
- `Enter` — **confirm**: keep the filter applied and return to the
|
||||
dispatch prompt (rows stay filtered; `Esc` later clears them).
|
||||
- `Esc` or `Ctrl+/` — **cancel**: clear the filter and exit search.
|
||||
- `↑` / `↓` — navigate the filtered rows.
|
||||
Prefixes (only inside search mode):
|
||||
|
||||
The query supports the same prefixes as before (they are only honoured
|
||||
*inside* search mode now):
|
||||
|
||||
- `a:<name>` — filter by agent label (case-insensitive substring,
|
||||
matches persona / role).
|
||||
- `s:<state>` — filter by row state. Accepts `working`, `idle`,
|
||||
`completed`, `failed`, `needs-input`, `blocked` and synonyms
|
||||
(`busy`/`running`/`done`/etc.).
|
||||
- `#<text>` — substring match on `#<text>` (matches the literal
|
||||
`#` in labels; reserved for future PR filtering).
|
||||
- anything else — plain substring match over label + working dir.
|
||||
- `a:<name>` — agent label (case-insensitive substring; persona / role).
|
||||
- `s:<state>` — row state: `working`, `idle`, `completed`, `failed`,
|
||||
`needs-input`, `blocked` and synonyms (`busy`/`running`/`done`/etc.).
|
||||
- `#<text>` — substring match on `#<text>` (literal `#` in labels).
|
||||
- anything else — substring over label + working dir.
|
||||
|
||||
---
|
||||
|
||||
## Persistence
|
||||
|
||||
Per-user dashboard preferences live under `[dashboard]` in
|
||||
`~/.grok/config.toml`:
|
||||
Per-user preferences under `[dashboard]` in `~/.grok/config.toml`:
|
||||
|
||||
```toml
|
||||
[dashboard]
|
||||
|
|
@ -333,19 +264,5 @@ pinned = ["top:<session_id>", "sub:<parent_session_id>:<child_session_id>"]
|
|||
reorder = ["top:<session_id>"]
|
||||
```
|
||||
|
||||
Pinned/reorder entries are keyed by **session id**, not by the
|
||||
per-process `AgentId(usize)`, so they survive restarts and don't
|
||||
attach to whatever agent happens to share the old slot number.
|
||||
|
||||
Set `GROK_AGENT_DASHBOARD=0` to force-disable the feature for a single
|
||||
pager invocation; the slash command and CLI subcommand will print a
|
||||
friendly toast.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 (out of scope for v1)
|
||||
|
||||
The current dashboard lists only agents owned by **this** pager
|
||||
process. The plan's Phase 4 ("supervisor / `grok --bg`") would list
|
||||
sessions that survive pager exit — that's a separate roadmap and not
|
||||
shipped yet.
|
||||
Pinned/reorder entries use **session id** (not a per-process agent slot), so
|
||||
they survive restarts.
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ pub(crate) fn handle_ask_user_question(
|
|||
LocalQuestionKind::AgentTypeMismatch { .. } => "model switch",
|
||||
LocalQuestionKind::ProjectSelect { .. } => "project select",
|
||||
LocalQuestionKind::DoctorFix { .. } => "/doctor fix",
|
||||
LocalQuestionKind::DeleteCurrentSession => "/delete",
|
||||
};
|
||||
let message = if matches!(kind, LocalQuestionKind::DoctorFix { .. }) {
|
||||
"/doctor fix was cancelled because another question opened.".to_owned()
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ pub enum Action {
|
|||
ExitSession,
|
||||
/// Exit session without double-press confirmation (e.g., from command palette).
|
||||
ExitSessionConfirmed,
|
||||
/// `/delete`: confirm, then delete history and return home.
|
||||
DeleteCurrentSession,
|
||||
DeleteCurrentSessionAnswered {
|
||||
confirmed: bool,
|
||||
},
|
||||
/// Open grok.com in the browser for SuperGrok subscription upsell.
|
||||
OpenSupergrokUrl,
|
||||
/// Re-check subscription status via the shell's `x.ai/auth/check_subscription`.
|
||||
|
|
@ -1368,6 +1373,14 @@ pub struct DoctorFixTarget {
|
|||
pub session_binding_epoch: u32,
|
||||
pub cwd: std::path::PathBuf,
|
||||
}
|
||||
/// Aftermath of a successful session delete.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AfterSessionDelete {
|
||||
/// Picker delete — stay put.
|
||||
Stay,
|
||||
/// `/delete` — return to welcome.
|
||||
Welcome,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub enum Effect {
|
||||
/// Create a new ACP session.
|
||||
|
|
@ -2012,6 +2025,7 @@ pub enum Effect {
|
|||
source: String,
|
||||
session_id: String,
|
||||
cwd: String,
|
||||
after: AfterSessionDelete,
|
||||
},
|
||||
/// Deep-search sessions by content (FTS via ACP).
|
||||
DeepSearchSessions { query: String, seq: u64 },
|
||||
|
|
@ -2587,6 +2601,7 @@ pub enum TaskResult {
|
|||
DeleteSessionComplete {
|
||||
source: String,
|
||||
session_id: String,
|
||||
after: AfterSessionDelete,
|
||||
},
|
||||
/// Session delete failed.
|
||||
DeleteSessionFailed {
|
||||
|
|
|
|||
|
|
@ -1733,6 +1733,11 @@ fn translate_local_submit(
|
|||
InputOutcome::Action(Action::DoctorFixCancelled(target))
|
||||
}
|
||||
}
|
||||
LocalQuestionKind::DeleteCurrentSession => {
|
||||
InputOutcome::Action(Action::DeleteCurrentSessionAnswered {
|
||||
confirmed: *idx == 0,
|
||||
})
|
||||
}
|
||||
LocalQuestionKind::ProjectSelect { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -448,7 +448,9 @@ impl AgentView {
|
|||
{
|
||||
let history = self.combined_prompt_history();
|
||||
let current_text = self.prompt.text().to_string();
|
||||
if !history.is_empty() {
|
||||
// Without a matcher thread the panel can never populate, and filling
|
||||
// the composer would only be undone by the next Down/Enter.
|
||||
if !history.is_empty() && self.prompt.history_search.is_available() {
|
||||
self.prompt
|
||||
.history_search
|
||||
.activate_browse(&history, ¤t_text);
|
||||
|
|
|
|||
|
|
@ -265,7 +265,7 @@ pub enum TickDemand {
|
|||
pub const SLOW_TICK_INTERVAL: Duration = Duration::from_millis(83);
|
||||
/// Welcome toast lifetime (wall clock, so the duration holds whether the
|
||||
/// event loop is ticking Slow or Fast).
|
||||
const WELCOME_TOAST_DURATION: Duration = Duration::from_secs(4);
|
||||
const WELCOME_TOAST_DURATION: Duration = Duration::from_secs(2);
|
||||
/// Which prompt box in-flight voice dictation appends its finalized text to.
|
||||
/// Captured when recording **starts** so a trailing STT final still lands where
|
||||
/// the user was dictating, even if they navigate away — or toggle a dashboard
|
||||
|
|
@ -1197,8 +1197,16 @@ fn privacy_banner_reshow_elapsed(acked_at: &str, reshow_days: Option<u64>) -> bo
|
|||
};
|
||||
chrono::Utc::now() >= next
|
||||
}
|
||||
/// Bottom-right toast overlay on the welcome screen (mirrors agent toast style).
|
||||
fn paint_welcome_toast(buf: &mut ratatui::buffer::Buffer, area: ratatui::layout::Rect, msg: &str) {
|
||||
/// Welcome-screen toast overlay (mirrors agent toast style).
|
||||
///
|
||||
/// Prefer one row above the prompt, right-aligned to it. Fall back to
|
||||
/// the view bottom-right when no prompt rect is available (login / gate).
|
||||
fn paint_welcome_toast(
|
||||
buf: &mut ratatui::buffer::Buffer,
|
||||
area: ratatui::layout::Rect,
|
||||
msg: &str,
|
||||
prompt_rect: Option<ratatui::layout::Rect>,
|
||||
) {
|
||||
let theme = crate::theme::Theme::current();
|
||||
let max_msg = (area.width as usize).saturating_sub(4);
|
||||
if max_msg == 0 || area.height == 0 {
|
||||
|
|
@ -1211,8 +1219,16 @@ fn paint_welcome_toast(buf: &mut ratatui::buffer::Buffer, area: ratatui::layout:
|
|||
format!(" {}… ", truncated.trim_end())
|
||||
};
|
||||
let w = toast.chars().count() as u16;
|
||||
let x = area.right().saturating_sub(w + 1);
|
||||
let y = area.bottom().saturating_sub(1);
|
||||
let (x, y) = if let Some(prompt) = prompt_rect.filter(|r| r.width > 0 && r.y > area.y) {
|
||||
let max_x = area.right().saturating_sub(w).max(area.x);
|
||||
let x = prompt.right().saturating_sub(w + 1).clamp(area.x, max_x);
|
||||
(x, prompt.y.saturating_sub(1))
|
||||
} else {
|
||||
(
|
||||
area.right().saturating_sub(w + 1),
|
||||
area.bottom().saturating_sub(1),
|
||||
)
|
||||
};
|
||||
for (i, ch) in toast.chars().enumerate() {
|
||||
if let Some(cell) = buf.cell_mut((x + i as u16, y)) {
|
||||
cell.set_char(ch);
|
||||
|
|
@ -1964,7 +1980,7 @@ impl AppView {
|
|||
///
|
||||
/// From the dashboard, toasts route into the dispatch input's inline
|
||||
/// error slot. From an agent view the existing per-agent toast machinery
|
||||
/// fires. On welcome, a bottom-right overlay for
|
||||
/// fires. On welcome, an overlay above the prompt for
|
||||
/// [`WELCOME_TOAST_DURATION`].
|
||||
pub fn show_toast(&mut self, msg: &str) {
|
||||
match self.active_view {
|
||||
|
|
@ -4373,7 +4389,12 @@ impl AppView {
|
|||
self.welcome_privacy_banner_policy_rect = result.privacy_banner_policy_rect;
|
||||
self.welcome_changelog_cta_rect = result.changelog_cta_rect;
|
||||
if let Some((ref msg, _)) = self.welcome_toast {
|
||||
paint_welcome_toast(f.buffer_mut(), view_area, msg);
|
||||
paint_welcome_toast(
|
||||
f.buffer_mut(),
|
||||
view_area,
|
||||
msg,
|
||||
self.welcome_prompt_rect,
|
||||
);
|
||||
}
|
||||
self.welcome_announcement.truncated = result.announcement_truncated;
|
||||
self.welcome_announcement.rect = result.announcement_rect;
|
||||
|
|
|
|||
|
|
@ -60,9 +60,10 @@ use super::session::fork::{
|
|||
dispatch_startup_fork_session,
|
||||
};
|
||||
use super::session::lifecycle::{
|
||||
clear_startup_actions, dispatch_agent_type_mismatch_answered, dispatch_exit_session,
|
||||
dispatch_new_session, dispatch_new_session_inner, dispatch_new_session_with_id,
|
||||
dispatch_new_worktree_session, dispatch_trust_folder, open_new_session_question,
|
||||
clear_startup_actions, dispatch_agent_type_mismatch_answered,
|
||||
dispatch_delete_current_session_answered, dispatch_exit_session, dispatch_new_session,
|
||||
dispatch_new_session_inner, dispatch_new_session_with_id, dispatch_new_worktree_session,
|
||||
dispatch_trust_folder, open_delete_current_session_question, open_new_session_question,
|
||||
};
|
||||
use super::session::load::{
|
||||
dispatch_cycle_session_source_filter, dispatch_load_session, dispatch_pick_content_session,
|
||||
|
|
@ -194,6 +195,10 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
|||
Action::NewSession => dispatch_new_session(app),
|
||||
Action::ChooseNewSessionMode => open_new_session_question(app),
|
||||
Action::ExitSession | Action::ExitSessionConfirmed => dispatch_exit_session(app),
|
||||
Action::DeleteCurrentSession => open_delete_current_session_question(app),
|
||||
Action::DeleteCurrentSessionAnswered { confirmed } => {
|
||||
dispatch_delete_current_session_answered(app, confirmed)
|
||||
}
|
||||
Action::NewWorktreeSession {
|
||||
load_session_id,
|
||||
label,
|
||||
|
|
@ -949,7 +954,11 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
|||
Action::SaveRememberNoteFromModal => dispatch_save_remember_note_from_modal(app),
|
||||
Action::SendBtw(question) => dispatch_send_btw(app, question),
|
||||
Action::SendRecap { auto } => dispatch_send_recap(app, auto),
|
||||
Action::SetCodingDataSharing { opted_in } => set_coding_data_sharing(app, opted_in),
|
||||
Action::SetCodingDataSharing { opted_in } => set_coding_data_sharing(
|
||||
app,
|
||||
opted_in,
|
||||
xai_grok_telemetry::events::CodingDataConsentSource::Settings,
|
||||
),
|
||||
Action::ToggleYolo => dispatch_toggle_yolo(app),
|
||||
Action::ToggleMultiline => dispatch_toggle_multiline(app),
|
||||
Action::ToggleCompactMode => dispatch_toggle_compact_mode(app),
|
||||
|
|
@ -1119,6 +1128,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
|||
source,
|
||||
session_id,
|
||||
cwd,
|
||||
after: crate::app::actions::AfterSessionDelete::Stay,
|
||||
}]
|
||||
}
|
||||
Action::Fork(args) => dispatch_fork(app, args),
|
||||
|
|
|
|||
|
|
@ -394,6 +394,108 @@ pub(in crate::app::dispatch) fn dispatch_exit_session(app: &mut AppView) -> Vec<
|
|||
app.exit_session_pending = None;
|
||||
effects
|
||||
}
|
||||
/// Confirm deleting the parent session (not a subagent view).
|
||||
pub(in crate::app::dispatch) fn open_delete_current_session_question(
|
||||
app: &mut AppView,
|
||||
) -> Vec<Effect> {
|
||||
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
||||
use xai_grok_tools::implementations::grok_build::ask_user_question::{
|
||||
Question, QuestionOption,
|
||||
};
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
if agent.session.session_id.is_none() {
|
||||
app.show_toast("No active session to delete");
|
||||
return vec![];
|
||||
}
|
||||
if agent.question_view.is_some() {
|
||||
app.show_toast("Finish answering the current question first");
|
||||
return vec![];
|
||||
}
|
||||
let question = Question {
|
||||
question: "Delete this session permanently?".into(),
|
||||
id: None,
|
||||
options: vec![
|
||||
QuestionOption {
|
||||
label: "Delete".into(),
|
||||
description: "Remove history and return home".into(),
|
||||
preview: None,
|
||||
id: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Cancel".into(),
|
||||
description: "Keep the session".into(),
|
||||
preview: None,
|
||||
id: None,
|
||||
},
|
||||
],
|
||||
multi_select: Some(false),
|
||||
};
|
||||
let stashed = agent.prompt.stash();
|
||||
agent.question_view = Some(
|
||||
QuestionViewState::new(
|
||||
format!("delete-session-{}", uuid::Uuid::new_v4()),
|
||||
vec![question],
|
||||
stashed,
|
||||
)
|
||||
.with_local_kind(LocalQuestionKind::DeleteCurrentSession)
|
||||
.with_no_freeform(),
|
||||
);
|
||||
agent.prompt.set_text("");
|
||||
vec![]
|
||||
}
|
||||
pub(in crate::app::dispatch) fn dispatch_delete_current_session_answered(
|
||||
app: &mut AppView,
|
||||
confirmed: bool,
|
||||
) -> Vec<Effect> {
|
||||
if !confirmed {
|
||||
return vec![];
|
||||
}
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some((session_id, cwd, running_bg_tasks)) = app.agents.get(&id).and_then(|agent| {
|
||||
let session_id = agent.session.session_id.clone()?;
|
||||
let cwd = agent.session.cwd.display().to_string();
|
||||
let running_bg_tasks: Vec<String> = agent
|
||||
.session
|
||||
.bg_tasks
|
||||
.values()
|
||||
.filter(|t| t.status == crate::app::agent::BgTaskStatus::Running)
|
||||
.map(|t| t.task_id.clone())
|
||||
.collect();
|
||||
Some((session_id, cwd, running_bg_tasks))
|
||||
}) else {
|
||||
app.show_toast("No active session to delete");
|
||||
return vec![];
|
||||
};
|
||||
let mut effects = vec![Effect::CancelTurn {
|
||||
session_id: session_id.clone(),
|
||||
cancel_subagents: true,
|
||||
trigger: None,
|
||||
rewind_if_pristine: false,
|
||||
}];
|
||||
effects.extend(
|
||||
running_bg_tasks
|
||||
.into_iter()
|
||||
.map(|task_id| Effect::KillBgTask {
|
||||
session_id: session_id.clone(),
|
||||
task_id,
|
||||
}),
|
||||
);
|
||||
app.show_toast("Deleting session\u{2026}");
|
||||
effects.push(Effect::DeleteSession {
|
||||
source: "current".into(),
|
||||
session_id: session_id.to_string(),
|
||||
cwd,
|
||||
after: crate::app::actions::AfterSessionDelete::Welcome,
|
||||
});
|
||||
effects
|
||||
}
|
||||
/// Handle the user accepting the folder-trust question: persist the grant for
|
||||
/// the workspace (writes `~/.grok/trusted_folders.toml`), mark trust resolved,
|
||||
/// then replay any deferred session startup (only if auth is also done).
|
||||
|
|
|
|||
|
|
@ -413,6 +413,18 @@ pub(in crate::app::dispatch) fn dispatch_pick_session_in_worktree(
|
|||
}
|
||||
dispatch_new_worktree_session(app, Some(session_id), None, None, None, None, None)
|
||||
}
|
||||
fn keep_picker_entry(
|
||||
entry: &crate::app::app_view::SessionPickerEntry,
|
||||
source: &str,
|
||||
session_id: &str,
|
||||
match_id_only: bool,
|
||||
) -> bool {
|
||||
if match_id_only {
|
||||
entry.id != session_id
|
||||
} else {
|
||||
entry.source != source || entry.id != session_id
|
||||
}
|
||||
}
|
||||
/// Remove a deleted session identity from the modal session picker and the
|
||||
/// welcome-screen picker, then re-anchor the selection on a real row.
|
||||
///
|
||||
|
|
@ -422,6 +434,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers(
|
|||
app: &mut AppView,
|
||||
source: &str,
|
||||
session_id: &str,
|
||||
match_id_only: bool,
|
||||
) {
|
||||
use crate::views::modal::ActiveModal;
|
||||
use crate::views::session_picker::build_entry_map;
|
||||
|
|
@ -447,7 +460,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers(
|
|||
*pending_delete = None;
|
||||
}
|
||||
if let Some(list) = entries.as_mut() {
|
||||
list.retain(|entry| entry.source != source || entry.id != session_id);
|
||||
list.retain(|entry| keep_picker_entry(entry, source, session_id, match_id_only));
|
||||
}
|
||||
if let Some(hits) = content_results.as_mut() {
|
||||
hits.retain(|h| h.session_id != session_id);
|
||||
|
|
@ -469,7 +482,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers(
|
|||
reanchor_grouped_selection(state, &map);
|
||||
}
|
||||
if let Some(list) = app.session_picker_entries.as_mut() {
|
||||
list.retain(|entry| entry.source != source || entry.id != session_id);
|
||||
list.retain(|entry| keep_picker_entry(entry, source, session_id, match_id_only));
|
||||
}
|
||||
if let Some(hits) = app.session_picker_content_results.as_mut() {
|
||||
hits.retain(|h| h.session_id != session_id);
|
||||
|
|
|
|||
|
|
@ -110,9 +110,27 @@ fn is_current_coding_data_write(app: &AppView, seq: u64, agent_id: AgentId) -> b
|
|||
false
|
||||
}
|
||||
|
||||
fn log_coding_data_consent_selected(
|
||||
source: xai_grok_telemetry::events::CodingDataConsentSource,
|
||||
opted_in: bool,
|
||||
previous_opted_in: bool,
|
||||
) {
|
||||
use xai_grok_telemetry::events::{CodingDataConsentChoice, CodingDataConsentSelected};
|
||||
xai_grok_telemetry::session_ctx::log_event(CodingDataConsentSelected {
|
||||
source,
|
||||
choice: CodingDataConsentChoice::from_opted_in(opted_in),
|
||||
previous_choice: CodingDataConsentChoice::from_opted_in(previous_opted_in),
|
||||
changed: opted_in != previous_opted_in,
|
||||
});
|
||||
}
|
||||
|
||||
/// Set coding-data-sharing preference. SHELL-owned, auth-metadata-backed
|
||||
/// (persists via ACP ext-request, NOT `~/.grok/config.toml`).
|
||||
pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<Effect> {
|
||||
pub(super) fn set_coding_data_sharing(
|
||||
app: &mut AppView,
|
||||
opted_in: bool,
|
||||
source: xai_grok_telemetry::events::CodingDataConsentSource,
|
||||
) -> Vec<Effect> {
|
||||
// ── Guard 1: Enterprise ZDR ──────────────────────────────────────
|
||||
if app.is_zdr {
|
||||
app.show_toast("\u{2717} Cannot change: Zero Data Retention enabled");
|
||||
|
|
@ -131,6 +149,7 @@ pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<
|
|||
}
|
||||
let agent_id = coding_data_sharing_agent_id(app);
|
||||
let prev = !app.coding_data_retention_opt_out;
|
||||
log_coding_data_consent_selected(source, opted_in, prev);
|
||||
|
||||
// ── Idempotent path: skip the ACP round-trip. ────────────────────
|
||||
if prev == opted_in {
|
||||
|
|
@ -455,7 +474,11 @@ pub(in crate::app::dispatch) fn dispatch_privacy_banner_opt_in(app: &mut AppView
|
|||
if app.privacy_banner_opt_in_inflight || !app.privacy_banner_should_show() {
|
||||
return vec![];
|
||||
}
|
||||
let effects = set_coding_data_sharing(app, true);
|
||||
let effects = set_coding_data_sharing(
|
||||
app,
|
||||
true,
|
||||
xai_grok_telemetry::events::CodingDataConsentSource::PrivacyBanner,
|
||||
);
|
||||
// should_show guarantees opted-out + unguarded, so effects is only empty
|
||||
// if a guard regresses; leaving inflight false keeps [Opt in] clickable.
|
||||
app.privacy_banner_opt_in_inflight = !effects.is_empty();
|
||||
|
|
@ -476,6 +499,12 @@ pub(in crate::app::dispatch) fn dispatch_privacy_banner_opt_out(app: &mut AppVie
|
|||
if app.privacy_banner_opt_in_inflight || !app.privacy_banner_should_show() {
|
||||
return vec![];
|
||||
}
|
||||
let previous_opted_in = !app.coding_data_retention_opt_out;
|
||||
log_coding_data_consent_selected(
|
||||
xai_grok_telemetry::events::CodingDataConsentSource::PrivacyBanner,
|
||||
false,
|
||||
previous_opted_in,
|
||||
);
|
||||
let mut effects = ack_privacy_banner(app);
|
||||
effects.push(Effect::SetCodingDataSharing {
|
||||
agent_id: coding_data_sharing_agent_id(app),
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ use super::session::load::{
|
|||
handle_session_loaded, handle_session_restore_failed, handle_session_restored,
|
||||
handle_session_search_debounce_expired, remove_session_from_pickers,
|
||||
};
|
||||
use super::session::modal::remove_agent_and_cleanup;
|
||||
use super::settings::ui::apply_setting_rollback;
|
||||
use super::status::{
|
||||
commit_session_usage_block, handle_coding_data_sharing_failed,
|
||||
|
|
@ -896,10 +897,40 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
|||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::DeleteSessionComplete { source, session_id } => {
|
||||
remove_session_from_pickers(app, &source, &session_id);
|
||||
TaskResult::DeleteSessionComplete {
|
||||
source,
|
||||
session_id,
|
||||
after,
|
||||
} => {
|
||||
use crate::app::actions::AfterSessionDelete;
|
||||
remove_session_from_pickers(
|
||||
app,
|
||||
&source,
|
||||
&session_id,
|
||||
after != AfterSessionDelete::Stay,
|
||||
);
|
||||
if after == AfterSessionDelete::Stay {
|
||||
app.show_toast("Session deleted");
|
||||
return vec![];
|
||||
}
|
||||
let sid = acp::SessionId::new(session_id.clone());
|
||||
let to_remove: Vec<_> = app
|
||||
.agents
|
||||
.iter()
|
||||
.filter(|(_, agent)| agent.session.session_id.as_ref() == Some(&sid))
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
let foreground =
|
||||
matches!(app.active_view, ActiveView::Agent(id) if to_remove.contains(&id));
|
||||
for id in to_remove {
|
||||
remove_agent_and_cleanup(app, id);
|
||||
}
|
||||
let mut effects = unregister_session_effect(Some(sid));
|
||||
if foreground && after == AfterSessionDelete::Welcome {
|
||||
effects.extend(dispatch_exit_session(app));
|
||||
}
|
||||
app.show_toast("Session deleted");
|
||||
vec![]
|
||||
effects
|
||||
}
|
||||
TaskResult::DeleteSessionFailed {
|
||||
source,
|
||||
|
|
|
|||
|
|
@ -1681,6 +1681,7 @@ fn translate_local_submit_never_returns_persist_never_for_new_session() {
|
|||
}
|
||||
#[test]
|
||||
fn delete_session_action_emits_delete_effect() {
|
||||
use crate::app::actions::AfterSessionDelete;
|
||||
let mut app = test_app_with_agent();
|
||||
open_session_picker_with(&mut app, vec![make_picker_entry("s1", "/repo")]);
|
||||
let effects = dispatch(
|
||||
|
|
@ -1691,17 +1692,108 @@ fn delete_session_action_emits_delete_effect() {
|
|||
},
|
||||
&mut app,
|
||||
);
|
||||
assert!(matches!(
|
||||
effects.as_slice(),
|
||||
[Effect::DeleteSession {
|
||||
source,
|
||||
session_id,
|
||||
cwd,
|
||||
after: AfterSessionDelete::Stay,
|
||||
}] if source == "local" && session_id == "s1" && cwd == "/repo"
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn delete_current_session_confirm_emits_effect() {
|
||||
use crate::app::actions::AfterSessionDelete;
|
||||
let mut app = test_app_with_agent();
|
||||
{
|
||||
let a = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
a.session.session_id = Some(acp::SessionId::new("sess-current"));
|
||||
a.session.cwd = std::path::PathBuf::from("/repo");
|
||||
}
|
||||
assert!(dispatch(Action::DeleteCurrentSession, &mut app).is_empty());
|
||||
assert!(matches!(
|
||||
app.agents[&AgentId(0)]
|
||||
.question_view
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.local_kind,
|
||||
Some(crate::views::question_view::LocalQuestionKind::DeleteCurrentSession)
|
||||
));
|
||||
assert!(
|
||||
dispatch(
|
||||
Action::DeleteCurrentSessionAnswered { confirmed: false },
|
||||
&mut app,
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
let effects = dispatch(
|
||||
Action::DeleteCurrentSessionAnswered { confirmed: true },
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
effects.as_slice(),
|
||||
[Effect::DeleteSession {
|
||||
source,
|
||||
session_id,
|
||||
cwd,
|
||||
}] if source == "local" && session_id == "s1" && cwd == "/repo"
|
||||
effects.first(),
|
||||
Some(Effect::CancelTurn {
|
||||
cancel_subagents: true,
|
||||
..
|
||||
})
|
||||
),
|
||||
"DeleteSession action must emit exactly one matching DeleteSession effect"
|
||||
"must cancel the turn/subagents before delete, got {effects:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
effects.last(),
|
||||
Some(Effect::DeleteSession {
|
||||
session_id,
|
||||
after: AfterSessionDelete::Welcome,
|
||||
..
|
||||
}) if session_id == "sess-current"
|
||||
),
|
||||
"got {effects:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn delete_current_session_complete_welcome_and_guard() {
|
||||
use crate::app::actions::{AfterSessionDelete, TaskResult};
|
||||
let mut app = test_app_with_agent();
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id =
|
||||
Some(acp::SessionId::new("sess-a"));
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::DeleteSessionComplete {
|
||||
source: "current".into(),
|
||||
session_id: "sess-a".into(),
|
||||
after: AfterSessionDelete::Welcome,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
assert!(matches!(app.active_view, ActiveView::Welcome));
|
||||
assert!(app.agents.is_empty());
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::UnregisterActiveSession { .. }))
|
||||
);
|
||||
let mut app = test_app_with_agent();
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id =
|
||||
Some(acp::SessionId::new("sess-a"));
|
||||
let other = AgentId(1);
|
||||
let session = make_test_agent_session(&app, other, "unused");
|
||||
app.agents
|
||||
.insert(other, AgentView::new(session, ScrollbackState::new()));
|
||||
app.agents.get_mut(&other).unwrap().session.session_id = Some(acp::SessionId::new("sess-b"));
|
||||
app.active_view = ActiveView::Agent(other);
|
||||
let effects = dispatch_task_result(
|
||||
TaskResult::DeleteSessionComplete {
|
||||
source: "current".into(),
|
||||
session_id: "sess-a".into(),
|
||||
after: AfterSessionDelete::Welcome,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
assert!(matches!(app.active_view, ActiveView::Agent(id) if id == other));
|
||||
assert!(!app.agents.contains_key(&AgentId(0)));
|
||||
assert!(!effects.iter().any(|e| matches!(e, Effect::Quit)));
|
||||
}
|
||||
#[test]
|
||||
fn entry_title_falls_back_to_short_session_id_when_no_prompt() {
|
||||
|
|
|
|||
|
|
@ -1701,6 +1701,7 @@ fn delete_session_complete_removes_only_matching_source_and_id() {
|
|||
TaskResult::DeleteSessionComplete {
|
||||
source: "local".into(),
|
||||
session_id: "s1".into(),
|
||||
after: crate::app::actions::AfterSessionDelete::Stay,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1781,6 +1782,7 @@ fn delete_both_session_clears_modal_and_welcome_content_hits() {
|
|||
TaskResult::DeleteSessionComplete {
|
||||
source: "both".into(),
|
||||
session_id: "shared".into(),
|
||||
after: crate::app::actions::AfterSessionDelete::Stay,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1878,6 +1880,7 @@ fn delete_remote_session_clears_modal_and_welcome_content_hits() {
|
|||
TaskResult::DeleteSessionComplete {
|
||||
source: "remote".into(),
|
||||
session_id: "remote-only".into(),
|
||||
after: crate::app::actions::AfterSessionDelete::Stay,
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3247,7 +3247,7 @@ pub(crate) fn execute(
|
|||
}
|
||||
});
|
||||
}
|
||||
Effect::DeleteSession { source, session_id, cwd } => {
|
||||
Effect::DeleteSession { source, session_id, cwd, after } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
.spawn(async move {
|
||||
|
|
@ -3291,6 +3291,7 @@ pub(crate) fn execute(
|
|||
TaskResult::DeleteSessionComplete {
|
||||
source,
|
||||
session_id,
|
||||
after,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -37,16 +37,9 @@ use std::time::Duration;
|
|||
|
||||
use agent_client_protocol as acp;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use xai_acp_lib::{
|
||||
AcpAgentGatewayReceiver as GatewayReceiver, AcpAgentGatewaySender as GatewaySender,
|
||||
AcpClientRx, LineBufferedRead, acp_send,
|
||||
};
|
||||
use xai_grok_shell::agent::config::Config as AgentConfig;
|
||||
use xai_grok_shell::agent::mvp_agent::MvpAgent;
|
||||
use xai_acp_lib::{AcpClientRx, acp_send};
|
||||
use xai_grok_shell::leader::{
|
||||
ClientCapabilities as LeaderClientCapabilities, ClientMode, ConnectionStatus,
|
||||
LEADER_SOCKET_ENV, LeaderClient, LeaderEnvUrls, LeaderLock, LeaderReconnector,
|
||||
|
|
@ -63,7 +56,6 @@ use crate::acp::leader_bridge::bridge_channels;
|
|||
use crate::acp::model_state::ModelState;
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
|
||||
const SIMPLEX_BUF: usize = 8 * 1024 * 1024;
|
||||
const PUMP_TICK: Duration = Duration::from_millis(10);
|
||||
const TURN_BUDGET: Duration = Duration::from_secs(60);
|
||||
|
||||
|
|
@ -358,7 +350,7 @@ impl PagerLeaderCluster {
|
|||
/// wire a fresh REAL agent behind it.
|
||||
async fn spawn_leader_generation(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.sock_path);
|
||||
let (acp_tx, mut acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let (acp_tx, acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let cancel = CancellationToken::new();
|
||||
self.server_cancel = cancel.clone();
|
||||
|
|
@ -396,66 +388,10 @@ impl PagerLeaderCluster {
|
|||
.await;
|
||||
}));
|
||||
|
||||
// Real agent behind the server. Copied from `run_leader`'s
|
||||
// agent-spawn + IPC/stdout bridge blocks in
|
||||
// xai-grok-shell/src/agent/app.rs (inside its LocalSet body) — a
|
||||
// deliberate copy so production stays untouched. Second copy of the
|
||||
// same wiring: xai-grok-shell/tests/test_leader_soak.rs ("Real agent
|
||||
// behind it" block) — keep the two copies behaviorally identical.
|
||||
let (agent_in_read, agent_in_write) = tokio::io::simplex(SIMPLEX_BUF);
|
||||
let (agent_out_read, agent_out_write) = tokio::io::simplex(SIMPLEX_BUF);
|
||||
|
||||
generation_tasks.push(tokio::task::spawn_local(async move {
|
||||
let agent_config = AgentConfig::default();
|
||||
let auth_manager = Arc::new(agent_config.create_auth_manager());
|
||||
let (gw_tx, gw_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let gateway = GatewaySender::new(gw_tx);
|
||||
let agent = MvpAgent::new(gateway, &agent_config, auth_manager, None)
|
||||
.expect("valid agent config");
|
||||
let incoming = LineBufferedRead::spawn_local(agent_in_read.compat());
|
||||
let (conn, handle_io) = acp::AgentSideConnection::new(
|
||||
agent,
|
||||
agent_out_write.compat_write(),
|
||||
incoming,
|
||||
|fut| {
|
||||
tokio::task::spawn_local(fut);
|
||||
},
|
||||
);
|
||||
tokio::task::spawn_local(
|
||||
GatewayReceiver::new(gw_rx, conn)
|
||||
.with_on_meta(xai_file_utils::trace_context::span_from_meta_traceparent)
|
||||
.run(),
|
||||
);
|
||||
let _ = handle_io.await;
|
||||
}));
|
||||
|
||||
generation_tasks.push(tokio::task::spawn_local(async move {
|
||||
let mut agent_in_write = agent_in_write;
|
||||
while let Some(msg) = acp_rx.recv().await {
|
||||
if agent_in_write.write_all(msg.as_bytes()).await.is_err()
|
||||
|| agent_in_write.write_all(b"\n").await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
generation_tasks.push(tokio::task::spawn_local(async move {
|
||||
let mut reader = BufReader::new(agent_out_read);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let msg = line.trim_end_matches(['\r', '\n']).to_string();
|
||||
if !msg.is_empty() && response_tx.send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}));
|
||||
generation_tasks.extend(xai_grok_shell::leader::in_process::spawn_agent(
|
||||
acp_rx,
|
||||
response_tx,
|
||||
));
|
||||
self.generation_tasks = generation_tasks;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
|
|
|
|||
|
|
@ -156,6 +156,16 @@ pub static USER_GUIDE: &[Doc] = &[
|
|||
"Permissions and Safety",
|
||||
"Modes, authorization order, allow/ask/deny rules, matching, and hooks"
|
||||
),
|
||||
guide!(
|
||||
"23-dashboard.md",
|
||||
"Agent Dashboard",
|
||||
"Live multi-session roster: peek, dispatch, pin, stop, and search"
|
||||
),
|
||||
guide!(
|
||||
"24-monitoring-usage.md",
|
||||
"Monitoring Usage (External OpenTelemetry)",
|
||||
"Export usage metrics to a customer OpenTelemetry collector"
|
||||
),
|
||||
];
|
||||
|
||||
/// Non-user-guide reference docs. Separate from USER_GUIDE because they
|
||||
|
|
|
|||
|
|
@ -111,6 +111,13 @@ impl SleepInhibitor {
|
|||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
// The spawned process is the lock holder: `systemd-inhibit` keeps
|
||||
// the idle-inhibit fd itself and runs `sleep infinity` as its child
|
||||
// — it is the same pid `release()` SIGTERMs on a clean turn end.
|
||||
// Bind that pid to us so a crashed/killed grok (SIGKILL,
|
||||
// `panic=abort` SIGABRT — no Drop runs) can't leave an immortal
|
||||
// inhibitor holding the lock and pid slots on shared hosts.
|
||||
xai_tty_utils::kill_on_parent_death_std(&mut cmd);
|
||||
let result = cmd.spawn();
|
||||
|
||||
match result {
|
||||
|
|
|
|||
31
crates/codegen/xai-grok-pager/src/slash/commands/delete.rs
Normal file
31
crates/codegen/xai-grok-pager/src/slash/commands/delete.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
//! `/delete` — delete this session's history and return home.
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
pub struct DeleteCommand;
|
||||
|
||||
impl SlashCommand for DeleteCommand {
|
||||
fn name(&self) -> &str {
|
||||
"delete"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Delete this session and return home"
|
||||
}
|
||||
|
||||
fn session_scoped(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/delete"
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
||||
if ctx.session_id.is_none() {
|
||||
return CommandResult::Error("No active session to delete".into());
|
||||
}
|
||||
CommandResult::Action(Action::DeleteCurrentSession)
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ pub mod context;
|
|||
pub mod copy;
|
||||
pub mod dashboard;
|
||||
pub mod debug;
|
||||
pub mod delete;
|
||||
pub mod docs;
|
||||
pub mod doctor;
|
||||
pub mod edit_prompt;
|
||||
|
|
@ -81,6 +82,7 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
|
|||
Arc::new(help::HelpCommand),
|
||||
Arc::new(docs::DocsCommand),
|
||||
Arc::new(home::HomeCommand),
|
||||
Arc::new(delete::DeleteCommand),
|
||||
Arc::new(new::NewCommand),
|
||||
Arc::new(fork::ForkCommand),
|
||||
Arc::new(compact::CompactCommand),
|
||||
|
|
@ -263,6 +265,7 @@ mod tests {
|
|||
"cost",
|
||||
"dashboard",
|
||||
"debug",
|
||||
"delete",
|
||||
"docs",
|
||||
"doctor",
|
||||
"edit-prompt",
|
||||
|
|
@ -397,6 +400,19 @@ mod tests {
|
|||
assert!(matches!(result, CommandResult::Action(Action::ExitSession)));
|
||||
}
|
||||
#[test]
|
||||
fn delete_requires_session_and_dispatches() {
|
||||
let models = ModelState::default();
|
||||
let cmd = delete::DeleteCommand;
|
||||
let mut ctx = make_ctx(&models);
|
||||
assert!(matches!(cmd.run(&mut ctx, ""), CommandResult::Error(_)));
|
||||
let session_id = acp::SessionId::new("sess-delete");
|
||||
ctx.session_id = Some(&session_id);
|
||||
assert!(matches!(
|
||||
cmd.run(&mut ctx, ""),
|
||||
CommandResult::Action(Action::DeleteCurrentSession)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn view_plan_returns_show_plan_action() {
|
||||
let models = ModelState::default();
|
||||
let mut ctx = make_ctx(&models);
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ enum Msg {
|
|||
struct Daemon {
|
||||
shared: Arc<Mutex<Snapshot>>,
|
||||
tx: SyncSender<Msg>,
|
||||
_handle: JoinHandle<()>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
const MAX_RESULTS: usize = 100;
|
||||
|
|
@ -74,7 +74,7 @@ impl Daemon {
|
|||
let (tx, rx) = sync_channel::<Msg>(256);
|
||||
|
||||
let out = shared.clone();
|
||||
let handle = thread::spawn(move || {
|
||||
let worker = move || {
|
||||
let mut pattern = MultiPattern::new(1);
|
||||
let mut matcher = Matcher::new(Config::DEFAULT);
|
||||
let mut items: Vec<(String, Utf32String)> = Vec::new();
|
||||
|
|
@ -82,7 +82,6 @@ impl Daemon {
|
|||
let mut prev_q = String::new();
|
||||
|
||||
while let Ok(msg) = rx.recv() {
|
||||
// Drain to latest — skip intermediate queries.
|
||||
let msg = drain_to_latest(msg, &rx);
|
||||
|
||||
match msg {
|
||||
|
|
@ -144,13 +143,23 @@ impl Daemon {
|
|||
Msg::Stop => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
let handle = thread::Builder::new()
|
||||
.name("history-search".into())
|
||||
.spawn(worker);
|
||||
|
||||
Self {
|
||||
shared,
|
||||
tx,
|
||||
_handle: handle,
|
||||
}
|
||||
let handle = match handle {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"history search daemon thread spawn failed; history search disabled"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Self { shared, tx, handle }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -355,6 +364,9 @@ impl HistorySearchState {
|
|||
}
|
||||
|
||||
fn activate_inner(&mut self, history: &[HistoryEntry], current_text: &str, browse: bool) {
|
||||
if !self.is_available() {
|
||||
return;
|
||||
}
|
||||
self.active = true;
|
||||
self.browse = browse;
|
||||
self.saved_text = current_text.to_string();
|
||||
|
|
@ -369,6 +381,12 @@ impl HistorySearchState {
|
|||
self.selected = self.snapshot.items.len().saturating_sub(1);
|
||||
}
|
||||
|
||||
/// False when the matcher thread never started, so the overlay cannot open
|
||||
/// and callers must leave the composer alone.
|
||||
pub fn is_available(&self) -> bool {
|
||||
self.daemon.handle.is_some()
|
||||
}
|
||||
|
||||
/// True while the overlay is in browse mode (see [`Self::activate_browse`]).
|
||||
pub fn is_browse(&self) -> bool {
|
||||
self.active && self.browse
|
||||
|
|
|
|||
|
|
@ -399,6 +399,11 @@ pub(crate) fn default_palette_entries(
|
|||
shortcut: "/home".into(),
|
||||
command: PaletteCommand::Home,
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Delete This Session".into(),
|
||||
shortcut: "/delete".into(),
|
||||
command: PaletteCommand::SlashCommand("/delete".into()),
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Resume Session".into(),
|
||||
shortcut: "/resume".into(),
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ pub enum LocalQuestionKind {
|
|||
target: crate::app::actions::DoctorFixTarget,
|
||||
plan: Box<crate::diagnostics::FixPlan>,
|
||||
},
|
||||
DeleteCurrentSession,
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -327,8 +327,9 @@ async fn subscription_watch_polls_free_tier_then_goes_dormant_after_upgrade() {
|
|||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn startup_gate_shows_paywall_for_free_user_after_live_check() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Gated settings (no allow_access), free user (no subscriptionTier).
|
||||
// Explicit deny + gate copy. Absent allow_access now fails open.
|
||||
content.server().set_settings(json!({
|
||||
"allow_access": false,
|
||||
"gate_message": GATE_MSG,
|
||||
"gate_url": "https://grok.com/supergrok?referrer=grok-build",
|
||||
"gate_label": "Subscribe",
|
||||
|
|
@ -398,7 +399,10 @@ async fn stale_gate_push_never_flashes_paywall_for_subscribed_user() {
|
|||
// One stale gated snapshot: the "remote settings stale moment".
|
||||
content.enqueue_response(
|
||||
"/v1/settings",
|
||||
ScriptedResponse::json(200, json!({ "gate_message": GATE_MSG })),
|
||||
ScriptedResponse::json(
|
||||
200,
|
||||
json!({ "allow_access": false, "gate_message": GATE_MSG }),
|
||||
),
|
||||
);
|
||||
harness.inject_keys(b"/new\r").expect("run /new");
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue