feat(agent): add terminal alert support
- commit
- 40856e8d62acaec31be949a36783fa647d6c930b
- author
- brice · · 2026-08-04 08:14 UTC
- parent
- 9eef239
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
21 files · +728 -15
M README.md +2 -1
| @@ -289,7 +289,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 289 | 289 | | `/unjail` | Allow tools to touch paths outside again. | | |
| 290 | 290 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| 291 | 291 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | |
| 292 | - | | `/settings` | Change persistent settings, including inline images, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | | |
| 292 | + | | `/settings` | Change persistent settings, including inline images, terminal alerts, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | | |
| 293 | 293 | | `/clear` | Clear the chat transcript. | | |
| 294 | 294 | | `/exit` | Exit zot. | | |
| 295 | 295 | ||
| @@ -390,6 +390,7 @@ zot discovers the common markdown/frontmatter profile layout from `~/.agents/age | |||
| 390 | 390 | Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings: | |
| 391 | 391 | ||
| 392 | 392 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | |
| 393 | + | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | |
| 393 | 394 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details. | |
| 394 | 395 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | |
| 395 | 396 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | |
| @@ -289,7 +289,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 289 | | `/unjail` | Allow tools to touch paths outside again. | | 289 | | `/unjail` | Allow tools to touch paths outside again. | | ||
| 290 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | 290 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | ||
| 291 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | 291 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | ||
| 292 | - | | `/settings` | Change persistent settings, including inline images, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | | 292 | + | | `/settings` | Change persistent settings, including inline images, terminal alerts, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | |
| 293 | | `/clear` | Clear the chat transcript. | | 293 | | `/clear` | Clear the chat transcript. | | ||
| 294 | | `/exit` | Exit zot. | | 294 | | `/exit` | Exit zot. | | ||
| 295 | 295 | ||||
| @@ -390,6 +390,7 @@ zot discovers the common markdown/frontmatter profile layout from `~/.agents/age | |||||
| 390 | Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings: | 390 | Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings: | ||
| 391 | 391 | ||||
| 392 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | 392 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | ||
| 393 | + | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | |||
| 393 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details. | 394 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details. | ||
| 394 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | 395 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | ||
| 395 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | 396 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | ||
M docs/extensions.md +31 -9
| @@ -8,7 +8,7 @@ Rust, shell with `jq`, anything. | |||
| 8 | 8 | ||
| 9 | 9 | Four phases shipped so far: | |
| 10 | 10 | ||
| 11 | - | - **Phase 1**: slash commands + chat notifications. | |
| 11 | + | - **Phase 1**: slash commands, chat notifications, and host alerts. | |
| 12 | 12 | - **Phase 2**: tools the LLM can call. | |
| 13 | 13 | - **Phase 3**: lifecycle event subscriptions + tool-call interception | |
| 14 | 14 | for guardrail extensions. | |
| @@ -149,10 +149,10 @@ manifest tells zot how to launch it: | |||
| 149 | 149 | (logged in the extension's own log file). | |
| 150 | 150 | 5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames | |
| 151 | 151 | when the user runs a registered command; the extension responds | |
| 152 | - | with `command_response`. Extensions can also push `notify` frames | |
| 153 | - | during runtime. Panel-capable extensions may open an interactive | |
| 154 | - | panel, receive key events, and push redraws while the panel is | |
| 155 | - | focused. | |
| 152 | + | with `command_response`. Extensions can also push `notify` and | |
| 153 | + | structured `alert` frames during runtime. Panel-capable extensions | |
| 154 | + | may open an interactive panel, receive key events, and push redraws | |
| 155 | + | while the panel is focused. | |
| 156 | 156 | 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to | |
| 157 | 157 | 2s for the extension to send `shutdown_ack`. Holdouts are | |
| 158 | 158 | SIGTERM'd, then SIGKILL'd. | |
| @@ -166,8 +166,8 @@ restarted. | |||
| 166 | 166 | All frames are one JSON object per line. Top-level `type` is the | |
| 167 | 167 | discriminator. Optional `id` correlates request frames with their | |
| 168 | 168 | responses. The canonical startup order is `hello`, `hello_ack`, | |
| 169 | - | registration frames, then `ready`. Do not send `notify`, logs, or any | |
| 170 | - | other stdout frame before `hello`. | |
| 169 | + | registration frames, then `ready`. Do not send `notify`, `alert`, logs, | |
| 170 | + | or any other stdout frame before `hello`. | |
| 171 | 171 | ||
| 172 | 172 | ### Extension → host | |
| 173 | 173 | ||
| @@ -175,7 +175,7 @@ other stdout frame before `hello`. | |||
| 175 | 175 | ||
| 176 | 176 | ```json | |
| 177 | 177 | {"type":"hello","name":"weather","version":"1.0.0", | |
| 178 | - | "capabilities":["commands","tools","panels"]} | |
| 178 | + | "capabilities":["commands","tools","alerts","panels"]} | |
| 179 | 179 | ``` | |
| 180 | 180 | ||
| 181 | 181 | #### `register_command` | |
| @@ -381,6 +381,28 @@ up below the transcript with the extension's name in brackets. Notes | |||
| 381 | 381 | are one-shot: they clear automatically when the user sends their next | |
| 382 | 382 | prompt (and on `esc` / `/clear`). | |
| 383 | 383 | ||
| 384 | + | #### `alert` (one-way, any time) | |
| 385 | + | ||
| 386 | + | Requests a host-owned structured alert. The first supported kind is | |
| 387 | + | `bell`, which asks the interactive terminal to emit one terminal bell. | |
| 388 | + | `reason` is semantic metadata and is not rendered as terminal text. | |
| 389 | + | The shared terminal-alert setting in `/settings` applies to extension | |
| 390 | + | alerts too; disabled or non-interactive hosts may ignore the request. | |
| 391 | + | ||
| 392 | + | ```json | |
| 393 | + | {"type":"alert","kind":"bell","reason":"question_ready"} | |
| 394 | + | ``` | |
| 395 | + | ||
| 396 | + | The Go SDK sends the same frame with: | |
| 397 | + | ||
| 398 | + | ```go | |
| 399 | + | e.Alert(ext.AlertRequest{Kind: ext.AlertKindBell, Reason: "question_ready"}) | |
| 400 | + | ``` | |
| 401 | + | ||
| 402 | + | Extensions must not write BEL, ANSI, or other terminal bytes to stdout; | |
| 403 | + | stdout is reserved for protocol frames. In `--mode rpc`, this becomes an | |
| 404 | + | `ext_alert` JSON event for the RPC client to interpret. | |
| 405 | + | ||
| 384 | 406 | #### `clear_notes` (one-way, any time) | |
| 385 | 407 | ||
| 386 | 408 | Removes every note this extension previously pushed via `notify` / | |
| @@ -682,7 +704,7 @@ trust or run zot under your platform's sandboxing tool (`bwrap` / | |||
| 682 | 704 | Phase 1 (shipped): | |
| 683 | 705 | - [x] subprocess lifecycle + hello handshake | |
| 684 | 706 | - [x] `register_command` + `command_invoked` | |
| 685 | - | - [x] `notify` + `clear_notes` | |
| 707 | + | - [x] `notify` + `clear_notes` + structured terminal alerts | |
| 686 | 708 | - [x] `zot ext` CLI | |
| 687 | 709 | ||
| 688 | 710 | Phase 2 (shipped): | |
| @@ -8,7 +8,7 @@ Rust, shell with `jq`, anything. | |||||
| 8 | 8 | ||||
| 9 | Four phases shipped so far: | 9 | Four phases shipped so far: | ||
| 10 | 10 | ||||
| 11 | - | - **Phase 1**: slash commands + chat notifications. | 11 | + | - **Phase 1**: slash commands, chat notifications, and host alerts. |
| 12 | - **Phase 2**: tools the LLM can call. | 12 | - **Phase 2**: tools the LLM can call. | ||
| 13 | - **Phase 3**: lifecycle event subscriptions + tool-call interception | 13 | - **Phase 3**: lifecycle event subscriptions + tool-call interception | ||
| 14 | for guardrail extensions. | 14 | for guardrail extensions. | ||
| @@ -149,10 +149,10 @@ manifest tells zot how to launch it: | |||||
| 149 | (logged in the extension's own log file). | 149 | (logged in the extension's own log file). | ||
| 150 | 5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames | 150 | 5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames | ||
| 151 | when the user runs a registered command; the extension responds | 151 | when the user runs a registered command; the extension responds | ||
| 152 | - | with `command_response`. Extensions can also push `notify` frames | 152 | + | with `command_response`. Extensions can also push `notify` and |
| 153 | - | during runtime. Panel-capable extensions may open an interactive | 153 | + | structured `alert` frames during runtime. Panel-capable extensions |
| 154 | - | panel, receive key events, and push redraws while the panel is | 154 | + | may open an interactive panel, receive key events, and push redraws |
| 155 | - | focused. | 155 | + | while the panel is focused. |
| 156 | 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to | 156 | 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to | ||
| 157 | 2s for the extension to send `shutdown_ack`. Holdouts are | 157 | 2s for the extension to send `shutdown_ack`. Holdouts are | ||
| 158 | SIGTERM'd, then SIGKILL'd. | 158 | SIGTERM'd, then SIGKILL'd. | ||
| @@ -166,8 +166,8 @@ restarted. | |||||
| 166 | All frames are one JSON object per line. Top-level `type` is the | 166 | All frames are one JSON object per line. Top-level `type` is the | ||
| 167 | discriminator. Optional `id` correlates request frames with their | 167 | discriminator. Optional `id` correlates request frames with their | ||
| 168 | responses. The canonical startup order is `hello`, `hello_ack`, | 168 | responses. The canonical startup order is `hello`, `hello_ack`, | ||
| 169 | - | registration frames, then `ready`. Do not send `notify`, logs, or any | 169 | + | registration frames, then `ready`. Do not send `notify`, `alert`, logs, |
| 170 | - | other stdout frame before `hello`. | 170 | + | or any other stdout frame before `hello`. |
| 171 | 171 | ||||
| 172 | ### Extension → host | 172 | ### Extension → host | ||
| 173 | 173 | ||||
| @@ -175,7 +175,7 @@ other stdout frame before `hello`. | |||||
| 175 | 175 | ||||
| 176 | ```json | 176 | ```json | ||
| 177 | {"type":"hello","name":"weather","version":"1.0.0", | 177 | {"type":"hello","name":"weather","version":"1.0.0", | ||
| 178 | - | "capabilities":["commands","tools","panels"]} | 178 | + | "capabilities":["commands","tools","alerts","panels"]} |
| 179 | ``` | 179 | ``` | ||
| 180 | 180 | ||||
| 181 | #### `register_command` | 181 | #### `register_command` | ||
| @@ -381,6 +381,28 @@ up below the transcript with the extension's name in brackets. Notes | |||||
| 381 | are one-shot: they clear automatically when the user sends their next | 381 | are one-shot: they clear automatically when the user sends their next | ||
| 382 | prompt (and on `esc` / `/clear`). | 382 | prompt (and on `esc` / `/clear`). | ||
| 383 | 383 | ||||
| 384 | + | #### `alert` (one-way, any time) | |||
| 385 | + | ||||
| 386 | + | Requests a host-owned structured alert. The first supported kind is | |||
| 387 | + | `bell`, which asks the interactive terminal to emit one terminal bell. | |||
| 388 | + | `reason` is semantic metadata and is not rendered as terminal text. | |||
| 389 | + | The shared terminal-alert setting in `/settings` applies to extension | |||
| 390 | + | alerts too; disabled or non-interactive hosts may ignore the request. | |||
| 391 | + | ||||
| 392 | + | ```json | |||
| 393 | + | {"type":"alert","kind":"bell","reason":"question_ready"} | |||
| 394 | + | ``` | |||
| 395 | + | ||||
| 396 | + | The Go SDK sends the same frame with: | |||
| 397 | + | ||||
| 398 | + | ```go | |||
| 399 | + | e.Alert(ext.AlertRequest{Kind: ext.AlertKindBell, Reason: "question_ready"}) | |||
| 400 | + | ``` | |||
| 401 | + | ||||
| 402 | + | Extensions must not write BEL, ANSI, or other terminal bytes to stdout; | |||
| 403 | + | stdout is reserved for protocol frames. In `--mode rpc`, this becomes an | |||
| 404 | + | `ext_alert` JSON event for the RPC client to interpret. | |||
| 405 | + | ||||
| 384 | #### `clear_notes` (one-way, any time) | 406 | #### `clear_notes` (one-way, any time) | ||
| 385 | 407 | ||||
| 386 | Removes every note this extension previously pushed via `notify` / | 408 | Removes every note this extension previously pushed via `notify` / | ||
| @@ -682,7 +704,7 @@ trust or run zot under your platform's sandboxing tool (`bwrap` / | |||||
| 682 | Phase 1 (shipped): | 704 | Phase 1 (shipped): | ||
| 683 | - [x] subprocess lifecycle + hello handshake | 705 | - [x] subprocess lifecycle + hello handshake | ||
| 684 | - [x] `register_command` + `command_invoked` | 706 | - [x] `register_command` + `command_invoked` | ||
| 685 | - | - [x] `notify` + `clear_notes` | 707 | + | - [x] `notify` + `clear_notes` + structured terminal alerts |
| 686 | - [x] `zot ext` CLI | 708 | - [x] `zot ext` CLI | ||
| 687 | 709 | ||||
| 688 | Phase 2 (shipped): | 710 | Phase 2 (shipped): | ||
M docs/rpc.md +1 -0
| @@ -202,6 +202,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||
| 202 | 202 | | `done` | (none) | The whole prompt/compact completed (success or error) | | |
| 203 | 203 | | `error` | `message` | Non-fatal error message | | |
| 204 | 204 | | `compact_done` | `summary` | Compaction finished, summary text included | | |
| 205 | + | | `ext_alert` | `extension`, `kind`, optional `reason` | An extension requested a host alert; RPC clients can map it to their own notification UI. | | |
| 205 | 206 | ||
| 206 | 207 | ## Message shape | |
| 207 | 208 | ||
| @@ -202,6 +202,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||||
| 202 | | `done` | (none) | The whole prompt/compact completed (success or error) | | 202 | | `done` | (none) | The whole prompt/compact completed (success or error) | | ||
| 203 | | `error` | `message` | Non-fatal error message | | 203 | | `error` | `message` | Non-fatal error message | | ||
| 204 | | `compact_done` | `summary` | Compaction finished, summary text included | | 204 | | `compact_done` | `summary` | Compaction finished, summary text included | | ||
| 205 | + | | `ext_alert` | `extension`, `kind`, optional `reason` | An extension requested a host alert; RPC clients can map it to their own notification UI. | | |||
| 205 | 206 | ||||
| 206 | ## Message shape | 207 | ## Message shape | ||
| 207 | 208 | ||||
A docs/superpowers/specs/2026-08-04-terminal-alerts-design.md +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | # Terminal Alerts and Extension Bells | |
| 2 | + | ||
| 3 | + | ## Goal | |
| 4 | + | ||
| 5 | + | Provide a reusable host alert boundary for the interactive terminal. The first alert kind is a terminal bell, emitted as `\a` only by the interactive host. | |
| 6 | + | ||
| 7 | + | The same alert policy serves the main agent session and trusted, user-installed extensions that need to draw attention to an interactive question or panel. | |
| 8 | + | ||
| 9 | + | ## User Experience | |
| 10 | + | ||
| 11 | + | The main interactive session emits one bell after a submitted main-agent run becomes idle and the paced final response has finished rendering when the outcome needs attention: | |
| 12 | + | ||
| 13 | + | - normal final answer | |
| 14 | + | - output-limit/truncated response | |
| 15 | + | - non-recoverable provider error | |
| 16 | + | - rescue dialog opened after a recoverable provider error | |
| 17 | + | ||
| 18 | + | The bell is suppressed for explicit cancellation, automatic compaction/retry, and work immediately followed by another queued turn. `/btw`, Telegram, SDK, JSON, print, stream, RPC, and swarm-agent modes do not write raw terminal bell bytes. | |
| 19 | + | ||
| 20 | + | Users can disable both main-agent and extension terminal alerts from `/settings`. Alerts are enabled by default when the configuration field is missing. | |
| 21 | + | ||
| 22 | + | ## Public Extension Surface | |
| 23 | + | ||
| 24 | + | Extensions send a fire-and-forget structured alert frame: | |
| 25 | + | ||
| 26 | + | ```json | |
| 27 | + | {"type":"alert","kind":"bell","reason":"question_ready"} | |
| 28 | + | ``` | |
| 29 | + | ||
| 30 | + | The Go extension SDK exposes the equivalent `Extension.Alert(AlertRequest)` method. The host-side alert hook is optional so existing `HostHooks` embedders remain compatible. `kind` is initially `bell`; `reason` is semantic metadata and is not rendered as terminal text. Extensions never provide raw terminal bytes, ANSI sequences, counts, or sound patterns. | |
| 31 | + | ||
| 32 | + | The host owns the output policy. Interactive mode writes a standalone BEL byte through its injected `tui.Terminal`; non-interactive hosts remain byte-clean and may ignore or translate the request. | |
| 33 | + | ||
| 34 | + | ## Configuration | |
| 35 | + | ||
| 36 | + | Persist the shared policy in `$ZOT_HOME/config.json`: | |
| 37 | + | ||
| 38 | + | ```json | |
| 39 | + | {"terminal_alerts_enabled": true} | |
| 40 | + | ``` | |
| 41 | + | ||
| 42 | + | The optional boolean resolves as follows: | |
| 43 | + | ||
| 44 | + | - missing or `null`: enabled | |
| 45 | + | - `true`: enabled | |
| 46 | + | - `false`: disabled | |
| 47 | + | ||
| 48 | + | The setting applies immediately to the current interactive session and persists for future sessions. | |
| 49 | + | ||
| 50 | + | ## Lifecycle Boundary | |
| 51 | + | ||
| 52 | + | The main-agent alert is scheduled from the interactive `startTurnRequest` completion path, not from `core.Agent` events. `EvTurnEnd` is one model step and `EvDone` is not emitted on every error path. The final outcome is tracked from both the last turn event and the returned `Prompt`/`Continue` error. | |
| 53 | + | ||
| 54 | + | A pending main-agent alert waits for the streaming pacer to drain `streamPending`. If a new queued/recovery turn begins before the drain completes, the pending alert is discarded. The bell is emitted only after the host is truly idle. | |
| 55 | + | ||
| 56 | + | ## Extension Routing | |
| 57 | + | ||
| 58 | + | `packages/agent/extproto` defines the alert wire frame. `packages/agent/extensions` routes it through the optional `AlertHostHooks` extension to `HostHooks`. Interactive mode applies the shared setting and terminal output. RPC mode translates extension alerts into an `ext_alert` JSON event; print, stream, JSON, Telegram, and other non-interactive hosts do not write BEL to their data streams. | |
| 59 | + | ||
| 60 | + | Unknown alert kinds are ignored by the interactive renderer. The wire shape is additive and permits future host alert kinds without exposing terminal implementation details. | |
| 61 | + | ||
| 62 | + | ## Tests | |
| 63 | + | ||
| 64 | + | Cover: | |
| 65 | + | ||
| 66 | + | - BEL writes as a standalone byte through an injected writer | |
| 67 | + | - extension SDK emits the structured alert frame | |
| 68 | + | - extension manager routes alert frames to host hooks | |
| 69 | + | - disabled terminal alerts suppress both main and extension alerts | |
| 70 | + | - missing configuration enables alerts | |
| 71 | + | - main completion alerts wait for paced output to drain | |
| 72 | + | - cancellation, recovery, queue, and side-chat paths do not produce an unwanted main bell | |
| 73 | + | - RPC alert translation does not emit raw BEL | |
| 74 | + | ||
| 75 | + | ## Documentation | |
| 76 | + | ||
| 77 | + | Document the `/settings` terminal-alert toggle and the extension alert frame/SDK method in `README.md` and `docs/extensions.md`. State that terminal bells depend on terminal emulator settings and may be audible, visual, or suppressed by the host terminal. | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | # Terminal Alerts and Extension Bells | |||
| 2 | + | ||||
| 3 | + | ## Goal | |||
| 4 | + | ||||
| 5 | + | Provide a reusable host alert boundary for the interactive terminal. The first alert kind is a terminal bell, emitted as `\a` only by the interactive host. | |||
| 6 | + | ||||
| 7 | + | The same alert policy serves the main agent session and trusted, user-installed extensions that need to draw attention to an interactive question or panel. | |||
| 8 | + | ||||
| 9 | + | ## User Experience | |||
| 10 | + | ||||
| 11 | + | The main interactive session emits one bell after a submitted main-agent run becomes idle and the paced final response has finished rendering when the outcome needs attention: | |||
| 12 | + | ||||
| 13 | + | - normal final answer | |||
| 14 | + | - output-limit/truncated response | |||
| 15 | + | - non-recoverable provider error | |||
| 16 | + | - rescue dialog opened after a recoverable provider error | |||
| 17 | + | ||||
| 18 | + | The bell is suppressed for explicit cancellation, automatic compaction/retry, and work immediately followed by another queued turn. `/btw`, Telegram, SDK, JSON, print, stream, RPC, and swarm-agent modes do not write raw terminal bell bytes. | |||
| 19 | + | ||||
| 20 | + | Users can disable both main-agent and extension terminal alerts from `/settings`. Alerts are enabled by default when the configuration field is missing. | |||
| 21 | + | ||||
| 22 | + | ## Public Extension Surface | |||
| 23 | + | ||||
| 24 | + | Extensions send a fire-and-forget structured alert frame: | |||
| 25 | + | ||||
| 26 | + | ```json | |||
| 27 | + | {"type":"alert","kind":"bell","reason":"question_ready"} | |||
| 28 | + | ``` | |||
| 29 | + | ||||
| 30 | + | The Go extension SDK exposes the equivalent `Extension.Alert(AlertRequest)` method. The host-side alert hook is optional so existing `HostHooks` embedders remain compatible. `kind` is initially `bell`; `reason` is semantic metadata and is not rendered as terminal text. Extensions never provide raw terminal bytes, ANSI sequences, counts, or sound patterns. | |||
| 31 | + | ||||
| 32 | + | The host owns the output policy. Interactive mode writes a standalone BEL byte through its injected `tui.Terminal`; non-interactive hosts remain byte-clean and may ignore or translate the request. | |||
| 33 | + | ||||
| 34 | + | ## Configuration | |||
| 35 | + | ||||
| 36 | + | Persist the shared policy in `$ZOT_HOME/config.json`: | |||
| 37 | + | ||||
| 38 | + | ```json | |||
| 39 | + | {"terminal_alerts_enabled": true} | |||
| 40 | + | ``` | |||
| 41 | + | ||||
| 42 | + | The optional boolean resolves as follows: | |||
| 43 | + | ||||
| 44 | + | - missing or `null`: enabled | |||
| 45 | + | - `true`: enabled | |||
| 46 | + | - `false`: disabled | |||
| 47 | + | ||||
| 48 | + | The setting applies immediately to the current interactive session and persists for future sessions. | |||
| 49 | + | ||||
| 50 | + | ## Lifecycle Boundary | |||
| 51 | + | ||||
| 52 | + | The main-agent alert is scheduled from the interactive `startTurnRequest` completion path, not from `core.Agent` events. `EvTurnEnd` is one model step and `EvDone` is not emitted on every error path. The final outcome is tracked from both the last turn event and the returned `Prompt`/`Continue` error. | |||
| 53 | + | ||||
| 54 | + | A pending main-agent alert waits for the streaming pacer to drain `streamPending`. If a new queued/recovery turn begins before the drain completes, the pending alert is discarded. The bell is emitted only after the host is truly idle. | |||
| 55 | + | ||||
| 56 | + | ## Extension Routing | |||
| 57 | + | ||||
| 58 | + | `packages/agent/extproto` defines the alert wire frame. `packages/agent/extensions` routes it through the optional `AlertHostHooks` extension to `HostHooks`. Interactive mode applies the shared setting and terminal output. RPC mode translates extension alerts into an `ext_alert` JSON event; print, stream, JSON, Telegram, and other non-interactive hosts do not write BEL to their data streams. | |||
| 59 | + | ||||
| 60 | + | Unknown alert kinds are ignored by the interactive renderer. The wire shape is additive and permits future host alert kinds without exposing terminal implementation details. | |||
| 61 | + | ||||
| 62 | + | ## Tests | |||
| 63 | + | ||||
| 64 | + | Cover: | |||
| 65 | + | ||||
| 66 | + | - BEL writes as a standalone byte through an injected writer | |||
| 67 | + | - extension SDK emits the structured alert frame | |||
| 68 | + | - extension manager routes alert frames to host hooks | |||
| 69 | + | - disabled terminal alerts suppress both main and extension alerts | |||
| 70 | + | - missing configuration enables alerts | |||
| 71 | + | - main completion alerts wait for paced output to drain | |||
| 72 | + | - cancellation, recovery, queue, and side-chat paths do not produce an unwanted main bell | |||
| 73 | + | - RPC alert translation does not emit raw BEL | |||
| 74 | + | ||||
| 75 | + | ## Documentation | |||
| 76 | + | ||||
| 77 | + | Document the `/settings` terminal-alert toggle and the extension alert frame/SDK method in `README.md` and `docs/extensions.md`. State that terminal bells depend on terminal emulator settings and may be audible, visual, or suppressed by the host terminal. | |||
M packages/agent/cli.go +7 -0
| @@ -48,6 +48,11 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) { | |||
| 48 | 48 | iv.Notify(extName, level, message) | |
| 49 | 49 | } | |
| 50 | 50 | } | |
| 51 | + | func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest) { | |
| 52 | + | if iv := h.iv(); iv != nil { | |
| 53 | + | iv.Alert(extName, alert) | |
| 54 | + | } | |
| 55 | + | } | |
| 51 | 56 | func (h *interactiveExtHooks) Submit(text string) { | |
| 52 | 57 | if iv := h.iv(); iv != nil { | |
| 53 | 58 | iv.SubmitOrQueue(text, nil) | |
| @@ -273,6 +278,7 @@ type nonInteractiveExtHooks struct{} | |||
| 273 | 278 | func (nonInteractiveExtHooks) Notify(ext, level, message string) { | |
| 274 | 279 | fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message) | |
| 275 | 280 | } | |
| 281 | + | func (nonInteractiveExtHooks) Alert(string, extproto.AlertRequest) {} | |
| 276 | 282 | func (nonInteractiveExtHooks) Submit(string) {} | |
| 277 | 283 | func (nonInteractiveExtHooks) SubmitSlash(string) {} | |
| 278 | 284 | func (nonInteractiveExtHooks) Insert(string) {} | |
| @@ -1286,6 +1292,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1286 | 1292 | Terminal: term, | |
| 1287 | 1293 | Theme: theme, | |
| 1288 | 1294 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 1295 | + | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | |
| 1289 | 1296 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 1290 | 1297 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |
| 1291 | 1298 | JailByDefault: initialCfg.JailByDefault, | |
| @@ -48,6 +48,11 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) { | |||||
| 48 | iv.Notify(extName, level, message) | 48 | iv.Notify(extName, level, message) | ||
| 49 | } | 49 | } | ||
| 50 | } | 50 | } | ||
| 51 | + | func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest) { | |||
| 52 | + | if iv := h.iv(); iv != nil { | |||
| 53 | + | iv.Alert(extName, alert) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 51 | func (h *interactiveExtHooks) Submit(text string) { | 56 | func (h *interactiveExtHooks) Submit(text string) { | ||
| 52 | if iv := h.iv(); iv != nil { | 57 | if iv := h.iv(); iv != nil { | ||
| 53 | iv.SubmitOrQueue(text, nil) | 58 | iv.SubmitOrQueue(text, nil) | ||
| @@ -273,6 +278,7 @@ type nonInteractiveExtHooks struct{} | |||||
| 273 | func (nonInteractiveExtHooks) Notify(ext, level, message string) { | 278 | func (nonInteractiveExtHooks) Notify(ext, level, message string) { | ||
| 274 | fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message) | 279 | fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message) | ||
| 275 | } | 280 | } | ||
| 281 | + | func (nonInteractiveExtHooks) Alert(string, extproto.AlertRequest) {} | |||
| 276 | func (nonInteractiveExtHooks) Submit(string) {} | 282 | func (nonInteractiveExtHooks) Submit(string) {} | ||
| 277 | func (nonInteractiveExtHooks) SubmitSlash(string) {} | 283 | func (nonInteractiveExtHooks) SubmitSlash(string) {} | ||
| 278 | func (nonInteractiveExtHooks) Insert(string) {} | 284 | func (nonInteractiveExtHooks) Insert(string) {} | ||
| @@ -1286,6 +1292,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1286 | Terminal: term, | 1292 | Terminal: term, | ||
| 1287 | Theme: theme, | 1293 | Theme: theme, | ||
| 1288 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | 1294 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | ||
| 1295 | + | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | |||
| 1289 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | 1296 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | ||
| 1290 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | 1297 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | ||
| 1291 | JailByDefault: initialCfg.JailByDefault, | 1298 | JailByDefault: initialCfg.JailByDefault, | ||
M packages/agent/config.go +5 -0
| @@ -53,6 +53,11 @@ type Config struct { | |||
| 53 | 53 | // detected protocol when available. | |
| 54 | 54 | InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"` | |
| 55 | 55 | ||
| 56 | + | // TerminalAlertsEnabled controls interactive terminal alerts from the | |
| 57 | + | // main agent and extensions. nil/missing means enabled; false disables | |
| 58 | + | // alerts. Toggle from /settings. | |
| 59 | + | TerminalAlertsEnabled *bool `json:"terminal_alerts_enabled,omitempty"` | |
| 60 | + | ||
| 56 | 61 | // AutoSwarmEnabled lets the main agent spawn background sub-agents | |
| 57 | 62 | // for parallel sub-tasks via a built-in swarm_spawn tool. Off by | |
| 58 | 63 | // default; nil/missing means disabled. Toggle from /settings. | |
| @@ -53,6 +53,11 @@ type Config struct { | |||||
| 53 | // detected protocol when available. | 53 | // detected protocol when available. | ||
| 54 | InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"` | 54 | InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"` | ||
| 55 | 55 | ||||
| 56 | + | // TerminalAlertsEnabled controls interactive terminal alerts from the | |||
| 57 | + | // main agent and extensions. nil/missing means enabled; false disables | |||
| 58 | + | // alerts. Toggle from /settings. | |||
| 59 | + | TerminalAlertsEnabled *bool `json:"terminal_alerts_enabled,omitempty"` | |||
| 60 | + | ||||
| 56 | // AutoSwarmEnabled lets the main agent spawn background sub-agents | 61 | // AutoSwarmEnabled lets the main agent spawn background sub-agents | ||
| 57 | // for parallel sub-tasks via a built-in swarm_spawn tool. Off by | 62 | // for parallel sub-tasks via a built-in swarm_spawn tool. Off by | ||
| 58 | // default; nil/missing means disabled. Toggle from /settings. | 63 | // default; nil/missing means disabled. Toggle from /settings. | ||
M packages/agent/ext/ext.go +16 -1
| @@ -158,6 +158,12 @@ type ToolResult struct { | |||
| 158 | 158 | ActivateTools []string | |
| 159 | 159 | } | |
| 160 | 160 | ||
| 161 | + | // AlertRequest asks the host to surface a structured alert. The host owns | |
| 162 | + | // the rendering policy; extensions never write terminal bytes directly. | |
| 163 | + | type AlertRequest = extproto.AlertRequest | |
| 164 | + | ||
| 165 | + | const AlertKindBell = extproto.AlertKindBell | |
| 166 | + | ||
| 161 | 167 | // ToolContent is one block of tool output. Either Text is set, or | |
| 162 | 168 | // MimeType+Data (base64-encoded). Use the Text/Image helpers below. | |
| 163 | 169 | type ToolContent struct { | |
| @@ -311,7 +317,7 @@ func New(name, version string) *Extension { | |||
| 311 | 317 | eventHandlers: map[string]EventHandler{}, | |
| 312 | 318 | panelKeys: map[string]func(key, text string){}, | |
| 313 | 319 | panelCloses: map[string]func(){}, | |
| 314 | - | caps: []string{"commands", "tools", "events", "panels"}, | |
| 320 | + | caps: []string{"commands", "tools", "events", "alerts", "panels"}, | |
| 315 | 321 | } | |
| 316 | 322 | } | |
| 317 | 323 | ||
| @@ -479,6 +485,15 @@ func (e *Extension) Notify(level, message string) { | |||
| 479 | 485 | }) | |
| 480 | 486 | } | |
| 481 | 487 | ||
| 488 | + | // Alert asks the host to surface a structured alert. It is fire-and-forget; | |
| 489 | + | // hosts that do not support the requested kind may ignore it. | |
| 490 | + | func (e *Extension) Alert(alert AlertRequest) { | |
| 491 | + | _ = e.send(extproto.AlertFromExt{ | |
| 492 | + | Type: "alert", | |
| 493 | + | AlertRequest: alert, | |
| 494 | + | }) | |
| 495 | + | } | |
| 496 | + | ||
| 482 | 497 | // Run starts the protocol loop. Blocks until stdin closes (zot has | |
| 483 | 498 | // shut us down). Returns the first fatal error, or nil on clean exit. | |
| 484 | 499 | func (e *Extension) Run() error { | |
| @@ -158,6 +158,12 @@ type ToolResult struct { | |||||
| 158 | ActivateTools []string | 158 | ActivateTools []string | ||
| 159 | } | 159 | } | ||
| 160 | 160 | ||||
| 161 | + | // AlertRequest asks the host to surface a structured alert. The host owns | |||
| 162 | + | // the rendering policy; extensions never write terminal bytes directly. | |||
| 163 | + | type AlertRequest = extproto.AlertRequest | |||
| 164 | + | ||||
| 165 | + | const AlertKindBell = extproto.AlertKindBell | |||
| 166 | + | ||||
| 161 | // ToolContent is one block of tool output. Either Text is set, or | 167 | // ToolContent is one block of tool output. Either Text is set, or | ||
| 162 | // MimeType+Data (base64-encoded). Use the Text/Image helpers below. | 168 | // MimeType+Data (base64-encoded). Use the Text/Image helpers below. | ||
| 163 | type ToolContent struct { | 169 | type ToolContent struct { | ||
| @@ -311,7 +317,7 @@ func New(name, version string) *Extension { | |||||
| 311 | eventHandlers: map[string]EventHandler{}, | 317 | eventHandlers: map[string]EventHandler{}, | ||
| 312 | panelKeys: map[string]func(key, text string){}, | 318 | panelKeys: map[string]func(key, text string){}, | ||
| 313 | panelCloses: map[string]func(){}, | 319 | panelCloses: map[string]func(){}, | ||
| 314 | - | caps: []string{"commands", "tools", "events", "panels"}, | 320 | + | caps: []string{"commands", "tools", "events", "alerts", "panels"}, |
| 315 | } | 321 | } | ||
| 316 | } | 322 | } | ||
| 317 | 323 | ||||
| @@ -479,6 +485,15 @@ func (e *Extension) Notify(level, message string) { | |||||
| 479 | }) | 485 | }) | ||
| 480 | } | 486 | } | ||
| 481 | 487 | ||||
| 488 | + | // Alert asks the host to surface a structured alert. It is fire-and-forget; | |||
| 489 | + | // hosts that do not support the requested kind may ignore it. | |||
| 490 | + | func (e *Extension) Alert(alert AlertRequest) { | |||
| 491 | + | _ = e.send(extproto.AlertFromExt{ | |||
| 492 | + | Type: "alert", | |||
| 493 | + | AlertRequest: alert, | |||
| 494 | + | }) | |||
| 495 | + | } | |||
| 496 | + | ||||
| 482 | // Run starts the protocol loop. Blocks until stdin closes (zot has | 497 | // Run starts the protocol loop. Blocks until stdin closes (zot has | ||
| 483 | // shut us down). Returns the first fatal error, or nil on clean exit. | 498 | // shut us down). Returns the first fatal error, or nil on clean exit. | ||
| 484 | func (e *Extension) Run() error { | 499 | func (e *Extension) Run() error { | ||
M packages/agent/ext/ext_test.go +18 -0
| @@ -220,6 +220,24 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) { | |||
| 220 | 220 | h.hostW.Close() | |
| 221 | 221 | } | |
| 222 | 222 | ||
| 223 | + | func TestAlertEmitsStructuredFrame(t *testing.T) { | |
| 224 | + | h := newHarness("alert-ext") | |
| 225 | + | go h.ext.Run() | |
| 226 | + | h.handshake(t) | |
| 227 | + | ||
| 228 | + | go h.ext.Alert(AlertRequest{Kind: AlertKindBell, Reason: "question_ready"}) | |
| 229 | + | f := h.drainUntil(t, "alert") | |
| 230 | + | ||
| 231 | + | var alert extproto.AlertFromExt | |
| 232 | + | if err := json.Unmarshal(f.raw, &alert); err != nil { | |
| 233 | + | t.Fatalf("unmarshal alert: %v", err) | |
| 234 | + | } | |
| 235 | + | if alert.Kind != AlertKindBell || alert.Reason != "question_ready" { | |
| 236 | + | t.Fatalf("alert = %+v, want bell/question_ready", alert) | |
| 237 | + | } | |
| 238 | + | h.hostW.Close() | |
| 239 | + | } | |
| 240 | + | ||
| 223 | 241 | func TestToolConfirmationRequestedEvent(t *testing.T) { | |
| 224 | 242 | h := newHarness("confirmation-events") | |
| 225 | 243 | received := make(chan Event, 1) | |
| @@ -220,6 +220,24 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) { | |||||
| 220 | h.hostW.Close() | 220 | h.hostW.Close() | ||
| 221 | } | 221 | } | ||
| 222 | 222 | ||||
| 223 | + | func TestAlertEmitsStructuredFrame(t *testing.T) { | |||
| 224 | + | h := newHarness("alert-ext") | |||
| 225 | + | go h.ext.Run() | |||
| 226 | + | h.handshake(t) | |||
| 227 | + | ||||
| 228 | + | go h.ext.Alert(AlertRequest{Kind: AlertKindBell, Reason: "question_ready"}) | |||
| 229 | + | f := h.drainUntil(t, "alert") | |||
| 230 | + | ||||
| 231 | + | var alert extproto.AlertFromExt | |||
| 232 | + | if err := json.Unmarshal(f.raw, &alert); err != nil { | |||
| 233 | + | t.Fatalf("unmarshal alert: %v", err) | |||
| 234 | + | } | |||
| 235 | + | if alert.Kind != AlertKindBell || alert.Reason != "question_ready" { | |||
| 236 | + | t.Fatalf("alert = %+v, want bell/question_ready", alert) | |||
| 237 | + | } | |||
| 238 | + | h.hostW.Close() | |||
| 239 | + | } | |||
| 240 | + | ||||
| 223 | func TestToolConfirmationRequestedEvent(t *testing.T) { | 241 | func TestToolConfirmationRequestedEvent(t *testing.T) { | ||
| 224 | h := newHarness("confirmation-events") | 242 | h := newHarness("confirmation-events") | ||
| 225 | received := make(chan Event, 1) | 243 | received := make(chan Event, 1) | ||
M packages/agent/extcmd.go +1 -0
| @@ -133,6 +133,7 @@ func extList() error { | |||
| 133 | 133 | type extDoctorHooks struct{} | |
| 134 | 134 | ||
| 135 | 135 | func (extDoctorHooks) Notify(string, string, string) {} | |
| 136 | + | func (extDoctorHooks) Alert(string, extproto.AlertRequest) {} | |
| 136 | 137 | func (extDoctorHooks) Submit(string) {} | |
| 137 | 138 | func (extDoctorHooks) SubmitSlash(string) {} | |
| 138 | 139 | func (extDoctorHooks) Insert(string) {} | |
| @@ -133,6 +133,7 @@ func extList() error { | |||||
| 133 | type extDoctorHooks struct{} | 133 | type extDoctorHooks struct{} | ||
| 134 | 134 | ||||
| 135 | func (extDoctorHooks) Notify(string, string, string) {} | 135 | func (extDoctorHooks) Notify(string, string, string) {} | ||
| 136 | + | func (extDoctorHooks) Alert(string, extproto.AlertRequest) {} | |||
| 136 | func (extDoctorHooks) Submit(string) {} | 137 | func (extDoctorHooks) Submit(string) {} | ||
| 137 | func (extDoctorHooks) SubmitSlash(string) {} | 138 | func (extDoctorHooks) SubmitSlash(string) {} | ||
| 138 | func (extDoctorHooks) Insert(string) {} | 139 | func (extDoctorHooks) Insert(string) {} | ||
M packages/agent/extensions/manager.go +14 -0
| @@ -132,6 +132,13 @@ type HostHooks interface { | |||
| 132 | 132 | ClosePanel(extName, panelID string) | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | + | // AlertHostHooks is an optional extension to HostHooks. Hosts that support | |
| 136 | + | // structured alerts implement it without breaking older embedders that only | |
| 137 | + | // implement the original HostHooks surface. | |
| 138 | + | type AlertHostHooks interface { | |
| 139 | + | Alert(extName string, alert extproto.AlertRequest) | |
| 140 | + | } | |
| 141 | + | ||
| 135 | 142 | type commandRegistration struct { | |
| 136 | 143 | ext *Extension | |
| 137 | 144 | name string | |
| @@ -845,6 +852,13 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 845 | 852 | if err := json.Unmarshal(line, &n); err == nil { | |
| 846 | 853 | m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message) | |
| 847 | 854 | } | |
| 855 | + | case "alert": | |
| 856 | + | var a extproto.AlertFromExt | |
| 857 | + | if err := json.Unmarshal(line, &a); err == nil { | |
| 858 | + | if hooks, ok := m.hooks.(AlertHostHooks); ok { | |
| 859 | + | hooks.Alert(ext.Manifest.Name, a.AlertRequest) | |
| 860 | + | } | |
| 861 | + | } | |
| 848 | 862 | case "clear_notes": | |
| 849 | 863 | m.hooks.ClearNotes(ext.Manifest.Name) | |
| 850 | 864 | case "submit": | |
| @@ -132,6 +132,13 @@ type HostHooks interface { | |||||
| 132 | ClosePanel(extName, panelID string) | 132 | ClosePanel(extName, panelID string) | ||
| 133 | } | 133 | } | ||
| 134 | 134 | ||||
| 135 | + | // AlertHostHooks is an optional extension to HostHooks. Hosts that support | |||
| 136 | + | // structured alerts implement it without breaking older embedders that only | |||
| 137 | + | // implement the original HostHooks surface. | |||
| 138 | + | type AlertHostHooks interface { | |||
| 139 | + | Alert(extName string, alert extproto.AlertRequest) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 135 | type commandRegistration struct { | 142 | type commandRegistration struct { | ||
| 136 | ext *Extension | 143 | ext *Extension | ||
| 137 | name string | 144 | name string | ||
| @@ -845,6 +852,13 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 845 | if err := json.Unmarshal(line, &n); err == nil { | 852 | if err := json.Unmarshal(line, &n); err == nil { | ||
| 846 | m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message) | 853 | m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message) | ||
| 847 | } | 854 | } | ||
| 855 | + | case "alert": | |||
| 856 | + | var a extproto.AlertFromExt | |||
| 857 | + | if err := json.Unmarshal(line, &a); err == nil { | |||
| 858 | + | if hooks, ok := m.hooks.(AlertHostHooks); ok { | |||
| 859 | + | hooks.Alert(ext.Manifest.Name, a.AlertRequest) | |||
| 860 | + | } | |||
| 861 | + | } | |||
| 848 | case "clear_notes": | 862 | case "clear_notes": | ||
| 849 | m.hooks.ClearNotes(ext.Manifest.Name) | 863 | m.hooks.ClearNotes(ext.Manifest.Name) | ||
| 850 | case "submit": | 864 | case "submit": | ||
M packages/agent/extensions/manager_test.go +73 -0
| @@ -19,6 +19,8 @@ type stubHooks struct { | |||
| 19 | 19 | mu sync.Mutex | |
| 20 | 20 | notifies []string | |
| 21 | 21 | displays []string | |
| 22 | + | alerts []extproto.AlertRequest | |
| 23 | + | alertExts []string | |
| 22 | 24 | submits []string | |
| 23 | 25 | submitSlash []string | |
| 24 | 26 | clearNotes []string | |
| @@ -31,6 +33,12 @@ func (s *stubHooks) Notify(name, level, message string) { | |||
| 31 | 33 | defer s.mu.Unlock() | |
| 32 | 34 | s.notifies = append(s.notifies, name+":"+level+":"+message) | |
| 33 | 35 | } | |
| 36 | + | func (s *stubHooks) Alert(name string, alert extproto.AlertRequest) { | |
| 37 | + | s.mu.Lock() | |
| 38 | + | defer s.mu.Unlock() | |
| 39 | + | s.alertExts = append(s.alertExts, name) | |
| 40 | + | s.alerts = append(s.alerts, alert) | |
| 41 | + | } | |
| 34 | 42 | func (s *stubHooks) Submit(text string) { | |
| 35 | 43 | s.mu.Lock() | |
| 36 | 44 | defer s.mu.Unlock() | |
| @@ -462,6 +470,71 @@ done | |||
| 462 | 470 | } | |
| 463 | 471 | } | |
| 464 | 472 | ||
| 473 | + | // TestSpontaneousAlert verifies that an extension sending an alert frame | |
| 474 | + | // causes the manager to call hooks.Alert with the structured request. | |
| 475 | + | func TestSpontaneousAlert(t *testing.T) { | |
| 476 | + | if runtime.GOOS == "windows" { | |
| 477 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | tmp := t.TempDir() | |
| 481 | + | extDir := filepath.Join(tmp, "extensions", "alert-mock") | |
| 482 | + | if err := os.MkdirAll(extDir, 0o755); err != nil { | |
| 483 | + | t.Fatal(err) | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | script := `#!/bin/sh | |
| 487 | + | printf '%s\n' '{"type":"hello","name":"alert-mock","version":"0.1","capabilities":["alerts"]}' | |
| 488 | + | printf '%s\n' '{"type":"ready"}' | |
| 489 | + | printf '%s\n' '{"type":"alert","kind":"bell","reason":"question_ready"}' | |
| 490 | + | while IFS= read -r line; do | |
| 491 | + | case "$line" in | |
| 492 | + | *'"type":"shutdown"'*) | |
| 493 | + | printf '%s\n' '{"type":"shutdown_ack"}' | |
| 494 | + | exit 0 | |
| 495 | + | ;; | |
| 496 | + | esac | |
| 497 | + | done | |
| 498 | + | ` | |
| 499 | + | if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil { | |
| 500 | + | t.Fatal(err) | |
| 501 | + | } | |
| 502 | + | manifest, _ := json.Marshal(map[string]any{"name": "alert-mock", "exec": "./run.sh"}) | |
| 503 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), manifest, 0o644); err != nil { | |
| 504 | + | t.Fatal(err) | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | hooks := &stubHooks{} | |
| 508 | + | mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-test", hooks) | |
| 509 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |
| 510 | + | t.Fatalf("discover errors: %v", errs) | |
| 511 | + | } | |
| 512 | + | defer mgr.Stop(2 * time.Second) | |
| 513 | + | ||
| 514 | + | deadline := time.Now().Add(2 * time.Second) | |
| 515 | + | for time.Now().Before(deadline) { | |
| 516 | + | hooks.mu.Lock() | |
| 517 | + | n := len(hooks.alerts) | |
| 518 | + | hooks.mu.Unlock() | |
| 519 | + | if n > 0 { | |
| 520 | + | break | |
| 521 | + | } | |
| 522 | + | time.Sleep(20 * time.Millisecond) | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | hooks.mu.Lock() | |
| 526 | + | defer hooks.mu.Unlock() | |
| 527 | + | if len(hooks.alerts) != 1 || len(hooks.alertExts) != 1 { | |
| 528 | + | t.Fatalf("alerts = %#v, extensions = %#v; want one alert", hooks.alerts, hooks.alertExts) | |
| 529 | + | } | |
| 530 | + | if hooks.alertExts[0] != "alert-mock" { | |
| 531 | + | t.Fatalf("alert extension = %q, want alert-mock", hooks.alertExts[0]) | |
| 532 | + | } | |
| 533 | + | if got := hooks.alerts[0]; got.Kind != extproto.AlertKindBell || got.Reason != "question_ready" { | |
| 534 | + | t.Fatalf("alert = %+v, want bell/question_ready", got) | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | ||
| 465 | 538 | // TestSpontaneousOpenPanel verifies that an extension sending an | |
| 466 | 539 | // open_panel frame outside of any command response causes the manager | |
| 467 | 540 | // to call hooks.OpenPanel with the correct PanelSpec fields. | |
| @@ -19,6 +19,8 @@ type stubHooks struct { | |||||
| 19 | mu sync.Mutex | 19 | mu sync.Mutex | ||
| 20 | notifies []string | 20 | notifies []string | ||
| 21 | displays []string | 21 | displays []string | ||
| 22 | + | alerts []extproto.AlertRequest | |||
| 23 | + | alertExts []string | |||
| 22 | submits []string | 24 | submits []string | ||
| 23 | submitSlash []string | 25 | submitSlash []string | ||
| 24 | clearNotes []string | 26 | clearNotes []string | ||
| @@ -31,6 +33,12 @@ func (s *stubHooks) Notify(name, level, message string) { | |||||
| 31 | defer s.mu.Unlock() | 33 | defer s.mu.Unlock() | ||
| 32 | s.notifies = append(s.notifies, name+":"+level+":"+message) | 34 | s.notifies = append(s.notifies, name+":"+level+":"+message) | ||
| 33 | } | 35 | } | ||
| 36 | + | func (s *stubHooks) Alert(name string, alert extproto.AlertRequest) { | |||
| 37 | + | s.mu.Lock() | |||
| 38 | + | defer s.mu.Unlock() | |||
| 39 | + | s.alertExts = append(s.alertExts, name) | |||
| 40 | + | s.alerts = append(s.alerts, alert) | |||
| 41 | + | } | |||
| 34 | func (s *stubHooks) Submit(text string) { | 42 | func (s *stubHooks) Submit(text string) { | ||
| 35 | s.mu.Lock() | 43 | s.mu.Lock() | ||
| 36 | defer s.mu.Unlock() | 44 | defer s.mu.Unlock() | ||
| @@ -462,6 +470,71 @@ done | |||||
| 462 | } | 470 | } | ||
| 463 | } | 471 | } | ||
| 464 | 472 | ||||
| 473 | + | // TestSpontaneousAlert verifies that an extension sending an alert frame | |||
| 474 | + | // causes the manager to call hooks.Alert with the structured request. | |||
| 475 | + | func TestSpontaneousAlert(t *testing.T) { | |||
| 476 | + | if runtime.GOOS == "windows" { | |||
| 477 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | tmp := t.TempDir() | |||
| 481 | + | extDir := filepath.Join(tmp, "extensions", "alert-mock") | |||
| 482 | + | if err := os.MkdirAll(extDir, 0o755); err != nil { | |||
| 483 | + | t.Fatal(err) | |||
| 484 | + | } | |||
| 485 | + | ||||
| 486 | + | script := `#!/bin/sh | |||
| 487 | + | printf '%s\n' '{"type":"hello","name":"alert-mock","version":"0.1","capabilities":["alerts"]}' | |||
| 488 | + | printf '%s\n' '{"type":"ready"}' | |||
| 489 | + | printf '%s\n' '{"type":"alert","kind":"bell","reason":"question_ready"}' | |||
| 490 | + | while IFS= read -r line; do | |||
| 491 | + | case "$line" in | |||
| 492 | + | *'"type":"shutdown"'*) | |||
| 493 | + | printf '%s\n' '{"type":"shutdown_ack"}' | |||
| 494 | + | exit 0 | |||
| 495 | + | ;; | |||
| 496 | + | esac | |||
| 497 | + | done | |||
| 498 | + | ` | |||
| 499 | + | if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil { | |||
| 500 | + | t.Fatal(err) | |||
| 501 | + | } | |||
| 502 | + | manifest, _ := json.Marshal(map[string]any{"name": "alert-mock", "exec": "./run.sh"}) | |||
| 503 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), manifest, 0o644); err != nil { | |||
| 504 | + | t.Fatal(err) | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | hooks := &stubHooks{} | |||
| 508 | + | mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-test", hooks) | |||
| 509 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |||
| 510 | + | t.Fatalf("discover errors: %v", errs) | |||
| 511 | + | } | |||
| 512 | + | defer mgr.Stop(2 * time.Second) | |||
| 513 | + | ||||
| 514 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 515 | + | for time.Now().Before(deadline) { | |||
| 516 | + | hooks.mu.Lock() | |||
| 517 | + | n := len(hooks.alerts) | |||
| 518 | + | hooks.mu.Unlock() | |||
| 519 | + | if n > 0 { | |||
| 520 | + | break | |||
| 521 | + | } | |||
| 522 | + | time.Sleep(20 * time.Millisecond) | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | hooks.mu.Lock() | |||
| 526 | + | defer hooks.mu.Unlock() | |||
| 527 | + | if len(hooks.alerts) != 1 || len(hooks.alertExts) != 1 { | |||
| 528 | + | t.Fatalf("alerts = %#v, extensions = %#v; want one alert", hooks.alerts, hooks.alertExts) | |||
| 529 | + | } | |||
| 530 | + | if hooks.alertExts[0] != "alert-mock" { | |||
| 531 | + | t.Fatalf("alert extension = %q, want alert-mock", hooks.alertExts[0]) | |||
| 532 | + | } | |||
| 533 | + | if got := hooks.alerts[0]; got.Kind != extproto.AlertKindBell || got.Reason != "question_ready" { | |||
| 534 | + | t.Fatalf("alert = %+v, want bell/question_ready", got) | |||
| 535 | + | } | |||
| 536 | + | } | |||
| 537 | + | ||||
| 465 | // TestSpontaneousOpenPanel verifies that an extension sending an | 538 | // TestSpontaneousOpenPanel verifies that an extension sending an | ||
| 466 | // open_panel frame outside of any command response causes the manager | 539 | // open_panel frame outside of any command response causes the manager | ||
| 467 | // to call hooks.OpenPanel with the correct PanelSpec fields. | 540 | // to call hooks.OpenPanel with the correct PanelSpec fields. | ||
M packages/agent/extproto/extproto.go +16 -0
| @@ -121,6 +121,22 @@ type PanelCloseFromExt struct { | |||
| 121 | 121 | PanelID string `json:"panel_id"` | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | const AlertKindBell = "bell" | |
| 125 | + | ||
| 126 | + | // AlertRequest is a host-owned alert request. Extensions provide semantic | |
| 127 | + | // metadata; the host decides whether and how to render it. | |
| 128 | + | type AlertRequest struct { | |
| 129 | + | Kind string `json:"kind"` | |
| 130 | + | Reason string `json:"reason,omitempty"` | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // AlertFromExt is a spontaneous one-way frame an extension can send at any | |
| 134 | + | // time to request a host alert. | |
| 135 | + | type AlertFromExt struct { | |
| 136 | + | Type string `json:"type"` | |
| 137 | + | AlertRequest | |
| 138 | + | } | |
| 139 | + | ||
| 124 | 140 | type NotifyFromExt struct { | |
| 125 | 141 | Type string `json:"type"` | |
| 126 | 142 | Level string `json:"level"` | |
| @@ -121,6 +121,22 @@ type PanelCloseFromExt struct { | |||||
| 121 | PanelID string `json:"panel_id"` | 121 | PanelID string `json:"panel_id"` | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | + | const AlertKindBell = "bell" | |||
| 125 | + | ||||
| 126 | + | // AlertRequest is a host-owned alert request. Extensions provide semantic | |||
| 127 | + | // metadata; the host decides whether and how to render it. | |||
| 128 | + | type AlertRequest struct { | |||
| 129 | + | Kind string `json:"kind"` | |||
| 130 | + | Reason string `json:"reason,omitempty"` | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // AlertFromExt is a spontaneous one-way frame an extension can send at any | |||
| 134 | + | // time to request a host alert. | |||
| 135 | + | type AlertFromExt struct { | |||
| 136 | + | Type string `json:"type"` | |||
| 137 | + | AlertRequest | |||
| 138 | + | } | |||
| 139 | + | ||||
| 124 | type NotifyFromExt struct { | 140 | type NotifyFromExt struct { | ||
| 125 | Type string `json:"type"` | 141 | Type string `json:"type"` | ||
| 126 | Level string `json:"level"` | 142 | Level string `json:"level"` | ||
A packages/agent/modes/alert_test.go +249 -0
| @@ -0,0 +1,249 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 12 | + | "github.com/patriceckhart/zot/packages/core" | |
| 13 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 14 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type alertTestTerminal struct { | |
| 18 | + | mu sync.Mutex | |
| 19 | + | data []byte | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (t *alertTestTerminal) Write(p []byte) (int, error) { | |
| 23 | + | t.mu.Lock() | |
| 24 | + | defer t.mu.Unlock() | |
| 25 | + | t.data = append(t.data, p...) | |
| 26 | + | return len(p), nil | |
| 27 | + | } | |
| 28 | + | func (t *alertTestTerminal) WriteString(s string) (int, error) { | |
| 29 | + | t.mu.Lock() | |
| 30 | + | defer t.mu.Unlock() | |
| 31 | + | t.data = append(t.data, s...) | |
| 32 | + | return len(s), nil | |
| 33 | + | } | |
| 34 | + | func (t *alertTestTerminal) String() string { | |
| 35 | + | t.mu.Lock() | |
| 36 | + | defer t.mu.Unlock() | |
| 37 | + | return string(t.data) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (*alertTestTerminal) Size() (int, int) { return 80, 24 } | |
| 41 | + | func (*alertTestTerminal) OnResize(func()) {} | |
| 42 | + | func (*alertTestTerminal) EnterRaw() (func() error, error) { | |
| 43 | + | return func() error { return nil }, nil | |
| 44 | + | } | |
| 45 | + | func (*alertTestTerminal) ReadByte() (byte, error) { return 0, io.EOF } | |
| 46 | + | func (*alertTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) { | |
| 47 | + | return 0, false, nil | |
| 48 | + | } | |
| 49 | + | func (*alertTestTerminal) SetNonblock(bool) error { return nil } | |
| 50 | + | ||
| 51 | + | func TestInteractiveAlertUsesSharedTerminalPolicy(t *testing.T) { | |
| 52 | + | term := &alertTestTerminal{} | |
| 53 | + | i := &Interactive{cfg: InteractiveConfig{Terminal: term}} | |
| 54 | + | ||
| 55 | + | i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"}) | |
| 56 | + | if got := term.String(); got != "\a" { | |
| 57 | + | t.Fatalf("alert output = %q, want standalone BEL", got) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestInteractiveAlertRespectsDisabledPolicy(t *testing.T) { | |
| 62 | + | term := &alertTestTerminal{} | |
| 63 | + | disabled := false | |
| 64 | + | i := &Interactive{cfg: InteractiveConfig{Terminal: term, TerminalAlertsEnabled: &disabled}} | |
| 65 | + | ||
| 66 | + | i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"}) | |
| 67 | + | if got := term.String(); got != "" { | |
| 68 | + | t.Fatalf("disabled alert output = %q, want empty", got) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | type alertBlockingClient struct{} | |
| 73 | + | ||
| 74 | + | func (alertBlockingClient) Name() string { return "alert-blocking" } | |
| 75 | + | func (alertBlockingClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) { | |
| 76 | + | out := make(chan provider.Event, 1) | |
| 77 | + | go func() { | |
| 78 | + | defer close(out) | |
| 79 | + | <-ctx.Done() | |
| 80 | + | out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()} | |
| 81 | + | }() | |
| 82 | + | return out, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestCancelledMainTurnDoesNotBell(t *testing.T) { | |
| 86 | + | term := &alertTestTerminal{} | |
| 87 | + | ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil) | |
| 88 | + | i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term}) | |
| 89 | + | i.startTurn(context.Background(), "question") | |
| 90 | + | ||
| 91 | + | deadline := time.Now().Add(time.Second) | |
| 92 | + | for time.Now().Before(deadline) { | |
| 93 | + | i.mu.Lock() | |
| 94 | + | busy := i.busy | |
| 95 | + | i.mu.Unlock() | |
| 96 | + | if busy { | |
| 97 | + | break | |
| 98 | + | } | |
| 99 | + | time.Sleep(time.Millisecond) | |
| 100 | + | } | |
| 101 | + | i.CancelTurn() | |
| 102 | + | ||
| 103 | + | deadline = time.Now().Add(time.Second) | |
| 104 | + | for time.Now().Before(deadline) { | |
| 105 | + | i.mu.Lock() | |
| 106 | + | busy := i.busy | |
| 107 | + | i.mu.Unlock() | |
| 108 | + | if !busy { | |
| 109 | + | break | |
| 110 | + | } | |
| 111 | + | time.Sleep(time.Millisecond) | |
| 112 | + | } | |
| 113 | + | if got := term.String(); got != "" { | |
| 114 | + | t.Fatalf("cancelled main turn alert output = %q, want empty", got) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestMainTurnEmitsBellAfterVisibleResponse(t *testing.T) { | |
| 119 | + | term := &alertTestTerminal{} | |
| 120 | + | client := &streamTestClient{events: []provider.Event{ | |
| 121 | + | provider.EventTextDelta{Delta: "answer"}, | |
| 122 | + | provider.EventDone{ | |
| 123 | + | Stop: provider.StopEnd, | |
| 124 | + | Message: provider.Message{ | |
| 125 | + | Role: provider.RoleAssistant, | |
| 126 | + | Content: []provider.Content{provider.TextBlock{Text: "answer"}}, | |
| 127 | + | }, | |
| 128 | + | }, | |
| 129 | + | }} | |
| 130 | + | ag := core.NewAgent(client, "test-model", "", nil) | |
| 131 | + | i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term}) | |
| 132 | + | i.rend = tui.NewRenderer(io.Discard) | |
| 133 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 134 | + | defer cancel() | |
| 135 | + | i.runCtx = ctx | |
| 136 | + | go i.runStreamPacer(ctx) | |
| 137 | + | i.startTurn(ctx, "question") | |
| 138 | + | ||
| 139 | + | deadline := time.Now().Add(time.Second) | |
| 140 | + | for time.Now().Before(deadline) { | |
| 141 | + | i.mu.Lock() | |
| 142 | + | ready := i.pendingAlert != nil && !i.streamFlushPending && len(i.streamPending) == 0 | |
| 143 | + | i.mu.Unlock() | |
| 144 | + | if ready { | |
| 145 | + | i.redraw() | |
| 146 | + | break | |
| 147 | + | } | |
| 148 | + | time.Sleep(time.Millisecond) | |
| 149 | + | } | |
| 150 | + | if got := term.String(); got != "\a" { | |
| 151 | + | t.Fatalf("main turn alert output = %q, want BEL after response redraw", got) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestMainAlertWaitsForPacedText(t *testing.T) { | |
| 156 | + | term := &alertTestTerminal{} | |
| 157 | + | i := NewInteractive(InteractiveConfig{Terminal: term}) | |
| 158 | + | i.rend = tui.NewRenderer(io.Discard) | |
| 159 | + | i.streamPending = []rune("final answer") | |
| 160 | + | i.streamFlushPending = true | |
| 161 | + | i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"} | |
| 162 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 163 | + | defer cancel() | |
| 164 | + | go i.runStreamPacer(ctx) | |
| 165 | + | ||
| 166 | + | deadline := time.Now().Add(time.Second) | |
| 167 | + | for time.Now().Before(deadline) { | |
| 168 | + | i.mu.Lock() | |
| 169 | + | ready := !i.streamFlushPending && len(i.streamPending) == 0 && i.pendingAlert != nil | |
| 170 | + | i.mu.Unlock() | |
| 171 | + | if ready { | |
| 172 | + | i.redraw() | |
| 173 | + | break | |
| 174 | + | } | |
| 175 | + | time.Sleep(time.Millisecond) | |
| 176 | + | } | |
| 177 | + | if got := term.String(); got != "\a" { | |
| 178 | + | t.Fatalf("paced alert output = %q, want BEL after final redraw", got) | |
| 179 | + | } | |
| 180 | + | i.mu.Lock() | |
| 181 | + | on, pending, alert := i.streamOn, len(i.streamPending), i.pendingAlert | |
| 182 | + | i.mu.Unlock() | |
| 183 | + | if on || pending != 0 || alert != nil { | |
| 184 | + | t.Fatalf("stream state after alert: on=%v pending=%d alert=%v", on, pending, alert) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestMainAlertReasonPolicy(t *testing.T) { | |
| 189 | + | cancelled, cancel := context.WithCancel(context.Background()) | |
| 190 | + | cancel() | |
| 191 | + | ||
| 192 | + | tests := []struct { | |
| 193 | + | name string | |
| 194 | + | ctx context.Context | |
| 195 | + | err error | |
| 196 | + | turnErr error | |
| 197 | + | stop provider.StopReason | |
| 198 | + | awaiting bool | |
| 199 | + | queued bool | |
| 200 | + | rescue bool | |
| 201 | + | recovering bool | |
| 202 | + | auto bool | |
| 203 | + | want string | |
| 204 | + | }{ | |
| 205 | + | {name: "normal", ctx: context.Background(), stop: provider.StopEnd, want: "agent_done"}, | |
| 206 | + | {name: "length", ctx: context.Background(), stop: provider.StopLength, want: "response_truncated"}, | |
| 207 | + | {name: "returned error", ctx: context.Background(), err: errors.New("boom"), stop: provider.StopError, want: "agent_error"}, | |
| 208 | + | {name: "guard error", ctx: context.Background(), stop: provider.StopError, want: "agent_error"}, | |
| 209 | + | {name: "rescue", ctx: context.Background(), err: errors.New("temporary"), stop: provider.StopError, rescue: true, want: "rescue_required"}, | |
| 210 | + | {name: "cancelled", ctx: cancelled, stop: provider.StopAborted}, | |
| 211 | + | {name: "aborted", ctx: context.Background(), stop: provider.StopAborted}, | |
| 212 | + | {name: "queued", ctx: context.Background(), stop: provider.StopEnd, queued: true}, | |
| 213 | + | {name: "recovering", ctx: context.Background(), stop: provider.StopError, recovering: true}, | |
| 214 | + | {name: "auto compacting", ctx: context.Background(), stop: provider.StopEnd, auto: true}, | |
| 215 | + | {name: "startup pre", ctx: context.Background(), stop: provider.StopEnd, awaiting: true}, | |
| 216 | + | } | |
| 217 | + | for _, tt := range tests { | |
| 218 | + | t.Run(tt.name, func(t *testing.T) { | |
| 219 | + | got := mainAlertReason(tt.ctx, tt.err, tt.turnErr, tt.stop, tt.awaiting, tt.queued, tt.rescue, tt.recovering, tt.auto) | |
| 220 | + | if got != tt.want { | |
| 221 | + | t.Fatalf("mainAlertReason() = %q, want %q", got, tt.want) | |
| 222 | + | } | |
| 223 | + | }) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestTerminalAlertsSettingDefaultsEnabledAndToggles(t *testing.T) { | |
| 228 | + | i := NewInteractive(InteractiveConfig{Terminal: &alertTestTerminal{}}) | |
| 229 | + | i.openSettingsDialog() | |
| 230 | + | var item *settingsItem | |
| 231 | + | for idx := range i.settingsDialog.items { | |
| 232 | + | if i.settingsDialog.items[idx].key == "terminal_alerts_enabled" { | |
| 233 | + | item = &i.settingsDialog.items[idx] | |
| 234 | + | break | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | if item == nil || !item.value { | |
| 238 | + | t.Fatal("terminal alerts setting should be present and enabled by default") | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | i.applySettingToggle("terminal_alerts_enabled", false) | |
| 242 | + | if i.cfg.TerminalAlertsEnabled == nil || *i.cfg.TerminalAlertsEnabled { | |
| 243 | + | t.Fatal("terminal alerts setting did not disable") | |
| 244 | + | } | |
| 245 | + | i.applySettingToggle("terminal_alerts_enabled", true) | |
| 246 | + | if i.cfg.TerminalAlertsEnabled == nil || !*i.cfg.TerminalAlertsEnabled { | |
| 247 | + | t.Fatal("terminal alerts setting did not re-enable") | |
| 248 | + | } | |
| 249 | + | } | |
| @@ -0,0 +1,249 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 12 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 13 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 14 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type alertTestTerminal struct { | |||
| 18 | + | mu sync.Mutex | |||
| 19 | + | data []byte | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (t *alertTestTerminal) Write(p []byte) (int, error) { | |||
| 23 | + | t.mu.Lock() | |||
| 24 | + | defer t.mu.Unlock() | |||
| 25 | + | t.data = append(t.data, p...) | |||
| 26 | + | return len(p), nil | |||
| 27 | + | } | |||
| 28 | + | func (t *alertTestTerminal) WriteString(s string) (int, error) { | |||
| 29 | + | t.mu.Lock() | |||
| 30 | + | defer t.mu.Unlock() | |||
| 31 | + | t.data = append(t.data, s...) | |||
| 32 | + | return len(s), nil | |||
| 33 | + | } | |||
| 34 | + | func (t *alertTestTerminal) String() string { | |||
| 35 | + | t.mu.Lock() | |||
| 36 | + | defer t.mu.Unlock() | |||
| 37 | + | return string(t.data) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (*alertTestTerminal) Size() (int, int) { return 80, 24 } | |||
| 41 | + | func (*alertTestTerminal) OnResize(func()) {} | |||
| 42 | + | func (*alertTestTerminal) EnterRaw() (func() error, error) { | |||
| 43 | + | return func() error { return nil }, nil | |||
| 44 | + | } | |||
| 45 | + | func (*alertTestTerminal) ReadByte() (byte, error) { return 0, io.EOF } | |||
| 46 | + | func (*alertTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) { | |||
| 47 | + | return 0, false, nil | |||
| 48 | + | } | |||
| 49 | + | func (*alertTestTerminal) SetNonblock(bool) error { return nil } | |||
| 50 | + | ||||
| 51 | + | func TestInteractiveAlertUsesSharedTerminalPolicy(t *testing.T) { | |||
| 52 | + | term := &alertTestTerminal{} | |||
| 53 | + | i := &Interactive{cfg: InteractiveConfig{Terminal: term}} | |||
| 54 | + | ||||
| 55 | + | i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"}) | |||
| 56 | + | if got := term.String(); got != "\a" { | |||
| 57 | + | t.Fatalf("alert output = %q, want standalone BEL", got) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestInteractiveAlertRespectsDisabledPolicy(t *testing.T) { | |||
| 62 | + | term := &alertTestTerminal{} | |||
| 63 | + | disabled := false | |||
| 64 | + | i := &Interactive{cfg: InteractiveConfig{Terminal: term, TerminalAlertsEnabled: &disabled}} | |||
| 65 | + | ||||
| 66 | + | i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"}) | |||
| 67 | + | if got := term.String(); got != "" { | |||
| 68 | + | t.Fatalf("disabled alert output = %q, want empty", got) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | type alertBlockingClient struct{} | |||
| 73 | + | ||||
| 74 | + | func (alertBlockingClient) Name() string { return "alert-blocking" } | |||
| 75 | + | func (alertBlockingClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) { | |||
| 76 | + | out := make(chan provider.Event, 1) | |||
| 77 | + | go func() { | |||
| 78 | + | defer close(out) | |||
| 79 | + | <-ctx.Done() | |||
| 80 | + | out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()} | |||
| 81 | + | }() | |||
| 82 | + | return out, nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestCancelledMainTurnDoesNotBell(t *testing.T) { | |||
| 86 | + | term := &alertTestTerminal{} | |||
| 87 | + | ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil) | |||
| 88 | + | i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term}) | |||
| 89 | + | i.startTurn(context.Background(), "question") | |||
| 90 | + | ||||
| 91 | + | deadline := time.Now().Add(time.Second) | |||
| 92 | + | for time.Now().Before(deadline) { | |||
| 93 | + | i.mu.Lock() | |||
| 94 | + | busy := i.busy | |||
| 95 | + | i.mu.Unlock() | |||
| 96 | + | if busy { | |||
| 97 | + | break | |||
| 98 | + | } | |||
| 99 | + | time.Sleep(time.Millisecond) | |||
| 100 | + | } | |||
| 101 | + | i.CancelTurn() | |||
| 102 | + | ||||
| 103 | + | deadline = time.Now().Add(time.Second) | |||
| 104 | + | for time.Now().Before(deadline) { | |||
| 105 | + | i.mu.Lock() | |||
| 106 | + | busy := i.busy | |||
| 107 | + | i.mu.Unlock() | |||
| 108 | + | if !busy { | |||
| 109 | + | break | |||
| 110 | + | } | |||
| 111 | + | time.Sleep(time.Millisecond) | |||
| 112 | + | } | |||
| 113 | + | if got := term.String(); got != "" { | |||
| 114 | + | t.Fatalf("cancelled main turn alert output = %q, want empty", got) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestMainTurnEmitsBellAfterVisibleResponse(t *testing.T) { | |||
| 119 | + | term := &alertTestTerminal{} | |||
| 120 | + | client := &streamTestClient{events: []provider.Event{ | |||
| 121 | + | provider.EventTextDelta{Delta: "answer"}, | |||
| 122 | + | provider.EventDone{ | |||
| 123 | + | Stop: provider.StopEnd, | |||
| 124 | + | Message: provider.Message{ | |||
| 125 | + | Role: provider.RoleAssistant, | |||
| 126 | + | Content: []provider.Content{provider.TextBlock{Text: "answer"}}, | |||
| 127 | + | }, | |||
| 128 | + | }, | |||
| 129 | + | }} | |||
| 130 | + | ag := core.NewAgent(client, "test-model", "", nil) | |||
| 131 | + | i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term}) | |||
| 132 | + | i.rend = tui.NewRenderer(io.Discard) | |||
| 133 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 134 | + | defer cancel() | |||
| 135 | + | i.runCtx = ctx | |||
| 136 | + | go i.runStreamPacer(ctx) | |||
| 137 | + | i.startTurn(ctx, "question") | |||
| 138 | + | ||||
| 139 | + | deadline := time.Now().Add(time.Second) | |||
| 140 | + | for time.Now().Before(deadline) { | |||
| 141 | + | i.mu.Lock() | |||
| 142 | + | ready := i.pendingAlert != nil && !i.streamFlushPending && len(i.streamPending) == 0 | |||
| 143 | + | i.mu.Unlock() | |||
| 144 | + | if ready { | |||
| 145 | + | i.redraw() | |||
| 146 | + | break | |||
| 147 | + | } | |||
| 148 | + | time.Sleep(time.Millisecond) | |||
| 149 | + | } | |||
| 150 | + | if got := term.String(); got != "\a" { | |||
| 151 | + | t.Fatalf("main turn alert output = %q, want BEL after response redraw", got) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestMainAlertWaitsForPacedText(t *testing.T) { | |||
| 156 | + | term := &alertTestTerminal{} | |||
| 157 | + | i := NewInteractive(InteractiveConfig{Terminal: term}) | |||
| 158 | + | i.rend = tui.NewRenderer(io.Discard) | |||
| 159 | + | i.streamPending = []rune("final answer") | |||
| 160 | + | i.streamFlushPending = true | |||
| 161 | + | i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"} | |||
| 162 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 163 | + | defer cancel() | |||
| 164 | + | go i.runStreamPacer(ctx) | |||
| 165 | + | ||||
| 166 | + | deadline := time.Now().Add(time.Second) | |||
| 167 | + | for time.Now().Before(deadline) { | |||
| 168 | + | i.mu.Lock() | |||
| 169 | + | ready := !i.streamFlushPending && len(i.streamPending) == 0 && i.pendingAlert != nil | |||
| 170 | + | i.mu.Unlock() | |||
| 171 | + | if ready { | |||
| 172 | + | i.redraw() | |||
| 173 | + | break | |||
| 174 | + | } | |||
| 175 | + | time.Sleep(time.Millisecond) | |||
| 176 | + | } | |||
| 177 | + | if got := term.String(); got != "\a" { | |||
| 178 | + | t.Fatalf("paced alert output = %q, want BEL after final redraw", got) | |||
| 179 | + | } | |||
| 180 | + | i.mu.Lock() | |||
| 181 | + | on, pending, alert := i.streamOn, len(i.streamPending), i.pendingAlert | |||
| 182 | + | i.mu.Unlock() | |||
| 183 | + | if on || pending != 0 || alert != nil { | |||
| 184 | + | t.Fatalf("stream state after alert: on=%v pending=%d alert=%v", on, pending, alert) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestMainAlertReasonPolicy(t *testing.T) { | |||
| 189 | + | cancelled, cancel := context.WithCancel(context.Background()) | |||
| 190 | + | cancel() | |||
| 191 | + | ||||
| 192 | + | tests := []struct { | |||
| 193 | + | name string | |||
| 194 | + | ctx context.Context | |||
| 195 | + | err error | |||
| 196 | + | turnErr error | |||
| 197 | + | stop provider.StopReason | |||
| 198 | + | awaiting bool | |||
| 199 | + | queued bool | |||
| 200 | + | rescue bool | |||
| 201 | + | recovering bool | |||
| 202 | + | auto bool | |||
| 203 | + | want string | |||
| 204 | + | }{ | |||
| 205 | + | {name: "normal", ctx: context.Background(), stop: provider.StopEnd, want: "agent_done"}, | |||
| 206 | + | {name: "length", ctx: context.Background(), stop: provider.StopLength, want: "response_truncated"}, | |||
| 207 | + | {name: "returned error", ctx: context.Background(), err: errors.New("boom"), stop: provider.StopError, want: "agent_error"}, | |||
| 208 | + | {name: "guard error", ctx: context.Background(), stop: provider.StopError, want: "agent_error"}, | |||
| 209 | + | {name: "rescue", ctx: context.Background(), err: errors.New("temporary"), stop: provider.StopError, rescue: true, want: "rescue_required"}, | |||
| 210 | + | {name: "cancelled", ctx: cancelled, stop: provider.StopAborted}, | |||
| 211 | + | {name: "aborted", ctx: context.Background(), stop: provider.StopAborted}, | |||
| 212 | + | {name: "queued", ctx: context.Background(), stop: provider.StopEnd, queued: true}, | |||
| 213 | + | {name: "recovering", ctx: context.Background(), stop: provider.StopError, recovering: true}, | |||
| 214 | + | {name: "auto compacting", ctx: context.Background(), stop: provider.StopEnd, auto: true}, | |||
| 215 | + | {name: "startup pre", ctx: context.Background(), stop: provider.StopEnd, awaiting: true}, | |||
| 216 | + | } | |||
| 217 | + | for _, tt := range tests { | |||
| 218 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 219 | + | got := mainAlertReason(tt.ctx, tt.err, tt.turnErr, tt.stop, tt.awaiting, tt.queued, tt.rescue, tt.recovering, tt.auto) | |||
| 220 | + | if got != tt.want { | |||
| 221 | + | t.Fatalf("mainAlertReason() = %q, want %q", got, tt.want) | |||
| 222 | + | } | |||
| 223 | + | }) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func TestTerminalAlertsSettingDefaultsEnabledAndToggles(t *testing.T) { | |||
| 228 | + | i := NewInteractive(InteractiveConfig{Terminal: &alertTestTerminal{}}) | |||
| 229 | + | i.openSettingsDialog() | |||
| 230 | + | var item *settingsItem | |||
| 231 | + | for idx := range i.settingsDialog.items { | |||
| 232 | + | if i.settingsDialog.items[idx].key == "terminal_alerts_enabled" { | |||
| 233 | + | item = &i.settingsDialog.items[idx] | |||
| 234 | + | break | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | if item == nil || !item.value { | |||
| 238 | + | t.Fatal("terminal alerts setting should be present and enabled by default") | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | i.applySettingToggle("terminal_alerts_enabled", false) | |||
| 242 | + | if i.cfg.TerminalAlertsEnabled == nil || *i.cfg.TerminalAlertsEnabled { | |||
| 243 | + | t.Fatal("terminal alerts setting did not disable") | |||
| 244 | + | } | |||
| 245 | + | i.applySettingToggle("terminal_alerts_enabled", true) | |||
| 246 | + | if i.cfg.TerminalAlertsEnabled == nil || !*i.cfg.TerminalAlertsEnabled { | |||
| 247 | + | t.Fatal("terminal alerts setting did not re-enable") | |||
| 248 | + | } | |||
| 249 | + | } | |||
M packages/agent/modes/interactive.go +109 -0
| @@ -59,6 +59,10 @@ type InteractiveConfig struct { | |||
| 59 | 59 | // the detected protocol when available. | |
| 60 | 60 | InlineImagesEnabled *bool | |
| 61 | 61 | ||
| 62 | + | // TerminalAlertsEnabled controls interactive alerts from the main | |
| 63 | + | // agent and extensions. nil means enabled; false disables them. | |
| 64 | + | TerminalAlertsEnabled *bool | |
| 65 | + | ||
| 62 | 66 | // AutoSwarmEnabled mirrors the persisted config flag at startup so | |
| 63 | 67 | // the /settings dialog can render the current state without | |
| 64 | 68 | // re-reading config.json on every open. | |
| @@ -354,6 +358,10 @@ type SettingsStore interface { | |||
| 354 | 358 | SetTheme(name string) error | |
| 355 | 359 | } | |
| 356 | 360 | ||
| 361 | + | type terminalAlertsSettingsStore interface { | |
| 362 | + | SetTerminalAlertsEnabled(enabled bool) error | |
| 363 | + | } | |
| 364 | + | ||
| 357 | 365 | type showInstructionsSettingsStore interface { | |
| 358 | 366 | SetShowInstructionsAtStartup(enabled bool) error | |
| 359 | 367 | } | |
| @@ -376,6 +384,7 @@ type Interactive struct { | |||
| 376 | 384 | managedAutoSwarmAddenda []string | |
| 377 | 385 | streaming strings.Builder // what's currently painted on screen | |
| 378 | 386 | streamOn bool | |
| 387 | + | pendingAlert *extproto.AlertRequest | |
| 379 | 388 | ||
| 380 | 389 | // streamPending is the runes buffered after each EvTextDelta that | |
| 381 | 390 | // haven't yet been promoted into `streaming` for rendering. It | |
| @@ -1755,6 +1764,11 @@ func (i *Interactive) redraw() { | |||
| 1755 | 1764 | } | |
| 1756 | 1765 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 1757 | 1766 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 1767 | + | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | |
| 1768 | + | alert := *i.pendingAlert | |
| 1769 | + | i.pendingAlert = nil | |
| 1770 | + | i.emitAlertLocked(alert) | |
| 1771 | + | } | |
| 1758 | 1772 | } | |
| 1759 | 1773 | ||
| 1760 | 1774 | func hasImageEscape(line string) bool { | |
| @@ -2978,6 +2992,45 @@ func (i *Interactive) Notify(extName, level, message string) { | |||
| 2978 | 2992 | i.invalidate() | |
| 2979 | 2993 | } | |
| 2980 | 2994 | ||
| 2995 | + | // Alert is the manager's AlertFromExt entry point. Alerts are emitted | |
| 2996 | + | // through the same terminal writer as redraws while holding the TUI mutex, | |
| 2997 | + | // so a BEL cannot be interleaved with a frame update. | |
| 2998 | + | func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) { | |
| 2999 | + | i.mu.Lock() | |
| 3000 | + | i.emitAlertLocked(alert) | |
| 3001 | + | i.mu.Unlock() | |
| 3002 | + | } | |
| 3003 | + | ||
| 3004 | + | func terminalAlertsEnabled(enabled *bool) bool { | |
| 3005 | + | return enabled == nil || *enabled | |
| 3006 | + | } | |
| 3007 | + | ||
| 3008 | + | // emitAlertLocked applies the shared terminal-alert policy. The caller must | |
| 3009 | + | // hold i.mu because terminal writes share the renderer's output boundary. | |
| 3010 | + | func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) { | |
| 3011 | + | if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil { | |
| 3012 | + | return | |
| 3013 | + | } | |
| 3014 | + | _ = tui.WriteBell(i.cfg.Terminal) | |
| 3015 | + | } | |
| 3016 | + | ||
| 3017 | + | // scheduleMainAlert emits a main-session alert now when no paced text remains, | |
| 3018 | + | // or defers it until the pacer drains and the next redraw commits the final frame. | |
| 3019 | + | func (i *Interactive) scheduleMainAlert(reason string) { | |
| 3020 | + | if reason == "" { | |
| 3021 | + | return | |
| 3022 | + | } | |
| 3023 | + | i.mu.Lock() | |
| 3024 | + | if len(i.streamPending) > 0 { | |
| 3025 | + | i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason} | |
| 3026 | + | i.mu.Unlock() | |
| 3027 | + | i.invalidate() | |
| 3028 | + | return | |
| 3029 | + | } | |
| 3030 | + | i.emitAlertLocked(extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}) | |
| 3031 | + | i.mu.Unlock() | |
| 3032 | + | } | |
| 3033 | + | ||
| 2981 | 3034 | // ClearNotes removes every note line owned by extName from the | |
| 2982 | 3035 | // bottom-sticky ext-notes block. Extensions use this to retract a | |
| 2983 | 3036 | // transient status line (e.g. an approval prompt) once it no longer | |
| @@ -3350,6 +3403,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3350 | 3403 | imgHint = "terminal supports " + imageProtocolName(detected) | |
| 3351 | 3404 | } | |
| 3352 | 3405 | ||
| 3406 | + | terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) | |
| 3353 | 3407 | autoSwarm := false | |
| 3354 | 3408 | if i.cfg.AutoSwarmEnabled != nil { | |
| 3355 | 3409 | autoSwarm = *i.cfg.AutoSwarmEnabled | |
| @@ -3459,6 +3513,12 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3459 | 3513 | disabled: imgDisabled, | |
| 3460 | 3514 | hint: imgHint, | |
| 3461 | 3515 | }, | |
| 3516 | + | { | |
| 3517 | + | key: "terminal_alerts_enabled", | |
| 3518 | + | label: "terminal alerts", | |
| 3519 | + | desc: "ring the terminal when the main session needs attention or an extension raises an alert", | |
| 3520 | + | value: terminalAlerts, | |
| 3521 | + | }, | |
| 3462 | 3522 | { | |
| 3463 | 3523 | key: "auto_swarm_enabled", | |
| 3464 | 3524 | label: "auto-swarm", | |
| @@ -3794,6 +3854,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 3794 | 3854 | i.statusOK = "inline image rendering " + onOff(value) | |
| 3795 | 3855 | i.statusErr = "" | |
| 3796 | 3856 | i.mu.Unlock() | |
| 3857 | + | case "terminal_alerts_enabled": | |
| 3858 | + | val := value | |
| 3859 | + | i.mu.Lock() | |
| 3860 | + | i.cfg.TerminalAlertsEnabled = &val | |
| 3861 | + | i.mu.Unlock() | |
| 3862 | + | if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok { | |
| 3863 | + | if err := store.SetTerminalAlertsEnabled(value); err != nil { | |
| 3864 | + | i.mu.Lock() | |
| 3865 | + | i.statusErr = "settings: " + err.Error() | |
| 3866 | + | i.mu.Unlock() | |
| 3867 | + | return | |
| 3868 | + | } | |
| 3869 | + | } | |
| 3870 | + | i.mu.Lock() | |
| 3871 | + | i.statusOK = "terminal alerts " + onOff(value) | |
| 3872 | + | i.statusErr = "" | |
| 3873 | + | i.mu.Unlock() | |
| 3797 | 3874 | case "auto_swarm_enabled": | |
| 3798 | 3875 | val := value | |
| 3799 | 3876 | i.cfg.AutoSwarmEnabled = &val | |
| @@ -5695,6 +5772,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 5695 | 5772 | i.statusOK = "" | |
| 5696 | 5773 | i.streaming.Reset() | |
| 5697 | 5774 | i.streamOn = true | |
| 5775 | + | i.pendingAlert = nil | |
| 5698 | 5776 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 5699 | 5777 | i.toolOrder = nil | |
| 5700 | 5778 | i.toolGate = map[string]int{} | |
| @@ -5727,7 +5805,15 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 5727 | 5805 | i.mu.Unlock() | |
| 5728 | 5806 | i.invalidate() | |
| 5729 | 5807 | ||
| 5808 | + | var ( | |
| 5809 | + | lastStop provider.StopReason | |
| 5810 | + | lastTurnErr error | |
| 5811 | + | ) | |
| 5730 | 5812 | sink := func(ev core.AgentEvent) { | |
| 5813 | + | if e, ok := ev.(core.EvTurnEnd); ok { | |
| 5814 | + | lastStop = e.Stop | |
| 5815 | + | lastTurnErr = e.Err | |
| 5816 | + | } | |
| 5731 | 5817 | i.handleEvent(ev) | |
| 5732 | 5818 | i.invalidate() | |
| 5733 | 5819 | } | |
| @@ -5829,7 +5915,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 5829 | 5915 | agentQueued = i.agent.QueuedMessageCount() | |
| 5830 | 5916 | } | |
| 5831 | 5917 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | |
| 5918 | + | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | |
| 5832 | 5919 | i.mu.Unlock() | |
| 5920 | + | if alertReason != "" { | |
| 5921 | + | i.scheduleMainAlert(alertReason) | |
| 5922 | + | } | |
| 5833 | 5923 | i.invalidate() | |
| 5834 | 5924 | parent := i.runCtx | |
| 5835 | 5925 | if parent == nil { | |
| @@ -5852,6 +5942,25 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 5852 | 5942 | }() | |
| 5853 | 5943 | } | |
| 5854 | 5944 | ||
| 5945 | + | func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string { | |
| 5946 | + | if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting { | |
| 5947 | + | return "" | |
| 5948 | + | } | |
| 5949 | + | if rescue { | |
| 5950 | + | return "rescue_required" | |
| 5951 | + | } | |
| 5952 | + | if stop == provider.StopAborted { | |
| 5953 | + | return "" | |
| 5954 | + | } | |
| 5955 | + | if err != nil || turnErr != nil || stop == provider.StopError { | |
| 5956 | + | return "agent_error" | |
| 5957 | + | } | |
| 5958 | + | if stop == provider.StopLength { | |
| 5959 | + | return "response_truncated" | |
| 5960 | + | } | |
| 5961 | + | return "agent_done" | |
| 5962 | + | } | |
| 5963 | + | ||
| 5855 | 5964 | // openRescueDialog surfaces the rescue model picker after a recoverable | |
| 5856 | 5965 | // provider failure. The pending prompt + images are stashed on the | |
| 5857 | 5966 | // Interactive so a later applyRescueSelection can re-run the same turn | |
| @@ -59,6 +59,10 @@ type InteractiveConfig struct { | |||||
| 59 | // the detected protocol when available. | 59 | // the detected protocol when available. | ||
| 60 | InlineImagesEnabled *bool | 60 | InlineImagesEnabled *bool | ||
| 61 | 61 | ||||
| 62 | + | // TerminalAlertsEnabled controls interactive alerts from the main | |||
| 63 | + | // agent and extensions. nil means enabled; false disables them. | |||
| 64 | + | TerminalAlertsEnabled *bool | |||
| 65 | + | ||||
| 62 | // AutoSwarmEnabled mirrors the persisted config flag at startup so | 66 | // AutoSwarmEnabled mirrors the persisted config flag at startup so | ||
| 63 | // the /settings dialog can render the current state without | 67 | // the /settings dialog can render the current state without | ||
| 64 | // re-reading config.json on every open. | 68 | // re-reading config.json on every open. | ||
| @@ -354,6 +358,10 @@ type SettingsStore interface { | |||||
| 354 | SetTheme(name string) error | 358 | SetTheme(name string) error | ||
| 355 | } | 359 | } | ||
| 356 | 360 | ||||
| 361 | + | type terminalAlertsSettingsStore interface { | |||
| 362 | + | SetTerminalAlertsEnabled(enabled bool) error | |||
| 363 | + | } | |||
| 364 | + | ||||
| 357 | type showInstructionsSettingsStore interface { | 365 | type showInstructionsSettingsStore interface { | ||
| 358 | SetShowInstructionsAtStartup(enabled bool) error | 366 | SetShowInstructionsAtStartup(enabled bool) error | ||
| 359 | } | 367 | } | ||
| @@ -376,6 +384,7 @@ type Interactive struct { | |||||
| 376 | managedAutoSwarmAddenda []string | 384 | managedAutoSwarmAddenda []string | ||
| 377 | streaming strings.Builder // what's currently painted on screen | 385 | streaming strings.Builder // what's currently painted on screen | ||
| 378 | streamOn bool | 386 | streamOn bool | ||
| 387 | + | pendingAlert *extproto.AlertRequest | |||
| 379 | 388 | ||||
| 380 | // streamPending is the runes buffered after each EvTextDelta that | 389 | // streamPending is the runes buffered after each EvTextDelta that | ||
| 381 | // haven't yet been promoted into `streaming` for rendering. It | 390 | // haven't yet been promoted into `streaming` for rendering. It | ||
| @@ -1755,6 +1764,11 @@ func (i *Interactive) redraw() { | |||||
| 1755 | } | 1764 | } | ||
| 1756 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | 1765 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | ||
| 1757 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | 1766 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | ||
| 1767 | + | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | |||
| 1768 | + | alert := *i.pendingAlert | |||
| 1769 | + | i.pendingAlert = nil | |||
| 1770 | + | i.emitAlertLocked(alert) | |||
| 1771 | + | } | |||
| 1758 | } | 1772 | } | ||
| 1759 | 1773 | ||||
| 1760 | func hasImageEscape(line string) bool { | 1774 | func hasImageEscape(line string) bool { | ||
| @@ -2978,6 +2992,45 @@ func (i *Interactive) Notify(extName, level, message string) { | |||||
| 2978 | i.invalidate() | 2992 | i.invalidate() | ||
| 2979 | } | 2993 | } | ||
| 2980 | 2994 | ||||
| 2995 | + | // Alert is the manager's AlertFromExt entry point. Alerts are emitted | |||
| 2996 | + | // through the same terminal writer as redraws while holding the TUI mutex, | |||
| 2997 | + | // so a BEL cannot be interleaved with a frame update. | |||
| 2998 | + | func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) { | |||
| 2999 | + | i.mu.Lock() | |||
| 3000 | + | i.emitAlertLocked(alert) | |||
| 3001 | + | i.mu.Unlock() | |||
| 3002 | + | } | |||
| 3003 | + | ||||
| 3004 | + | func terminalAlertsEnabled(enabled *bool) bool { | |||
| 3005 | + | return enabled == nil || *enabled | |||
| 3006 | + | } | |||
| 3007 | + | ||||
| 3008 | + | // emitAlertLocked applies the shared terminal-alert policy. The caller must | |||
| 3009 | + | // hold i.mu because terminal writes share the renderer's output boundary. | |||
| 3010 | + | func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) { | |||
| 3011 | + | if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil { | |||
| 3012 | + | return | |||
| 3013 | + | } | |||
| 3014 | + | _ = tui.WriteBell(i.cfg.Terminal) | |||
| 3015 | + | } | |||
| 3016 | + | ||||
| 3017 | + | // scheduleMainAlert emits a main-session alert now when no paced text remains, | |||
| 3018 | + | // or defers it until the pacer drains and the next redraw commits the final frame. | |||
| 3019 | + | func (i *Interactive) scheduleMainAlert(reason string) { | |||
| 3020 | + | if reason == "" { | |||
| 3021 | + | return | |||
| 3022 | + | } | |||
| 3023 | + | i.mu.Lock() | |||
| 3024 | + | if len(i.streamPending) > 0 { | |||
| 3025 | + | i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason} | |||
| 3026 | + | i.mu.Unlock() | |||
| 3027 | + | i.invalidate() | |||
| 3028 | + | return | |||
| 3029 | + | } | |||
| 3030 | + | i.emitAlertLocked(extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}) | |||
| 3031 | + | i.mu.Unlock() | |||
| 3032 | + | } | |||
| 3033 | + | ||||
| 2981 | // ClearNotes removes every note line owned by extName from the | 3034 | // ClearNotes removes every note line owned by extName from the | ||
| 2982 | // bottom-sticky ext-notes block. Extensions use this to retract a | 3035 | // bottom-sticky ext-notes block. Extensions use this to retract a | ||
| 2983 | // transient status line (e.g. an approval prompt) once it no longer | 3036 | // transient status line (e.g. an approval prompt) once it no longer | ||
| @@ -3350,6 +3403,7 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3350 | imgHint = "terminal supports " + imageProtocolName(detected) | 3403 | imgHint = "terminal supports " + imageProtocolName(detected) | ||
| 3351 | } | 3404 | } | ||
| 3352 | 3405 | ||||
| 3406 | + | terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) | |||
| 3353 | autoSwarm := false | 3407 | autoSwarm := false | ||
| 3354 | if i.cfg.AutoSwarmEnabled != nil { | 3408 | if i.cfg.AutoSwarmEnabled != nil { | ||
| 3355 | autoSwarm = *i.cfg.AutoSwarmEnabled | 3409 | autoSwarm = *i.cfg.AutoSwarmEnabled | ||
| @@ -3459,6 +3513,12 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3459 | disabled: imgDisabled, | 3513 | disabled: imgDisabled, | ||
| 3460 | hint: imgHint, | 3514 | hint: imgHint, | ||
| 3461 | }, | 3515 | }, | ||
| 3516 | + | { | |||
| 3517 | + | key: "terminal_alerts_enabled", | |||
| 3518 | + | label: "terminal alerts", | |||
| 3519 | + | desc: "ring the terminal when the main session needs attention or an extension raises an alert", | |||
| 3520 | + | value: terminalAlerts, | |||
| 3521 | + | }, | |||
| 3462 | { | 3522 | { | ||
| 3463 | key: "auto_swarm_enabled", | 3523 | key: "auto_swarm_enabled", | ||
| 3464 | label: "auto-swarm", | 3524 | label: "auto-swarm", | ||
| @@ -3794,6 +3854,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 3794 | i.statusOK = "inline image rendering " + onOff(value) | 3854 | i.statusOK = "inline image rendering " + onOff(value) | ||
| 3795 | i.statusErr = "" | 3855 | i.statusErr = "" | ||
| 3796 | i.mu.Unlock() | 3856 | i.mu.Unlock() | ||
| 3857 | + | case "terminal_alerts_enabled": | |||
| 3858 | + | val := value | |||
| 3859 | + | i.mu.Lock() | |||
| 3860 | + | i.cfg.TerminalAlertsEnabled = &val | |||
| 3861 | + | i.mu.Unlock() | |||
| 3862 | + | if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok { | |||
| 3863 | + | if err := store.SetTerminalAlertsEnabled(value); err != nil { | |||
| 3864 | + | i.mu.Lock() | |||
| 3865 | + | i.statusErr = "settings: " + err.Error() | |||
| 3866 | + | i.mu.Unlock() | |||
| 3867 | + | return | |||
| 3868 | + | } | |||
| 3869 | + | } | |||
| 3870 | + | i.mu.Lock() | |||
| 3871 | + | i.statusOK = "terminal alerts " + onOff(value) | |||
| 3872 | + | i.statusErr = "" | |||
| 3873 | + | i.mu.Unlock() | |||
| 3797 | case "auto_swarm_enabled": | 3874 | case "auto_swarm_enabled": | ||
| 3798 | val := value | 3875 | val := value | ||
| 3799 | i.cfg.AutoSwarmEnabled = &val | 3876 | i.cfg.AutoSwarmEnabled = &val | ||
| @@ -5695,6 +5772,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 5695 | i.statusOK = "" | 5772 | i.statusOK = "" | ||
| 5696 | i.streaming.Reset() | 5773 | i.streaming.Reset() | ||
| 5697 | i.streamOn = true | 5774 | i.streamOn = true | ||
| 5775 | + | i.pendingAlert = nil | |||
| 5698 | i.toolCalls = map[string]*tui.ToolCallView{} | 5776 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 5699 | i.toolOrder = nil | 5777 | i.toolOrder = nil | ||
| 5700 | i.toolGate = map[string]int{} | 5778 | i.toolGate = map[string]int{} | ||
| @@ -5727,7 +5805,15 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 5727 | i.mu.Unlock() | 5805 | i.mu.Unlock() | ||
| 5728 | i.invalidate() | 5806 | i.invalidate() | ||
| 5729 | 5807 | ||||
| 5808 | + | var ( | |||
| 5809 | + | lastStop provider.StopReason | |||
| 5810 | + | lastTurnErr error | |||
| 5811 | + | ) | |||
| 5730 | sink := func(ev core.AgentEvent) { | 5812 | sink := func(ev core.AgentEvent) { | ||
| 5813 | + | if e, ok := ev.(core.EvTurnEnd); ok { | |||
| 5814 | + | lastStop = e.Stop | |||
| 5815 | + | lastTurnErr = e.Err | |||
| 5816 | + | } | |||
| 5731 | i.handleEvent(ev) | 5817 | i.handleEvent(ev) | ||
| 5732 | i.invalidate() | 5818 | i.invalidate() | ||
| 5733 | } | 5819 | } | ||
| @@ -5829,7 +5915,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 5829 | agentQueued = i.agent.QueuedMessageCount() | 5915 | agentQueued = i.agent.QueuedMessageCount() | ||
| 5830 | } | 5916 | } | ||
| 5831 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | 5917 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | ||
| 5918 | + | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | |||
| 5832 | i.mu.Unlock() | 5919 | i.mu.Unlock() | ||
| 5920 | + | if alertReason != "" { | |||
| 5921 | + | i.scheduleMainAlert(alertReason) | |||
| 5922 | + | } | |||
| 5833 | i.invalidate() | 5923 | i.invalidate() | ||
| 5834 | parent := i.runCtx | 5924 | parent := i.runCtx | ||
| 5835 | if parent == nil { | 5925 | if parent == nil { | ||
| @@ -5852,6 +5942,25 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 5852 | }() | 5942 | }() | ||
| 5853 | } | 5943 | } | ||
| 5854 | 5944 | ||||
| 5945 | + | func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string { | |||
| 5946 | + | if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting { | |||
| 5947 | + | return "" | |||
| 5948 | + | } | |||
| 5949 | + | if rescue { | |||
| 5950 | + | return "rescue_required" | |||
| 5951 | + | } | |||
| 5952 | + | if stop == provider.StopAborted { | |||
| 5953 | + | return "" | |||
| 5954 | + | } | |||
| 5955 | + | if err != nil || turnErr != nil || stop == provider.StopError { | |||
| 5956 | + | return "agent_error" | |||
| 5957 | + | } | |||
| 5958 | + | if stop == provider.StopLength { | |||
| 5959 | + | return "response_truncated" | |||
| 5960 | + | } | |||
| 5961 | + | return "agent_done" | |||
| 5962 | + | } | |||
| 5963 | + | ||||
| 5855 | // openRescueDialog surfaces the rescue model picker after a recoverable | 5964 | // openRescueDialog surfaces the rescue model picker after a recoverable | ||
| 5856 | // provider failure. The pending prompt + images are stashed on the | 5965 | // provider failure. The pending prompt + images are stashed on the | ||
| 5857 | // Interactive so a later applyRescueSelection can re-run the same turn | 5966 | // Interactive so a later applyRescueSelection can re-run the same turn | ||
M packages/agent/rpc.go +10 -0
| @@ -134,6 +134,16 @@ func (h *rpcExtHooks) Notify(extName, level, message string) { | |||
| 134 | 134 | }) | |
| 135 | 135 | } | |
| 136 | 136 | } | |
| 137 | + | func (h *rpcExtHooks) Alert(extName string, alert extproto.AlertRequest) { | |
| 138 | + | if h.server != nil { | |
| 139 | + | h.server.writeEvent(map[string]any{ | |
| 140 | + | "type": "ext_alert", | |
| 141 | + | "extension": extName, | |
| 142 | + | "kind": alert.Kind, | |
| 143 | + | "reason": alert.Reason, | |
| 144 | + | }) | |
| 145 | + | } | |
| 146 | + | } | |
| 137 | 147 | func (h *rpcExtHooks) Display(extName, text string) { | |
| 138 | 148 | if h.server != nil { | |
| 139 | 149 | h.server.writeEvent(map[string]any{ | |
| @@ -134,6 +134,16 @@ func (h *rpcExtHooks) Notify(extName, level, message string) { | |||||
| 134 | }) | 134 | }) | ||
| 135 | } | 135 | } | ||
| 136 | } | 136 | } | ||
| 137 | + | func (h *rpcExtHooks) Alert(extName string, alert extproto.AlertRequest) { | |||
| 138 | + | if h.server != nil { | |||
| 139 | + | h.server.writeEvent(map[string]any{ | |||
| 140 | + | "type": "ext_alert", | |||
| 141 | + | "extension": extName, | |||
| 142 | + | "kind": alert.Kind, | |||
| 143 | + | "reason": alert.Reason, | |||
| 144 | + | }) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 137 | func (h *rpcExtHooks) Display(extName, text string) { | 147 | func (h *rpcExtHooks) Display(extName, text string) { | ||
| 138 | if h.server != nil { | 148 | if h.server != nil { | ||
| 139 | h.server.writeEvent(map[string]any{ | 149 | h.server.writeEvent(map[string]any{ | ||
A packages/agent/rpc_alert_test.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRPCExtAlertIsJSONOnly(t *testing.T) { | |
| 13 | + | var out bytes.Buffer | |
| 14 | + | hooks := &rpcExtHooks{server: &rpcServer{out: &out}} | |
| 15 | + | hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"}) | |
| 16 | + | ||
| 17 | + | if strings.ContainsRune(out.String(), '\a') { | |
| 18 | + | t.Fatalf("RPC alert contains raw BEL: %q", out.String()) | |
| 19 | + | } | |
| 20 | + | var frame map[string]any | |
| 21 | + | if err := json.Unmarshal(out.Bytes(), &frame); err != nil { | |
| 22 | + | t.Fatalf("decode RPC alert: %v", err) | |
| 23 | + | } | |
| 24 | + | if frame["type"] != "ext_alert" || frame["extension"] != "question-ext" || frame["kind"] != extproto.AlertKindBell || frame["reason"] != "question_ready" { | |
| 25 | + | t.Fatalf("RPC alert frame = %#v", frame) | |
| 26 | + | } | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestRPCExtAlertIsJSONOnly(t *testing.T) { | |||
| 13 | + | var out bytes.Buffer | |||
| 14 | + | hooks := &rpcExtHooks{server: &rpcServer{out: &out}} | |||
| 15 | + | hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"}) | |||
| 16 | + | ||||
| 17 | + | if strings.ContainsRune(out.String(), '\a') { | |||
| 18 | + | t.Fatalf("RPC alert contains raw BEL: %q", out.String()) | |||
| 19 | + | } | |||
| 20 | + | var frame map[string]any | |||
| 21 | + | if err := json.Unmarshal(out.Bytes(), &frame); err != nil { | |||
| 22 | + | t.Fatalf("decode RPC alert: %v", err) | |||
| 23 | + | } | |||
| 24 | + | if frame["type"] != "ext_alert" || frame["extension"] != "question-ext" || frame["kind"] != extproto.AlertKindBell || frame["reason"] != "question_ready" { | |||
| 25 | + | t.Fatalf("RPC alert frame = %#v", frame) | |||
| 26 | + | } | |||
| 27 | + | } | |||
M packages/agent/settings_store.go +9 -0
| @@ -41,6 +41,15 @@ func (configSettingsStore) SetInlineImages(enabled bool) error { | |||
| 41 | 41 | return SaveConfig(cfg) | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | + | func (configSettingsStore) SetTerminalAlertsEnabled(enabled bool) error { | |
| 45 | + | cfg, err := LoadConfig() | |
| 46 | + | if err != nil { | |
| 47 | + | return err | |
| 48 | + | } | |
| 49 | + | cfg.TerminalAlertsEnabled = &enabled | |
| 50 | + | return SaveConfig(cfg) | |
| 51 | + | } | |
| 52 | + | ||
| 44 | 53 | func (configSettingsStore) SetAutoSwarm(enabled bool) error { | |
| 45 | 54 | cfg, err := LoadConfig() | |
| 46 | 55 | if err != nil { | |
| @@ -41,6 +41,15 @@ func (configSettingsStore) SetInlineImages(enabled bool) error { | |||||
| 41 | return SaveConfig(cfg) | 41 | return SaveConfig(cfg) | ||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | + | func (configSettingsStore) SetTerminalAlertsEnabled(enabled bool) error { | |||
| 45 | + | cfg, err := LoadConfig() | |||
| 46 | + | if err != nil { | |||
| 47 | + | return err | |||
| 48 | + | } | |||
| 49 | + | cfg.TerminalAlertsEnabled = &enabled | |||
| 50 | + | return SaveConfig(cfg) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 44 | func (configSettingsStore) SetAutoSwarm(enabled bool) error { | 53 | func (configSettingsStore) SetAutoSwarm(enabled bool) error { | ||
| 45 | cfg, err := LoadConfig() | 54 | cfg, err := LoadConfig() | ||
| 46 | if err != nil { | 55 | if err != nil { | ||
M packages/agent/settings_store_test.go +21 -0
| @@ -23,6 +23,27 @@ func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) { | |||
| 23 | 23 | } | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | func TestConfigSettingsStorePersistsTerminalAlerts(t *testing.T) { | |
| 27 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 28 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | if err := (configSettingsStore{}).SetTerminalAlertsEnabled(false); err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | cfg, err := LoadConfig() | |
| 36 | + | if err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | if cfg.TerminalAlertsEnabled == nil || *cfg.TerminalAlertsEnabled { | |
| 40 | + | t.Fatal("terminal_alerts_enabled was not persisted as disabled") | |
| 41 | + | } | |
| 42 | + | if cfg.Theme != "dark" { | |
| 43 | + | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 26 | 47 | func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) { | |
| 27 | 48 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 28 | 49 | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| @@ -23,6 +23,27 @@ func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) { | |||||
| 23 | } | 23 | } | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | + | func TestConfigSettingsStorePersistsTerminalAlerts(t *testing.T) { | |||
| 27 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 28 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | if err := (configSettingsStore{}).SetTerminalAlertsEnabled(false); err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | cfg, err := LoadConfig() | |||
| 36 | + | if err != nil { | |||
| 37 | + | t.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | if cfg.TerminalAlertsEnabled == nil || *cfg.TerminalAlertsEnabled { | |||
| 40 | + | t.Fatal("terminal_alerts_enabled was not persisted as disabled") | |||
| 41 | + | } | |||
| 42 | + | if cfg.Theme != "dark" { | |||
| 43 | + | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 26 | func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) { | 47 | func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) { | ||
| 27 | t.Setenv("ZOT_HOME", t.TempDir()) | 48 | t.Setenv("ZOT_HOME", t.TempDir()) | ||
| 28 | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | 49 | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | ||
M packages/agent/skills/builtin/write-zot-extension/SKILL.md +18 -4
| @@ -97,7 +97,7 @@ The very first frame the extension sends is `hello`: | |||
| 97 | 97 | ``` | |
| 98 | 98 | ||
| 99 | 99 | Capabilities are advisory; current values are `commands`, `tools`, | |
| 100 | - | `events`. Send all that apply. | |
| 100 | + | `events`, `alerts`, and `panels`. Send all that apply. | |
| 101 | 101 | ||
| 102 | 102 | zot replies with `hello_ack`: | |
| 103 | 103 | ||
| @@ -150,6 +150,7 @@ process itself runs from the extension directory, so do not use | |||
| 150 | 150 | {"type":"event_intercept_response","id":"ghi","block":true, | |
| 151 | 151 | "reason":"refused: command matches the danger pattern \"rm -rf\""} | |
| 152 | 152 | {"type":"notify","level":"info","message":"refreshed cache"} | |
| 153 | + | {"type":"alert","kind":"bell","reason":"question_ready"} | |
| 153 | 154 | {"type":"clear_notes"} | |
| 154 | 155 | {"type":"shutdown_ack"} | |
| 155 | 156 | ``` | |
| @@ -159,6 +160,12 @@ prompt (and on `esc` / `/clear`). Send `clear_notes` to retract every | |||
| 159 | 160 | note this extension pushed earlier (e.g. a transient approval prompt) | |
| 160 | 161 | without waiting for the next turn; other extensions' notes are kept. | |
| 161 | 162 | ||
| 163 | + | `alert` is a host-owned, fire-and-forget request. The first supported | |
| 164 | + | kind is `bell`; `reason` is semantic metadata. The interactive host | |
| 165 | + | applies the user's shared terminal-alert setting and non-interactive | |
| 166 | + | hosts may ignore it. Extensions must never write BEL or ANSI bytes to | |
| 167 | + | stdout. | |
| 168 | + | ||
| 162 | 169 | `command_response.action` values: | |
| 163 | 170 | - `"prompt"` — submit `prompt` as a fresh user message | |
| 164 | 171 | - `"insert"` — drop `insert` into the editor at the cursor | |
| @@ -177,9 +184,9 @@ never stalls the agent. | |||
| 177 | 184 | ||
| 178 | 185 | - **stdout is reserved for the protocol.** Anything you print to | |
| 179 | 186 | stdout that isn't a JSON frame breaks the wire. The first stdout | |
| 180 | - | frame must be `hello`; do not send `notify`, logs, or registration | |
| 181 | - | frames before that handshake starts. Use stderr for logs / debug | |
| 182 | - | output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`). | |
| 187 | + | frame must be `hello`; do not send `notify`, `alert`, logs, or | |
| 188 | + | registration frames before that handshake starts. Use stderr for | |
| 189 | + | logs / debug output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`). | |
| 183 | 190 | - **One JSON object per line.** No multi-line JSON. Always end | |
| 184 | 191 | every frame with `\n`. | |
| 185 | 192 | - **Flush after writing.** Most stdout writes are line-buffered when | |
| @@ -245,6 +252,13 @@ host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`, | |||
| 245 | 252 | {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true} | |
| 246 | 253 | ``` | |
| 247 | 254 | ||
| 255 | + | After the hello handshake completes inside `Run`, call `e.Alert` from a | |
| 256 | + | command, tool, or panel handler when the user needs to be drawn back to zot: | |
| 257 | + | ||
| 258 | + | ```go | |
| 259 | + | e.Alert(ext.AlertRequest{Kind: ext.AlertKindBell, Reason: "question_ready"}) | |
| 260 | + | ``` | |
| 261 | + | ||
| 248 | 262 | ### TypeScript (no SDK; handles the protocol directly) | |
| 249 | 263 | ||
| 250 | 264 | Run via `tsx`, which executes `.ts` files without a build step. | |
| @@ -97,7 +97,7 @@ The very first frame the extension sends is `hello`: | |||||
| 97 | ``` | 97 | ``` | ||
| 98 | 98 | ||||
| 99 | Capabilities are advisory; current values are `commands`, `tools`, | 99 | Capabilities are advisory; current values are `commands`, `tools`, | ||
| 100 | - | `events`. Send all that apply. | 100 | + | `events`, `alerts`, and `panels`. Send all that apply. |
| 101 | 101 | ||||
| 102 | zot replies with `hello_ack`: | 102 | zot replies with `hello_ack`: | ||
| 103 | 103 | ||||
| @@ -150,6 +150,7 @@ process itself runs from the extension directory, so do not use | |||||
| 150 | {"type":"event_intercept_response","id":"ghi","block":true, | 150 | {"type":"event_intercept_response","id":"ghi","block":true, | ||
| 151 | "reason":"refused: command matches the danger pattern \"rm -rf\""} | 151 | "reason":"refused: command matches the danger pattern \"rm -rf\""} | ||
| 152 | {"type":"notify","level":"info","message":"refreshed cache"} | 152 | {"type":"notify","level":"info","message":"refreshed cache"} | ||
| 153 | + | {"type":"alert","kind":"bell","reason":"question_ready"} | |||
| 153 | {"type":"clear_notes"} | 154 | {"type":"clear_notes"} | ||
| 154 | {"type":"shutdown_ack"} | 155 | {"type":"shutdown_ack"} | ||
| 155 | ``` | 156 | ``` | ||
| @@ -159,6 +160,12 @@ prompt (and on `esc` / `/clear`). Send `clear_notes` to retract every | |||||
| 159 | note this extension pushed earlier (e.g. a transient approval prompt) | 160 | note this extension pushed earlier (e.g. a transient approval prompt) | ||
| 160 | without waiting for the next turn; other extensions' notes are kept. | 161 | without waiting for the next turn; other extensions' notes are kept. | ||
| 161 | 162 | ||||
| 163 | + | `alert` is a host-owned, fire-and-forget request. The first supported | |||
| 164 | + | kind is `bell`; `reason` is semantic metadata. The interactive host | |||
| 165 | + | applies the user's shared terminal-alert setting and non-interactive | |||
| 166 | + | hosts may ignore it. Extensions must never write BEL or ANSI bytes to | |||
| 167 | + | stdout. | |||
| 168 | + | ||||
| 162 | `command_response.action` values: | 169 | `command_response.action` values: | ||
| 163 | - `"prompt"` — submit `prompt` as a fresh user message | 170 | - `"prompt"` — submit `prompt` as a fresh user message | ||
| 164 | - `"insert"` — drop `insert` into the editor at the cursor | 171 | - `"insert"` — drop `insert` into the editor at the cursor | ||
| @@ -177,9 +184,9 @@ never stalls the agent. | |||||
| 177 | 184 | ||||
| 178 | - **stdout is reserved for the protocol.** Anything you print to | 185 | - **stdout is reserved for the protocol.** Anything you print to | ||
| 179 | stdout that isn't a JSON frame breaks the wire. The first stdout | 186 | stdout that isn't a JSON frame breaks the wire. The first stdout | ||
| 180 | - | frame must be `hello`; do not send `notify`, logs, or registration | 187 | + | frame must be `hello`; do not send `notify`, `alert`, logs, or |
| 181 | - | frames before that handshake starts. Use stderr for logs / debug | 188 | + | registration frames before that handshake starts. Use stderr for |
| 182 | - | output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`). | 189 | + | logs / debug output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`). |
| 183 | - **One JSON object per line.** No multi-line JSON. Always end | 190 | - **One JSON object per line.** No multi-line JSON. Always end | ||
| 184 | every frame with `\n`. | 191 | every frame with `\n`. | ||
| 185 | - **Flush after writing.** Most stdout writes are line-buffered when | 192 | - **Flush after writing.** Most stdout writes are line-buffered when | ||
| @@ -245,6 +252,13 @@ host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`, | |||||
| 245 | {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true} | 252 | {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true} | ||
| 246 | ``` | 253 | ``` | ||
| 247 | 254 | ||||
| 255 | + | After the hello handshake completes inside `Run`, call `e.Alert` from a | |||
| 256 | + | command, tool, or panel handler when the user needs to be drawn back to zot: | |||
| 257 | + | ||||
| 258 | + | ```go | |||
| 259 | + | e.Alert(ext.AlertRequest{Kind: ext.AlertKindBell, Reason: "question_ready"}) | |||
| 260 | + | ``` | |||
| 261 | + | ||||
| 248 | ### TypeScript (no SDK; handles the protocol directly) | 262 | ### TypeScript (no SDK; handles the protocol directly) | ||
| 249 | 263 | ||||
| 250 | Run via `tsx`, which executes `.ts` files without a build step. | 264 | Run via `tsx`, which executes `.ts` files without a build step. | ||
A packages/tui/alert_test.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestWriteBellWritesStandaloneAlert(t *testing.T) { | |
| 9 | + | var out bytes.Buffer | |
| 10 | + | if err := WriteBell(&out); err != nil { | |
| 11 | + | t.Fatalf("WriteBell() error = %v", err) | |
| 12 | + | } | |
| 13 | + | if got := out.String(); got != "\a" { | |
| 14 | + | t.Fatalf("WriteBell() = %q, want standalone BEL", got) | |
| 15 | + | } | |
| 16 | + | } | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestWriteBellWritesStandaloneAlert(t *testing.T) { | |||
| 9 | + | var out bytes.Buffer | |||
| 10 | + | if err := WriteBell(&out); err != nil { | |||
| 11 | + | t.Fatalf("WriteBell() error = %v", err) | |||
| 12 | + | } | |||
| 13 | + | if got := out.String(); got != "\a" { | |||
| 14 | + | t.Fatalf("WriteBell() = %q, want standalone BEL", got) | |||
| 15 | + | } | |||
| 16 | + | } | |||
M packages/tui/terminal.go +8 -0
| @@ -43,6 +43,14 @@ func NewProcTerm() *ProcTerm { | |||
| 43 | 43 | ||
| 44 | 44 | func (p *ProcTerm) Write(b []byte) (int, error) { return p.out.Write(b) } | |
| 45 | 45 | ||
| 46 | + | // WriteBell emits one standalone terminal alert character. Callers should | |
| 47 | + | // route it through the same output writer used for the rest of the terminal | |
| 48 | + | // so redraws and alerts share the host's output boundary. | |
| 49 | + | func WriteBell(w io.Writer) error { | |
| 50 | + | _, err := io.WriteString(w, "\a") | |
| 51 | + | return err | |
| 52 | + | } | |
| 53 | + | ||
| 46 | 54 | func (p *ProcTerm) Size() (int, int) { | |
| 47 | 55 | w, h, err := term.GetSize(int(p.out.Fd())) | |
| 48 | 56 | if err != nil || w <= 0 || h <= 0 { | |
| @@ -43,6 +43,14 @@ func NewProcTerm() *ProcTerm { | |||||
| 43 | 43 | ||||
| 44 | func (p *ProcTerm) Write(b []byte) (int, error) { return p.out.Write(b) } | 44 | func (p *ProcTerm) Write(b []byte) (int, error) { return p.out.Write(b) } | ||
| 45 | 45 | ||||
| 46 | + | // WriteBell emits one standalone terminal alert character. Callers should | |||
| 47 | + | // route it through the same output writer used for the rest of the terminal | |||
| 48 | + | // so redraws and alerts share the host's output boundary. | |||
| 49 | + | func WriteBell(w io.Writer) error { | |||
| 50 | + | _, err := io.WriteString(w, "\a") | |||
| 51 | + | return err | |||
| 52 | + | } | |||
| 53 | + | ||||
| 46 | func (p *ProcTerm) Size() (int, int) { | 54 | func (p *ProcTerm) Size() (int, int) { | ||
| 47 | w, h, err := term.GetSize(int(p.out.Fd())) | 55 | w, h, err := term.GetSize(int(p.out.Fd())) | ||
| 48 | if err != nil || w <= 0 || h <= 0 { | 56 | if err != nil || w <= 0 || h <= 0 { | ||