@skaile/forge-app-actions 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +61 -2
  2. package/dist/module.d.mts +23 -0
  3. package/dist/module.json +9 -0
  4. package/dist/module.mjs +37 -0
  5. package/dist/runtime/chat/index.d.ts +52 -0
  6. package/dist/runtime/chat/index.js +100 -0
  7. package/dist/runtime/chat/server.d.ts +36 -0
  8. package/dist/runtime/chat/server.js +27 -0
  9. package/dist/runtime/chat/types.d.ts +10 -0
  10. package/dist/runtime/chat/types.js +0 -0
  11. package/dist/runtime/client-capabilities.d.ts +29 -0
  12. package/dist/runtime/client-capabilities.js +25 -0
  13. package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.d.vue.ts +2 -0
  14. package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +125 -0
  15. package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue.d.ts +2 -0
  16. package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.d.vue.ts +2 -0
  17. package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue +218 -0
  18. package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue.d.ts +2 -0
  19. package/dist/runtime/composables/defineClientCapability.d.ts +64 -0
  20. package/dist/runtime/composables/defineClientCapability.js +40 -0
  21. package/dist/runtime/composables/defineForgeAction.d.ts +82 -0
  22. package/dist/runtime/composables/defineForgeAction.js +147 -0
  23. package/dist/runtime/composables/useAgentCapabilities.d.ts +78 -0
  24. package/dist/runtime/composables/useAgentCapabilities.js +94 -0
  25. package/dist/runtime/composables/useCapabilityApproval.d.ts +109 -0
  26. package/dist/runtime/composables/useCapabilityApproval.js +74 -0
  27. package/dist/runtime/composables/useCapabilityInvocations.d.ts +150 -0
  28. package/dist/runtime/composables/useCapabilityInvocations.js +118 -0
  29. package/dist/runtime/composables/useCommandPalette.d.ts +28 -0
  30. package/dist/runtime/composables/useCommandPalette.js +49 -0
  31. package/dist/runtime/composables/usePaletteArgStep.d.ts +47 -0
  32. package/dist/runtime/composables/usePaletteArgStep.js +51 -0
  33. package/dist/runtime/internal/describe-error.d.ts +10 -0
  34. package/dist/runtime/internal/describe-error.js +12 -0
  35. package/dist/runtime/server/index.d.ts +7 -0
  36. package/dist/runtime/server/index.js +2 -0
  37. package/dist/runtime/server/utils/capability-registry.d.ts +35 -0
  38. package/dist/runtime/server/utils/capability-registry.js +24 -0
  39. package/dist/runtime/server/utils/client-capability-bridge.d.ts +647 -0
  40. package/dist/runtime/server/utils/client-capability-bridge.js +755 -0
  41. package/dist/runtime/shims-vue.d.ts +8 -0
  42. package/dist/runtime/types/client-capability.d.ts +159 -0
  43. package/dist/runtime/types/client-capability.js +0 -0
  44. package/dist/runtime/types/command-palette.d.ts +75 -0
  45. package/dist/runtime/types/command-palette.js +0 -0
  46. package/dist/runtime/types/forge-action.d.ts +152 -0
  47. package/dist/runtime/types/forge-action.js +0 -0
  48. package/dist/runtime/types/index.d.ts +3 -0
  49. package/dist/runtime/types/index.js +0 -0
  50. package/dist/types.d.mts +9 -0
  51. package/package.json +94 -4
  52. package/src/runtime/chat/index.ts +190 -0
  53. package/src/runtime/chat/server.ts +64 -0
  54. package/src/runtime/chat/types.ts +13 -0
  55. package/src/runtime/client-capabilities.ts +82 -0
  56. package/src/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +240 -0
  57. package/src/runtime/components-nuxt-ui/ForgeCommandPalette.vue +363 -0
  58. package/src/runtime/composables/defineClientCapability.ts +119 -0
  59. package/src/runtime/composables/defineForgeAction.ts +278 -0
  60. package/src/runtime/composables/useAgentCapabilities.ts +215 -0
  61. package/src/runtime/composables/useCapabilityApproval.ts +219 -0
  62. package/src/runtime/composables/useCapabilityInvocations.ts +325 -0
  63. package/src/runtime/composables/useCommandPalette.ts +97 -0
  64. package/src/runtime/composables/usePaletteArgStep.ts +110 -0
  65. package/src/runtime/internal/describe-error.ts +21 -0
  66. package/src/runtime/server/index.ts +8 -0
  67. package/src/runtime/server/utils/capability-registry.ts +66 -0
  68. package/src/runtime/server/utils/client-capability-bridge.ts +1201 -0
  69. package/src/runtime/shims-vue.d.ts +8 -0
  70. package/src/runtime/types/client-capability.ts +178 -0
  71. package/src/runtime/types/command-palette.ts +78 -0
  72. package/src/runtime/types/forge-action.ts +166 -0
  73. package/src/runtime/types/index.ts +24 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Browser half of the AI-SDK chat binding (`@skaile/forge-app-actions/chat`).
3
+ *
4
+ * Turns registered {@link ForgeAction}s into forge-chat **client tools**:
5
+ *
6
+ * - `descriptors()` — what the model may call. Goes into the chat request body
7
+ * (`createAiSdkTransport({ body })`); the server validates it with
8
+ * `clientToolsFromBody` from `@skaile/forge-app-actions/chat/server`.
9
+ * - `handlers` — what runs when the model calls one. Pass as
10
+ * `createChatSession({ tools })`. Same contract as the SSE bridge's
11
+ * `useCapabilityInvocations().handle`: validate against the live zod schema,
12
+ * ask for approval (`<ForgeCapabilityApproval />`) when the action declares
13
+ * `requiresApproval`, then run the handler with a `ClientCapabilityContext`.
14
+ *
15
+ * Only actions whose audience includes `'llm'` are exposed. Tools are named
16
+ * {@link toolNameOf}(capability name) — `app.<id>` becomes `app_<id>` — because
17
+ * model providers reject dots in tool names.
18
+ */
19
+
20
+ import {
21
+ DEFAULT_APPROVAL_TIMEOUT_MS,
22
+ useCapabilityApproval,
23
+ } from "../composables/useCapabilityApproval";
24
+ import { describeError } from "../internal/describe-error";
25
+ import type {
26
+ ClientCapabilityContext,
27
+ ClientCapabilityLogger,
28
+ DefinedClientCapability,
29
+ } from "../types/client-capability";
30
+ import type { ForgeAction } from "../types/forge-action";
31
+ import type { ClientToolDecls, ClientToolHandlers } from "./types";
32
+
33
+ export type { ClientToolDecls, ClientToolHandlers } from "./types";
34
+
35
+ export interface UseChatActionsOptions {
36
+ /**
37
+ * The actions to expose, read on every use — the list may change over the
38
+ * lifetime of the chat (an editor that mounts later, a route-scoped action).
39
+ */
40
+ actions: () => readonly ForgeAction[];
41
+ /** Opaque session key for the handler's {@link ClientCapabilityContext}. Informational. */
42
+ sessionKey?: string;
43
+ /** Logger handed to handlers. Defaults to `console`. */
44
+ log?: ClientCapabilityLogger;
45
+ /**
46
+ * Deadline for an unanswered approval dialog, in ms; the action's own
47
+ * `callTimeoutMs` wins. Defaults to {@link DEFAULT_APPROVAL_TIMEOUT_MS}; `0`
48
+ * disables the deadline.
49
+ */
50
+ approvalTimeoutMs?: number;
51
+ }
52
+
53
+ export interface UseChatActionsReturn {
54
+ /** Tool descriptors for the request body: one per LLM-audience action. */
55
+ descriptors(): ClientToolDecls;
56
+ /** Tool handlers for `createChatSession({ tools })`, resolved against `actions()` at call time. */
57
+ handlers: ClientToolHandlers;
58
+ }
59
+
60
+ const defaultLog: ClientCapabilityLogger = {
61
+ info: (message, ...args) => console.log(message, ...args),
62
+ warn: (message, ...args) => console.warn(message, ...args),
63
+ error: (message, ...args) => console.error(message, ...args),
64
+ };
65
+
66
+ type ToolHandler = ClientToolHandlers[string];
67
+
68
+ const TOOL_NAME_MAX = 64;
69
+
70
+ /**
71
+ * The provider-safe tool name of a capability name: every character outside
72
+ * `[A-Za-z0-9_-]` becomes `_` (Anthropic and OpenAI reject others), truncated
73
+ * to 64. `app.doc.read` → `app_doc_read`. Hosts use it to name tools in prompts.
74
+ */
75
+ export function toolNameOf(capabilityName: string): string {
76
+ return capabilityName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, TOOL_NAME_MAX);
77
+ }
78
+
79
+ /** The derived JSON Schema without its top-level `$schema` draft marker; everything else stays. */
80
+ function withoutSchemaKey(schema: Record<string, unknown>): ClientToolDecls[string]["inputSchema"] {
81
+ const { $schema: _draft, ...rest } = schema;
82
+ return rest as ClientToolDecls[string]["inputSchema"];
83
+ }
84
+
85
+ export function useChatActions(options: UseChatActionsOptions): UseChatActionsReturn {
86
+ const log = options.log ?? defaultLog;
87
+
88
+ /** The LLM projection of every exposed action, keyed by provider-safe tool name. */
89
+ function exposed(): Map<string, DefinedClientCapability> {
90
+ const byTool = new Map<string, DefinedClientCapability>();
91
+ for (const action of options.actions()) {
92
+ const capability = action.capability;
93
+ if (!capability || !action.audience.includes("llm")) continue;
94
+ const toolName = toolNameOf(capability.name);
95
+ const other = byTool.get(toolName);
96
+ if (other && other.name !== capability.name) {
97
+ throw new Error(
98
+ `[useChatActions] "${other.name}" and "${capability.name}" both map to the tool name "${toolName}"; rename one action.`,
99
+ );
100
+ }
101
+ byTool.set(toolName, capability);
102
+ }
103
+ return byTool;
104
+ }
105
+
106
+ function descriptors(): ClientToolDecls {
107
+ const decls: ClientToolDecls = {};
108
+ for (const [toolName, capability] of exposed()) {
109
+ decls[toolName] = {
110
+ description: capability.description,
111
+ // Derived by `defineClientCapability` via `z.toJSONSchema()`; the
112
+ // draft marker is dropped, providers do not take it.
113
+ inputSchema: withoutSchemaKey(capability.inputSchema),
114
+ };
115
+ }
116
+ return decls;
117
+ }
118
+
119
+ function handlerFor(toolName: string): ToolHandler | undefined {
120
+ const capability = exposed().get(toolName);
121
+ if (!capability) return undefined;
122
+ const name = capability.name;
123
+
124
+ return async (rawInput, { toolCallId, signal }) => {
125
+ if (signal.aborted) throw new Error("aborted");
126
+
127
+ // Validate BEFORE anything else: `inputSchema` on the wire only shapes
128
+ // what the model tries to send; the live zod schema is the enforcement.
129
+ const checked = capability.inputZod.safeParse(rawInput ?? {});
130
+ if (!checked.success) throw new Error(`invalid input: ${describeError(checked.error)}`);
131
+ const input = checked.data;
132
+
133
+ // The approval gate sits after validation: the user sees the arguments
134
+ // that would actually run.
135
+ if (capability.requiresApproval) {
136
+ const decision = await useCapabilityApproval().request({
137
+ callId: toolCallId,
138
+ name,
139
+ displayName: capability.displayName,
140
+ description: capability.description,
141
+ input,
142
+ timeoutMs:
143
+ capability.callTimeoutMs ?? options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS,
144
+ signal,
145
+ });
146
+ if (decision === "declined") throw new Error("declined");
147
+ if (decision === "timeout") throw new Error("approval timed out");
148
+ if (decision !== "approved") throw new Error("approval cancelled");
149
+ }
150
+
151
+ const ctx: ClientCapabilityContext = {
152
+ sessionKey: options.sessionKey ?? "",
153
+ callId: toolCallId,
154
+ name,
155
+ log,
156
+ };
157
+ const result = await capability.handler(input, ctx);
158
+
159
+ // Output validation is advisory, as in `useCapabilityInvocations`.
160
+ if (capability.outputZod) {
161
+ const check = capability.outputZod.safeParse(result);
162
+ if (!check.success) {
163
+ log.warn(
164
+ `[useChatActions] ${name} returned a value that does not match its declared ` +
165
+ `output schema: ${describeError(check.error)}`,
166
+ );
167
+ }
168
+ }
169
+ // forge-chat replays the result to the model on the next request, so it
170
+ // must be JSON-serialisable; `undefined` is not.
171
+ return result ?? null;
172
+ };
173
+ }
174
+
175
+ // A Proxy rather than a snapshot: the session looks handlers up by name at
176
+ // call time, and `actions()` may have changed since `useChatActions` ran.
177
+ const handlers = new Proxy({} as ClientToolHandlers, {
178
+ get: (_target, key) => (typeof key === "string" ? handlerFor(key) : undefined),
179
+ has: (_target, key) => typeof key === "string" && exposed().has(key),
180
+ ownKeys: () => [...exposed().keys()],
181
+ getOwnPropertyDescriptor: (_target, key) => {
182
+ const handler = typeof key === "string" ? handlerFor(key) : undefined;
183
+ return handler
184
+ ? { value: handler, enumerable: true, configurable: true, writable: false }
185
+ : undefined;
186
+ },
187
+ });
188
+
189
+ return { descriptors, handlers };
190
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Server half of the AI-SDK chat binding (`@skaile/forge-app-actions/chat/server`).
3
+ *
4
+ * The browser sends the descriptors of its LLM-audience actions in the chat
5
+ * request body (`clientTools`). This module validates that untrusted blob so a
6
+ * host can hand it straight to `createAiSdkChatHandler`:
7
+ *
8
+ * ```ts
9
+ * createAiSdkChatHandler({ clientTools: (ctx) => clientToolsFromBody(ctx.body), … })
10
+ * ```
11
+ *
12
+ * No Vue, no Nuxt, no `ai` import — safe for any Nitro route.
13
+ */
14
+
15
+ import * as z from "zod";
16
+ import { describeError } from "../internal/describe-error";
17
+ import type { ClientToolDecls } from "./types";
18
+
19
+ /** Same pattern forge-chat's handler enforces; checked here first for a precise message. */
20
+ export const CLIENT_TOOL_NAME_PATTERN = /^[a-z][a-z0-9_.-]{0,63}$/i;
21
+
22
+ /** Upper bound on a tool description — descriptors come from an untrusted browser. */
23
+ export const CLIENT_TOOL_DESCRIPTION_MAX = 2000;
24
+
25
+ const clientToolsSchema = z.record(
26
+ z.string().regex(CLIENT_TOOL_NAME_PATTERN, "invalid tool name"),
27
+ z.object({
28
+ description: z.string().max(CLIENT_TOOL_DESCRIPTION_MAX),
29
+ inputSchema: z.record(z.string(), z.unknown()),
30
+ }),
31
+ );
32
+
33
+ /**
34
+ * Thrown for a malformed `clientTools` body field. Carries `statusCode: 400`, so
35
+ * `createAiSdkChatHandler` answers with that status when it is thrown from an
36
+ * option callback.
37
+ */
38
+ export class ClientToolsBodyError extends Error {
39
+ readonly statusCode = 400;
40
+ constructor(message: string) {
41
+ super(message);
42
+ this.name = "ClientToolsBodyError";
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Read and validate `body.clientTools`.
48
+ *
49
+ * - absent (`undefined`) → `undefined`
50
+ * - valid → the descriptors, unchanged
51
+ * - malformed → throws {@link ClientToolsBodyError} (`statusCode` 400,
52
+ * message `clientTools: <path>: <issue>; …`)
53
+ */
54
+ export function clientToolsFromBody(body: Record<string, unknown>): ClientToolDecls | undefined {
55
+ const raw = body.clientTools;
56
+ if (raw === undefined) return undefined;
57
+ const parsed = clientToolsSchema.safeParse(raw);
58
+ if (!parsed.success) {
59
+ throw new ClientToolsBodyError(`clientTools: ${describeError(parsed.error)}`);
60
+ }
61
+ // The schema fixes names, description and an object-shaped inputSchema; a
62
+ // JSON-Schema document itself is the model provider's to judge.
63
+ return parsed.data as ClientToolDecls;
64
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The two forge-chat contracts this binding speaks. Type-only, so
3
+ * `@skaile/forge-chat` stays an optional peer.
4
+ *
5
+ * `ClientToolHandlers` is derived from `ChatSessionOptions['tools']` because
6
+ * forge-chat does not export the alias by name.
7
+ */
8
+
9
+ import type { ChatSessionOptions } from "@skaile/forge-chat/core";
10
+
11
+ export type { ClientToolDecls } from "@skaile/forge-chat/server/ai-sdk";
12
+
13
+ export type ClientToolHandlers = NonNullable<ChatSessionOptions["tools"]>;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Explicit entry point for the browser-side agent capability API.
3
+ *
4
+ * In a Nuxt consumer both symbols are auto-imported (the module's
5
+ * `addImportsDir` on `runtime/composables`), so application code needs no
6
+ * import at all. This subpath exists for the contexts auto-imports do not
7
+ * reach: vitest specs, plain `.ts` modules outside the Nuxt app dir, and
8
+ * non-Nuxt consumers.
9
+ *
10
+ * import { defineClientCapability } from "@skaile/forge-app-actions/client-capabilities"
11
+ *
12
+ * Since ticket 07 this is also where the *unified* action API lives —
13
+ * `defineForgeAction` / `useForgeActions`, plus `useCommandPalette` itself, so a
14
+ * consumer's tests can reach both halves of one declaration without relying on
15
+ * Nuxt's auto-imports.
16
+ *
17
+ * The Nitro-side counterpart (`ClientCapabilityBridge`) is server-only and
18
+ * ships through the `./server` subpath instead.
19
+ */
20
+
21
+ export {
22
+ defineClientCapability,
23
+ deprecatedClientCapabilityAlias,
24
+ } from "./composables/defineClientCapability";
25
+ export {
26
+ DEFAULT_FORGE_ACTION_AUDIENCE,
27
+ defineForgeAction,
28
+ type UseForgeActionsReturn,
29
+ useForgeActions,
30
+ } from "./composables/defineForgeAction";
31
+ export {
32
+ type AnnounceOptions,
33
+ type AnnounceResult,
34
+ type CapabilityAnnounceFetcher,
35
+ type UseAgentCapabilitiesReturn,
36
+ useAgentCapabilities,
37
+ } from "./composables/useAgentCapabilities";
38
+ export {
39
+ type CapabilityApprovalDecision,
40
+ type CapabilityApprovalOptions,
41
+ type CapabilityApprovalRequest,
42
+ DEFAULT_APPROVAL_TIMEOUT_MS,
43
+ type UseCapabilityApprovalReturn,
44
+ useCapabilityApproval,
45
+ } from "./composables/useCapabilityApproval";
46
+ export {
47
+ type CapabilityInvokedEvent,
48
+ type HandleInvocationOptions,
49
+ type InvocationFailure,
50
+ type InvocationOutcome,
51
+ type UseCapabilityInvocationsReturn,
52
+ useCapabilityInvocations,
53
+ } from "./composables/useCapabilityInvocations";
54
+ export {
55
+ type UseCommandPaletteReturn,
56
+ useCommandPalette,
57
+ } from "./composables/useCommandPalette";
58
+ export {
59
+ type UsePaletteArgStepReturn,
60
+ usePaletteArgStep,
61
+ } from "./composables/usePaletteArgStep";
62
+ export type {
63
+ ClientCapabilityAnnouncement,
64
+ ClientCapabilityContext,
65
+ ClientCapabilityDescriptor,
66
+ ClientCapabilityLogger,
67
+ ClientCapabilityOrigin,
68
+ DefineClientCapabilityArgs,
69
+ DefinedClientCapability,
70
+ } from "./types/client-capability";
71
+ export type {
72
+ PaletteAction,
73
+ PaletteArgOption,
74
+ PaletteArgPrompt,
75
+ } from "./types/command-palette";
76
+ export type {
77
+ DefineForgeActionArgs,
78
+ ForgeAction,
79
+ ForgeActionArg,
80
+ ForgeActionAudience,
81
+ ForgeActionContext,
82
+ } from "./types/forge-action";
@@ -0,0 +1,240 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ForgeCapabilityApproval — the confirm gate for `requiresApproval`
4
+ * capabilities, built on UModal + UButton.
5
+ *
6
+ * No props, no emits. All state lives in the `useCapabilityApproval` singleton,
7
+ * which `useCapabilityInvocations().handle()` feeds. Mount once in `app.vue`.
8
+ *
9
+ * Closing without choosing (Escape, backdrop) counts as a decline. The safe
10
+ * default for "should this thing touch your workspace?" is no.
11
+ */
12
+ import { computed, onMounted, onUnmounted, ref } from "vue";
13
+ import { useCapabilityApproval } from "../composables/useCapabilityApproval";
14
+
15
+ const { current, approve, decline } = useCapabilityApproval();
16
+
17
+ const title = computed(() => current.value?.displayName || current.value?.name || "");
18
+
19
+ /**
20
+ * The arguments, as rows. An object becomes one row per key; anything else
21
+ * (a bare string, a number) becomes a single unnamed row. The user is
22
+ * approving a specific call, so the specific values have to be on screen.
23
+ */
24
+ const argumentRows = computed<Array<{ key: string; value: string }>>(() => {
25
+ const input = current.value?.input;
26
+ if (input === undefined || input === null) return [];
27
+ if (typeof input === "object" && !Array.isArray(input)) {
28
+ return Object.entries(input as Record<string, unknown>).map(([key, value]) => ({
29
+ key,
30
+ value: format(value),
31
+ }));
32
+ }
33
+ return [{ key: "", value: format(input) }];
34
+ });
35
+
36
+ function format(value: unknown): string {
37
+ if (typeof value === "string") return value;
38
+ try {
39
+ return JSON.stringify(value);
40
+ } catch {
41
+ return String(value);
42
+ }
43
+ }
44
+
45
+ // ── Countdown ──────────────────────────────────────────────────────────────
46
+ // A gate with an invisible deadline is a trap: the user needs to know the
47
+ // dialog will answer for them if they walk away.
48
+ const now = ref(Date.now());
49
+ // Started on mount: the component renders on the server too, where an
50
+ // interval would never be cleared.
51
+ let ticker: ReturnType<typeof setInterval> | undefined;
52
+ onMounted(() => {
53
+ ticker = setInterval(() => {
54
+ now.value = Date.now();
55
+ }, 500);
56
+ });
57
+ onUnmounted(() => clearInterval(ticker));
58
+
59
+ const secondsLeft = computed<number | null>(() => {
60
+ const request = current.value;
61
+ if (request?.expiresAt === undefined) return null;
62
+ // Clamped to the declared window: `now` only ticks twice a second and is
63
+ // seeded at mount, which is earlier than any request it will ever show.
64
+ const remaining = Math.min(
65
+ request.expiresAt - now.value,
66
+ request.timeoutMs ?? Number.MAX_SAFE_INTEGER,
67
+ );
68
+ return Math.max(0, Math.ceil(remaining / 1000));
69
+ });
70
+
71
+ function onApprove() {
72
+ const id = current.value?.id;
73
+ if (id) approve(id);
74
+ }
75
+
76
+ function onDecline() {
77
+ const id = current.value?.id;
78
+ if (id) decline(id);
79
+ }
80
+ </script>
81
+
82
+ <template>
83
+ <UModal
84
+ :open="current !== null"
85
+ title="Allow this action?"
86
+ :ui="{ content: 'max-w-[480px] w-[95vw]' }"
87
+ @update:open="(v: boolean) => !v && onDecline()"
88
+ >
89
+ <template #content>
90
+ <div class="forge-capability-approval" data-testid="capability-approval" @keydown.esc.prevent="onDecline">
91
+ <h2 class="forge-capability-approval__heading">Allow this action?</h2>
92
+
93
+ <template v-if="current">
94
+ <p class="forge-capability-approval__intro">
95
+ The agent wants to run
96
+ <strong data-testid="capability-approval-title">{{ title }}</strong>
97
+ </p>
98
+
99
+ <code class="forge-capability-approval__name" data-testid="capability-approval-name">{{
100
+ current.name
101
+ }}</code>
102
+
103
+ <p
104
+ v-if="current.description"
105
+ class="forge-capability-approval__description"
106
+ data-testid="capability-approval-description"
107
+ >
108
+ {{ current.description }}
109
+ </p>
110
+
111
+ <div class="forge-capability-approval__args" data-testid="capability-approval-args">
112
+ <div class="forge-capability-approval__args-label">Arguments</div>
113
+ <div v-if="argumentRows.length === 0" class="forge-capability-approval__arg-empty">
114
+ none
115
+ </div>
116
+ <div
117
+ v-for="row in argumentRows"
118
+ :key="row.key"
119
+ class="forge-capability-approval__arg"
120
+ >
121
+ <span v-if="row.key" class="forge-capability-approval__arg-key">{{ row.key }}</span>
122
+ <span class="forge-capability-approval__arg-value">{{ row.value }}</span>
123
+ </div>
124
+ </div>
125
+
126
+ <p
127
+ v-if="secondsLeft !== null"
128
+ class="forge-capability-approval__countdown"
129
+ data-testid="capability-approval-countdown"
130
+ >
131
+ Expires in {{ secondsLeft }}s if unanswered.
132
+ </p>
133
+ </template>
134
+
135
+ <div class="forge-capability-approval__actions">
136
+ <!-- Decline is focused first on purpose: this dialog can appear while
137
+ the user is typing, and a stray Enter must not approve an action. -->
138
+ <UButton
139
+ label="Decline"
140
+ color="neutral"
141
+ variant="subtle"
142
+ autofocus
143
+ data-testid="capability-approval-decline"
144
+ @click="onDecline"
145
+ />
146
+ <UButton label="Allow" data-testid="capability-approval-approve" @click="onApprove" />
147
+ </div>
148
+ </div>
149
+ </template>
150
+ </UModal>
151
+ </template>
152
+
153
+ <style scoped>
154
+ .forge-capability-approval {
155
+ display: flex;
156
+ flex-direction: column;
157
+ gap: 10px;
158
+ padding: 16px;
159
+ font-size: 0.9rem;
160
+ }
161
+
162
+ .forge-capability-approval__heading {
163
+ margin: 0;
164
+ font-size: 1rem;
165
+ font-weight: 600;
166
+ }
167
+
168
+ .forge-capability-approval__intro {
169
+ margin: 0;
170
+ }
171
+
172
+ .forge-capability-approval__name {
173
+ align-self: flex-start;
174
+ padding: 2px 6px;
175
+ border-radius: 4px;
176
+ background: #f1f5f9;
177
+ font-size: 0.8rem;
178
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
179
+ color: #475569;
180
+ }
181
+
182
+ .forge-capability-approval__description {
183
+ margin: 0;
184
+ color: #64748b;
185
+ }
186
+
187
+ .forge-capability-approval__args {
188
+ display: flex;
189
+ flex-direction: column;
190
+ gap: 4px;
191
+ padding: 8px 10px;
192
+ border-radius: 6px;
193
+ background: #f8fafc;
194
+ border: 1px solid #e2e8f0;
195
+ }
196
+
197
+ .forge-capability-approval__args-label {
198
+ font-size: 0.7rem;
199
+ font-weight: 700;
200
+ text-transform: uppercase;
201
+ letter-spacing: 0.08em;
202
+ color: #94a3b8;
203
+ }
204
+
205
+ .forge-capability-approval__arg {
206
+ display: flex;
207
+ gap: 8px;
208
+ align-items: baseline;
209
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
210
+ font-size: 0.8rem;
211
+ word-break: break-all;
212
+ }
213
+
214
+ .forge-capability-approval__arg-key {
215
+ color: #64748b;
216
+ flex-shrink: 0;
217
+ }
218
+
219
+ .forge-capability-approval__arg-key::after {
220
+ content: ":";
221
+ }
222
+
223
+ .forge-capability-approval__arg-empty {
224
+ color: #94a3b8;
225
+ font-size: 0.8rem;
226
+ }
227
+
228
+ .forge-capability-approval__countdown {
229
+ margin: 0;
230
+ font-size: 0.75rem;
231
+ color: #94a3b8;
232
+ }
233
+
234
+ .forge-capability-approval__actions {
235
+ display: flex;
236
+ justify-content: flex-end;
237
+ gap: 8px;
238
+ margin-top: 4px;
239
+ }
240
+ </style>