Synced from monorepo
Synced from monorepo Changes: - Refresh tool search when the managed MCP catalog is re-fetched - Prevent duplicate leader process spawn and startup hang from stale leaders - Document marketplaces, plugins, and organization controls - Stamp session ID on image generation direct-to-API requests - Fix auto mode blocked documentation - Auto mode considers recent user intent - Expose deploy archive, taken-down, limit, and in-progress reasons on the chat API - Fail-closed auth refresh contract for shell clients - Emit a chat-supplied per-session turn index in turn hooks - Show bash mode chrome in minimal mode - Add metrics for true-noop and stationarity stops - Include voice interim text on prompt submit - Silently end turn on true-noop thrash - Quiet copy toast when clipboard delivery is confirmed - Fix session fork truncating at the wrong prompt in rewound sessions - Make the idle "still running" watcher cue clickable to open the tasks pane - Default web search model to grok-4.5 - Let plugin subagents inherit parent MCP servers - Gate no-op end-turn reminder on system reminders - Add gateway bridge lifecycle telemetry - Allow editing finalized text while voice is open - Relocate token carrier to turn-commit events and plumb per-turn origin context - Raise workflow scratch quotas and make failed runs resumable - Workflows overlay: auto-progress phases, live agent status, and drop budget meter Source-Revision: 9b8d35b46d959c042ea9aa31cbbebbd1f0c5c527
This commit is contained in:
parent
69f0ba880a
commit
6e38642082
103 changed files with 4964 additions and 1261 deletions
|
|
@ -66,7 +66,7 @@ Copy the most recent response to the clipboard. Pass a number to copy the Nth-la
|
|||
/copy 2 ~/exports/last-reply.md
|
||||
```
|
||||
|
||||
Every copy is also written to a backup file — `~/.grok/last-copy.txt` by default, or `GROK_COPY_FILE` if set — and the toast tells you exactly where the text landed, so you can retrieve it even when the clipboard couldn't be reached or the copy went out as an OSC 52 escape this terminal couldn't confirm.
|
||||
Every copy is also written to a backup file — `~/.grok/last-copy.txt` by default, or `GROK_COPY_FILE` if set. Confirmed copies toast briefly (e.g. `Copied!`). Unverified OSC 52 deliveries and clipboard-unreachable fallbacks name the backup path so you can recover the text.
|
||||
|
||||
### `/export`
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ Location: `~/.grok/config.toml`. If the file is missing, Grok uses its built-in
|
|||
auto_update = true # check for updates on launch
|
||||
|
||||
[models]
|
||||
default = "grok-build" # model used for new sessions
|
||||
web_search = "grok-4.20-multi-agent" # model used by the web_search tool
|
||||
default = "grok-4.5" # model used for new sessions
|
||||
web_search = "grok-4.5" # model used by the web_search tool
|
||||
|
||||
# Defaults applied to every model; a per-model [model.<id>] value always wins.
|
||||
# See "Custom Models" for the per-model overrides and full details.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ See the [MCP specification](https://modelcontextprotocol.io) for protocol detail
|
|||
|
||||
MCP servers are configured in `~/.grok/config.toml` under `[mcp_servers.<name>]` sections.
|
||||
|
||||
To distribute MCP servers to a team, or to restrict which servers users may run, see [Distribute across an organization](09-plugins.md#distribute-across-an-organization) in the Plugins guide.
|
||||
|
||||
### stdio Transport (Local Process)
|
||||
|
||||
Grok spawns a local process and communicates over stdin/stdout:
|
||||
|
|
@ -310,6 +312,18 @@ See the [MCP Server Registry](https://github.com/modelcontextprotocol/servers) f
|
|||
|
||||
---
|
||||
|
||||
## Subagents and MCP
|
||||
|
||||
Subagents inherit the parent session’s connected MCP servers by default, including plugin-sourced agents. Use agent frontmatter `mcpInheritance` to restrict that set (`all`, `none`, `named`, or `except`). Details are in [Subagents — MCP inheritance](16-subagents.md#mcp-inheritance).
|
||||
|
||||
If a child lists `search_tool` / `use_tool` but returns an empty catalog, check that:
|
||||
|
||||
1. The parent session actually connected the server (see Extensions / `grok inspect`)
|
||||
2. The agent’s `mcpInheritance` is not `none` or a filter that excludes the server
|
||||
3. Plugin agents cannot declare their own `mcpServers` in frontmatter — they only see parent-connected servers
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Not Starting
|
||||
|
|
|
|||
|
|
@ -140,6 +140,8 @@ Grok asks where to save the skill:
|
|||
- **Project** (`<repo_root>/.grok/skills/<name>/`) -- available only in this repository and shareable with teammates through version control. Grok recommends this scope inside a git repository.
|
||||
- **User** (`~/.grok/skills/<name>/`) -- available across all your projects.
|
||||
|
||||
To distribute a skill to a whole team or organization, package it in a plugin and publish it through a marketplace. See [Create your own marketplace](09-plugins.md#create-your-own-marketplace) and [Distribute across an organization](09-plugins.md#distribute-across-an-organization).
|
||||
|
||||
The new skill appears in the slash menu within a few seconds, because Grok reloads skills when files change on disk.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,229 +1,33 @@
|
|||
# Plugins
|
||||
|
||||
A plugin bundles skills, slash commands, agents, hooks, MCP server configurations, and LSP server configurations into one installable unit.
|
||||
A plugin bundles skills, slash commands, agents, hooks, and MCP servers into one installable unit. You get plugins from a marketplace, install the ones you want, and Grok loads what they add. To build and share your own, see [Create your own marketplace](#create-your-own-marketplace).
|
||||
|
||||
---
|
||||
|
||||
## What a plugin contains
|
||||
## How marketplaces work
|
||||
|
||||
A plugin is a directory that holds any combination of these components:
|
||||
A marketplace is a catalog of plugins that someone has published and shared. Using one takes two steps, like adding an app store: adding the marketplace lets you browse its plugins, and you then choose which to install.
|
||||
|
||||
- **Skills** -- a `skills/` directory of SKILL.md files
|
||||
- **Slash commands** -- a `commands/` directory of command files
|
||||
- **Agents** -- an `agents/` directory of agent definitions
|
||||
- **Hooks** -- a `hooks/hooks.json` file of lifecycle hooks. Plugin hooks also receive `GROK_PLUGIN_ROOT` and `GROK_PLUGIN_DATA` (see the [Hooks guide](10-hooks.md) for every environment variable passed to hooks).
|
||||
- **MCP servers** -- a `.mcp.json` file of server configurations
|
||||
- **LSP servers** -- a `.lsp.json` file of language server configurations
|
||||
1. **Add the marketplace** so Grok can show what it offers. Nothing installs yet.
|
||||
2. **Install the plugins you want**, one at a time.
|
||||
|
||||
If a plugin includes a `plugin.json` manifest, the manifest can override paths or add metadata; otherwise components load from the convention directories. The manifest is optional: without one, Grok discovers the components above from their standard directories.
|
||||
|
||||
For example, a `team-tools` plugin might include a deploy skill, a code-review agent, pre-commit hooks, and a Linear MCP server. Install them together in one step.
|
||||
|
||||
## Environment variables in plugin hooks
|
||||
|
||||
Plugin hooks receive two environment variables beyond the standard ones set for every hook:
|
||||
|
||||
| Variable | Description |
|
||||
|----------------------|-------------|
|
||||
| `GROK_PLUGIN_ROOT` | Absolute path to the plugin's installed directory. |
|
||||
| `GROK_PLUGIN_DATA` | Absolute path to the plugin's writable data directory, for plugin state, caches, and logs. |
|
||||
|
||||
Grok sets these values and overrides any value you declare for the same key in the hook JSON's `env` map. (Grok also sets the `CLAUDE_PLUGIN_ROOT` and `CLAUDE_PLUGIN_DATA` aliases for compatibility.) See the [Hooks guide](10-hooks.md) for every environment variable passed to hooks.
|
||||
Plugins stay off until you install and enable them, and a plugin's hooks and MCP servers stay inactive until you [trust](#trust-and-security) it.
|
||||
|
||||
---
|
||||
|
||||
## Plugin locations
|
||||
## Add a marketplace
|
||||
|
||||
Grok discovers plugins from these locations, in priority order:
|
||||
|
||||
| Location | Scope | Trust |
|
||||
|----------|-------|-------|
|
||||
| `_meta.pluginDirs` (`session/new` / `session/load`) | Session -- loaded for that session only | Trusted automatically |
|
||||
| `--plugin-dir` (CLI flag, `grok agent`) | Process -- loaded for that agent process only | Trusted automatically |
|
||||
| `.grok/plugins/` | Project -- shared with the team through version control | Requires trust |
|
||||
| `~/.grok/plugins/` | User -- personal plugins for every project | Trusted automatically |
|
||||
| `[plugins].paths` (config) | Custom directories you add in `config.toml` | Depends on location |
|
||||
|
||||
Grok also reads the `.claude/plugins/` equivalents for compatibility. When two plugins share a name, the higher-priority location wins.
|
||||
|
||||
The Agent SDKs load per-session plugins through `GrokOptions.plugins`, which arrives as `_meta.pluginDirs` on `session/new` and `session/load`; because the caller controls the directory, these plugins are always trusted -- their hooks and MCP servers activate without a prompt, and they never persist beyond the session. The `--plugin-dir` flag is the process-wide equivalent for direct CLI use (repeatable: `grok agent --no-leader --plugin-dir A --plugin-dir B stdio`); it applies to dedicated agent processes only and is ignored in leader mode (the shared leader discovers its own plugins).
|
||||
|
||||
---
|
||||
|
||||
## Manage plugins in the TUI
|
||||
|
||||
### Open the modal
|
||||
|
||||
| Action | Opens |
|
||||
|--------|-------|
|
||||
| `Ctrl+L` (from any pane; **non–VS Code family**) | Plugins tab |
|
||||
| `/plugins` (any terminal; **required on VS Code family**) | Plugins tab |
|
||||
|
||||
The modal has five tabs: **Hooks**, **Plugins**, **Marketplace**, **Skills**, and **MCP Servers**. Switch tabs with `Tab` (forward) or `Shift+Tab` (backward). The `/hooks`, `/marketplace`, `/skills`, and `/mcps` commands each open the modal on the matching tab.
|
||||
|
||||
### Plugins tab
|
||||
|
||||
Press `Enter` to expand a plugin row and show its details:
|
||||
|
||||
- **Name** and **version**
|
||||
- **Scope** -- `cli`, `project`, `user`, `custom path`, or the marketplace source name
|
||||
- **Skills** -- names or count
|
||||
- **Agents** -- names or count
|
||||
- **Hooks** -- count
|
||||
- **MCP servers** -- count (or `blocked` when the plugin is not trusted)
|
||||
- **Description** and **path**
|
||||
|
||||
Use these keys in the Plugins tab:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `r` | Reload all plugins |
|
||||
| `a` | Add a plugin from `owner/repo`, a URL, or a local path |
|
||||
| `Space` | Enable or disable the selected plugin |
|
||||
| `x` | Uninstall the selected plugin (asks for confirmation) |
|
||||
| `f` | Filter by status (all, enabled, or disabled) |
|
||||
| `Enter` | Expand or collapse plugin details |
|
||||
| `/` | Search plugins by name |
|
||||
|
||||
Uninstall asks for confirmation: press lowercase `y` to confirm, or any other key (including `Esc`) to cancel.
|
||||
|
||||
### Marketplace tab
|
||||
|
||||
Browse and install plugins from your configured marketplace sources.
|
||||
|
||||
Use these keys in the Marketplace tab:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `i` | Install the selected plugin |
|
||||
| `d` | Uninstall the selected plugin (asks for confirmation) |
|
||||
| `a` | Add a marketplace source |
|
||||
| `x` | Remove the selected source and all its plugins (asks for confirmation) |
|
||||
| `r` | Refresh marketplace sources |
|
||||
| `u` | Update the selected marketplace plugin |
|
||||
| `Enter` | Expand or collapse a source or plugin |
|
||||
| `/` | Search plugins by name |
|
||||
|
||||
Component summaries on list rows and per-category component details in the
|
||||
expanded view appear only for marketplaces that publish a `plugin-index.json`
|
||||
catalog.
|
||||
|
||||
---
|
||||
|
||||
## CLI commands
|
||||
|
||||
Manage plugins without starting an interactive session.
|
||||
|
||||
### Plugin commands
|
||||
A marketplace source is a GitHub repository, a git URL on any host, or a local folder. Add one from the command line:
|
||||
|
||||
```bash
|
||||
grok plugin list [--json] [--available] # List installed plugins (--available requires --json)
|
||||
grok plugin install <source> --trust # Git URL, GitHub shorthand (user/repo), or local path
|
||||
grok plugin uninstall <name> [--confirm] [--keep-data] # Aliases: rm, remove
|
||||
grok plugin update [<name>] # Omit the name to update all plugins
|
||||
grok plugin enable <name>
|
||||
grok plugin disable <name>
|
||||
grok plugin details <name> # Show the plugin's component inventory
|
||||
grok plugin validate [<path>] # Validate plugin.json (default: current directory)
|
||||
grok plugin tag [<path>] [--push] [--force] [--dry-run] # Tag a release from the manifest version
|
||||
grok plugin marketplace add my-org/team-plugins # GitHub shorthand (owner/repo)
|
||||
grok plugin marketplace add https://gitlab.com/acme/plugins.git # any git host, include https:// and .git
|
||||
grok plugin marketplace add ./my-marketplace # a local folder
|
||||
```
|
||||
|
||||
Run `grok plugin install <source>` without `--trust` and Grok prints the source and warns that installing will activate the plugin's hooks, MCP servers, and skills, then stops without installing. Add `--trust` to install it.
|
||||
List, refresh, and remove sources with `grok plugin marketplace list`, `grok plugin marketplace update [<name>]`, and `grok plugin marketplace remove <url>`.
|
||||
|
||||
The `<source>` argument accepts:
|
||||
|
||||
- `user/repo` -- GitHub shorthand
|
||||
- `user/repo@v1.0` -- pinned to a ref
|
||||
- `user/repo@<commit-sha>` -- pinned to an exact commit (verified after fetch)
|
||||
- `user/repo#subdir` -- subdirectory within the repo
|
||||
- `https://github.com/user/repo.git` -- full URL
|
||||
- `git@github.com:user/repo.git` -- SSH
|
||||
- `./local-dir` or `/absolute/path` -- local directory
|
||||
|
||||
### Requiring commit pins (`require_sha`)
|
||||
|
||||
Remote plugins are not cryptographically signed: an install that tracks a
|
||||
branch or tag runs whatever that ref points at tomorrow. Operators can require
|
||||
every remote install and update to pin a full commit sha (40- or 64-hex,
|
||||
verified against the fetched checkout):
|
||||
|
||||
```toml
|
||||
# config.toml
|
||||
[marketplace]
|
||||
require_sha = true
|
||||
```
|
||||
|
||||
or `GROK_MARKETPLACE_REQUIRE_SHA=1`. Both are tighten-only: either one enables
|
||||
the policy and neither can switch it back off. With the policy on, unpinned
|
||||
remote installs, marketplace installs without a published `sha`, and updates of
|
||||
branch-tracking installs are refused.
|
||||
|
||||
Scope: the policy covers everything fetched from a remote git URL at install or
|
||||
update time. Plugins vendored inside a marketplace source itself are copied
|
||||
from that source's synced checkout and are not covered — pin your marketplace
|
||||
source's content by publishing `sha` entries in `plugin-index.json`.
|
||||
|
||||
### Marketplace commands
|
||||
|
||||
```bash
|
||||
grok plugin marketplace list [--json]
|
||||
grok plugin marketplace add <url> # Git URL, GitHub shorthand (user/repo), or local path
|
||||
grok plugin marketplace remove <url> # Git URL or local path of a configured source
|
||||
grok plugin marketplace update [<name>] # Omit the name to refresh all sources
|
||||
```
|
||||
|
||||
### Example: set up a team marketplace
|
||||
|
||||
```bash
|
||||
grok plugin marketplace add my-org/team-plugins
|
||||
grok plugin marketplace list
|
||||
grok plugin install my-org/team-plugins --trust
|
||||
grok plugin list
|
||||
grok plugin update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slash commands
|
||||
|
||||
In an interactive session, these commands open the modal on a specific tab. They take no arguments — manage plugins from the modal or with the `grok plugin` CLI.
|
||||
|
||||
| Command | Opens |
|
||||
|---------|-------|
|
||||
| `/plugins` | Plugins tab |
|
||||
| `/hooks` | Hooks tab |
|
||||
| `/marketplace` | Marketplace tab |
|
||||
| `/skills` | Skills tab |
|
||||
| `/mcps` | MCP Servers tab |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure plugin directories and per-plugin state in `~/.grok/config.toml`:
|
||||
|
||||
```toml
|
||||
[plugins]
|
||||
paths = ["~/my-plugins/custom-tools"] # Additional plugin directories
|
||||
disabled = ["user/a1b2c3d4/noisy-plugin"] # Plugin IDs or names to skip
|
||||
enabled = ["project/9f8e7d6c/team-tools"] # Plugin IDs or names to force on
|
||||
```
|
||||
|
||||
List a plugin in `disabled` to discover it but skip loading its components. List a plugin in `enabled` to activate it — plugins are disabled by default unless a CLI override or an explicit config path enables them, so add them here to turn them on. Each entry is either a plain plugin name (as shown by `grok plugin list`) or a full plugin ID in the form `<scope>/<hash>/<name>`.
|
||||
|
||||
### Hide the plugins UI
|
||||
|
||||
To hide the hooks and plugins UI — the `/hooks` and `/plugins` commands and the scrollback annotations — set this in `~/.grok/pager.toml`:
|
||||
|
||||
```toml
|
||||
disable_plugins = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Marketplace sources
|
||||
|
||||
Add git or local marketplace sources to discover and install plugins.
|
||||
You can also declare sources in config so they are always present.
|
||||
|
||||
### In config.toml
|
||||
|
||||
|
|
@ -257,43 +61,326 @@ Place this file at `~/.grok/settings.json` or `~/.claude/settings.json`.
|
|||
|
||||
---
|
||||
|
||||
## Trust model
|
||||
## Install and use a plugin
|
||||
|
||||
Enabling a plugin loads its skills, slash commands, and agents. Trust is separate and controls whether a plugin's code runs: even for an enabled plugin, its hooks, MCP servers, and LSP servers stay inactive until you trust it. This prevents an untrusted repository from running code on your machine.
|
||||
Once a marketplace is added, install a plugin by name. You can also install straight from a repository or a local path:
|
||||
|
||||
Grok trusts plugins from `~/.grok/plugins/` automatically. Project plugins in `.grok/plugins/` require explicit trust. To trust a plugin, install it with `--trust`:
|
||||
```bash
|
||||
grok plugin install deploy-tools --trust
|
||||
```
|
||||
|
||||
The source you install accepts several forms:
|
||||
|
||||
- `owner/repo` (GitHub shorthand), `owner/repo@v1.0` (a ref), `owner/repo@<commit-sha>` (an exact commit, verified after fetch), or `owner/repo#subdir`
|
||||
- a full git URL (`https://github.com/user/repo.git`) or SSH (`git@github.com:user/repo.git`)
|
||||
- a local path (`./local-dir` or `/absolute/path`)
|
||||
|
||||
Run `grok plugin install <source>` without `--trust` and Grok shows the source, warns that installing activates the plugin's hooks, MCP servers, and skills, then stops. Add `--trust` to go ahead. Only install plugins from sources you trust (see [Trust and security](#trust-and-security)).
|
||||
|
||||
A plugin's skills appear in the slash menu. When a skill name is ambiguous, Grok shows the qualified form prefixed by the plugin name, for example `/deploy-tools:release`. To pick up a newly installed plugin, press `r` in the Plugins tab or start a new session.
|
||||
|
||||
---
|
||||
|
||||
## Manage plugins
|
||||
|
||||
### From the command line
|
||||
|
||||
```bash
|
||||
grok plugin list [--json] [--available] # installed plugins (--available requires --json)
|
||||
grok plugin uninstall <name> [--confirm] [--keep-data] # aliases: rm, remove
|
||||
grok plugin update [<name>] # omit the name to update every plugin
|
||||
grok plugin enable <name>
|
||||
grok plugin disable <name>
|
||||
grok plugin details <name> # show the plugin's component inventory
|
||||
```
|
||||
|
||||
### In the terminal UI
|
||||
|
||||
Open the plugins modal with `Ctrl+L` (outside the VS Code family) or `/plugins` (any terminal, and required on the VS Code family). It has five tabs, **Hooks**, **Plugins**, **Marketplace**, **Skills**, and **MCP Servers**; switch with `Tab` / `Shift+Tab`. The `/hooks`, `/marketplace`, `/skills`, and `/mcps` commands open the modal on the matching tab.
|
||||
|
||||
In the **Plugins** tab, press `Enter` to expand a plugin and see its name, version, scope (`cli`, `project`, `user`, `custom path`, or the marketplace source name), skills, agents, hooks, MCP servers (shown as `blocked` when the plugin is not trusted), description, and path. Then:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `r` | Reload all plugins |
|
||||
| `a` | Add a plugin from `owner/repo`, a URL, or a local path |
|
||||
| `Space` | Enable or disable the selected plugin |
|
||||
| `x` | Uninstall the selected plugin |
|
||||
| `f` | Filter by status (all, enabled, or disabled) |
|
||||
| `/` | Search by name |
|
||||
|
||||
In the **Marketplace** tab, browse and install from your sources:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `i` | Install the selected plugin |
|
||||
| `d` | Uninstall the selected plugin |
|
||||
| `a` | Add a marketplace source |
|
||||
| `x` | Remove the selected source and its plugins |
|
||||
| `r` | Refresh sources |
|
||||
| `u` | Update the selected plugin |
|
||||
|
||||
Component summaries in the Marketplace tab appear only for marketplaces that publish a [`plugin-index.json`](#add-a-catalog-optional) catalog. Destructive actions ask for confirmation: press lowercase `y` to confirm, any other key (including `Esc`) to cancel.
|
||||
|
||||
### Turn plugins on or off in config
|
||||
|
||||
Set these in `~/.grok/config.toml`:
|
||||
|
||||
```toml
|
||||
[plugins]
|
||||
paths = ["~/my-plugins/custom-tools"] # extra plugin directories
|
||||
disabled = ["user/a1b2c3d4/noisy-plugin"] # names or IDs to skip
|
||||
enabled = ["project/9f8e7d6c/team-tools"] # names or IDs to force on
|
||||
```
|
||||
|
||||
Plugins are off by default, so list one in `enabled` to turn it on, or in `disabled` to discover it but skip loading it. Each entry is a plain plugin name (from `grok plugin list`) or a full ID (`<scope>/<hash>/<name>`).
|
||||
|
||||
To hide the plugins and hooks interface entirely, set `disable_plugins = true` in `~/.grok/pager.toml`.
|
||||
|
||||
---
|
||||
|
||||
## Trust and security
|
||||
|
||||
Plugins run with your privileges, so treat them like any software you install: only add marketplaces and install plugins from sources you trust.
|
||||
|
||||
Enabling a plugin loads its skills, commands, and agents. Trust is separate and controls whether a plugin's code runs: even when enabled, its hooks, MCP servers, and LSP servers stay inactive until you trust it. Grok trusts plugins in `~/.grok/plugins/` automatically; project plugins in `.grok/plugins/` require trust. Install with `--trust` to grant it:
|
||||
|
||||
```bash
|
||||
grok plugin install <source> --trust
|
||||
```
|
||||
|
||||
Trusted plugin `.mcp.json` servers attach to the session like other MCP config, and child agents inherit them. Plugin agents (`plugin-name:agent-name`) use the parent session's MCP servers by default, the same as user agents under `~/.grok/agents/`; restrict that with the `mcpInheritance` frontmatter (see [Subagents](16-subagents.md#mcp-inheritance)). For safety, plugin agent frontmatter cannot declare `mcpServers` or hooks, or set `permissionMode: bypassPermissions`.
|
||||
|
||||
---
|
||||
|
||||
## Inspect plugins
|
||||
## Create your own marketplace
|
||||
|
||||
Run `grok inspect` to see every discovered plugin and what it provides:
|
||||
A marketplace is a git repository (or a local folder) that lists a set of plugins. Adding one works like adding an app store: it lets people browse your plugins, and they choose which to install. Publishing your own is how a team or an organization shares its skills, commands, agents, hooks, and MCP servers from one place.
|
||||
|
||||
```bash
|
||||
grok inspect # Show plugins with their skills, agents, hooks, and MCP servers
|
||||
grok inspect --json # Emit machine-readable JSON
|
||||
You need three things: a git repository, one folder per plugin, and a single index file that lists them.
|
||||
|
||||
### Set up the repository
|
||||
|
||||
1. **Create a git repository.** A private repository is fine; access uses each person's own git credentials.
|
||||
2. **Add each plugin as a folder.** A plugin folder holds any of `skills/`, `commands/`, `agents/`, `hooks/hooks.json`, `.mcp.json`, and an optional `plugin.json` manifest (see [What a plugin contains](#what-a-plugin-contains)).
|
||||
3. **List the plugins in `.grok-plugin/marketplace.json`.** This is the index Grok reads.
|
||||
4. **Push the repository.**
|
||||
|
||||
A typical layout:
|
||||
|
||||
```
|
||||
my-org-plugins/
|
||||
.grok-plugin/
|
||||
marketplace.json # the index Grok reads (required)
|
||||
plugin-index.json # optional catalog for richer browsing
|
||||
plugins/
|
||||
gdrive/
|
||||
plugin.json # optional manifest
|
||||
skills/gdrive/SKILL.md
|
||||
.mcp.json # MCP servers this plugin adds
|
||||
```
|
||||
|
||||
Plugin-provided components appear in their sections (Skills, Agents, MCP Servers, and so on) with a `plugin: <name>` label, so you can see where each component originates.
|
||||
Grok reads the index from `.grok-plugin/marketplace.json`. It also accepts `.grok-plugin/plugin.json` and the `.claude-plugin/` equivalents.
|
||||
|
||||
### Write the index
|
||||
|
||||
`marketplace.json` names the marketplace and lists each plugin:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Org Plugins",
|
||||
"description": "Internal skills and tools",
|
||||
"owner": { "name": "Platform Team", "email": "platform@example.com" },
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gdrive",
|
||||
"description": "Search and edit Google Drive, Docs, Sheets, and Slides",
|
||||
"category": "productivity",
|
||||
"source": { "type": "local", "path": "./plugins/gdrive" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each plugin's `source` points at its files, in one of two ways:
|
||||
|
||||
- **In this repository**: `{ "type": "local", "path": "./plugins/gdrive" }`. The plain string `"./plugins/gdrive"` also works.
|
||||
- **In a separate repository**: `{ "source": "url", "url": "https://github.com/my-org/gdrive.git", "sha": "<full commit sha>" }`. Pin a `sha` so installs are reproducible (required when you [require pinned versions](#require-pinned-versions)).
|
||||
|
||||
Optional per-plugin fields: `version`, `author`, `homepage`, `tags`, and `keywords`.
|
||||
|
||||
### Add a catalog (optional)
|
||||
|
||||
A `plugin-index.json` catalog lets the marketplace browser show each plugin's skills, commands, hooks, and agents before anyone installs it. It is for display only, installs work without it, and teams usually generate it in CI:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"plugins": {
|
||||
"gdrive": {
|
||||
"components": {
|
||||
"skills": [{ "name": "gdrive", "description": "Google Drive access" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Check and share it
|
||||
|
||||
Validate a plugin before publishing with `grok plugin validate [<path>]`, and tag a release from the manifest version with `grok plugin tag [<path>] [--push]`. Then point people at the repository. They add it once and install the plugins they want:
|
||||
|
||||
```bash
|
||||
grok plugin marketplace add my-org/my-org-plugins # GitHub shorthand, a git URL, or a local path
|
||||
grok plugin install gdrive --trust
|
||||
```
|
||||
|
||||
To install it for everyone automatically instead of person by person, see [Distribute across an organization](#distribute-across-an-organization).
|
||||
|
||||
---
|
||||
|
||||
## General keyboard shortcuts
|
||||
## Distribute across an organization
|
||||
|
||||
These keys work across every tab in the modal:
|
||||
Admins control plugins, marketplaces, and MCP servers through two managed layers the deployment sends to each user:
|
||||
|
||||
- **`managed_config.toml`** holds the same settings as a user's `config.toml` and merges into it. Use it to hand everyone a marketplace and turn plugins on.
|
||||
- **`managed-settings.json`** is a protected policy file for allowlists and defaults. Its values take precedence over user, project, and local config and cannot be overridden.
|
||||
|
||||
### Roll a marketplace out to everyone
|
||||
|
||||
Add the source, and turn on the plugins you want, in `managed_config.toml`:
|
||||
|
||||
```toml
|
||||
[[marketplace.sources]]
|
||||
name = "My Org Plugins"
|
||||
git = "https://github.com/my-org/my-org-plugins.git"
|
||||
|
||||
# Plugins stay off until enabled. List plugin names (from `grok plugin list`)
|
||||
# or full IDs (`<scope>/<hash>/<name>`).
|
||||
[plugins]
|
||||
enabled = ["gdrive"]
|
||||
```
|
||||
|
||||
For a hands-off install with no per-person step, also place the plugin's files where Grok discovers and trusts them automatically: `~/.grok/plugins/`, or a directory your device-management tool manages that you point to with `[plugins].paths`. Then enable them with `[plugins].enabled`.
|
||||
|
||||
A managed workspace can also sync skills to users directly, without a plugin. Synced skills appear with the `server` scope and are administered by the workspace; a user's own skill of the same name shadows the synced one. See [Skills](08-skills.md).
|
||||
|
||||
### Restrict which marketplaces can be added
|
||||
|
||||
List the only sources people may add in `managed-settings.json`. Any other marketplace is refused:
|
||||
|
||||
```json
|
||||
{
|
||||
"strictKnownMarketplaces": [
|
||||
{ "source": "git", "url": "git@github.enterprise.example:ACME/my-org-plugins.git" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Restrict which MCP servers can run
|
||||
|
||||
Also in `managed-settings.json`. Each entry allows an HTTP address (with `*` wildcards) or a local command; anything unlisted is denied:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowedMcpServers": [
|
||||
{ "serverUrl": "https://*.example.com/*" },
|
||||
{ "command": "npx" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The deployment can also send MCP servers to users directly. The allowlist bounds what any configuration, managed or personal, is allowed to run.
|
||||
|
||||
### Require pinned versions
|
||||
|
||||
Refuse any remote plugin install or update that is not pinned to a full commit sha:
|
||||
|
||||
```toml
|
||||
[marketplace]
|
||||
require_sha = true
|
||||
```
|
||||
|
||||
You can also set `GROK_MARKETPLACE_REQUIRE_SHA=1`. Both only tighten the policy; neither turns it back off. Publish `sha` values in your marketplace's `plugin-index.json` so installs from it satisfy the rule. Plugins vendored directly inside a marketplace repository are copied from that repository's checkout, so pin them the same way, with `sha` values in `plugin-index.json`.
|
||||
|
||||
### Turn off the plugins UI
|
||||
|
||||
To hide the plugins and hooks interface, set this in `pager.toml`:
|
||||
|
||||
```toml
|
||||
disable_plugins = true
|
||||
```
|
||||
|
||||
### What this does not cover
|
||||
|
||||
Marketplaces distribute Grok content: skills, commands, agents, hooks, and MCP server configurations. They do not install a program onto a machine. A skill or MCP server that runs a helper binary (for example a custom sign-in tool) still needs that binary delivered separately, bundled with your deployment or pushed through your device-management tool.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**A plugin you installed isn't showing up.** Plugins are off until enabled. Check `grok plugin list`, then add the plugin's name or ID to `[plugins].enabled`, or press `Space` on it in the Plugins tab. Reload with `r` in the Plugins tab or start a new session.
|
||||
|
||||
**A plugin's hooks or MCP servers don't run.** They stay inactive until the plugin is trusted. Reinstall with `--trust`, or place the plugin under `~/.grok/plugins/` (auto-trusted). See [Trust and security](#trust-and-security).
|
||||
|
||||
**A skill or MCP server from a marketplace is missing.** Refresh the source with `grok plugin marketplace update`, confirm the plugin is installed and enabled, and, if your organization restricts sources, check that the marketplace is still allowed (see [Distribute across an organization](#distribute-across-an-organization)). Some MCP servers require a sign-in and will not appear until you authenticate.
|
||||
|
||||
**An install is refused as unpinned.** Your deployment requires pinned commits. Install an exact commit (`owner/repo@<sha>`), or use a marketplace whose `plugin-index.json` publishes `sha` values. See [Require pinned versions](#require-pinned-versions).
|
||||
|
||||
**See exactly what loaded.** Run `grok inspect` (add `--json` for machine-readable output) to list every discovered plugin and the skills, agents, hooks, and MCP servers it provides, each labeled with its `plugin: <name>` source.
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
### What a plugin contains
|
||||
|
||||
A plugin is a directory with any combination of:
|
||||
|
||||
- **Skills**: a `skills/` directory of SKILL.md files
|
||||
- **Slash commands**: a `commands/` directory
|
||||
- **Agents**: an `agents/` directory
|
||||
- **Hooks**: a `hooks/hooks.json` file
|
||||
- **MCP servers**: a `.mcp.json` file
|
||||
- **LSP servers**: a `.lsp.json` file
|
||||
|
||||
An optional `plugin.json` manifest can override paths or add metadata; without one, Grok discovers components from these standard directories. For example, a `team-tools` plugin might bundle a deploy skill, a code-review agent, pre-commit hooks, and a Linear MCP server, installed together in one step.
|
||||
|
||||
A skill or command may ship a **helper script** next to its SKILL.md (for example a Python file it calls). Put the script in the plugin and have the skill run it by relative path; it is copied to the machine with the plugin. The script's runtime and any packages it imports must already be present, plugins deliver files, not runtimes or native binaries (see [What this does not cover](#what-this-does-not-cover)).
|
||||
|
||||
### Where Grok looks for plugins
|
||||
|
||||
Grok discovers plugins from these locations, in priority order. The `.claude/plugins/` equivalents also work, and when two plugins share a name the higher-priority one wins:
|
||||
|
||||
| Location | Scope | Trust |
|
||||
|----------|-------|-------|
|
||||
| `_meta.pluginDirs` (`session/new` / `session/load`) | Session, that session only | Trusted automatically |
|
||||
| `--plugin-dir` (the `grok agent … stdio` flag) | Process, that agent process only | Trusted automatically |
|
||||
| `.grok/plugins/` | Project, shared through version control | Requires trust |
|
||||
| `~/.grok/plugins/` | User, every project | Trusted automatically |
|
||||
| `[plugins].paths` (config) | Custom directories you add | Depends on location |
|
||||
|
||||
The `_meta.pluginDirs` field on the `session/new` and `session/load` requests loads plugins for a single session; because the caller supplies the directory, those plugins are trusted automatically and do not persist after the session. `--plugin-dir` is the process-wide equivalent for a dedicated `grok agent … stdio` process, repeatable (`grok agent --no-leader --plugin-dir A --plugin-dir B stdio`), and ignored in leader mode, where the shared leader discovers its own plugins.
|
||||
|
||||
### Environment variables in plugin hooks
|
||||
|
||||
Plugin hooks receive two variables beyond the standard hook environment:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GROK_PLUGIN_ROOT` | Absolute path to the plugin's installed directory. |
|
||||
| `GROK_PLUGIN_DATA` | Absolute path to the plugin's writable data directory, for state, caches, and logs. |
|
||||
|
||||
Grok sets these and overrides any same-named value in the hook's `env` map (the `CLAUDE_PLUGIN_ROOT` and `CLAUDE_PLUGIN_DATA` aliases are set too). See the [Hooks guide](10-hooks.md) for every variable passed to hooks.
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
These keys work across every tab in the plugins modal:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Tab` | Next tab |
|
||||
| `Shift+Tab` | Previous tab |
|
||||
| `j` / down-arrow | Move selection down |
|
||||
| `k` / up-arrow | Move selection up |
|
||||
| `Tab` / `Shift+Tab` | Next / previous tab |
|
||||
| `j` / `k` or arrow keys | Move the selection |
|
||||
| `Enter` | Expand or collapse the selected item |
|
||||
| `/` | Search the current tab by name |
|
||||
| `Esc` | Clear the search, or close the modal |
|
||||
|
||||
Destructive remove and uninstall actions in the modal ask for confirmation. Press lowercase `y` to confirm, or any other key (including `Esc`) to cancel.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Grok connects to custom model endpoints for alternative providers, self-hosted m
|
|||
|
||||
## Default Models
|
||||
|
||||
By default, Grok uses models hosted by SpaceXAI, and new sessions start with `grok-build`. Default models require no configuration. Authenticate with `grok login` or an API key, then start a session.
|
||||
By default, Grok uses models hosted by SpaceXAI, and new sessions start with `grok-4.5`. Default models require no configuration. Authenticate with `grok login` or an API key, then start a session.
|
||||
|
||||
List all available models:
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ Set a persistent default in `~/.grok/config.toml`:
|
|||
|
||||
```toml
|
||||
[models]
|
||||
default = "grok-build"
|
||||
default = "grok-4.5"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -315,13 +315,13 @@ The `web_search` tool uses a separate model. Configure it with:
|
|||
|
||||
```toml
|
||||
[models]
|
||||
web_search = "grok-4.20-multi-agent"
|
||||
web_search = "grok-4.5"
|
||||
```
|
||||
|
||||
Or via environment variable:
|
||||
|
||||
```bash
|
||||
export GROK_WEB_SEARCH_MODEL="grok-4.20-multi-agent"
|
||||
export GROK_WEB_SEARCH_MODEL="grok-4.5"
|
||||
```
|
||||
|
||||
If you point web search at a custom model, you also need a `[model.*]` entry so Grok can reach it. Server-side ("backend") web search runs only when the model sets `supports_backend_search = true` (and the build enables backend search); it does not depend on `api_backend`:
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ Grok processes the prompt, runs any necessary tools, and prints the result to st
|
|||
| `--disallowed-tools <TOOLS>` | Denylist of built-in tools to remove (comma-separated). Supports `Agent` entries. Headless only. |
|
||||
| `--max-turns <N>` | Maximum number of agentic turns before stopping. Headless only. |
|
||||
| `--reasoning-effort` / `--effort <LEVEL>` | Reasoning effort for reasoning models. Canonical levels: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` (each a distinct tier; a model only accepts the levels its menu advertises). Also accepts per-model menu option ids (e.g. `deep` → mapped wire value), same as `/effort`. Works in TUI and headless. |
|
||||
| `--permission-mode <MODE>` | Permission mode. `bypassPermissions` enables always-approve via this flag (see [22-permissions-and-safety.md](22-permissions-and-safety.md)); for deny-by-default use `defaultMode` in `.claude/settings.json`. |
|
||||
| `--permission-mode <MODE>` | Permission mode. `bypassPermissions` enables always-approve (see [Permissions and safety](22-permissions-and-safety.md#permission-modes)); for deny-by-default use `defaultMode` in `.claude/settings.json`. |
|
||||
| `--allow <RULE>` | Permission allow rule with glob patterns (repeatable). Works in TUI and headless. |
|
||||
| `--deny <RULE>` | Permission deny rule with glob patterns (repeatable). Works in TUI and headless. |
|
||||
| `--prompt-json <JSON>` | Prompt as JSON content blocks |
|
||||
|
|
@ -431,20 +431,16 @@ echo "No issues found"
|
|||
|
||||
---
|
||||
|
||||
## Fully Automated Runs with --yolo
|
||||
## Always-approve for automation
|
||||
|
||||
The `--yolo` flag enables always-approve mode (the same mode as `--permission-mode bypassPermissions` and `--always-approve`), auto-approving tool executions (file writes, command execution, etc.) without prompting for confirmation. Explicit `deny` rules and `PreToolUse` hooks still apply, and administrators can disable the mode via `requirements.toml` (see [22-permissions-and-safety.md](22-permissions-and-safety.md)). This is required for unattended automation:
|
||||
`--always-approve` (alias `--yolo`, same as `--permission-mode bypassPermissions`) runs tool calls without interactive permission prompts. Deny rules, hooks, and admin locks still apply (see [Permissions and safety](22-permissions-and-safety.md#permission-modes)).
|
||||
|
||||
```bash
|
||||
# Format all files without asking
|
||||
grok -p "Format all files" --yolo
|
||||
|
||||
# Run tests and fix failures
|
||||
grok -p "Run the tests and fix any failures" --cwd ~/projects/my-app --yolo
|
||||
grok -p "Format all files" --always-approve
|
||||
grok -p "Run the tests and fix any failures" --cwd ~/projects/my-app --always-approve
|
||||
```
|
||||
|
||||
**Use `--yolo` with care.** It grants the agent full autonomy to modify files and run commands. Only use it in trusted environments or with well-scoped prompts.
|
||||
|
||||
For agent servers and SDKs, see [Agent mode](15-agent-mode.md#automation-and-sdks).
|
||||
---
|
||||
|
||||
## Environment Variables for Headless
|
||||
|
|
|
|||
|
|
@ -1,70 +1,94 @@
|
|||
# Agent Mode (ACP) and IDE Integration
|
||||
# Agent mode (ACP) and IDE integration
|
||||
|
||||
Agent mode runs Grok as an ACP (Agent Client Protocol) server for integration with IDEs, editors, and custom tooling. Unlike single-prompt mode (`grok -p`, which prints one response and exits), agent mode keeps a persistent process running and communicates through structured JSON-RPC messages.
|
||||
Agent mode runs Grok as a long-lived server that clients talk to over [ACP](https://agentclientprotocol.com) (JSON-RPC). Use it from IDEs, SDKs, eval harnesses, and custom apps. For a one-shot prompt that prints and exits, use `grok -p` instead ([headless mode](14-headless-mode.md)).
|
||||
|
||||
---
|
||||
|
||||
## Automation and SDKs
|
||||
|
||||
For scripts, CI, evals, and agent servers, start with always-approve so tools run without interactive permission prompts. Deny rules and hooks still apply.
|
||||
|
||||
```bash
|
||||
# stdio (local process / many SDKs)
|
||||
grok agent --always-approve stdio
|
||||
|
||||
# WebSocket server
|
||||
grok agent --always-approve serve --bind 127.0.0.1:2419 --secret <token>
|
||||
```
|
||||
|
||||
You can also set always-approve per session on `session/new`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cwd": "/path/to/project",
|
||||
"mcpServers": [],
|
||||
"_meta": { "yoloMode": true }
|
||||
}
|
||||
```
|
||||
|
||||
Interactive TUI users typically leave the default ask mode (or use auto). See [Permissions and safety](22-permissions-and-safety.md).
|
||||
|
||||
---
|
||||
|
||||
## What is ACP?
|
||||
|
||||
The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is a standard for AI agent communication. It defines how clients (IDEs, editors, custom apps) interact with AI agents through a structured JSON-RPC protocol. ACP provides:
|
||||
The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) defines how clients talk to coding agents over JSON-RPC. With Grok it covers:
|
||||
|
||||
- **Session management** -- create, load, and resume conversations
|
||||
- **Prompt submission** -- send user messages and receive streamed responses
|
||||
- **Tool visibility** -- see what tools the agent is using in real time
|
||||
- **Thought streams** -- observe the agent's reasoning process
|
||||
- **Permission handling** -- approve or deny tool executions interactively
|
||||
- Sessions (create, load, resume)
|
||||
- Prompts and streamed replies
|
||||
- Tool call updates
|
||||
- Reasoning / thought streams
|
||||
- Permission prompts when the session is not always-approve
|
||||
|
||||
---
|
||||
|
||||
## stdio transport
|
||||
|
||||
stdio is the primary integration mode. The agent exchanges JSON-RPC messages over stdin and stdout:
|
||||
stdio is the common local integration path. The agent speaks JSON-RPC on stdin and stdout:
|
||||
|
||||
```bash
|
||||
grok agent stdio
|
||||
grok agent --always-approve stdio
|
||||
```
|
||||
|
||||
Clients that use this mode include:
|
||||
|
||||
- IDE extensions (for example, Zed, Neovim, and Emacs)
|
||||
- Custom automation tools
|
||||
- ACP client libraries
|
||||
Typical clients: IDE extensions (Zed, Neovim, Emacs), custom tools, and ACP SDKs.
|
||||
|
||||
### Options
|
||||
|
||||
These options belong to the `grok agent` command and apply to every mode. Pass them before the mode name, for example `grok agent --model grok-build stdio`. The `stdio` subcommand itself takes no options.
|
||||
Agent options apply to every transport (`stdio`, `serve`, `headless`, `leader`). They go after `agent` and before the mode name. Mode-specific flags go after the mode (for example `serve --bind`).
|
||||
|
||||
| Flag | Description |
|
||||
| -------------------------- | ---------------------------------------------------------------- |
|
||||
| `-m, --model <MODEL>` | Set the model ID (for example, `grok-build`). |
|
||||
| `--always-approve` | Auto-approve every tool execution. (Alias: `--yolo`.) |
|
||||
| `--reauth` | Run authentication before starting the agent. |
|
||||
| `--agent-profile <PATH>` | Load an agent profile from a file. |
|
||||
```bash
|
||||
grok agent --always-approve --model grok-build stdio
|
||||
grok agent --always-approve serve --bind 127.0.0.1:2419 --secret <token>
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
| ---- | ----------- |
|
||||
| `-m, --model <MODEL>` | Model ID (for example `grok-build`). |
|
||||
| `--always-approve` | Run without interactive tool-permission prompts. Alias: `--yolo`. |
|
||||
| `--reauth` | Authenticate before the agent starts. |
|
||||
| `--agent-profile <PATH>` | Load an agent profile from a file. |
|
||||
| `--leader` / `--no-leader` | Connect to a shared leader process, or force a local agent. |
|
||||
|
||||
---
|
||||
|
||||
## Server mode
|
||||
|
||||
Run the agent as a WebSocket server for remote clients:
|
||||
|
||||
```bash
|
||||
grok agent serve --bind 127.0.0.1:2419 --secret <token>
|
||||
grok agent --always-approve serve --bind 127.0.0.1:2419 --secret <token>
|
||||
```
|
||||
|
||||
Clients connect over WebSocket and authenticate with the secret token. If you omit `--secret`, the agent generates a token and prints it at startup; you can also supply one through the `GROK_AGENT_SECRET` environment variable. The agent persists across reconnections, so a client can disconnect and later resume in-flight work.
|
||||
Clients connect over WebSocket and authenticate with the secret token. If you omit `--secret`, the agent prints a generated token at startup, or set `GROK_AGENT_SECRET`. The process keeps state across client reconnects. Permissions match other entry points; see [Permissions and safety](22-permissions-and-safety.md).
|
||||
|
||||
---
|
||||
|
||||
## WebSocket relay
|
||||
|
||||
To reach the agent over the internet instead of the local network, run a WebSocket relay server and have the agent connect to it:
|
||||
To reach the agent over the internet, connect the agent to a relay and point browsers at the same relay:
|
||||
|
||||
```bash
|
||||
grok agent headless --grok-ws-url wss://your-relay.example.com/ws
|
||||
grok agent --always-approve headless --grok-ws-url wss://your-relay.example.com/ws
|
||||
```
|
||||
|
||||
The agent connects out to your relay, and your web clients connect to the same relay. This is useful for building web UIs where browsers cannot spawn local processes.
|
||||
|
||||
---
|
||||
|
||||
## ACP protocol basics
|
||||
|
|
@ -75,7 +99,7 @@ Communication follows the JSON-RPC 2.0 format. A typical session lifecycle:
|
|||
2. **Create session** -- client sends `session/new` with working directory
|
||||
3. **Send prompts** -- client sends `session/prompt` with user messages
|
||||
4. **Receive updates** -- agent sends `session/update` notifications with streamed content
|
||||
5. **Handle permissions** -- agent may request tool execution approval
|
||||
5. **Handle permissions** -- agent may request tool execution approval (or allow or deny based on permission mode)
|
||||
|
||||
### Architecture
|
||||
|
||||
|
|
@ -149,13 +173,23 @@ The agent sends push notifications to clients for real-time updates:
|
|||
|
||||
## Session `_meta` options
|
||||
|
||||
The `session/new` request accepts these optional `_meta` fields:
|
||||
Optional fields on `session/new`:
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------- | ---------------------------------------------- |
|
||||
| `rules` | Extra rules appended to the system prompt. |
|
||||
| `systemPromptOverride` | A replacement system prompt. |
|
||||
| `agentProfile` | An agent profile, as a name or a JSON object. |
|
||||
| Field | Description |
|
||||
| ----- | ----------- |
|
||||
| `rules` | Extra rules appended to the system prompt. |
|
||||
| `systemPromptOverride` | Replacement system prompt. |
|
||||
| `agentProfile` | Agent profile name or JSON object. |
|
||||
| `yoloMode` | When `true`, always-approve for this session. |
|
||||
| `autoMode` | When `true`, auto permission mode for this session. Superseded when always-approve is already on. |
|
||||
|
||||
```json
|
||||
{
|
||||
"cwd": "/path/to/project",
|
||||
"mcpServers": [],
|
||||
"_meta": { "yoloMode": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -199,10 +233,9 @@ class GrokACPChat {
|
|||
constructor(private cwd = ".") {}
|
||||
|
||||
async init() {
|
||||
this.proc = spawn("grok", ["agent", "stdio"]);
|
||||
this.proc = spawn("grok", ["agent", "--always-approve", "stdio"]);
|
||||
this.rl = readline.createInterface({ input: this.proc.stdout! });
|
||||
|
||||
// Initialize
|
||||
await this.request("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
|
|
@ -211,10 +244,10 @@ class GrokACPChat {
|
|||
},
|
||||
});
|
||||
|
||||
// Create session
|
||||
const { sessionId } = await this.request("session/new", {
|
||||
cwd: this.cwd,
|
||||
mcpServers: [],
|
||||
_meta: { yoloMode: true },
|
||||
});
|
||||
this.sessionId = sessionId;
|
||||
return this;
|
||||
|
|
|
|||
|
|
@ -183,6 +183,40 @@ The `resume_from` parameter lets a new subagent continue where a completed subag
|
|||
|
||||
The new subagent inherits the source's transcript, tool state, and model; its system prompt and tools are re-rendered from the current agent definition. The source must be completed (not running), belong to the current session, and use the same agent type.
|
||||
|
||||
### MCP inheritance
|
||||
|
||||
Subagents inherit the parent session’s **already-connected** MCP servers by default. That includes local stdio/HTTP servers and plugin-sourced agents (for example `my-plugin:reviewer`). The child discovers and calls those tools with `search_tool` / `use_tool` the same way the parent does.
|
||||
|
||||
Control inheritance with agent frontmatter `mcpInheritance`:
|
||||
|
||||
| Value | Effect |
|
||||
| ----- | ------ |
|
||||
| `all` (default if omitted) | Inherit every parent-connected MCP server |
|
||||
| `none` | Inherit no parent MCP servers |
|
||||
| `named: [server, …]` | Inherit only the listed server names |
|
||||
| `except: [server, …]` | Inherit all parent servers except the listed names |
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: research-only
|
||||
description: Read MCP tools but not internal connectors
|
||||
tools: search_tool, use_tool, Read
|
||||
mcpInheritance:
|
||||
except:
|
||||
- internal-tools
|
||||
---
|
||||
```
|
||||
|
||||
**Plugin agents** inherit parent MCP the same way. For security they still cannot:
|
||||
|
||||
- Declare their own `mcpServers` in agent frontmatter (ignored with a warning)
|
||||
- Declare hooks in agent frontmatter
|
||||
- Set `permissionMode: bypassPermissions`
|
||||
|
||||
Plugin-bundled MCP servers (plugin `.mcp.json`) still attach to the **parent/session** after the plugin is trusted — they are not a child-only frontmatter declaration. See [Plugins](09-plugins.md) and [MCP Servers](07-mcp-servers.md).
|
||||
|
||||
---
|
||||
|
||||
## Isolation: Worktree Mode
|
||||
|
|
|
|||
|
|
@ -124,15 +124,16 @@ terminal-native `Shift+Insert`, or hold `Shift` while middle-clicking when the
|
|||
terminal uses that gesture to bypass mouse reporting.
|
||||
|
||||
When Grok cannot identify the outer terminal over SSH, it predicts that OSC 52
|
||||
will be sent but marks the route as not verified. The copy message shows the
|
||||
actual result and backup file. Run `/doctor` for other copy options.
|
||||
will be sent but marks the route as not verified. The copy toast then names the
|
||||
backup file so you can retrieve the text. Run `/doctor` for other copy options.
|
||||
|
||||
#### Apple Terminal over SSH
|
||||
|
||||
Apple Terminal does not support OSC 52, so a remote copy cannot directly reach
|
||||
the local clipboard. Grok also saves each copy to the backup file named in the
|
||||
copy message (`~/.grok/last-copy.txt` by default; override with
|
||||
`GROK_COPY_FILE`). You can also use `/copy <file>` or `/minimal`.
|
||||
Apple Terminal does not support OSC 52, so a remote copy cannot reach the local
|
||||
clipboard. Each copy is still saved to a backup file (`~/.grok/last-copy.txt` by
|
||||
default; override with `GROK_COPY_FILE`); the toast names that path when delivery
|
||||
is unverified or the clipboard is unreachable. You can also use `/copy <file>` or
|
||||
`/minimal`.
|
||||
|
||||
For direct clipboard forwarding, run the SSH command from the local computer
|
||||
through `grok wrap`, for example `grok wrap ssh user@host`. The same command can
|
||||
|
|
|
|||
|
|
@ -1,12 +1,122 @@
|
|||
# Permissions and Safety Controls
|
||||
# Permissions and safety
|
||||
|
||||
Grok can read files, search code, edit files, and run shell commands. The permission system controls what the agent is allowed to do. You can combine several independent layers: permission rules, permission modes, hooks, and the OS-level sandbox.
|
||||
Control what Grok can access and do: permission modes, allow/ask/deny rules, hooks, and the optional OS-level sandbox.
|
||||
|
||||
This guide explains how a tool call is authorized, how to configure permission rules from the CLI, native configuration, or Claude settings, and how to use `PreToolUse` hooks for allow lists that apply in every mode.
|
||||
- **Modes** set how often Grok asks for approval (always-approve, auto, ask, and related).
|
||||
- **Rules** set which tools are allowed, asked about, or blocked within that baseline.
|
||||
|
||||
---
|
||||
|
||||
## How a Tool Call Is Authorized
|
||||
## Permission modes
|
||||
|
||||
When Grok edits a file, runs a command, or calls an external tool, it may pause for approval. Permission modes control how often that happens.
|
||||
|
||||
Modes set a baseline. Allow, ask, and deny [rules](#configuring-permissions) still apply on top of any mode.
|
||||
|
||||
### Starting points
|
||||
|
||||
| Situation | Mode |
|
||||
| --------- | ---- |
|
||||
| Interactive TUI | Default (ask), or auto for fewer prompts with background checks |
|
||||
| Scripts, SDKs, CI, agent servers | Always-approve; add [deny rules](#configuring-permissions) or hooks for hard limits |
|
||||
|
||||
```bash
|
||||
grok -p "Run the tests" --always-approve
|
||||
grok agent --always-approve stdio
|
||||
grok agent --always-approve serve --bind 127.0.0.1:2419 --secret <token>
|
||||
```
|
||||
|
||||
ACP clients can set `"_meta": { "yoloMode": true }` on `session/new`. See [Agent mode](15-agent-mode.md#automation-and-sdks).
|
||||
|
||||
### Available modes
|
||||
|
||||
| Mode | What runs without asking | Best for |
|
||||
| ---- | ------------------------ | -------- |
|
||||
| `default` (**ask**) | Read-only tools and built-in read-only shell commands | Interactive day-to-day use |
|
||||
| `acceptEdits` | File edits without a prompt | Local coding while you review diffs later |
|
||||
| `plan` | Accepted for compatibility; use [plan mode](19-plan-mode.md) for gated planning | Claude-compatible settings |
|
||||
| `auto` | Work the safety check allows; other calls are blocked or escalated | Interactive sessions that want fewer prompts |
|
||||
| `dontAsk` | Only pre-approved tools and built-in read-only handling | Strict CI allowlists |
|
||||
| `bypassPermissions` (**always-approve**) | Tool calls in general (`deny` rules, hooks, and some shell `ask` rules still apply) | Trusted automation and agent servers |
|
||||
|
||||
**Always-approve** is the product name; config and Claude-compatible settings may use `bypassPermissions` for the same mode. Always-approve and auto are mutually exclusive (always-approve takes precedence when both are requested).
|
||||
|
||||
### How to set the mode
|
||||
|
||||
**Interactive TUI:** `Shift+Tab` / `Ctrl+O`, `/always-approve` or `/auto`, or `/settings` ([shortcuts](03-keyboard-shortcuts.md), [commands](04-slash-commands.md)).
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
grok --always-approve -p "Run the test suite"
|
||||
grok --permission-mode auto
|
||||
grok agent --always-approve serve --bind 127.0.0.1:2419 --secret <token>
|
||||
```
|
||||
|
||||
**Config:**
|
||||
|
||||
```toml
|
||||
[ui]
|
||||
permission_mode = "always-approve" # or "auto", "ask", …
|
||||
```
|
||||
|
||||
Claude-compatible `defaultMode` in `.claude/settings.json` is also supported (see [Claude-compatible settings](#3-claude-code-compatibility-claudesettingsjson)). CLI overrides config for that process.
|
||||
|
||||
### Always-approve
|
||||
|
||||
Skips ordinary permission prompts so tools run without waiting for a click. `deny` rules, hooks, and some shell `ask` rules still apply. Admins can lock the mode off (below).
|
||||
|
||||
| Mechanism | Example |
|
||||
| --------- | ------- |
|
||||
| CLI | `--always-approve` (alias `--yolo`), or `--permission-mode bypassPermissions` |
|
||||
| Config | `[ui] permission_mode = "always-approve"` |
|
||||
| Interactive | `/always-approve`, `Ctrl+O` |
|
||||
| ACP | `_meta.yoloMode: true` on `session/new` |
|
||||
|
||||
#### Always-approve with hard limits
|
||||
|
||||
Keep always-approve for automation, and add deny rules for paths or commands you never want run:
|
||||
|
||||
```toml
|
||||
# project .grok/config.toml
|
||||
[ui]
|
||||
permission_mode = "always-approve"
|
||||
|
||||
[permission]
|
||||
deny = [
|
||||
"Bash(rm -rf *)",
|
||||
"MCPTool(sales__delete_*)",
|
||||
]
|
||||
```
|
||||
|
||||
```bash
|
||||
grok -p "Deploy the service" --always-approve --deny 'Bash(rm -rf *)'
|
||||
```
|
||||
|
||||
Deny always wins over allow and over always-approve’s normal pass-through. See [Configuring permissions](#configuring-permissions).
|
||||
|
||||
### Auto mode
|
||||
|
||||
Reduces interactive prompts by checking many tool calls before they run. Routine local work often proceeds; other calls may be blocked or escalated. In non-interactive sessions, a blocked call fails and is reported to the model (for example `Auto mode blocked this action …`). Behavior is the same for `grok -p`, `agent stdio`, and `agent serve`.
|
||||
|
||||
For automation that must run tools without interactive approval, use always-approve (and deny rules if you need hard blocks) rather than auto alone.
|
||||
|
||||
### Disable always-approve (administrators)
|
||||
|
||||
Organizations can prevent always-approve from being enabled via CLI, TUI, or `/always-approve`. Set this in `requirements.toml` (user-level under `~/.grok/`, or system-wide under `/etc/grok/` for enforcement users cannot remove):
|
||||
|
||||
```toml
|
||||
[ui]
|
||||
disable_bypass_permissions_mode = true
|
||||
```
|
||||
|
||||
Do not use `permission_mode` for this lock; that key is a switchable default. The legacy `[ui] yolo = false` key in `requirements.toml` also disables always-approve for compatibility.
|
||||
|
||||
Grok can still load Claude-style permission **rules** from managed settings; always-approve is locked with `requirements.toml` as shown above.
|
||||
|
||||
---
|
||||
|
||||
## How a tool call is authorized
|
||||
|
||||
When the model requests a tool, the following checks happen in order:
|
||||
|
||||
|
|
@ -23,7 +133,7 @@ When the model requests a tool, the following checks happen in order:
|
|||
|
||||
5. **Prompt policy** (set by the [permission mode](#permission-modes)): prompt you, auto-approve, or auto-deny the call.
|
||||
|
||||
Always-approve mode (`bypassPermissions`) short-circuits this pipeline after step 2: `deny` rules, hooks, and `ask` rules that match a shell command's segments still apply, but remembered grants (including remembered "never allow" entries) are not consulted, and `ask` rules on non-shell tools do not prompt.
|
||||
[Always-approve](#always-approve) short-circuits this pipeline after step 2: `deny` rules, hooks, and `ask` rules that match a shell command's segments still apply, but remembered grants (including remembered "never allow" entries) are not consulted, and `ask` rules on non-shell tools do not prompt.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -58,47 +168,12 @@ After splitting chained commands (on `&&`, `||`, `;`, and pipes), the following
|
|||
**Kubernetes (read-only):**
|
||||
- `kubectl get`, `kubectl logs`, `kubectl describe`
|
||||
|
||||
> **Note:** `tee` is not on this list because it can write its input to arbitrary files. `cargo check` is not on this list because it compiles and runs `build.rs`, proc-macros, and any `build.rustc-wrapper` from the repo (in Ask mode it therefore prompts; Auto mode may still heuristic-allow `cargo` as a project code runner). `sort --compress-program=…` (including unique long-option abbreviations), `git -c` / `--config-env` overrides, and a git command whose local/worktree config installs an executable hook (`core.fsmonitor`, a `diff.*.command`/`textconv`/`external` driver, or a shell `alias.<safe-subcommand> = !…`) raise a request-level floor and prompt rather than auto-approve, unless the user granted that exact full script or YOLO is on.
|
||||
> **Note:** `tee` is not on this list because it can write its input to arbitrary files. `cargo check` is not on this list because it compiles and runs `build.rs`, proc-macros, and any `build.rustc-wrapper` from the repo (in Ask mode it therefore prompts; Auto mode may still heuristic-allow `cargo` as a project code runner). `sort --compress-program=…` (including unique long-option abbreviations), `git -c` / `--config-env` overrides, and a git command whose local/worktree config installs an executable hook (`core.fsmonitor`, a `diff.*.command`/`textconv`/`external` driver, or a shell `alias.<safe-subcommand> = !…`) raise a request-level floor and prompt rather than auto-approve, unless the user granted that exact full script or always-approve is enabled.
|
||||
|
||||
These checks apply per segment. In a command like `ls && rm -rf /`, the `ls` segment is recognized as read-only, but the `rm` segment is not on the list. In `default` mode the `rm` segment prompts; under `dontAsk` it is denied.
|
||||
|
||||
---
|
||||
|
||||
## Permission Modes
|
||||
|
||||
The prompt policy is named by one of these modes:
|
||||
|
||||
| Mode | Behavior | Typical Use |
|
||||
|---------------------|--------------------------------------------------------------------------|---------------------------------|
|
||||
| `default` | Prompt for anything not pre-approved | Daily interactive use |
|
||||
| `dontAsk` | Deny anything without an explicit allow rule or built-in auto-approval | Headless, CI, high-security |
|
||||
| `bypassPermissions` | Auto-approve tool calls (`deny` rules, hooks, and shell `ask` rules still apply) | Trusted environments |
|
||||
| `acceptEdits` | Auto-approve file edits (`search_replace`, `write`, etc.) | "Accept edits" workflows |
|
||||
| `plan` | Accepted for compatibility; plan sessions are a separate feature (see [19-plan-mode.md](19-plan-mode.md)) | Structured planning sessions |
|
||||
|
||||
### Setting the Mode
|
||||
|
||||
The mode is set by `defaultMode` in `.claude/settings.json` (see [Claude Code Compatibility](#3-claude-code-compatibility-claudesettingsjson)). `dontAsk`, `acceptEdits`, and `bypassPermissions` change the prompt policy from there; `default` and `plan` keep standard prompting.
|
||||
|
||||
The `--permission-mode` CLI flag applies `bypassPermissions` (always-approve) and `default`; an explicit flag value always wins over a mode set in configuration. Passing `dontAsk`, `acceptEdits`, or `plan` to the flag is accepted but does not enable that policy; set those through `defaultMode` instead.
|
||||
|
||||
In headless runs (`-p`), a tool call that would prompt is cancelled and reported to the model instead of waiting for input. For deny-by-default in automation, set `defaultMode: "dontAsk"`.
|
||||
|
||||
### Disabling Always-Approve Mode
|
||||
|
||||
Administrators can turn always-approve (`bypassPermissions` / `--always-approve`) off so it cannot be enabled from the CLI, the TUI toggle, or the `/always-approve` command. Set the dedicated key in `requirements.toml`:
|
||||
|
||||
```toml
|
||||
[ui]
|
||||
disable_bypass_permissions_mode = true # default: false. true = locked off.
|
||||
```
|
||||
|
||||
Do not use `permission_mode` for this; it is a user-switchable default, not a lock. The legacy `[ui] yolo = false` key in `requirements.toml` also disables the mode, for backward compatibility; in `config.toml` the same key remains a togglable preference.
|
||||
|
||||
The user-level `~/.grok/requirements.toml` is under the user's control, so a developer can remove the lock by editing that file. For enforcement that users cannot override, deploy the setting in the root-owned system file `/etc/grok/requirements.toml`.
|
||||
|
||||
> **Note:** Grok honors the permission rules in Claude Code's `managed-settings.json`, but not its `disableBypassPermissionsMode` lock. To disable always-approve in Grok, use `requirements.toml` as shown above.
|
||||
|
||||
---
|
||||
|
||||
## Configuring Permissions
|
||||
|
|
@ -220,7 +295,7 @@ Example:
|
|||
}
|
||||
```
|
||||
|
||||
Supported `defaultMode` values are `default`, `acceptEdits`, `bypassPermissions`, `dontAsk`, and `plan`. Grok reads `defaultMode` from its canonical location under `permissions`; a top-level `defaultMode` is also accepted when the nested key is absent.
|
||||
Supported `defaultMode` values include `default`, `auto`, `acceptEdits`, `bypassPermissions`, `dontAsk`, and `plan`. Grok reads `defaultMode` from its canonical location under `permissions`; a top-level `defaultMode` is also accepted when the nested key is absent.
|
||||
|
||||
`permissions.allow`, `permissions.deny`, and `permissions.ask` entries are translated into native rules and then matched with the semantics in the [Rule Matching Reference](#rule-matching-reference). Translation notes:
|
||||
|
||||
|
|
@ -451,7 +526,7 @@ Recommended combination for untrusted code:
|
|||
## Managing Permissions in the TUI
|
||||
|
||||
- Permission decisions appear in the transcript.
|
||||
- The `/always-approve` command toggles always-approve mode; other modes are set through `defaultMode` (see [Setting the Mode](#setting-the-mode)).
|
||||
- The `/always-approve` command toggles always-approve mode; other modes are set through `defaultMode` (see [How to set the mode](#how-to-set-the-mode)).
|
||||
- With `[ui] remember_tool_approvals = true`, permission prompts include per-command "Always allow" options that persist for the current project only. See [Interactive Approvals](#interactive-approvals-and-where-they-persist).
|
||||
- To manage hooks and plugins, run `/hooks` or `/plugins` (on most terminals, **Ctrl+L** also opens the Extensions modal; on VS Code, Cursor, Windsurf, and Zed, `Ctrl+L` is mid-turn interject instead). See [10-hooks.md](10-hooks.md).
|
||||
|
||||
|
|
@ -467,9 +542,11 @@ Recommended combination for untrusted code:
|
|||
|
||||
---
|
||||
|
||||
## See Also
|
||||
## See also
|
||||
|
||||
- [Hooks](10-hooks.md) — PreToolUse and other lifecycle scripts
|
||||
- [Headless mode](14-headless-mode.md) — One-shot CLI and automation flags
|
||||
- [Agent mode](15-agent-mode.md) — ACP, stdio, and agent servers
|
||||
- [Sandbox](18-sandbox.md) — OS-level isolation profiles
|
||||
- [Configuration](05-configuration.md) — Native `config.toml` structure
|
||||
|
||||
- [10-hooks.md](10-hooks.md) — Hook authoring guide
|
||||
- [14-headless-mode.md](14-headless-mode.md) — Headless flags, including permission-related ones
|
||||
- [18-sandbox.md](18-sandbox.md) — OS-level isolation profiles
|
||||
- [05-configuration.md](05-configuration.md) — Native `config.toml` structure
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Customize and extend Grok Build.
|
|||
| 6 | [Theming and Appearance](06-theming.md) | Themes, the `/theme` command, `pager.toml`, and color-support detection |
|
||||
| 7 | [MCP Servers](07-mcp-servers.md) | External tool integrations through the Model Context Protocol |
|
||||
| 8 | [Skills](08-skills.md) | Reusable prompt packages in the SKILL.md format |
|
||||
| 9 | [Plugins](09-plugins.md) | Bundle and share skills, commands, agents, hooks, and MCP servers; install from marketplace sources |
|
||||
| 9 | [Plugins](09-plugins.md) | Bundle and share skills, commands, agents, hooks, and MCP servers; install from, author, and govern marketplaces (organization controls) |
|
||||
| 10 | [Hooks](10-hooks.md) | Lifecycle scripts and HTTP callbacks for pre- and post-tool-use events |
|
||||
| 11 | [Custom Models](11-custom-models.md) | Bring-your-own-key, Ollama, and OpenAI-compatible endpoints |
|
||||
| 12 | [Project Rules (AGENTS.md)](12-project-rules.md) | Per-directory AGENTS.md instructions and their precedence |
|
||||
|
|
@ -49,6 +49,6 @@ Automate, script, and integrate Grok Build with other systems.
|
|||
| 19 | [Plan Mode](19-plan-mode.md) | Structured planning, plan-file edits, and approval before coding |
|
||||
| 20 | [Background Tasks and Monitoring](20-background-tasks.md) | `background: true`, `/loop`, `monitor`, and `Ctrl+B` to demote |
|
||||
| 21 | [Terminal Support and Troubleshooting](21-terminal-support.md) | tmux, SSH, truecolor, clipboard, and OSC 52 |
|
||||
| 22 | [Permissions and Safety Controls](22-permissions-and-safety.md) | `dontAsk` mode, auto-approved tools, the safe-bash list, and restrictive PreToolUse hooks (such as git/gh-only) |
|
||||
| 22 | [Permissions and Safety](22-permissions-and-safety.md) | Modes (always-approve, auto, ask), rules, matching, hooks, and examples |
|
||||
| 23 | [Agent Dashboard](23-dashboard.md) | Central overview of local sessions and forks |
|
||||
| 24 | [Monitoring Usage (External OpenTelemetry)](24-monitoring-usage.md) | Customer OTEL export |
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ pub(super) fn ingest_workflow_update(agent: &mut AgentView, update: XaiSessionUp
|
|||
model: a.model.clone(),
|
||||
state: a.state.clone(),
|
||||
tokens_used: a.tokens_used,
|
||||
duration_ms: a.duration_ms,
|
||||
})
|
||||
.collect(),
|
||||
agent_budget,
|
||||
|
|
|
|||
|
|
@ -733,6 +733,38 @@ mod link_click_tests {
|
|||
"click where stop used to be must not cancel the turn under a dropdown"
|
||||
);
|
||||
}
|
||||
/// Clicking the still-running watcher cue toggles the tasks pane like
|
||||
/// Ctrl+G; only the first click that reveals the pane shows the one-time
|
||||
/// shortcut toast.
|
||||
#[test]
|
||||
fn watching_cue_click_opens_tasks_pane_with_one_time_shortcut_toast() {
|
||||
let reg = ActionRegistry::defaults();
|
||||
let mut agent = make_agent();
|
||||
agent.last_terminal_size = (80, 30);
|
||||
super::test_fixtures::add_running_bg_task(&mut agent);
|
||||
draw_banner_frame(&mut agent, ®, &[], 0);
|
||||
let rect = agent.hit_watching_cue.rect.expect("cue rect must be armed");
|
||||
let click = Event::Mouse(mouse_down(rect.x + 1, rect.y));
|
||||
let _ = agent.handle_input(&click, ®);
|
||||
assert!(agent.tasks.overlay.focused);
|
||||
assert!(agent.toast.is_none(), "focus-only click must not toast");
|
||||
agent.tasks.overlay.hide();
|
||||
agent.tasks.on_state_change();
|
||||
draw_banner_frame(&mut agent, ®, &[], 0);
|
||||
let _ = agent.handle_input(&click, ®);
|
||||
assert!(agent.tasks.overlay.visible && agent.tasks.overlay.focused);
|
||||
assert_eq!(agent.active_pane, AgentPane::Tasks);
|
||||
let toast = agent.toast.clone().map(|(msg, _)| msg);
|
||||
assert_eq!(toast.as_deref(), Some("Tip: Ctrl+G toggles the tasks pane"));
|
||||
agent.toast = None;
|
||||
draw_banner_frame(&mut agent, ®, &[], 0);
|
||||
let _ = agent.handle_input(&click, ®);
|
||||
assert!(!agent.tasks.overlay.visible);
|
||||
draw_banner_frame(&mut agent, ®, &[], 0);
|
||||
let _ = agent.handle_input(&click, ®);
|
||||
assert!(agent.tasks.overlay.visible);
|
||||
assert!(agent.toast.is_none(), "toast fires only once per session");
|
||||
}
|
||||
/// Bg twin: the `[↓]` demote button rides the same turn-status row, so its
|
||||
/// rect must drop under an open dropdown too — a dropdown click must never
|
||||
/// background the running execute tool.
|
||||
|
|
|
|||
|
|
@ -1108,6 +1108,11 @@ pub struct AgentView {
|
|||
pub hit_cwd: HitArea,
|
||||
/// Cancel button in turn status line (`[stop]`).
|
||||
pub hit_cancel_button: HitArea,
|
||||
/// Still-running watcher cue on the turn-status row (click opens the
|
||||
/// tasks pane, same as `Ctrl+G`).
|
||||
pub hit_watching_cue: HitArea,
|
||||
/// One-time Ctrl+G toast already fired for a watching-cue click.
|
||||
pub(crate) watching_cue_toast_shown: bool,
|
||||
/// `[hide]` button on the announcement banner (click == `/announcements hide`).
|
||||
pub hit_announcement_hide: HitArea,
|
||||
/// `[label]` CTA button on the promo banner row (click opens its link).
|
||||
|
|
|
|||
|
|
@ -2005,6 +2005,7 @@ impl AgentView {
|
|||
));
|
||||
self.hit_cancel_button.rect = None;
|
||||
self.hit_bg_button.rect = None;
|
||||
self.hit_watching_cue.rect = None;
|
||||
} else {
|
||||
let has_running_execute = !self.is_subagent_view
|
||||
&& self
|
||||
|
|
@ -2023,36 +2024,42 @@ impl AgentView {
|
|||
let turn_output = turn_status::render_turn_status(
|
||||
buf,
|
||||
turn_area,
|
||||
&self.session.state,
|
||||
&activity,
|
||||
self.turn_elapsed(),
|
||||
self.activity_started_at,
|
||||
tick,
|
||||
drain_blocked,
|
||||
Some(turn_status::MouseButtons {
|
||||
cancel_hovered: self.hit_cancel_button.hovered,
|
||||
bg_hovered: self.hit_bg_button.hovered,
|
||||
}),
|
||||
has_running_execute,
|
||||
self.context_state.as_ref().map(|c| c.used),
|
||||
self.mcp_init_progress.as_ref(),
|
||||
self.bash_turn,
|
||||
is_pending_user_input,
|
||||
goal_verifying,
|
||||
watchers,
|
||||
parked,
|
||||
false,
|
||||
held_queue,
|
||||
held_queue_top_sendable,
|
||||
turn_status::TurnStatusArgs {
|
||||
state: &self.session.state,
|
||||
activity: &activity,
|
||||
turn_elapsed: self.turn_elapsed(),
|
||||
activity_started_at: self.activity_started_at,
|
||||
tick,
|
||||
drain_blocked,
|
||||
buttons: Some(turn_status::MouseButtons {
|
||||
cancel_hovered: self.hit_cancel_button.hovered,
|
||||
bg_hovered: self.hit_bg_button.hovered,
|
||||
watching_hovered: self.hit_watching_cue.hovered,
|
||||
}),
|
||||
has_running_execute,
|
||||
total_tokens: self.context_state.as_ref().map(|c| c.used),
|
||||
mcp_init_progress: self.mcp_init_progress.as_ref(),
|
||||
is_bash_turn: self.bash_turn,
|
||||
is_pending_user_input,
|
||||
goal_verifying,
|
||||
watchers,
|
||||
parked,
|
||||
flat_background: false,
|
||||
held_queue,
|
||||
held_queue_top_sendable,
|
||||
},
|
||||
);
|
||||
self.hit_cancel_button
|
||||
.set_unless_dropdown(turn_output.cancel_button, dropdown_open);
|
||||
self.hit_bg_button
|
||||
.set_unless_dropdown(turn_output.bg_button, dropdown_open);
|
||||
self.hit_watching_cue
|
||||
.set_unless_dropdown(turn_output.watching_cue, dropdown_open);
|
||||
}
|
||||
} else {
|
||||
self.hit_cancel_button.clear();
|
||||
self.hit_bg_button.clear();
|
||||
self.hit_watching_cue.clear();
|
||||
self.hit_plan_approval_status.clear();
|
||||
}
|
||||
let privacy_banner_owns_slot = privacy_banner && layout.banner.height >= 2;
|
||||
|
|
@ -2735,7 +2742,6 @@ impl AgentView {
|
|||
};
|
||||
let voice_overlay = if voice_available && (voice_listening || voice_interim.is_some()) {
|
||||
Some(crate::views::prompt_widget::VoicePromptOverlay {
|
||||
listening: voice_listening,
|
||||
interim: voice_interim,
|
||||
color: theme.accent_running,
|
||||
})
|
||||
|
|
@ -4208,8 +4214,23 @@ impl AgentView {
|
|||
let mut view = self.workflows_view.clone();
|
||||
view.normalize(&runs);
|
||||
let tick = self.tasks.tick_count() as usize;
|
||||
let live: crate::views::workflows::WorkflowAgentLiveMap = self
|
||||
.subagent_sessions
|
||||
.iter()
|
||||
.filter(|(_, info)| info.workflow_run_id.is_some() && info.is_running())
|
||||
.map(|(id, info)| {
|
||||
(
|
||||
id.clone(),
|
||||
crate::views::workflows::WorkflowAgentLiveStatus {
|
||||
activity: info.activity_label.clone(),
|
||||
tokens_used: info.tokens_used,
|
||||
elapsed_ms: Some(info.display_elapsed().as_millis() as u64),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let popup =
|
||||
crate::views::workflows::render_workflows(buf, area, &runs, &mut view, tick);
|
||||
crate::views::workflows::render_workflows(buf, area, &runs, &mut view, tick, &live);
|
||||
self.workflows_view = view;
|
||||
if let Some(popup) = popup {
|
||||
self.frame_occluder_rects.push(popup);
|
||||
|
|
|
|||
|
|
@ -196,6 +196,8 @@ impl AgentView {
|
|||
hit_follow_indicator: Default::default(),
|
||||
hit_cwd: Default::default(),
|
||||
hit_cancel_button: Default::default(),
|
||||
hit_watching_cue: Default::default(),
|
||||
watching_cue_toast_shown: false,
|
||||
hit_announcement_hide: Default::default(),
|
||||
hit_announcement_cta: Default::default(),
|
||||
privacy_banner: Default::default(),
|
||||
|
|
|
|||
|
|
@ -464,6 +464,7 @@ mod workflows_overlay_key_tests {
|
|||
model: None,
|
||||
state: "done".to_owned(),
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
},
|
||||
crate::views::workflows::WorkflowAgentRowView {
|
||||
agent_id: "child-running".to_owned(),
|
||||
|
|
@ -472,6 +473,7 @@ mod workflows_overlay_key_tests {
|
|||
model: None,
|
||||
state: "running".to_owned(),
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
},
|
||||
];
|
||||
agent
|
||||
|
|
@ -511,7 +513,7 @@ mod workflows_overlay_key_tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn only_explicitly_paused_background_runs_are_resumable() {
|
||||
fn paused_budget_limited_and_failed_runs_are_resumable_others_fail_closed() {
|
||||
let mut agent = workflows_agent(&["wf_run"]);
|
||||
let reg = ActionRegistry::defaults();
|
||||
agent.workflow_runs[0].status = "user_paused".to_string();
|
||||
|
|
@ -540,8 +542,24 @@ mod workflows_overlay_key_tests {
|
|||
agent.show_workflows = true;
|
||||
agent.workflow_runs[0].status = "failed".to_string();
|
||||
let out = agent.handle_input(&key(KeyCode::Char('r')), ®);
|
||||
assert!(
|
||||
matches!(
|
||||
out,
|
||||
InputOutcome::Action(Action::SendSlashCommandPreservingDraft(ref command))
|
||||
if command == "/workflow resume deep-research"
|
||||
),
|
||||
"failed runs resume via journal replay"
|
||||
);
|
||||
assert!(
|
||||
!agent.show_workflows,
|
||||
"failed r dispatches a resume and closes the overlay"
|
||||
);
|
||||
|
||||
agent.show_workflows = true;
|
||||
agent.workflow_runs[0].status = "complete".to_string();
|
||||
let out = agent.handle_input(&key(KeyCode::Char('r')), ®);
|
||||
assert!(matches!(out, InputOutcome::Changed));
|
||||
assert!(agent.show_workflows, "failed runs must not be resumed");
|
||||
assert!(agent.show_workflows, "completed runs must not be resumed");
|
||||
|
||||
agent.workflow_runs[0].status = "user_paused".to_string();
|
||||
agent.workflow_runs[0].management_available = false;
|
||||
|
|
|
|||
|
|
@ -1881,6 +1881,27 @@ impl AppView {
|
|||
|| self.voice_listening()
|
||||
|| self.voice_state.pending_cold_start()
|
||||
}
|
||||
/// Commit interim on real send keys only (not multiline bare Enter).
|
||||
fn maybe_commit_voice_interim_before_submit_key(&mut self, key: &crossterm::event::KeyEvent) {
|
||||
if self.registry.matches_id(ActionId::InterjectPrompt, key) {
|
||||
let _ = crate::voice::commit_interim_into_prompt(self);
|
||||
return;
|
||||
}
|
||||
let multiline = match self.active_view {
|
||||
ActiveView::Agent(id) => self.agents.get(&id).is_some_and(|a| a.multiline_mode),
|
||||
ActiveView::AgentDashboard => self.dashboard.as_ref().is_some_and(|d| d.multiline_mode),
|
||||
_ => false,
|
||||
};
|
||||
let is_send = if multiline {
|
||||
crate::input::is_mod_enter(key)
|
||||
} else {
|
||||
matches!(key.code, KeyCode::Enter)
|
||||
|| self.registry.matches_id(ActionId::SendPrompt, key)
|
||||
};
|
||||
if is_send {
|
||||
let _ = crate::voice::commit_interim_into_prompt(self);
|
||||
}
|
||||
}
|
||||
/// The active agent's view, when an agent tab is focused.
|
||||
///
|
||||
/// Always the root agent, even when a subagent view is focused within the
|
||||
|
|
@ -2623,6 +2644,11 @@ impl AppView {
|
|||
if let Some(outcome) = self.voice_esc_outcome(key_event) {
|
||||
return outcome;
|
||||
}
|
||||
if let Event::Key(key) = ev
|
||||
&& key.kind != KeyEventKind::Release
|
||||
{
|
||||
self.maybe_commit_voice_interim_before_submit_key(key);
|
||||
}
|
||||
if self.screen_mode.is_minimal()
|
||||
&& let Event::Key(key) = ev
|
||||
&& key.kind != KeyEventKind::Release
|
||||
|
|
@ -2669,6 +2695,11 @@ impl AppView {
|
|||
if let Some(outcome) = self.voice_esc_outcome(key_event) {
|
||||
return outcome;
|
||||
}
|
||||
if let Event::Key(key) = ev
|
||||
&& key.kind != KeyEventKind::Release
|
||||
{
|
||||
self.maybe_commit_voice_interim_before_submit_key(key);
|
||||
}
|
||||
let attached_raw = self.dashboard.as_ref().and_then(|d| d.attached_agent);
|
||||
let attached = attached_raw.filter(|id| self.agents.contains_key(id));
|
||||
if attached_raw.is_some()
|
||||
|
|
@ -9919,6 +9950,7 @@ pub(crate) mod tests {
|
|||
model: None,
|
||||
state: "running".to_owned(),
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
}],
|
||||
agent_budget: None,
|
||||
agents_used: 0,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use super::session::load::dispatch_load_session;
|
|||
use super::session::load::focus_if_session_already_open;
|
||||
use super::session::modal::dispatch_sessions_confirm_close;
|
||||
use super::turn::dispatch_cancel_turn;
|
||||
use super::voice::voice_stop_on_submit;
|
||||
use super::voice::{merge_prompt_with_voice_interim, voice_stop_on_submit};
|
||||
use crate::app::actions::{Action, Effect};
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
|
|
@ -667,9 +667,7 @@ fn open_dashboard_worktree_dialog(
|
|||
/// Mirrors `dispatch_dashboard_dispatch`'s new-session arm with `attach=true`,
|
||||
/// minus the prompt enqueue.
|
||||
pub(super) fn dispatch_dashboard_create_new_agent_with_detail(app: &mut AppView) -> Vec<Effect> {
|
||||
// Creating/switching consumes the dispatch surface — stop voice and drop the
|
||||
// target so a late final can't refill the box after the view switch.
|
||||
voice_stop_on_submit(app);
|
||||
let _ = voice_stop_on_submit(app);
|
||||
// Worktree mode armed + git repo: open the label dialog (which spawns the
|
||||
// agent in a fresh worktree on confirm) instead of a plain session. The
|
||||
// button opens the detail view, so confirm attaches (`attach = true`).
|
||||
|
|
@ -1101,10 +1099,7 @@ pub(super) fn dispatch_dashboard_dispatch(
|
|||
text: String,
|
||||
attach: bool,
|
||||
) -> Vec<Effect> {
|
||||
// Enter is a submit attempt — stop voice and drop the target up front (as the
|
||||
// agent path does), so even a rejected send (empty / over-cap) can't leave a
|
||||
// hot mic or let a late final refill the box.
|
||||
voice_stop_on_submit(app);
|
||||
let text = merge_prompt_with_voice_interim(text, voice_stop_on_submit(app));
|
||||
// Paste-then-immediate-send: a Cmd+V image probe is still off-thread. Stash
|
||||
// this send and re-issue it once the probe completes so the image is never
|
||||
// dropped from the dispatched prompt's content blocks.
|
||||
|
|
@ -1284,8 +1279,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
|
|||
use crate::slash::command::{CommandExecCtx, CommandResult};
|
||||
use crate::slash::parse_invocation;
|
||||
|
||||
// Enter is a submit attempt — stop voice and drop the target up front.
|
||||
voice_stop_on_submit(app);
|
||||
let text = merge_prompt_with_voice_interim(text, voice_stop_on_submit(app));
|
||||
let trimmed = text.trim().to_string();
|
||||
if trimmed.is_empty() || !trimmed.starts_with('/') {
|
||||
return vec![];
|
||||
|
|
@ -1680,9 +1674,7 @@ pub(super) fn dispatch_dashboard_peek_reply(
|
|||
) -> Vec<Effect> {
|
||||
use crate::views::dashboard::DashboardRowId;
|
||||
|
||||
// Enter is a submit attempt — stop voice and drop the target up front so a
|
||||
// rejected reply can't leave a hot mic or let a late final refill the box.
|
||||
voice_stop_on_submit(app);
|
||||
let text = merge_prompt_with_voice_interim(text, voice_stop_on_submit(app));
|
||||
|
||||
// Paste-then-immediate-send: a Cmd+V image probe is still off-thread. Stash
|
||||
// this reply and re-issue it once the probe completes so the image is never
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
//! `x.ai/interject` effect, and prompt-history recording. Split out of
|
||||
//! `dispatch.rs` verbatim (pure code motion).
|
||||
|
||||
use super::voice::voice_stop_on_submit;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
|
|
@ -23,6 +24,8 @@ pub(super) fn dispatch_interject(
|
|||
text: String,
|
||||
images: Vec<crate::prompt_images::PastedImage>,
|
||||
) -> Vec<Effect> {
|
||||
// Hard-reset only — `text` may not be from the composer.
|
||||
let _ = voice_stop_on_submit(app);
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
|
|
@ -90,6 +93,8 @@ pub(super) fn dispatch_send_prompt_now(
|
|||
text: String,
|
||||
images: Vec<crate::prompt_images::PastedImage>,
|
||||
) -> Vec<Effect> {
|
||||
// Hard-reset only — `text` may be a queue row, not the composer.
|
||||
let _ = voice_stop_on_submit(app);
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use super::queue::{
|
|||
use super::router::dispatch;
|
||||
use super::session::fork::open_project_question;
|
||||
use super::session::lifecycle::skip_picker_and_create_session;
|
||||
use super::voice::voice_stop_on_submit;
|
||||
use super::voice::{merge_prompt_with_voice_interim, voice_stop_on_submit};
|
||||
use crate::app::actions::{Action, DoctorFixTarget, Effect};
|
||||
use crate::app::agent::{AgentId, AgentState};
|
||||
use crate::app::agent_view::AgentView;
|
||||
|
|
@ -441,9 +441,13 @@ pub(super) fn dispatch_send_prompt_inner(
|
|||
// the common funnel so every submit path is covered, before any early-return
|
||||
// guard below.
|
||||
app.pending_action = None;
|
||||
// Releases the mic and drops the recording target so a late in-flight final
|
||||
// can't refill the prompt the user just sent.
|
||||
voice_stop_on_submit(app);
|
||||
// Promote interim + hard-reset; merge only when consuming the composer.
|
||||
let interim = voice_stop_on_submit(app);
|
||||
let text = if consume_input {
|
||||
merge_prompt_with_voice_interim(text, interim)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
|
||||
if app.reconnect_pending {
|
||||
app.show_toast("Reconnecting, please wait...");
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@ fn voice_final_appends_to_prompt_with_single_space() {
|
|||
target: VoiceTarget::Agent(id),
|
||||
interim: None,
|
||||
};
|
||||
app.agents.get_mut(&id).unwrap().prompt.set_text("hello");
|
||||
let p = &mut app.agents.get_mut(&id).unwrap().prompt;
|
||||
p.set_text("hello");
|
||||
p.set_cursor(5);
|
||||
let redraw = crate::voice::handle_voice_event(
|
||||
&mut app,
|
||||
xai_grok_voice::VoiceEvent::UtteranceFinal {
|
||||
|
|
@ -75,7 +77,36 @@ fn voice_final_appends_to_prompt_with_single_space() {
|
|||
},
|
||||
);
|
||||
assert!(redraw);
|
||||
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "hello world");
|
||||
let p = &app.agents.get(&id).unwrap().prompt;
|
||||
assert_eq!(p.text(), "hello world");
|
||||
assert_eq!(p.cursor(), "hello world".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_final_preserves_mid_text_cursor() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
app.voice_state = VoiceState::Recording {
|
||||
hold: false,
|
||||
target: VoiceTarget::Agent(id),
|
||||
interim: Some("partial".into()),
|
||||
};
|
||||
let p = &mut app.agents.get_mut(&id).unwrap().prompt;
|
||||
p.set_text("hello world");
|
||||
p.set_cursor(5);
|
||||
|
||||
crate::voice::handle_voice_event(
|
||||
&mut app,
|
||||
xai_grok_voice::VoiceEvent::UtteranceFinal {
|
||||
text: "again".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let p = &app.agents.get(&id).unwrap().prompt;
|
||||
assert_eq!(p.text(), "hello world again");
|
||||
assert_eq!(p.cursor(), 5);
|
||||
assert!(app.voice_listening());
|
||||
assert!(app.voice_interim().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -92,7 +123,29 @@ fn voice_final_into_empty_prompt_has_no_leading_space() {
|
|||
text: "hi there".into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "hi there");
|
||||
let p = &app.agents.get(&id).unwrap().prompt;
|
||||
assert_eq!(p.text(), "hi there");
|
||||
assert_eq!(p.cursor(), "hi there".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_final_replaces_whitespace_only_draft() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
app.voice_state = VoiceState::Stopping {
|
||||
target: VoiceTarget::Agent(id),
|
||||
interim: None,
|
||||
};
|
||||
let p = &mut app.agents.get_mut(&id).unwrap().prompt;
|
||||
p.set_text(" \n");
|
||||
p.set_cursor(0);
|
||||
crate::voice::handle_voice_event(
|
||||
&mut app,
|
||||
xai_grok_voice::VoiceEvent::UtteranceFinal { text: "hi".into() },
|
||||
);
|
||||
let p = &app.agents.get(&id).unwrap().prompt;
|
||||
assert_eq!(p.text(), "hi");
|
||||
assert_eq!(p.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -733,3 +786,66 @@ fn voice_stt_language_auto_stored_unresolved() {
|
|||
assert_eq!(app.voice_config.language, "auto");
|
||||
assert_eq!(app.current_ui.voice_stt_language.as_deref(), Some("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_submit_includes_interim() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
|
||||
app.voice_cmd_tx = Some(tx);
|
||||
app.agents.get_mut(&id).unwrap().prompt.set_text("hello");
|
||||
app.voice_state = VoiceState::Recording {
|
||||
hold: false,
|
||||
target: VoiceTarget::Agent(id),
|
||||
interim: Some("world".into()),
|
||||
};
|
||||
|
||||
let effects = dispatch(Action::SendPrompt("hello".into()), &mut app);
|
||||
let Effect::SendPrompt { text, .. } = &effects[0] else {
|
||||
panic!("expected SendPrompt, got {effects:?}");
|
||||
};
|
||||
assert_eq!(text, "hello world");
|
||||
assert!(!app.voice_listening());
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(xai_grok_voice::VoiceCommand::PttRelease)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_submit_interim_only() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
app.voice_state = VoiceState::Recording {
|
||||
hold: false,
|
||||
target: VoiceTarget::Agent(id),
|
||||
interim: Some("ghost only".into()),
|
||||
};
|
||||
|
||||
let effects = dispatch(Action::SendPrompt(String::new()), &mut app);
|
||||
let Effect::SendPrompt { text, .. } = &effects[0] else {
|
||||
panic!("expected SendPrompt, got {effects:?}");
|
||||
};
|
||||
assert_eq!(text, "ghost only");
|
||||
assert!(!app.voice_listening());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_submit_follow_up_keeps_chip_literal() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
app.agents.get_mut(&id).unwrap().prompt.set_text("draft");
|
||||
app.voice_state = VoiceState::Recording {
|
||||
hold: false,
|
||||
target: VoiceTarget::Agent(id),
|
||||
interim: Some("dictated".into()),
|
||||
};
|
||||
|
||||
let effects = dispatch(Action::SubmitFollowUp("chip text".into()), &mut app);
|
||||
let Effect::SendPrompt { text, .. } = &effects[0] else {
|
||||
panic!("expected SendPrompt, got {effects:?}");
|
||||
};
|
||||
assert_eq!(text, "chip text");
|
||||
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "draft dictated");
|
||||
assert!(!app.voice_listening());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,20 @@ use super::session::lifecycle::dispatch_new_session;
|
|||
use crate::app::actions::Effect;
|
||||
use crate::app::app_view::{ActiveView, AppView, VoiceState, VoiceTarget};
|
||||
|
||||
/// Tear down voice when a prompt box is **submitted** (Enter / send): release
|
||||
/// the mic and forget the session entirely (no trailing final) so a late
|
||||
/// in-flight final can't refill the box the user just sent, and a queued
|
||||
/// cold-start can't open the mic afterwards. Used by the agent prompt and every
|
||||
/// dashboard submit path (dispatch / peek reply / new-agent / slash).
|
||||
pub(super) fn voice_stop_on_submit(app: &mut AppView) {
|
||||
/// Promote live interim into the bound prompt, then hard-reset (no trailing
|
||||
/// final). Returns the fragment for callers that captured text earlier.
|
||||
pub(super) fn voice_stop_on_submit(app: &mut AppView) -> Option<String> {
|
||||
let interim = crate::voice::commit_interim_into_prompt(app);
|
||||
app.voice_reset();
|
||||
interim
|
||||
}
|
||||
|
||||
/// Merge interim into a payload captured before [`voice_stop_on_submit`].
|
||||
pub(super) fn merge_prompt_with_voice_interim(existing: String, interim: Option<String>) -> String {
|
||||
match interim {
|
||||
Some(interim) => crate::voice::combine_prompt_with_voice_text(&existing, &interim),
|
||||
None => existing,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompt box dictation should target for the current surface: a top-level
|
||||
|
|
|
|||
|
|
@ -151,6 +151,23 @@ impl AgentView {
|
|||
crate::views::privacy_banner::PRIVACY_BANNER_LEGAL_URL.to_string(),
|
||||
));
|
||||
}
|
||||
if self.hit_watching_cue.contains(mouse.column, mouse.row)
|
||||
&& !self.pos_occluded(mouse.column, mouse.row)
|
||||
{
|
||||
let was_visible = self.tasks.overlay.visible;
|
||||
self.tasks.overlay.toggle();
|
||||
self.tasks.on_state_change();
|
||||
if self.tasks.overlay.focused {
|
||||
self.set_active_pane(AgentPane::Tasks, false);
|
||||
} else if self.active_pane == AgentPane::Tasks {
|
||||
self.set_active_pane(AgentPane::Scrollback, false);
|
||||
}
|
||||
if !was_visible && !self.watching_cue_toast_shown {
|
||||
self.watching_cue_toast_shown = true;
|
||||
self.show_toast("Tip: Ctrl+G toggles the tasks pane");
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if self.hit_announcement_hide.contains(mouse.column, mouse.row)
|
||||
&& !self.pos_occluded(mouse.column, mouse.row)
|
||||
{
|
||||
|
|
@ -1068,6 +1085,7 @@ impl AgentView {
|
|||
.update_hover(mouse.column, mouse.row);
|
||||
changed |= self.hit_cancel_button.update_hover(mouse.column, mouse.row);
|
||||
changed |= self.hit_bg_button.update_hover(mouse.column, mouse.row);
|
||||
changed |= self.hit_watching_cue.update_hover(mouse.column, mouse.row);
|
||||
changed |= self
|
||||
.hit_announcement_hide
|
||||
.update_hover(mouse.column, mouse.row);
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ pub static USER_GUIDE: &[Doc] = &[
|
|||
guide!(
|
||||
"22-permissions-and-safety.md",
|
||||
"Permissions and Safety",
|
||||
"Tool approval, sandbox, security"
|
||||
"Modes, authorization order, allow/ask/deny rules, matching, and hooks"
|
||||
),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -868,12 +868,9 @@ pub fn render_peek_panel(
|
|||
image_preview: false,
|
||||
..PromptStyle::default()
|
||||
};
|
||||
// Stream the interim transcript into the reply box (and hide the caret)
|
||||
// while dictating, so voice on the dashboard is visible even with a row's
|
||||
// peek panel open — it stands in for the dispatch box's voice overlay.
|
||||
// Interim STT into the reply box so voice stays visible with a peek open.
|
||||
let voice_overlay = (voice_listening || voice_interim.is_some()).then_some(
|
||||
crate::views::prompt_widget::VoicePromptOverlay {
|
||||
listening: voice_listening,
|
||||
interim: voice_interim,
|
||||
color: theme.accent_running,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2981,13 +2981,10 @@ fn render_dispatch(
|
|||
let prefix = "\u{276F} ";
|
||||
let prefix_w = UnicodeWidthStr::width(prefix) as u16;
|
||||
|
||||
// Voice overlay: stream the interim transcript into the box and hide the
|
||||
// caret while listening. When active we render through `PromptWidget::draw`
|
||||
// (below) even on an empty buffer, so the manual empty-state branch is
|
||||
// skipped in that case.
|
||||
// When voice is active, draw through PromptWidget even on an empty buffer
|
||||
// so the manual empty-state branch is skipped.
|
||||
let voice_overlay = (state.voice_listening || state.voice_interim.is_some()).then_some(
|
||||
crate::views::prompt_widget::VoicePromptOverlay {
|
||||
listening: state.voice_listening,
|
||||
interim: state.voice_interim.as_deref(),
|
||||
color: theme.accent_running,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -293,22 +293,17 @@ pub struct PromptInfo<'a> {
|
|||
pub usage_warning_critical: bool,
|
||||
}
|
||||
|
||||
/// Live voice-capture overlay state for the prompt.
|
||||
/// Live voice-capture overlay for the prompt.
|
||||
///
|
||||
/// When voice capture is active the interim STT transcript streams
|
||||
/// directly into the prompt body (in [`color`](Self::color)) so the user
|
||||
/// sees their words land in the input box instead of a status-bar indicator.
|
||||
/// The prompt prefix stays the normal `❯` chevron; the recording state is
|
||||
/// signalled by a pulsating record indicator rendered above the prompt box.
|
||||
/// Interim STT paints as muted italic ghost text (not in the textarea).
|
||||
/// Finalized STT is real prompt content and stays editable while the mic is open.
|
||||
/// Overlay presence (even with no interim) marks voice active for callers that
|
||||
/// skip empty-state placeholders while capturing.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct VoicePromptOverlay<'a> {
|
||||
/// Whether the mic is currently capturing (suppresses the caret while the
|
||||
/// interim transcript stands in for it).
|
||||
pub listening: bool,
|
||||
/// Latest interim transcript to stream into the prompt body, if any.
|
||||
/// Latest interim transcript, if any.
|
||||
pub interim: Option<&'a str>,
|
||||
/// Accent color used for both the mic prefix and the streamed text so
|
||||
/// voice input is visually distinct from typed text.
|
||||
/// Theme accent associated with this overlay.
|
||||
pub color: ratatui::style::Color,
|
||||
}
|
||||
|
||||
|
|
@ -3094,8 +3089,8 @@ impl PromptWidget {
|
|||
(snap.active, snap.inline_ghost.is_some())
|
||||
};
|
||||
|
||||
// Voice interim transcript rendered in muted text_secondary so
|
||||
// in-progress words are visually distinct from finalized text.
|
||||
// Interim STT: muted italic overlay (not in the textarea). Finalized
|
||||
// text remains the real, editable draft.
|
||||
let voice_interim_shown = if let Some(v) = voice
|
||||
&& let Some(interim) = v.interim.filter(|t| !t.trim().is_empty())
|
||||
&& ta_area.width > 0
|
||||
|
|
@ -3103,7 +3098,10 @@ impl PromptWidget {
|
|||
{
|
||||
let interim_fg = crate::render::color::blend_color(bg, theme.text_secondary, 0.7)
|
||||
.unwrap_or(theme.gray);
|
||||
let interim_style = Style::default().fg(interim_fg).bg(bg);
|
||||
let interim_style = Style::default()
|
||||
.fg(interim_fg)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::ITALIC);
|
||||
if self.textarea.text().is_empty() {
|
||||
let lines =
|
||||
wrap_voice_interim(interim, ta_area.width as usize, ta_area.height as usize);
|
||||
|
|
@ -3111,11 +3109,11 @@ impl PromptWidget {
|
|||
buf.set_string(ta_area.x, ta_area.y + i as u16, line, interim_style);
|
||||
}
|
||||
} else {
|
||||
// Append interim as ghost-text suffix after finalized text.
|
||||
let cursor = self.textarea.text().len();
|
||||
// Ghost suffix after the finalized draft (not at the caret).
|
||||
let end = self.textarea.text().len();
|
||||
if let Some((start_x, row_y)) =
|
||||
self.textarea
|
||||
.screen_position_of(cursor, ta_area, self.textarea_state)
|
||||
.screen_position_of(end, ta_area, self.textarea_state)
|
||||
{
|
||||
let display = format!(" {interim}");
|
||||
let avail = (ta_area.x + ta_area.width).saturating_sub(start_x) as usize;
|
||||
|
|
@ -3208,44 +3206,41 @@ impl PromptWidget {
|
|||
crate::render::color::blend_area(buf, dim_area, Some((bg, 0.66)), None);
|
||||
}
|
||||
|
||||
// Hide the cursor while voice capture is active — the streamed
|
||||
// transcript stands in for the caret, so a blinking cursor over it
|
||||
// is noise.
|
||||
let voice_listening = voice.is_some_and(|v| v.listening);
|
||||
let cursor_pos = if style.focused && !voice_listening {
|
||||
// Finalized draft stays editable during voice; hide the caret only when
|
||||
// the box is empty and interim is standing in for it.
|
||||
let hide_caret_for_empty_interim = self.textarea.text().is_empty()
|
||||
&& voice.is_some_and(|v| v.interim.is_some_and(|t| !t.trim().is_empty()));
|
||||
let cursor_pos = if style.focused && !hide_caret_for_empty_interim {
|
||||
self.textarea
|
||||
.cursor_pos_with_state(ta_area, self.textarea_state)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Shell command ghost text: render suggestion suffix after cursor.
|
||||
if let Some(ghost) = self.suggestions.ghost_text()
|
||||
&& self.textarea.cursor() == self.textarea.text().len()
|
||||
&& !slash_active
|
||||
&& !slash_has_inline_ghost
|
||||
&& let Some((cx, cy)) = cursor_pos
|
||||
{
|
||||
let avail = (ta_area.x + ta_area.width).saturating_sub(cx) as usize;
|
||||
if avail > 0 {
|
||||
let truncated = crate::render::line_utils::truncate_str(ghost, avail);
|
||||
buf.set_string(cx, cy, &truncated, theme.ghost_text_style().bg(bg));
|
||||
// Ghost suffixes (shell completion / predicted prompt). Voice interim
|
||||
// owns the end-of-text cells when shown, so skip both ghosts then.
|
||||
if !voice_interim_shown {
|
||||
if let Some(ghost) = self.suggestions.ghost_text()
|
||||
&& self.textarea.cursor() == self.textarea.text().len()
|
||||
&& !slash_active
|
||||
&& !slash_has_inline_ghost
|
||||
&& let Some((cx, cy)) = cursor_pos
|
||||
{
|
||||
let avail = (ta_area.x + ta_area.width).saturating_sub(cx) as usize;
|
||||
if avail > 0 {
|
||||
let truncated = crate::render::line_utils::truncate_str(ghost, avail);
|
||||
buf.set_string(cx, cy, &truncated, theme.ghost_text_style().bg(bg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Predicted-next-prompt ghost (tab autocomplete): render the remainder
|
||||
// of the suggestion after the cursor. `prompt_suggestion_ghost()`
|
||||
// owns all gating (per-frame active flag, no competing completion UI,
|
||||
// cursor at end-of-text); voice interim already occupies the row when
|
||||
// shown, so it wins.
|
||||
if !voice_interim_shown
|
||||
&& let Some(ghost) = self.prompt_suggestion_ghost()
|
||||
&& let Some((cx, cy)) = cursor_pos
|
||||
{
|
||||
let avail = (ta_area.x + ta_area.width).saturating_sub(cx) as usize;
|
||||
if avail > 0 {
|
||||
let truncated = crate::render::line_utils::truncate_str(ghost, avail);
|
||||
buf.set_string(cx, cy, &truncated, theme.ghost_text_style().bg(bg));
|
||||
if let Some(ghost) = self.prompt_suggestion_ghost()
|
||||
&& let Some((cx, cy)) = cursor_pos
|
||||
{
|
||||
let avail = (ta_area.x + ta_area.width).saturating_sub(cx) as usize;
|
||||
if avail > 0 {
|
||||
let truncated = crate::render::line_utils::truncate_str(ghost, avail);
|
||||
buf.set_string(cx, cy, &truncated, theme.ghost_text_style().bg(bg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3486,6 +3486,7 @@ mod tests {
|
|||
model: None,
|
||||
state: "running".into(),
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
},
|
||||
crate::views::workflows::WorkflowAgentRowView {
|
||||
agent_id: "a2".into(),
|
||||
|
|
@ -3494,6 +3495,7 @@ mod tests {
|
|||
model: None,
|
||||
state: "done".into(),
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
},
|
||||
];
|
||||
let entry = TaskEntry::from_workflow_run(&run);
|
||||
|
|
|
|||
|
|
@ -74,20 +74,22 @@ pub struct TurnStatusOutput {
|
|||
pub cancel_button: Option<Rect>,
|
||||
/// Hit area for the background-demote button, if rendered.
|
||||
pub bg_button: Option<Rect>,
|
||||
/// Hit area for the still-running watcher cue (click opens the tasks
|
||||
/// pane). `None` on keyboard-only hosts.
|
||||
pub watching_cue: Option<Rect>,
|
||||
}
|
||||
|
||||
/// Mouse-clickable affordances on the turn-status row — the `[stop]` cancel and
|
||||
/// `[↓]` send-to-background buttons — with their current hover state. Passing
|
||||
/// `Some(_)` to [`render_turn_status`] renders the buttons; passing `None`
|
||||
/// marks a keyboard-only host (minimal mode has no mouse capture) and suppresses
|
||||
/// both — that host cancels the turn via `Ctrl+C` and sends to background via
|
||||
/// `Ctrl+B` instead.
|
||||
/// Hover state for the turn-status row's mouse affordances (`[stop]`, `[↓]`,
|
||||
/// the still-running watcher cue). `Some(_)` renders them; `None` marks a
|
||||
/// keyboard-only host (minimal mode — no mouse capture) and suppresses all.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct MouseButtons {
|
||||
/// Whether the mouse is over the `[stop]` cancel button.
|
||||
pub cancel_hovered: bool,
|
||||
/// Whether the mouse is over the `[↓]` send-to-background button.
|
||||
pub bg_hovered: bool,
|
||||
/// Whether the mouse is over the still-running watcher cue.
|
||||
pub watching_hovered: bool,
|
||||
}
|
||||
|
||||
/// Counts of idle-surviving "watcher" work — background jobs that can wake
|
||||
|
|
@ -191,49 +193,64 @@ pub fn is_sendable_wait(activity: &Option<TurnActivity>) -> bool {
|
|||
)
|
||||
}
|
||||
|
||||
/// Inputs to [`render_turn_status`] — one frame's worth of turn state.
|
||||
#[derive(Debug)]
|
||||
pub struct TurnStatusArgs<'a> {
|
||||
pub state: &'a AgentState,
|
||||
pub activity: &'a Option<TurnActivity>,
|
||||
pub turn_elapsed: Option<Duration>,
|
||||
pub activity_started_at: Option<Instant>,
|
||||
pub tick: u64,
|
||||
pub drain_blocked: bool,
|
||||
/// Mouse affordances + hover state; `None` for keyboard-only hosts.
|
||||
pub buttons: Option<MouseButtons>,
|
||||
pub has_running_execute: bool,
|
||||
/// Context-window tokens used, shown as `⇣Nk`.
|
||||
pub total_tokens: Option<u64>,
|
||||
pub mcp_init_progress: Option<&'a McpInitProgress>,
|
||||
pub is_bash_turn: bool,
|
||||
pub is_pending_user_input: bool,
|
||||
pub goal_verifying: bool,
|
||||
pub watchers: Watchers,
|
||||
/// Parked on a sendable wait (`AgentView::renders_parked`): suppress the
|
||||
/// running-turn chrome and render only the still-running cue.
|
||||
pub parked: bool,
|
||||
/// Transparent right-side background so the row blends with the
|
||||
/// terminal's own background (minimal mode).
|
||||
pub flat_background: bool,
|
||||
pub held_queue: usize,
|
||||
pub held_queue_top_sendable: bool,
|
||||
}
|
||||
|
||||
/// Render the turn status line into the given area.
|
||||
///
|
||||
/// The caller is responsible for only allocating a 1-row area when
|
||||
/// `should_show()` returns true (and 0 rows when false).
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `buttons`: `Some(MouseButtons { .. })` to render the mouse-clickable
|
||||
/// `[stop]` / `[↓]` buttons with their hover state; `None` for a keyboard-only
|
||||
/// host (minimal mode — no mouse capture), which suppresses both buttons.
|
||||
/// - `total_tokens`: Total tokens used (context window usage), shown as `⇣Nk`.
|
||||
/// - `parked`: the turn is parked on a sendable wait and renders the stopped
|
||||
/// look (`AgentView::renders_parked`). The running-turn chrome is suppressed;
|
||||
/// only the "… still running" cue renders (the parked turn is by definition
|
||||
/// waiting on background work, so the cue explains the idle-looking chrome).
|
||||
/// - `flat_background`: when `true`, right-side timer/buttons use a transparent
|
||||
/// (`Color::Reset`) background instead of `theme.bg_base`, so the row blends
|
||||
/// with the terminal's own background (minimal mode).
|
||||
///
|
||||
/// # Returns
|
||||
/// A [`TurnStatusOutput`] containing the cancel button hit area (if rendered).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_turn_status(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
state: &AgentState,
|
||||
activity: &Option<TurnActivity>,
|
||||
turn_elapsed: Option<Duration>,
|
||||
activity_started_at: Option<Instant>,
|
||||
tick: u64,
|
||||
drain_blocked: bool,
|
||||
buttons: Option<MouseButtons>,
|
||||
has_running_execute: bool,
|
||||
total_tokens: Option<u64>,
|
||||
mcp_init_progress: Option<&McpInitProgress>,
|
||||
is_bash_turn: bool,
|
||||
is_pending_user_input: bool,
|
||||
goal_verifying: bool,
|
||||
watchers: Watchers,
|
||||
parked: bool,
|
||||
flat_background: bool,
|
||||
held_queue: usize,
|
||||
held_queue_top_sendable: bool,
|
||||
args: TurnStatusArgs<'_>,
|
||||
) -> TurnStatusOutput {
|
||||
let TurnStatusArgs {
|
||||
state,
|
||||
activity,
|
||||
turn_elapsed,
|
||||
activity_started_at,
|
||||
tick,
|
||||
drain_blocked,
|
||||
buttons,
|
||||
has_running_execute,
|
||||
total_tokens,
|
||||
mcp_init_progress,
|
||||
is_bash_turn,
|
||||
is_pending_user_input,
|
||||
goal_verifying,
|
||||
watchers,
|
||||
parked,
|
||||
flat_background,
|
||||
held_queue,
|
||||
held_queue_top_sendable,
|
||||
} = args;
|
||||
// Resolve the mouse affordances: a keyboard-only host (`None`) suppresses
|
||||
// both buttons and reports no hover.
|
||||
let show_buttons = buttons.is_some();
|
||||
|
|
@ -289,15 +306,22 @@ pub fn render_turn_status(
|
|||
// turn spinner (see MONITOR_PULSE_DIVISOR).
|
||||
let frames = crate::glyphs::monitor_icon_frames();
|
||||
let frame_idx = (tick / MONITOR_PULSE_DIVISOR) as usize % frames.len();
|
||||
let icon = format!("{} ", frames[frame_idx]);
|
||||
let label_fg = if buttons.is_some_and(|b| b.watching_hovered) {
|
||||
theme.text_primary
|
||||
} else {
|
||||
theme.gray
|
||||
};
|
||||
let cue_width = (icon.width() + cue.width()).min(area.width as usize) as u16;
|
||||
let spans = vec![
|
||||
Span::styled(
|
||||
format!("{} ", frames[frame_idx]),
|
||||
Style::default().fg(theme.accent_system),
|
||||
),
|
||||
Span::styled(cue, Style::default().fg(theme.gray)),
|
||||
Span::styled(icon, Style::default().fg(theme.accent_system)),
|
||||
Span::styled(cue, Style::default().fg(label_fg)),
|
||||
];
|
||||
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
|
||||
return TurnStatusOutput::default();
|
||||
return TurnStatusOutput {
|
||||
watching_cue: show_buttons.then(|| Rect::new(area.x, area.y, cue_width, 1)),
|
||||
..TurnStatusOutput::default()
|
||||
};
|
||||
}
|
||||
|
||||
// Parked with no watchers left: render nothing. The stopped look must
|
||||
|
|
@ -603,6 +627,7 @@ pub fn render_turn_status(
|
|||
TurnStatusOutput {
|
||||
cancel_button: cancel_button_rect,
|
||||
bg_button: bg_button_rect,
|
||||
watching_cue: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1123,33 +1148,49 @@ mod tests {
|
|||
.join("\n")
|
||||
}
|
||||
|
||||
/// Baseline render args: idle agent on a mouse host with the given watchers.
|
||||
fn idle_args<'a>(watchers: Watchers) -> TurnStatusArgs<'a> {
|
||||
TurnStatusArgs {
|
||||
state: &AgentState::Idle,
|
||||
activity: &None,
|
||||
turn_elapsed: None,
|
||||
activity_started_at: None,
|
||||
tick: 0,
|
||||
drain_blocked: false,
|
||||
buttons: Some(MouseButtons::default()),
|
||||
has_running_execute: false,
|
||||
total_tokens: None,
|
||||
mcp_init_progress: None,
|
||||
is_bash_turn: false,
|
||||
is_pending_user_input: false,
|
||||
goal_verifying: false,
|
||||
watchers,
|
||||
parked: false,
|
||||
flat_background: false,
|
||||
held_queue: 0,
|
||||
held_queue_top_sendable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `args` into a `width`×1 row.
|
||||
fn render_row(args: TurnStatusArgs<'_>, width: u16) -> (TurnStatusOutput, Buffer) {
|
||||
let area = Rect::new(0, 0, width, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let output = render_turn_status(&mut buf, area, args);
|
||||
(output, buf)
|
||||
}
|
||||
|
||||
/// Render `args` into a `width`×1 row, returning the visible text.
|
||||
fn render_row_text(args: TurnStatusArgs<'_>, width: u16) -> String {
|
||||
let (_, buf) = render_row(args, width);
|
||||
buffer_text(&buf, buf.area)
|
||||
}
|
||||
|
||||
/// Invoke `render_turn_status` for an idle agent with the given MCP seed.
|
||||
fn render_idle_with_mcp(progress: &McpInitProgress) -> String {
|
||||
let area = Rect::new(0, 0, 60, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_turn_status(
|
||||
&mut buf,
|
||||
area,
|
||||
&AgentState::Idle,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
false,
|
||||
Some(MouseButtons::default()),
|
||||
false,
|
||||
None,
|
||||
Some(progress),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Watchers::default(),
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
buffer_text(&buf, area)
|
||||
let mut args = idle_args(Watchers::default());
|
||||
args.mcp_init_progress = Some(progress);
|
||||
render_row_text(args, 60)
|
||||
}
|
||||
|
||||
/// Invoke `render_turn_status` for an idle agent with the given watcher
|
||||
|
|
@ -1160,61 +1201,21 @@ mod tests {
|
|||
|
||||
/// [`render_idle_with_watchers_at_tick`] with an explicit row width.
|
||||
fn render_idle_with_watchers_in_width(watchers: Watchers, tick: u64, width: u16) -> String {
|
||||
let area = Rect::new(0, 0, width, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_turn_status(
|
||||
&mut buf,
|
||||
area,
|
||||
&AgentState::Idle,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
tick,
|
||||
false,
|
||||
Some(MouseButtons::default()),
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
watchers,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
buffer_text(&buf, area)
|
||||
let mut args = idle_args(watchers);
|
||||
args.tick = tick;
|
||||
render_row_text(args, width)
|
||||
}
|
||||
|
||||
/// Invoke `render_turn_status` for a PARKED running turn (the stopped
|
||||
/// look) with the given watcher counts.
|
||||
fn render_parked_with_watchers(watchers: Watchers) -> String {
|
||||
let area = Rect::new(0, 0, 72, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_turn_status(
|
||||
&mut buf,
|
||||
area,
|
||||
&AgentState::TurnRunning,
|
||||
&Some(TurnActivity::Waiting(WaitingReason::TasksComplete)),
|
||||
Some(Duration::from_secs(5)),
|
||||
None,
|
||||
0,
|
||||
false,
|
||||
Some(MouseButtons::default()),
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
watchers,
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
buffer_text(&buf, area)
|
||||
let activity = Some(TurnActivity::Waiting(WaitingReason::TasksComplete));
|
||||
let mut args = idle_args(watchers);
|
||||
args.state = &AgentState::TurnRunning;
|
||||
args.activity = &activity;
|
||||
args.turn_elapsed = Some(Duration::from_secs(5));
|
||||
args.parked = true;
|
||||
render_row_text(args, 72)
|
||||
}
|
||||
|
||||
/// Invoke `render_turn_status` for an idle agent with the given watcher
|
||||
|
|
@ -1268,6 +1269,38 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Mouse hosts get a hit rect hugging exactly the rendered cue text, and
|
||||
/// hover brightens the label; keyboard-only hosts get neither.
|
||||
#[test]
|
||||
fn watching_cue_is_clickable_on_mouse_hosts_only() {
|
||||
let theme = Theme::current();
|
||||
let watchers = Watchers {
|
||||
monitors: 1,
|
||||
..Watchers::default()
|
||||
};
|
||||
// First label cell (after the 2-col icon).
|
||||
let label_fg = |buf: &Buffer| buf.cell((2, 0)).map(|c| c.fg);
|
||||
|
||||
let (output, buf) = render_row(idle_args(watchers), 60);
|
||||
let rect = output.watching_cue.expect("mouse host must get a hit rect");
|
||||
let rendered_width = buffer_text(&buf, buf.area).trim_end().width() as u16;
|
||||
assert_eq!(rect, Rect::new(0, 0, rendered_width, 1));
|
||||
assert_eq!(label_fg(&buf), Some(theme.gray));
|
||||
|
||||
let mut args = idle_args(watchers);
|
||||
args.buttons = Some(MouseButtons {
|
||||
watching_hovered: true,
|
||||
..MouseButtons::default()
|
||||
});
|
||||
let (_, buf) = render_row(args, 60);
|
||||
assert_eq!(label_fg(&buf), Some(theme.text_primary));
|
||||
|
||||
let mut args = idle_args(watchers);
|
||||
args.buttons = None;
|
||||
let (output, _) = render_row(args, 60);
|
||||
assert!(output.watching_cue.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_with_loops_renders_still_running_cue() {
|
||||
let text = render_idle_with_watchers(Watchers {
|
||||
|
|
@ -1439,31 +1472,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn queued_hint_renders_after_phase_timer() {
|
||||
let area = Rect::new(0, 0, 80, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_turn_status(
|
||||
&mut buf,
|
||||
area,
|
||||
&AgentState::TurnRunning,
|
||||
&Some(TurnActivity::Waiting(WaitingReason::Subagent)),
|
||||
None,
|
||||
Some(Instant::now() - Duration::from_secs(359)),
|
||||
0,
|
||||
false,
|
||||
Some(MouseButtons::default()),
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Watchers::default(),
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
true,
|
||||
);
|
||||
let text = buffer_text(&buf, area);
|
||||
let activity = Some(TurnActivity::Waiting(WaitingReason::Subagent));
|
||||
let mut args = idle_args(Watchers::default());
|
||||
args.state = &AgentState::TurnRunning;
|
||||
args.activity = &activity;
|
||||
args.activity_started_at = Some(Instant::now() - Duration::from_secs(359));
|
||||
args.held_queue = 1;
|
||||
args.held_queue_top_sendable = true;
|
||||
let text = render_row_text(args, 80);
|
||||
assert!(
|
||||
text.contains("Waiting on subagent… 5m59s · 1 queued — Enter to send now"),
|
||||
"phase timer must sit between the wait label and the queued hint, got: {text:?}"
|
||||
|
|
|
|||
|
|
@ -16,8 +16,18 @@ pub struct WorkflowAgentRowView {
|
|||
pub model: Option<String>,
|
||||
pub state: String,
|
||||
pub tokens_used: u64,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WorkflowAgentLiveStatus {
|
||||
pub activity: Option<String>,
|
||||
pub tokens_used: Option<u64>,
|
||||
pub elapsed_ms: Option<u64>,
|
||||
}
|
||||
|
||||
pub type WorkflowAgentLiveMap = std::collections::HashMap<String, WorkflowAgentLiveStatus>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkflowRunSnapshot {
|
||||
pub run_id: String,
|
||||
|
|
@ -63,7 +73,12 @@ impl WorkflowRunSnapshot {
|
|||
}
|
||||
matches!(
|
||||
self.status.as_str(),
|
||||
"user_paused" | "back_off_paused" | "no_progress_paused" | "infra_paused" | "blocked"
|
||||
"user_paused"
|
||||
| "back_off_paused"
|
||||
| "no_progress_paused"
|
||||
| "infra_paused"
|
||||
| "blocked"
|
||||
| "failed"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +114,21 @@ impl WorkflowRunSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn phase_has_running_agents(&self, phase: &str) -> bool {
|
||||
self.agents
|
||||
.iter()
|
||||
.any(|a| a.state == "running" && a.phase.as_deref() == Some(phase))
|
||||
}
|
||||
|
||||
pub fn effective_active_phase(&self) -> Option<String> {
|
||||
phase_rail(self)
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(title, _)| self.phase_has_running_agents(title))
|
||||
.map(|(title, _)| title.clone())
|
||||
.or_else(|| self.current_phase.clone())
|
||||
}
|
||||
|
||||
fn done_agents(&self) -> usize {
|
||||
self.agents.iter().filter(|a| a.state != "running").count()
|
||||
}
|
||||
|
|
@ -114,6 +144,7 @@ pub struct WorkflowsViewState {
|
|||
pub selected_phase_name: Option<String>,
|
||||
pub phase_viewport: usize,
|
||||
pub phase_pinned: bool,
|
||||
pub pin_active_phase: Option<String>,
|
||||
pub window: crate::views::modal_window::ModalWindowState,
|
||||
pub run_hits: Vec<(Rect, String)>,
|
||||
pub phase_hits: Vec<(Rect, String)>,
|
||||
|
|
@ -264,6 +295,10 @@ impl WorkflowsViewState {
|
|||
self.selected_run = idx;
|
||||
}
|
||||
let rail = phase_rail(run);
|
||||
if self.phase_pinned && run.effective_active_phase() != self.pin_active_phase {
|
||||
self.phase_pinned = false;
|
||||
self.pin_active_phase = None;
|
||||
}
|
||||
if self.phase_pinned {
|
||||
if let Some(name) = self.selected_phase_name.as_deref()
|
||||
&& let Some(idx) = rail.iter().position(|(title, _)| title == name)
|
||||
|
|
@ -312,6 +347,7 @@ impl WorkflowsViewState {
|
|||
.get(self.selected_phase)
|
||||
.map(|(title, _)| title.clone());
|
||||
self.phase_pinned = true;
|
||||
self.pin_active_phase = run.effective_active_phase();
|
||||
}
|
||||
|
||||
pub fn ensure_run_visible(&mut self, visible_rows: usize, total_rows: usize) {
|
||||
|
|
@ -424,9 +460,8 @@ pub fn phase_rail(run: &WorkflowRunSnapshot) -> Vec<(String, String)> {
|
|||
|
||||
fn default_phase_index(run: &WorkflowRunSnapshot) -> usize {
|
||||
let rail = phase_rail(run);
|
||||
run.current_phase
|
||||
.as_deref()
|
||||
.and_then(|current| rail.iter().position(|(title, _)| title == current))
|
||||
run.effective_active_phase()
|
||||
.and_then(|current| rail.iter().position(|(title, _)| title == ¤t))
|
||||
.or_else(|| rail.iter().position(|(_, state)| state == "active"))
|
||||
.unwrap_or_else(|| {
|
||||
if rail.iter().all(|(_, state)| state == "done") {
|
||||
|
|
@ -497,6 +532,7 @@ pub fn render_workflows(
|
|||
runs: &[&WorkflowRunSnapshot],
|
||||
state: &mut WorkflowsViewState,
|
||||
tick: usize,
|
||||
live: &WorkflowAgentLiveMap,
|
||||
) -> Option<Rect> {
|
||||
use crate::views::modal_window::{ModalWindowConfig, render_modal_window};
|
||||
|
||||
|
|
@ -523,7 +559,7 @@ pub fn render_workflows(
|
|||
let inner = content.content;
|
||||
|
||||
match state.detail_run(runs) {
|
||||
Some(run) => render_detail(buf, inner, run, state, tick, &theme),
|
||||
Some(run) => render_detail(buf, inner, run, state, tick, &theme, live),
|
||||
None => render_list(buf, inner, runs, state, &theme),
|
||||
}
|
||||
state.window.popup_area
|
||||
|
|
@ -582,23 +618,11 @@ fn render_list(
|
|||
if run.phases.len() == 1 { "" } else { "s" }
|
||||
)
|
||||
};
|
||||
let agents = run
|
||||
.agent_budget
|
||||
.map(|total| {
|
||||
format!(
|
||||
" · agents {}/{} ({} left)",
|
||||
run.agents_used,
|
||||
total,
|
||||
run.agents_remaining.unwrap_or(0)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let meta = format!(
|
||||
"{phase_part} · {}/{} agent{}{} · {}",
|
||||
"{phase_part} · {}/{} agent{} · {}",
|
||||
run.done_agents(),
|
||||
run.agents.len(),
|
||||
if run.agents.len() == 1 { "" } else { "s" },
|
||||
agents,
|
||||
format_elapsed(run.live_elapsed_ms()),
|
||||
);
|
||||
let label = format!(
|
||||
|
|
@ -650,6 +674,7 @@ fn render_detail(
|
|||
state: &mut WorkflowsViewState,
|
||||
tick: usize,
|
||||
theme: &Theme,
|
||||
live: &WorkflowAgentLiveMap,
|
||||
) {
|
||||
let name = strip_control(&run.name);
|
||||
let (glyph, glyph_style) = status_glyph_and_style(&run.status, theme);
|
||||
|
|
@ -659,26 +684,11 @@ fn render_detail(
|
|||
} else {
|
||||
format!("{glyph} ")
|
||||
};
|
||||
let agent_budget = run.agent_budget.map(|total| {
|
||||
let remaining = run.agents_remaining.unwrap_or(0);
|
||||
format!(
|
||||
" · agents {}/{} ({} left{})",
|
||||
run.agents_used,
|
||||
total,
|
||||
remaining,
|
||||
if run.agent_usage_incomplete {
|
||||
", incomplete"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)
|
||||
});
|
||||
let meta = format!(
|
||||
"{}/{} agent{}{} · {}",
|
||||
"{}/{} agent{} · {}",
|
||||
run.done_agents(),
|
||||
run.agents.len(),
|
||||
if run.agents.len() == 1 { "" } else { "s" },
|
||||
agent_budget.unwrap_or_default(),
|
||||
format_elapsed(run.live_elapsed_ms()),
|
||||
);
|
||||
let meta_w = unicode_width::UnicodeWidthStr::width(meta.as_str()) as u16;
|
||||
|
|
@ -750,7 +760,7 @@ fn render_detail(
|
|||
))
|
||||
} else if run.status == "failed" {
|
||||
Some((
|
||||
"failed — see scrollback for details".to_string(),
|
||||
"failed — see scrollback for details; r resumes from the journal".to_string(),
|
||||
Style::default().fg(theme.accent_error),
|
||||
))
|
||||
} else {
|
||||
|
|
@ -849,8 +859,18 @@ fn render_detail(
|
|||
.filter(|agent| agent.state != "running")
|
||||
.count()
|
||||
};
|
||||
let running_in = if all_agents_phase {
|
||||
run.active_agent_count() > 0
|
||||
} else {
|
||||
run.phase_has_running_agents(title)
|
||||
};
|
||||
let effective_state = if running_in {
|
||||
"active"
|
||||
} else {
|
||||
phase_state.as_str()
|
||||
};
|
||||
let marker = if selected { "❯" } else { " " };
|
||||
let num_style = match phase_state.as_str() {
|
||||
let num_style = match effective_state {
|
||||
"done" => Style::default().fg(theme.accent_success),
|
||||
"active" => Style::default().fg(theme.accent_plan),
|
||||
_ => Style::default().fg(theme.gray_dim),
|
||||
|
|
@ -859,16 +879,23 @@ fn render_detail(
|
|||
Style::default()
|
||||
.fg(theme.text_primary)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if phase_state == "pending" {
|
||||
} else if effective_state == "pending" {
|
||||
Style::default().fg(theme.gray_dim)
|
||||
} else {
|
||||
Style::default().fg(theme.gray_bright)
|
||||
};
|
||||
let count = if agents_in > 0 {
|
||||
let count = if running_in {
|
||||
format!("● {done_in}/{agents_in}")
|
||||
} else if agents_in > 0 {
|
||||
format!("{done_in}/{agents_in}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let count_style = if running_in {
|
||||
Style::default().fg(theme.accent_plan)
|
||||
} else {
|
||||
Style::default().fg(theme.gray_dim)
|
||||
};
|
||||
let count_w = unicode_width::UnicodeWidthStr::width(count.as_str()) as u16;
|
||||
let count_x = rail_inner.right().saturating_sub(count_w);
|
||||
|
||||
|
|
@ -889,14 +916,7 @@ fn render_detail(
|
|||
title_style,
|
||||
count_x,
|
||||
);
|
||||
span_at(
|
||||
buf,
|
||||
count_x,
|
||||
y,
|
||||
&count,
|
||||
Style::default().fg(theme.gray_dim),
|
||||
rail_inner.right(),
|
||||
);
|
||||
span_at(buf, count_x, y, &count, count_style, rail_inner.right());
|
||||
state.phase_hits.push((
|
||||
Rect::new(rail_inner.x, y, rail_inner.width, 1),
|
||||
title.clone(),
|
||||
|
|
@ -970,8 +990,34 @@ fn render_detail(
|
|||
if y >= roster_inner.bottom() {
|
||||
break;
|
||||
}
|
||||
let (glyph, glyph_style) = agent_glyph_and_style(&agent.state, theme);
|
||||
let tokens = fmt_tokens(agent.tokens_used);
|
||||
let running = agent.state == "running";
|
||||
let (glyph, glyph_style) = if running {
|
||||
let frames = crate::glyphs::dot_spinner_frames();
|
||||
(
|
||||
frames[(tick / 4) % frames.len()],
|
||||
Style::default().fg(theme.accent_plan),
|
||||
)
|
||||
} else {
|
||||
agent_glyph_and_style(&agent.state, theme)
|
||||
};
|
||||
let live_status = running.then(|| live.get(&agent.agent_id)).flatten();
|
||||
let tokens_val = live_status
|
||||
.and_then(|l| l.tokens_used)
|
||||
.unwrap_or(agent.tokens_used);
|
||||
let elapsed_ms = if running {
|
||||
live_status.and_then(|l| l.elapsed_ms).unwrap_or(0)
|
||||
} else {
|
||||
agent.duration_ms
|
||||
};
|
||||
let mut meta_parts: Vec<String> = Vec::new();
|
||||
let tokens_txt = fmt_tokens(tokens_val);
|
||||
if !tokens_txt.is_empty() {
|
||||
meta_parts.push(tokens_txt);
|
||||
}
|
||||
if elapsed_ms > 0 {
|
||||
meta_parts.push(format_elapsed(elapsed_ms));
|
||||
}
|
||||
let tokens = meta_parts.join(" · ");
|
||||
let tokens_w = unicode_width::UnicodeWidthStr::width(tokens.as_str()) as u16;
|
||||
let tokens_x = roster_inner.right().saturating_sub(tokens_w + 1);
|
||||
|
||||
|
|
@ -996,16 +1042,32 @@ fn render_detail(
|
|||
tokens_x,
|
||||
);
|
||||
let label_w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
|
||||
let model_x = roster_inner.x + 2 + label_w + 2;
|
||||
let mut trail_x = roster_inner.x + 2 + label_w + 2;
|
||||
if let Some(model) = agent.model.as_deref() {
|
||||
let model_txt = truncate_to_width(model, tokens_x.saturating_sub(trail_x + 1) as usize);
|
||||
span_at(
|
||||
buf,
|
||||
model_x,
|
||||
trail_x,
|
||||
y,
|
||||
&truncate_to_width(model, tokens_x.saturating_sub(model_x + 1) as usize),
|
||||
&model_txt,
|
||||
Style::default().fg(theme.gray),
|
||||
tokens_x,
|
||||
);
|
||||
trail_x += unicode_width::UnicodeWidthStr::width(model_txt.as_str()) as u16 + 2;
|
||||
}
|
||||
if let Some(activity) = live_status.and_then(|l| l.activity.as_deref()) {
|
||||
let activity_txt = truncate_to_width(
|
||||
&format!("— {}", strip_control(activity)),
|
||||
tokens_x.saturating_sub(trail_x + 1) as usize,
|
||||
);
|
||||
span_at(
|
||||
buf,
|
||||
trail_x,
|
||||
y,
|
||||
&activity_txt,
|
||||
Style::default().fg(theme.gray_dim),
|
||||
tokens_x,
|
||||
);
|
||||
}
|
||||
span_at(
|
||||
buf,
|
||||
|
|
@ -1048,6 +1110,7 @@ mod tests {
|
|||
model: None,
|
||||
state: "done".into(),
|
||||
tokens_used: 12_300,
|
||||
duration_ms: 0,
|
||||
},
|
||||
WorkflowAgentRowView {
|
||||
agent_id: "a2".into(),
|
||||
|
|
@ -1056,6 +1119,7 @@ mod tests {
|
|||
model: Some("grok-4.5".into()),
|
||||
state: "running".into(),
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
},
|
||||
],
|
||||
agent_budget: Some(128),
|
||||
|
|
@ -1086,7 +1150,14 @@ mod tests {
|
|||
let area = Rect::new(0, 0, 100, 30);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut state = state.clone();
|
||||
render_workflows(&mut buf, area, runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
area,
|
||||
runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
buf_text(&buf, area)
|
||||
}
|
||||
|
||||
|
|
@ -1146,7 +1217,14 @@ mod tests {
|
|||
state.normalize(&runs);
|
||||
let area = Rect::new(0, 0, 140, 30);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_workflows(&mut buf, area, &runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
area,
|
||||
&runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
let text = buf_text(&buf, area);
|
||||
assert!(text.contains("raise agent budget above 2"), "{text}");
|
||||
assert!(text.contains("bare resume disabled"), "{text}");
|
||||
|
|
@ -1162,12 +1240,36 @@ mod tests {
|
|||
state.normalize(&runs);
|
||||
let narrow = Rect::new(0, 0, 84, 30);
|
||||
let mut buf = Buffer::empty(narrow);
|
||||
render_workflows(&mut buf, narrow, &runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
narrow,
|
||||
&runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
let text = buf_text(&buf, narrow);
|
||||
assert!(text.contains("bare resume disabled"), "{text}");
|
||||
assert!(text.contains("raise agent budget"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_run_offers_resume_but_not_stop() {
|
||||
let run = make_run("wf_1", "deep-research", "failed");
|
||||
assert!(
|
||||
run.can_resume(),
|
||||
"failed runs resume via journal replay of completed agents"
|
||||
);
|
||||
assert!(!run.can_stop(), "failed is terminal");
|
||||
|
||||
let labels = footer_shortcuts(true, false, Some(&run))
|
||||
.into_iter()
|
||||
.map(|shortcut| shortcut.label)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(labels.contains(&"r resume"));
|
||||
assert!(!labels.contains(&"x stop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_detail_layout_is_panic_free() {
|
||||
let run = make_run("wf_1", "deep-research", "active");
|
||||
|
|
@ -1176,7 +1278,14 @@ mod tests {
|
|||
let mut buf = Buffer::empty(area);
|
||||
let mut state = WorkflowsViewState::default();
|
||||
state.normalize(&runs);
|
||||
render_workflows(&mut buf, area, &runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
area,
|
||||
&runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1190,11 +1299,19 @@ mod tests {
|
|||
|
||||
let area = Rect::new(0, 0, 180, 30);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_workflows(&mut buf, area, &runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
area,
|
||||
&runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
let text = buf_text(&buf, area);
|
||||
assert!(text.contains("deep-research"), "{text}");
|
||||
assert!(text.contains("count-v2"), "{text}");
|
||||
assert!(text.contains("agents 2/128 (126 left)"), "{text}");
|
||||
assert!(text.contains("1/2 agents"), "{text}");
|
||||
assert!(!text.contains("128"), "budget cap is not shown: {text}");
|
||||
assert!(!text.contains(" · out "), "{text}");
|
||||
assert!(text.contains("enter open"), "{text}");
|
||||
}
|
||||
|
|
@ -1257,6 +1374,7 @@ mod tests {
|
|||
model: None,
|
||||
state: "running".to_owned(),
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
}];
|
||||
let runs = vec![&run];
|
||||
let mut state = WorkflowsViewState::default();
|
||||
|
|
@ -1301,7 +1419,14 @@ mod tests {
|
|||
let mut state = WorkflowsViewState::default();
|
||||
state.normalize(&runs);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_workflows(&mut buf, area, &runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
area,
|
||||
&runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.run_hits
|
||||
|
|
@ -1318,7 +1443,14 @@ mod tests {
|
|||
state.normalize(&runs);
|
||||
assert_eq!(state.selected_phase, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_workflows(&mut buf, area, &runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
area,
|
||||
&runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
assert!(state.run_hits.is_empty());
|
||||
assert!(state.list_area.is_none());
|
||||
assert_eq!(
|
||||
|
|
@ -1341,7 +1473,14 @@ mod tests {
|
|||
assert!(rect.width > 0 && rect.height == 1);
|
||||
let tiny = Rect::new(0, 0, 4, 2);
|
||||
let mut buf = Buffer::empty(tiny);
|
||||
render_workflows(&mut buf, tiny, &runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
tiny,
|
||||
&runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
assert!(state.agent_hits.is_empty());
|
||||
assert!(state.phase_hits.is_empty());
|
||||
assert!(state.rail_area.is_none() && state.roster_area.is_none());
|
||||
|
|
@ -1419,6 +1558,7 @@ mod tests {
|
|||
model: None,
|
||||
state: "done".into(),
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
})
|
||||
.collect();
|
||||
let runs = vec![&run];
|
||||
|
|
@ -1427,7 +1567,14 @@ mod tests {
|
|||
state.normalize(&runs);
|
||||
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_workflows(&mut buf, area, &runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
area,
|
||||
&runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
let visible = state.agent_hits.len();
|
||||
assert!(visible > 0 && visible < 30, "fixture must overflow");
|
||||
let newest_first_visible = format!("a{:02}", 30 - visible);
|
||||
|
|
@ -1435,7 +1582,14 @@ mod tests {
|
|||
|
||||
state.roster_scroll = 5;
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_workflows(&mut buf, area, &runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
area,
|
||||
&runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
assert_eq!(state.agent_hits[0].1, format!("a{:02}", 30 - visible - 5));
|
||||
let text = buf_text(&buf, area);
|
||||
assert!(text.contains("↑5"), "{text}");
|
||||
|
|
@ -1448,17 +1602,32 @@ mod tests {
|
|||
model: None,
|
||||
state: "running".to_owned(),
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
});
|
||||
let runs = vec![&run];
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_workflows(&mut buf, area, &runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
area,
|
||||
&runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
assert_eq!(state.agent_hits[0].1, anchored_top);
|
||||
assert_eq!(state.roster_scroll, 6);
|
||||
|
||||
state.roster_scroll = 10_000;
|
||||
state.roster_top_agent_id = None;
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_workflows(&mut buf, area, &runs, &mut state, 0);
|
||||
render_workflows(
|
||||
&mut buf,
|
||||
area,
|
||||
&runs,
|
||||
&mut state,
|
||||
0,
|
||||
&WorkflowAgentLiveMap::default(),
|
||||
);
|
||||
assert_eq!(state.roster_scroll, 31 - visible);
|
||||
assert_eq!(state.agent_hits[0].1, "a00");
|
||||
|
||||
|
|
@ -1474,14 +1643,115 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn detail_renders_agent_budget_breakdown() {
|
||||
fn detail_header_omits_agent_budget() {
|
||||
let run = make_run("wf_1", "deep-research", "active");
|
||||
let runs = vec![&run];
|
||||
let mut state = WorkflowsViewState::default();
|
||||
state.normalize(&runs);
|
||||
let text = render_to_text(&runs, &state);
|
||||
assert!(text.contains("agents 2/128"), "{text}");
|
||||
assert!(text.contains("126 left"), "{text}");
|
||||
assert!(text.contains("1/2 agents"), "{text}");
|
||||
assert!(!text.contains("128"), "budget cap is not shown: {text}");
|
||||
assert!(!text.contains("left"), "{text}");
|
||||
}
|
||||
|
||||
fn run_with_lagging_current_phase() -> WorkflowRunSnapshot {
|
||||
let mut run = make_run("wf_lag", "morefixes-quality-audit", "active");
|
||||
run.phases = vec![
|
||||
("Export".to_owned(), "done".to_owned()),
|
||||
("Audit".to_owned(), "active".to_owned()),
|
||||
("Synthesize".to_owned(), "pending".to_owned()),
|
||||
];
|
||||
run.current_phase = Some("Audit".to_owned());
|
||||
run.agents = vec![
|
||||
WorkflowAgentRowView {
|
||||
agent_id: "a1".into(),
|
||||
label: "audit-batch-0".into(),
|
||||
phase: Some("Audit".into()),
|
||||
model: None,
|
||||
state: "done".into(),
|
||||
tokens_used: 1_000,
|
||||
duration_ms: 0,
|
||||
},
|
||||
WorkflowAgentRowView {
|
||||
agent_id: "a2".into(),
|
||||
label: "synthesizer".into(),
|
||||
phase: Some("Synthesize".into()),
|
||||
model: None,
|
||||
state: "running".into(),
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
},
|
||||
];
|
||||
run
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_selection_follows_phase_with_running_agents() {
|
||||
let run = run_with_lagging_current_phase();
|
||||
assert_eq!(run.effective_active_phase().as_deref(), Some("Synthesize"));
|
||||
let runs = vec![&run];
|
||||
let mut state = WorkflowsViewState::default();
|
||||
state.normalize(&runs);
|
||||
assert_eq!(state.selected_phase_name.as_deref(), Some("Synthesize"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_phase_unpins_when_run_progresses() {
|
||||
let mut run = make_run("wf_1", "deep-research", "active");
|
||||
run.agents[1].state = "running".to_owned();
|
||||
let runs = vec![&run];
|
||||
let mut state = WorkflowsViewState::default();
|
||||
state.normalize(&runs);
|
||||
state.select_phase(0, &run);
|
||||
state.normalize(&runs);
|
||||
assert!(state.phase_pinned);
|
||||
assert_eq!(state.selected_phase_name.as_deref(), Some("Plan"));
|
||||
|
||||
run.agents[1].state = "done".to_owned();
|
||||
run.agents.push(WorkflowAgentRowView {
|
||||
agent_id: "a3".into(),
|
||||
label: "synthesizer".into(),
|
||||
phase: Some("Synthesize".into()),
|
||||
model: None,
|
||||
state: "running".into(),
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
});
|
||||
let runs = vec![&run];
|
||||
state.normalize(&runs);
|
||||
assert!(!state.phase_pinned);
|
||||
assert_eq!(state.selected_phase_name.as_deref(), Some("Synthesize"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rail_marks_running_phase_and_roster_streams_live_status() {
|
||||
let run = run_with_lagging_current_phase();
|
||||
let runs = vec![&run];
|
||||
let mut state = WorkflowsViewState::default();
|
||||
state.normalize(&runs);
|
||||
|
||||
let area = Rect::new(0, 0, 100, 30);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut live = WorkflowAgentLiveMap::default();
|
||||
live.insert(
|
||||
"a2".to_owned(),
|
||||
WorkflowAgentLiveStatus {
|
||||
activity: Some("Running: rg -n needle /data".to_owned()),
|
||||
tokens_used: Some(42_000),
|
||||
elapsed_ms: Some(75_000),
|
||||
},
|
||||
);
|
||||
render_workflows(&mut buf, area, &runs, &mut state, 0, &live);
|
||||
let text = buf_text(&buf, area);
|
||||
assert!(
|
||||
text.contains("● 0/1"),
|
||||
"running phase gets a ● marker: {text}"
|
||||
);
|
||||
assert!(text.contains("— Running: rg -n needle"), "{text}");
|
||||
assert!(
|
||||
text.contains("42k tok · 1m15s"),
|
||||
"live tokens + elapsed match the header meta style: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1527,6 +1797,7 @@ mod tests {
|
|||
selected_phase: 9,
|
||||
selected_phase_name: Some("missing".to_owned()),
|
||||
phase_pinned: true,
|
||||
pin_active_phase: Some("Research".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
state.normalize(&runs);
|
||||
|
|
|
|||
|
|
@ -3,42 +3,48 @@
|
|||
use xai_grok_voice::VoiceEvent;
|
||||
|
||||
use crate::app::app_view::{AppView, VoiceTarget};
|
||||
use crate::views::prompt_widget::PromptWidget;
|
||||
|
||||
/// Append finalized text to whichever prompt started capture
|
||||
/// (`voice_recording_target`) — the agent prompt or the dashboard dispatch input
|
||||
/// — not necessarily the active view, so a late final after a view switch still
|
||||
/// lands in the right place. Inserts a single separating space unless the prompt
|
||||
/// is empty or already ends in whitespace (preserves trailing newlines).
|
||||
/// Join committed prompt text with a voice fragment. Space-separated unless the
|
||||
/// prompt is empty or already ends in whitespace (keeps trailing newlines).
|
||||
pub(crate) fn combine_prompt_with_voice_text(existing: &str, text: &str) -> String {
|
||||
if existing.trim().is_empty() {
|
||||
text.to_string()
|
||||
} else if existing.ends_with(char::is_whitespace) {
|
||||
format!("{existing}{text}")
|
||||
} else {
|
||||
format!("{existing} {text}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `text` to the prompt bound at capture start (agent or dashboard).
|
||||
///
|
||||
/// Finals always append at end (or replace a blank draft). The caret follows
|
||||
/// when it was at end; mid-text edits keep their place.
|
||||
fn append_voice_text_to_prompt(app: &mut AppView, text: &str) {
|
||||
let combine = |existing: &str| -> String {
|
||||
if existing.trim().is_empty() {
|
||||
text.to_string()
|
||||
} else if existing.ends_with(char::is_whitespace) {
|
||||
format!("{existing}{text}")
|
||||
} else {
|
||||
format!("{existing} {text}")
|
||||
}
|
||||
let append = |prompt: &mut PromptWidget| {
|
||||
let existing = prompt.text();
|
||||
let cursor = prompt.cursor();
|
||||
let blank = existing.trim().is_empty();
|
||||
// Blank draft is a full replace — park the caret at the new end.
|
||||
// Otherwise append at end; only follow the caret if it was already there.
|
||||
let follow_end = blank || cursor >= existing.len();
|
||||
let combined = combine_prompt_with_voice_text(existing, text);
|
||||
prompt.set_text(&combined);
|
||||
prompt.set_cursor(if follow_end { combined.len() } else { cursor });
|
||||
};
|
||||
match app.voice_recording_target() {
|
||||
Some(VoiceTarget::Agent(id)) => {
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return;
|
||||
};
|
||||
let combined = combine(agent.prompt.text());
|
||||
agent.prompt.set_text(&combined);
|
||||
agent.prompt.set_cursor(combined.len());
|
||||
append(&mut agent.prompt);
|
||||
}
|
||||
Some(target @ (VoiceTarget::DashboardDispatch | VoiceTarget::DashboardPeekReply(_))) => {
|
||||
let Some(dashboard) = app.dashboard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// Route to the box bound at capture start. The dispatch box is stable,
|
||||
// but the peek reply widget is *shared* across rows and reassigned when
|
||||
// the peeked row changes. While listening `enforce_voice_session_bound`
|
||||
// stops capture on a row change, but after an explicit stop the target
|
||||
// is kept for the trailing final and that guard no longer runs — so
|
||||
// re-check the bound row here, or a final would land in (and send from)
|
||||
// another agent's reply.
|
||||
// Peek reply is shared across rows: only land if still on the bound row.
|
||||
let prompt = match target {
|
||||
VoiceTarget::DashboardPeekReply(rec) => {
|
||||
let peeked = match dashboard.peek.as_ref().map(|p| &p.row) {
|
||||
|
|
@ -52,14 +58,25 @@ fn append_voice_text_to_prompt(app: &mut AppView, text: &str) {
|
|||
}
|
||||
_ => &mut dashboard.dispatch,
|
||||
};
|
||||
let combined = combine(prompt.text());
|
||||
prompt.set_text(&combined);
|
||||
prompt.set_cursor(combined.len());
|
||||
append(prompt);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move non-empty interim into the bound prompt and clear the overlay.
|
||||
/// Does not stop the mic. Returns the promoted fragment.
|
||||
pub(crate) fn commit_interim_into_prompt(app: &mut AppView) -> Option<String> {
|
||||
let interim = app
|
||||
.voice_interim()
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(str::to_owned)?;
|
||||
append_voice_text_to_prompt(app, &interim);
|
||||
app.voice_clear_interim();
|
||||
Some(interim)
|
||||
}
|
||||
|
||||
/// Apply a voice event to app state. Returns whether the frame should redraw.
|
||||
pub fn handle_voice_event(app: &mut AppView, event: VoiceEvent) -> bool {
|
||||
match event {
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@
|
|||
//! dashboard's dispatch (new-agent) input, captured at start via
|
||||
//! [`crate::app::app_view::VoiceTarget`] — while capture stays open across
|
||||
//! speech pauses. The user always submits with Enter; nothing is auto-sent.
|
||||
//! Submit promotes any remaining interim into the bound prompt, then hard-resets.
|
||||
|
||||
mod auth;
|
||||
mod handle;
|
||||
|
||||
pub use auth::build_voice_auth;
|
||||
pub use handle::handle_voice_event;
|
||||
pub(crate) use handle::{combine_prompt_with_voice_text, commit_interim_into_prompt};
|
||||
// Hidden `__mic-capture` helper intercept (macOS out-of-process capture),
|
||||
// re-exported for the composition-root binary, which links the pager library
|
||||
// rather than the voice crate. Called at the very top of `main`.
|
||||
|
|
|
|||
Loading…
Reference in a new issue