diff --git a/CHANGELOG.md b/CHANGELOG.md index 9196b39..cf87217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,24 @@ All notable changes to this project are documented here. host. Both bridges (Rust companion and the `aiui-mcp` PyPI package for remote/headless use) implement the same routing so behavior doesn't drift between the two. +- **`notify` tool — native macOS notification (#17).** A fire-and-forget + async-completion signal for the agent ("tests green", "deploy done", + "merge conflicts, need you") that, unlike `confirm`/`ask`/`form`/ + `gallery`, does not block on a user response: the companion hands the + notification to macOS's `UNUserNotificationCenter` (via + `tauri-plugin-notification`) and the tool call returns `{ok: true}` + immediately — no dialog window, no registry entry, no progress + keepalive. Takes `title`/`body` (required) plus optional `subtitle` and + `sound`. New `POST /notify` companion endpoint, registered in both the + native Rust MCP server (`mcp.rs`) and the Python remote bridge + (`aiui_mcp/server.py`) with matching behavior. First send triggers the + one-time macOS notification-permission prompt, same as any native app. + +### Changed + +- **`docs/skill.md`** documents `notify` alongside `confirm`/`ask`/`form`/ + `gallery`, with the "when NOT to use chat" guidance extended to cover + async-completion signals. ## [0.8.3] — 2026-07-29 diff --git a/README.md b/README.md index 5498d39..6be09c0 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ back in chat — without it, the agent might forget aiui exists. | Destructive actions with a vague "please confirm" | Red-styled yes/no, unambiguous | | Ad-hoc local web UIs for one-off tasks | No longer needed | | Remote hosts where the agent has no way to ask you | Dialogs tunnel back to your Mac automatically | +| A long task finishes while you've tabbed away | A native macOS notification — no dialog, nothing to click |

Claude Desktop App session with an aiui dialog on the Mac desktop diff --git a/companion/src-tauri/Cargo.lock b/companion/src-tauri/Cargo.lock index 852ae46..425d2f7 100644 --- a/companion/src-tauri/Cargo.lock +++ b/companion/src-tauri/Cargo.lock @@ -52,6 +52,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-log", + "tauri-plugin-notification", "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", @@ -2368,6 +2369,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2537,6 +2552,20 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -4709,6 +4738,25 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.4", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-process" version = "2.3.1" @@ -4867,6 +4915,17 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/companion/src-tauri/Cargo.toml b/companion/src-tauri/Cargo.toml index 2cb25f3..7dea1ae 100644 --- a/companion/src-tauri/Cargo.toml +++ b/companion/src-tauri/Cargo.toml @@ -33,6 +33,11 @@ tauri-plugin-single-instance = "2" tauri-plugin-updater = "2" tauri-plugin-dialog = "2" tauri-plugin-process = "2" +# Native OS notification for the `notify` MCP tool (#17) — fire-and-forget +# agent → user signal ("tests green", "deploy done") that doesn't block on a +# dialog response. On macOS this drives UNUserNotificationCenter (one-time +# permission prompt on first send, same as any other native app). +tauri-plugin-notification = "2" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" diff --git a/companion/src-tauri/capabilities/default.json b/companion/src-tauri/capabilities/default.json index 7524905..e1aad8e 100644 --- a/companion/src-tauri/capabilities/default.json +++ b/companion/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "updater:default", "dialog:default", - "process:default" + "process:default", + "notification:default" ] } diff --git a/companion/src-tauri/src/http.rs b/companion/src-tauri/src/http.rs index 2ab4a74..cbcc3f8 100644 --- a/companion/src-tauri/src/http.rs +++ b/companion/src-tauri/src/http.rs @@ -17,6 +17,7 @@ use std::time::{Duration, Instant}; use crate::logging::trace; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_dialog::DialogExt; +use tauri_plugin_notification::NotificationExt; use tauri_plugin_updater::UpdaterExt; /// How long `/health` waits for a `ui:ping` round-trip from the frontend @@ -162,6 +163,34 @@ struct UpdateResponse { note: Option, } +/// Body for `POST /notify` — backs the `notify` MCP tool (#17). Unlike +/// confirm/ask/form/gallery this is fire-and-forget: no dialog window, no +/// registry entry, no wait on a user response. `title` and `body` are +/// required (empty `title` is rejected below, mirroring the `confirm` +/// tool's requirement); `subtitle` and `sound` are optional and silently +/// ignored on platforms/notification backends that don't support them. +#[derive(Deserialize)] +struct NotifyRequest { + #[serde(default)] + title: String, + #[serde(default)] + body: String, + #[serde(default)] + subtitle: Option, + /// Notification sound name. macOS: a system sound name (e.g. + /// `"default"`) or omit for silent. Passed through as-is; an invalid + /// name is swallowed by the OS rather than erroring the call. + #[serde(default)] + sound: Option, +} + +#[derive(Serialize)] +struct NotifyResponse { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + pub async fn serve( cfg: Arc, dialog: Arc, @@ -197,6 +226,7 @@ pub async fn serve( .route("/health", get(health)) .route("/render", post(render)) .route("/render/:id", get(render_poll)) + .route("/notify", post(notify)) .route("/version", get(version)) .route("/update", post(update)) .route("/ping", get(ping)) @@ -1248,6 +1278,93 @@ async fn render( .into_response() } +/// A `notify` title must carry actual text — an empty/whitespace-only title +/// would show a blank banner headline. Pulled out as a pure predicate (like +/// `validate_spec` above) so the rule is unit-testable without a Tauri app. +fn notify_title_is_valid(title: &str) -> bool { + !title.trim().is_empty() +} + +/// Combine `body` and an optional `subtitle` into the single text handed to +/// `NotificationBuilder::body`. `tauri-plugin-notification`'s builder has no +/// dedicated `subtitle()` — that concept only exists on macOS's notification +/// banner — so a caller-supplied subtitle is folded into the body instead of +/// silently disappearing cross-platform. Returns `None` when there is +/// nothing to show (both blank), so the caller can skip `.body()` entirely +/// rather than rendering an empty line. +fn compose_notify_body(body: &str, subtitle: Option<&str>) -> Option { + let subtitle = subtitle.map(str::trim).filter(|s| !s.is_empty()); + let body = Some(body.trim()).filter(|s| !s.is_empty()); + match (subtitle, body) { + (Some(s), Some(b)) => Some(format!("{s}\n{b}")), + (Some(s), None) => Some(s.to_string()), + (None, Some(b)) => Some(b.to_string()), + (None, None) => None, + } +} + +/// `POST /notify` — fire a native OS notification and return immediately +/// (#17). Deliberately NOT modeled on `/render`: there is no dialog window, +/// no registry entry, no user response to wait on — the whole point of +/// `notify` (vs. `confirm`/`ask`/`form`) is that the agent doesn't block on +/// it. `tauri-plugin-notification` hands the request to the OS notification +/// center (`UNUserNotificationCenter` on macOS) and returns as soon as that +/// hand-off succeeds; it does not wait for the user to see or dismiss it. +async fn notify( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> impl IntoResponse { + if !auth_ok(&headers, &state.cfg.token) { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "unauthorized"})), + ) + .into_response(); + } + if !notify_title_is_valid(&req.title) { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(serde_json::json!({ + "error": "invalid_request", + "detail": "title must not be empty", + })), + ) + .into_response(); + } + + let mut builder = state.app.notification().builder().title(&req.title); + if let Some(body) = compose_notify_body(&req.body, req.subtitle.as_deref()) { + builder = builder.body(body); + } + if let Some(sound) = req.sound.as_deref().filter(|s| !s.is_empty()) { + builder = builder.sound(sound); + } + + match builder.show() { + Ok(()) => { + trace("notify: shown"); + (StatusCode::OK, Json(NotifyResponse { ok: true, error: None })).into_response() + } + Err(e) => { + // Not fatal to the caller — surface `{ok: false, error}` rather + // than a 5xx, mirroring the tolerant style of the other + // best-effort endpoints (e.g. media upload). A missing/denied + // OS notification permission is the expected failure mode here, + // not a bug. + trace(&format!("notify: show failed: {e}")); + ( + StatusCode::OK, + Json(NotifyResponse { + ok: false, + error: Some(e.to_string()), + }), + ) + .into_response() + } + } +} + #[cfg(test)] mod validate_tests { use super::validate_spec; @@ -1457,6 +1574,66 @@ mod render_guard_tests { } } +#[cfg(test)] +mod notify_tests { + use super::{compose_notify_body, notify_title_is_valid}; + + #[test] + fn title_must_be_non_empty() { + assert!(!notify_title_is_valid("")); + assert!(!notify_title_is_valid(" ")); + assert!(!notify_title_is_valid("\t\n")); + } + + #[test] + fn title_with_content_is_valid() { + assert!(notify_title_is_valid("Deploy finished")); + // Leading/trailing whitespace around real content is fine — only + // whitespace-*only* titles are rejected. + assert!(notify_title_is_valid(" Deploy finished ")); + } + + #[test] + fn compose_body_prefers_both_when_present() { + assert_eq!( + compose_notify_body("All tests passed.", Some("CI")), + Some("CI\nAll tests passed.".to_string()) + ); + } + + #[test] + fn compose_body_falls_back_to_subtitle_only() { + assert_eq!( + compose_notify_body("", Some("CI finished")), + Some("CI finished".to_string()) + ); + } + + #[test] + fn compose_body_falls_back_to_body_only() { + assert_eq!( + compose_notify_body("All tests passed.", None), + Some("All tests passed.".to_string()) + ); + } + + #[test] + fn compose_body_none_when_both_blank() { + assert_eq!(compose_notify_body("", None), None); + assert_eq!(compose_notify_body(" ", Some(" ")), None); + } + + #[test] + fn compose_body_ignores_blank_subtitle() { + // A whitespace-only subtitle must not produce a spurious blank line + // above the real body text. + assert_eq!( + compose_notify_body("All tests passed.", Some(" ")), + Some("All tests passed.".to_string()) + ); + } +} + #[cfg(test)] mod async_render_tests { use super::{drain_async_slot, AsyncSlot, SlotLook}; diff --git a/companion/src-tauri/src/lib.rs b/companion/src-tauri/src/lib.rs index 7859777..e919746 100644 --- a/companion/src-tauri/src/lib.rs +++ b/companion/src-tauri/src/lib.rs @@ -1409,6 +1409,10 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) + // Backs the `notify` MCP tool (#17) — native OS notification shown + // directly from Rust (http.rs `/notify`), no capability grant needed + // since it's never invoked from the WebView side. + .plugin(tauri_plugin_notification::init()) .manage(cfg.clone()) .manage(dialog_state.clone()) .manage(ui_acks.clone()) diff --git a/companion/src-tauri/src/mcp.rs b/companion/src-tauri/src/mcp.rs index 13879ff..3120eb4 100644 --- a/companion/src-tauri/src/mcp.rs +++ b/companion/src-tauri/src/mcp.rs @@ -47,6 +47,9 @@ instead of asking via chat. Default behaviour for this session: - User wants to hand you a file from their Mac (`/aiui:upload`, \ \"take this file\", \"upload …\") → call `upload` with the target \ directory on your host; don't ask them to `scp` it. +- Async-completion signal the user doesn't need to answer (tests green, \ + deploy done, merge conflict) → call `notify` — it returns immediately, \ + no dialog, no reply expected. - Pure information the user only reads → keep it in chat. Type `/aiui:teach` for the full widget catalog when composing a \ @@ -466,6 +469,20 @@ fn tools_list() -> Value { } } }, + { + "name": "notify", + "description": "Fire a native macOS notification and return immediately — use this for an async-completion signal to a user who isn't watching this session (\"tests green\", \"deploy finished\", \"merge conflicts, need you\"). Unlike confirm/ask/form/gallery, this tool does NOT wait for the user: it hands the notification to the OS and returns {ok: true} right away, with no dialog, no window, no response to parse. Use it instead of a chat message when the point is exactly that the user doesn't have to be looking at this session to notice. For anything that needs an answer (yes/no, a choice, input), use confirm/ask/form — notify has no way to carry a reply back. `title` is required and short (≤ ~40 chars, notification banners truncate); `body` carries the detail. `subtitle` is optional extra context (folded into the body on platforms without a distinct subtitle slot). `sound` is an optional OS sound name (e.g. \"default\"); omit for a silent notification.", + "inputSchema": { + "type": "object", + "required": ["title", "body"], + "properties": { + "title": { "type": "string", "description": "Short headline, ≤ ~40 chars — notification banners truncate longer text." }, + "body": { "type": "string", "description": "The detail — what finished, what needs attention." }, + "subtitle": { "type": "string", "description": "Optional extra context line." }, + "sound": { "type": "string", "description": "Optional OS notification sound name (e.g. \"default\"). Omit for silent." } + } + } + }, { "name": "aiui_health", "description": "Reachability check against the local aiui companion. Returns version + ready flag if the companion is running and responding.", @@ -759,6 +776,20 @@ async fn tools_call( format_dialog_result, ), + "notify" => post_json( + http, + cfg, + "/notify", + json!({ + "title": args.get("title"), + "body": args.get("body"), + "subtitle": args.get("subtitle"), + "sound": args.get("sound") + }), + ) + .await + .map(value_to_tool_text), + "aiui_health" => get_json(http, cfg, "/health").await.map(value_to_tool_text), "version" => get_json(http, cfg, "/version").await.map(value_to_tool_text), "update" => post_empty(http, cfg, "/update") @@ -1299,6 +1330,42 @@ async fn post_empty( .map_err(|e| format!("parse {path}: {e}")) } +/// POST with a JSON body, returning the parsed response. Backs `notify` +/// (#17) — unlike `render_dialog`, there is no async-poll dance here: the +/// companion's `/notify` handler is itself fire-and-forget and answers +/// synchronously the moment the OS accepts the notification. A non-2xx +/// status surfaces the response body (if any) so a 422 `invalid_request` +/// detail from the companion reaches the agent instead of a bare status +/// code. +async fn post_json( + http: &reqwest::Client, + cfg: &AppConfig, + path: &str, + body: Value, +) -> Result { + let token = load_token(cfg)?; + let url = format!("{}{}", base_url(cfg), path); + let resp = http + .post(&url) + .bearer_auth(&token) + .json(&body) + .send() + .await + .map_err(|e| format!("POST {path}: {e}"))?; + let status = resp.status(); + if !status.is_success() { + let detail = resp.text().await.unwrap_or_default(); + return Err(if detail.is_empty() { + format!("{path} http {status}") + } else { + format!("{path} http {status}: {detail}") + }); + } + resp.json::() + .await + .map_err(|e| format!("parse {path}: {e}")) +} + // MCP tool-result shape: { content: [...], structuredContent?: ..., isError? } fn value_to_tool_text(v: Value) -> Value { json!({ diff --git a/docs/skill.md b/docs/skill.md index 74a0103..700935c 100644 --- a/docs/skill.md +++ b/docs/skill.md @@ -1,17 +1,20 @@ --- name: aiui -description: Render native desktop dialogs on the user's machine via aiui's MCP server — `confirm` before destructive actions (delete, drop, force-push, deploy), `ask` for pick-one-of-N where context per option matters, `form` for multi-input requests, secrets, dates, sliders, sortable lists, or image confirmation, `compare` for A/B(/C) side-by-side picks, `gallery` for batch image/video review. +description: Render native desktop dialogs on the user's machine via aiui's MCP server — `confirm` before destructive actions (delete, drop, force-push, deploy), `ask` for pick-one-of-N where context per option matters, `form` for multi-input requests, secrets, dates, sliders, sortable lists, or image confirmation, `compare` for A/B(/C) side-by-side picks, `gallery` for batch image/video review, `notify` for a fire-and-forget completion signal that doesn't block on a reply. --- # aiui — Dialog design for Claude agents -aiui exposes five MCP tools that render native dialogs on the user's machine: +aiui exposes MCP tools that render native dialogs on the user's machine, +plus one that doesn't wait for the user at all: - `confirm` — irreversible yes/no - `ask` — single- or multi-choice with descriptions and optional free-text fallback - `form` — composite window with typed fields and multiple action buttons - `gallery` — batch review of images/videos, one decision per item - `compare` — side-by-side A/B (or A/B/C) content compare, pick one +- `notify` — fire-and-forget native OS notification; no dialog, no + response, returns immediately ## Default to a dialog, not to chat @@ -54,6 +57,12 @@ instead: before choosing one — two drafts, three headlines, before/after an edit — → `compare`. Don't reach for `ask`+thumbnail here: a thumbnail is too small to actually compare, `compare` renders the full pane. +- Any **async-completion signal** the user doesn't need to answer — "tests + are green", "deploy finished", "hit a merge conflict, need you" — where + the point is exactly that they don't have to be watching this session + → `notify`, not a chat message and not `confirm`. If you catch yourself + about to end a turn with nothing but "done!" while the user has tabbed + away, that's a `notify`, not silence. ## When chat actually wins @@ -63,7 +72,9 @@ Skip the dialog for content the user reads, doesn't answer: in chat. - Single free-text answers where the user would type the same thing into a dialog box anyway — just ask in chat. -- Anything where the answer is "go on", and the user is paying attention. +- Anything where the answer is "go on", and the user is paying attention + (i.e. they're actually looking at this session — otherwise see `notify` + above). ## Tool choice @@ -79,9 +90,46 @@ Skip the dialog for content the user reads, doesn't answer: | Per-item verdict on a *batch* of images/videos ("approve/revise/skip each") | `gallery` | | Pick one of 2–3 full variants shown side by side (drafts, headlines, before/after) | `compare` | | Mark *where* on an image (point / region) | `form` with `annotated_image` | +| Async-completion signal, no reply needed, user may not be watching | `notify` | | Single free-text answer | just ask in chat | | More than 8 fields | split into multiple `form` calls; do not cram one dialog | +## Fire-and-forget: `notify` + +`notify` is the odd one out: it does not open a window and does not wait +for the user. Call it, get `{ok: true}` back immediately, move on. Use it +for the class of thing you'd otherwise announce in chat and hope the user +notices — "tests green", "deploy finished", "hit a merge conflict, need +you" — when they may not be looking at this session at all. That's also +the line that separates it from `confirm`: if the message expects a reply, +it's the wrong tool — `notify` has no way to carry an answer back. + +Spec: `{title, body, subtitle?, sound?}`. `title` and `body` are required. + +- `title` — short headline, notification banners truncate anything past + ~40 characters. State the outcome, not the process ("Deploy finished", + not "Deploying..."). +- `body` — the detail: what finished, what needs attention, what broke. +- `subtitle` — optional extra context line (folded into `body` on + platforms/backends without a distinct subtitle slot — don't rely on it + rendering as a visually separate line). +- `sound` — optional OS sound name (e.g. `"default"`); omit for a silent + notification. + +First call triggers the one-time macOS notification-permission prompt, +same as any other native app — if the user has denied it, `notify` +returns `{ok: false, error}` rather than erroring the tool call; that's +an expected outcome, not a bug to retry around. + +**Anti-patterns:** + +- Using `notify` for anything that expects an answer — that's `confirm`/ + `ask`/`form`. `notify` is one-way. +- Firing `notify` for routine intermediate progress ("step 3 of 10") — + reserve it for the completion/attention-worthy moment, or every step + becomes a notification and the signal drowns. +- Padding `title` with a full sentence — put the sentence in `body`. + ## Writing labels and copy - Imperative or noun, ≤ 6 words per label, no punctuation, no emoji. diff --git a/python/src/aiui_mcp/server.py b/python/src/aiui_mcp/server.py index 49a057f..6a605b3 100644 --- a/python/src/aiui_mcp/server.py +++ b/python/src/aiui_mcp/server.py @@ -127,6 +127,9 @@ def _default_token_path() -> str: - User wants to hand you a file from their Mac (`/aiui:upload`, \ "take this file", "upload …") → call `upload` with the target \ directory on your host; don't ask them to `scp` it. +- Async-completion signal the user doesn't need to answer (tests green, \ + deploy done, merge conflict) → call `notify` — it returns immediately, \ + no dialog, no reply expected. - Pure information the user only reads → keep it in chat. Type `/aiui:teach` for the full widget catalog when composing a \ @@ -1329,6 +1332,73 @@ async def compare( return _format_result(await _post_render(spec, ctx, session)) +@mcp.tool() +async def notify( + title: str, + body: str, + subtitle: str | None = None, + sound: str | None = None, +) -> dict[str, Any]: + """Fire a native macOS notification and return immediately — use this + for an async-completion signal to a user who isn't watching this + session ("tests green", "deploy finished", "merge conflicts, need + you"). Unlike `confirm`/`ask`/`form`/`gallery`, this tool does NOT wait + for the user: it hands the notification to the OS and returns + `{ok: True}` right away, with no dialog, no window, no response to + parse. + + WHEN TO USE: the point is exactly that the user doesn't have to be + looking at this session to notice — a long-running task just finished, + something needs their attention whenever they get to it. Use it + instead of a chat message for that case. + + WHEN NOT TO USE: anything that needs an answer (yes/no, a choice, + input) — `notify` has no way to carry a reply back. Use `confirm`, + `ask`, or `form` instead. + + Runs against the *user's Mac*, regardless of whether this MCP is local + or reached via an SSH reverse-tunnel — same as `update`/`version`, + the notification always renders on the Mac side. + + Returns `{ok: bool, error?: str}`. `ok: False` most commonly means the + user hasn't granted aiui notification permission on macOS yet (the OS + prompts for this once, on the first `notify` call) — not a bug to + retry around. + + Args: + title: Short headline, ≤ ~40 chars — notification banners + truncate longer text. + body: The detail — what finished, what needs attention. + subtitle: Optional extra context line. + sound: Optional OS notification sound name (e.g. "default"). + Omit for silent. + """ + try: + async with httpx.AsyncClient(timeout=TIMEOUT_S) as client: + r = await client.post( + f"{ENDPOINT}/notify", + headers={"Authorization": f"Bearer {_token()}"}, + json={"title": title, "body": body, "subtitle": subtitle, "sound": sound}, + ) + if r.status_code == 422: + # Structured invalid_request from the companion (e.g. empty + # title) — surface detail so the agent can fix the call. + try: + detail = r.json().get("detail", r.text) + except ValueError: + detail = r.text + raise RuntimeError(f"aiui rejected the notification: {detail}") + r.raise_for_status() + return r.json() + except RuntimeError: + raise # our own structured error above — pass through verbatim + except Exception as e: + raise RuntimeError( + f"aiui /notify failed at {ENDPOINT}: {_explain_exc(e)}. " + f"Run `aiui_health` first to check whether aiui.app is reachable." + ) from e + + @mcp.prompt(name="teach") def teach_prompt() -> str: """Brief the agent on aiui. Loads the full widget catalog, design diff --git a/python/src/aiui_mcp/skill.md b/python/src/aiui_mcp/skill.md index 3383620..0de11ee 100644 --- a/python/src/aiui_mcp/skill.md +++ b/python/src/aiui_mcp/skill.md @@ -1,15 +1,19 @@ --- name: aiui -description: Before writing a yes/no question, a numbered option list, or a multi-question request into the chat, open a native macOS dialog instead — `confirm` for yes/no (always for delete/force-push/drop/deploy), `ask` for one-of-N with per-option context, `form` for ≥ 2 related inputs / secrets / dates / sliders / sortable lists / table-row triage / image confirm. +description: Before writing a yes/no question, a numbered option list, or a multi-question request into the chat, open a native macOS dialog instead — `confirm` for yes/no (always for delete/force-push/drop/deploy), `ask` for one-of-N with per-option context, `form` for ≥ 2 related inputs / secrets / dates / sliders / sortable lists / table-row triage / image confirm, `notify` for a fire-and-forget completion signal that doesn't block on a reply. --- # aiui — Dialog design for Claude agents -aiui exposes three MCP tools that render native dialogs on the user's Mac: +aiui exposes MCP tools that render native dialogs on the user's Mac, plus +one that doesn't wait for the user at all: - `confirm` — irreversible yes/no - `ask` — single- or multi-choice with descriptions and optional free-text fallback - `form` — composite window with typed fields and multiple action buttons +- `gallery` — batch review of images/videos with a per-item verdict +- `notify` — fire-and-forget native OS notification; no dialog, no + response, returns immediately ## Default to a dialog, not to chat @@ -37,6 +41,10 @@ instead: or generated sound clip** before confirming, choosing, or triaging it → `form` with an `audio` field (native `