@tapcue/extension-sdk 0.1.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.
package/src/types.ts ADDED
@@ -0,0 +1,614 @@
1
+ /**
2
+ * Requests, items, and the invocation context — the data shapes that cross the
3
+ * guest ↔ host boundary on every call.
4
+ */
5
+
6
+ import type {
7
+ BridgeCapability,
8
+ CacheCapability,
9
+ ClipboardCapability,
10
+ ClipboardReadCapability,
11
+ EnvironmentSnapshot,
12
+ ExecCapability,
13
+ FilesCapability,
14
+ FontsCapability,
15
+ HttpCapability,
16
+ InvocationContextCapability,
17
+ LocationCapability,
18
+ NativeActionsCapability,
19
+ QueriesCapability,
20
+ ReadonlyProcessManager,
21
+ ScreenCapability,
22
+ SecretsCapability,
23
+ SettingsSnapshot,
24
+ StorageCapability,
25
+ WritableProcessManager,
26
+ WritableSettings,
27
+ } from "./capabilities.js";
28
+ import type { ExtensionErrorInit } from "./errors.js";
29
+ import type { PermissionUnit } from "./manifest.js";
30
+ import type { Translate } from "./i18n.js";
31
+ import type { JsonValue } from "./json.js";
32
+ import type { Color } from "./capabilities.js";
33
+ import type { IconRef, Scene, SceneScheme, TableCell } from "./scene.js";
34
+ import type { ViewNode } from "./view.js";
35
+
36
+ /**
37
+ * Open, dotted type vocabulary shared with native scopes (`file`, `file.pdf`).
38
+ * Extension-declared types MUST be namespaced `ext.<extension-id>.<name>` so an
39
+ * extension cannot squat a native subject type; the host rejects the rest.
40
+ */
41
+ export type ScopeType = string;
42
+
43
+ /**
44
+ * Subject types Tapcue owns. An extension may **produce** an item that enters one of
45
+ * these — a colour sampled off the screen enters the same colour scope a typed
46
+ * `#aaeeff` does — but it may never **own** one: `defineScope` requires `ext.<id>.*`,
47
+ * and the host refuses to load an extension that declares otherwise (§11.1).
48
+ *
49
+ * Owning is what gives a subject type one implementation of its formats, its actions,
50
+ * and its look. Producing is open to anyone, and that is the whole trade: hand Tapcue a
51
+ * colour and you inherit everything it already knows about colours, for free.
52
+ */
53
+ export const NATIVE_SCOPE_TYPES = ["color", "application", "file", "file.folder"] as const;
54
+ export type NativeScopeType = (typeof NATIVE_SCOPE_TYPES)[number];
55
+
56
+ /** The subject shape each native type expects. The host validates against this. */
57
+ export interface NativeSubjects {
58
+ color: Color;
59
+ application: ApplicationSubject;
60
+ file: FileSubject;
61
+ "file.folder": FileSubject;
62
+ }
63
+
64
+ /** An installed application, as the `application` scope names it. */
65
+ export interface ApplicationSubject {
66
+ /** Reverse-domain bundle identifier, e.g. `com.1password.1password`. */
67
+ bundleId: string;
68
+ /** The app's localized display name. */
69
+ name: string;
70
+ }
71
+
72
+ /** A filesystem item, as the `file` / `file.folder` scopes name it. */
73
+ export interface FileSubject {
74
+ path: string;
75
+ /** Last path component, extension included. */
76
+ name: string;
77
+ /** Lowercased, no leading dot. `""` for a name that has none. */
78
+ extension: string;
79
+ isDirectory: boolean;
80
+ }
81
+
82
+ /**
83
+ * **The native scopes an extension may contribute rows *into*** (architecture §11.3).
84
+ *
85
+ * A third right, next to owning a scope (`defineScope`, `ext.<id>.*` only) and producing an item
86
+ * that *enters* one (`CommandItem.scope`): standing inside a scope somebody else owns and adding
87
+ * rows to it. A 1Password extension belongs in 1Password's app scope; a PDF tool belongs in a
88
+ * PDF's file scope. Neither owns the scope, and neither is reached through a row it produced —
89
+ * the user got there on their own.
90
+ *
91
+ * Narrower than `NATIVE_SCOPE_TYPES` on purpose. A type is contributable when two things hold:
92
+ * the host can hand over a **subject** naming what the scope is about, and the scope shows a
93
+ * **list of rows** there is something to add to. `color` has a subject and no list — it is a
94
+ * value Tapcue formats, not a place — so it is not here.
95
+ */
96
+ export const CONTRIBUTABLE_SCOPE_TYPES = ["application", "file", "file.folder"] as const;
97
+ export type ContributableScopeType = (typeof CONTRIBUTABLE_SCOPE_TYPES)[number];
98
+
99
+ /**
100
+ * The subject fields a `contributes[].when` predicate may match on, per scope type.
101
+ *
102
+ * **String fields only, matched as whole values.** `path` is deliberately absent: matching a path
103
+ * is a glob problem, and a predicate that could say "anything under /Users" would hand an
104
+ * extension every file you ever open a scope on. What a contribution may name is a *kind* of
105
+ * subject (this app, this file extension), never a location.
106
+ */
107
+ export const CONTRIBUTION_MATCH_FIELDS: Readonly<
108
+ Record<ContributableScopeType, readonly string[]>
109
+ > = Object.freeze({
110
+ application: ["bundleId", "name"],
111
+ file: ["extension", "name"],
112
+ "file.folder": ["name"],
113
+ });
114
+
115
+ export function isContributableScopeType(type: string): type is ContributableScopeType {
116
+ return (CONTRIBUTABLE_SCOPE_TYPES as readonly string[]).includes(type);
117
+ }
118
+
119
+ export type AliasKind = "keyword" | "name" | "romanized" | "abbreviation" | (string & {});
120
+
121
+ export interface SearchAlias {
122
+ value: string;
123
+ kind: AliasKind;
124
+ }
125
+
126
+ /**
127
+ * Scope semantics carried by an item: entering the item opens the scope whose
128
+ * `type` matches, with `subject` handed back to the scope's handlers.
129
+ *
130
+ * The subject is opaque to Tapcue: it is round-tripped verbatim (JSON, size
131
+ * limited) and never interpreted, ranked, or indexed.
132
+ */
133
+ export interface ItemScope<S extends JsonValue = JsonValue> {
134
+ type: ScopeType;
135
+ subject?: S;
136
+ /** Chip label for the scope breadcrumb. Defaults to the item title. */
137
+ displayName?: string;
138
+ /** Search-field placeholder inside the scope. */
139
+ placeholder?: string;
140
+ /**
141
+ * How the entered scope is laid out — layout, and a `table`'s `columns`/`live`. Stamped by
142
+ * `defineScope().entry` from the scope's own `scheme`, not set by hand: an extension declares its
143
+ * scopes in code, so the shell only learns a scope's layout from a row that enters it.
144
+ */
145
+ scheme?: SceneScheme;
146
+ /**
147
+ * Whether the shell filters the scope's rows locally (`catalog`) or forwards the query to the
148
+ * scope (`remote`). Stamped by `defineScope().entry` from the scope's own `queryMode`, not set
149
+ * by hand — declared in code, learned by the shell from the row that enters the scope.
150
+ */
151
+ queryMode?: "catalog" | "remote";
152
+ }
153
+
154
+ export interface CommandItem<S extends JsonValue = JsonValue> {
155
+ /**
156
+ * Stable for the same logical entity: used for selection stability, recents,
157
+ * and reconciliation. The host namespaces it before it enters core state.
158
+ *
159
+ * Returning an item whose id equals the command id updates that command's
160
+ * static manifest row in place instead of adding a second row.
161
+ */
162
+ id: string;
163
+ title: string;
164
+ subtitle?: string;
165
+ icon?: IconRef;
166
+ aliases?: SearchAlias[];
167
+ /**
168
+ * Grouping: rows sharing a facet id render as one section (and one chip when
169
+ * the scheme shows a facet bar). The title travels with the item because facets
170
+ * are usually dynamic — "Today", "Tomorrow" — and cannot be declared up front.
171
+ */
172
+ facet?: { id: string; title: string };
173
+ /** Omit to make Enter *enter* the item's scope rather than run an action. */
174
+ defaultActionId?: string;
175
+ /**
176
+ * What to call that action in the footer. Supply it whenever you supply `defaultActionId`.
177
+ *
178
+ * The shell draws a `⏎` keycap beside exactly one footer action, and that keycap is a promise
179
+ * about what Return does. With no title for the row's action the shell had to label the keycap
180
+ * with the *detail pane's* first action instead — so the footer read "Copy File Path ⏎" while
181
+ * Return copied the family name. Both reference extensions shipped that way before anyone
182
+ * pressed the key on a real screen.
183
+ *
184
+ * Dropped along with the action itself when `requires` was not granted.
185
+ */
186
+ defaultActionTitle?: string;
187
+ /**
188
+ * The unit `defaultActionId` needs. **Tapcue drops the action if it was not granted.**
189
+ *
190
+ * A query cannot check: `clipboard.write` is not on an `ExtensionContext`, because a
191
+ * keystroke may not change what your next paste produces. But the rule that an action a
192
+ * capability cannot perform is *not offered* still has to hold, and it should never have
193
+ * depended on an extension remembering to check. So the row declares what its action needs
194
+ * and core decides whether to show it — which is core's job, and it is now core's guarantee
195
+ * rather than the extension's good manners.
196
+ */
197
+ requires?: PermissionUnit;
198
+ /** Provider-local ordering hints only. Tapcue remains the ranking authority. */
199
+ metadata?: Record<string, string>;
200
+ /**
201
+ * A `table` scope row's cells, keyed by column id (`{ pid: "1234", cpu: "5.0%" }`). A `numeric`
202
+ * column takes `{ value, sort }` so the display text and the sort order can differ. Ignored
203
+ * outside a `table` layout; the shell reads `title`/`subtitle` for the other row layouts.
204
+ */
205
+ cells?: Record<string, TableCell>;
206
+ scope?: ItemScope<S>;
207
+ }
208
+
209
+ export interface RequestIdentity {
210
+ invocationId: string;
211
+ sessionId: string;
212
+ /** Bumped when the user types again; results from a stale generation are dropped. */
213
+ generation: number;
214
+ }
215
+
216
+ export interface QueryRequest extends RequestIdentity {
217
+ commandId: string;
218
+ /**
219
+ * Search text for the current surface. At root this is the text Tapcue could
220
+ * not attribute to the command's keyword or arguments.
221
+ */
222
+ query: string;
223
+ /**
224
+ * Named arguments parsed by Tapcue from the manifest's `arguments` declaration.
225
+ * Keyword stripping is the core's job — it owns aliases and user renames — so
226
+ * an extension never parses the raw query itself.
227
+ */
228
+ arguments: Readonly<Record<string, string>>;
229
+ limit: number;
230
+ }
231
+
232
+ export interface ScopeQueryRequest<S extends JsonValue = JsonValue> extends RequestIdentity {
233
+ scopeType: ScopeType;
234
+ /** Host-assigned scope identity (namespaced). Useful for cache keys. */
235
+ scopeId: string;
236
+ subject: S;
237
+ /** Scope-local search text. */
238
+ query: string;
239
+ limit: number;
240
+ }
241
+
242
+ export interface ScopeDetailRequest<S extends JsonValue = JsonValue> extends RequestIdentity {
243
+ scopeType: ScopeType;
244
+ scopeId: string;
245
+ subject: S;
246
+ /** Guest-facing id of the selected row. */
247
+ itemId: string;
248
+ }
249
+
250
+ /** What a `canvas`-layout scope's `surface` handler is asked for: no row, just the subject. */
251
+ export interface ScopeSurfaceRequest<S extends JsonValue = JsonValue> extends RequestIdentity {
252
+ scopeType: ScopeType;
253
+ scopeId: string;
254
+ subject: S;
255
+ }
256
+
257
+ export interface InvokeRequest<S extends JsonValue = JsonValue> extends RequestIdentity {
258
+ commandId: string;
259
+ actionId: string;
260
+ itemId?: string;
261
+ /** Present when the action was invoked on a row inside one of the extension's scopes. */
262
+ scopeType?: ScopeType;
263
+ subject?: S;
264
+ payload?: Uint8Array;
265
+ }
266
+
267
+ export interface ActionResult {
268
+ status: "completed" | "failed";
269
+ /** A hint. Tapcue decides what actually happens to the panel. */
270
+ presentation?: "dismiss" | "keep-open" | "refresh";
271
+ /** Short, user-safe confirmation ("Copied forecast"). */
272
+ message?: string;
273
+ error?: ExtensionErrorInit;
274
+ }
275
+
276
+ /**
277
+ * **What an extension can always reach.**
278
+ *
279
+ * Handed to `query`, `detail`, and a scope's `items` — everything Tapcue calls to *find out
280
+ * something*. It can read the world and it can read its own state, and that is the whole of
281
+ * it: nothing on this context changes anything outside the extension.
282
+ *
283
+ * That is not a style rule. These handlers run **on a keystroke**: Tapcue calls `query` while
284
+ * a person is typing, possibly for every installed extension. A capability that acts on the
285
+ * user's machine — covering the screen, writing the clipboard, opening a URL — must not be
286
+ * reachable from a keystroke, so it is not on this type at all.
287
+ *
288
+ * Capabilities are present only when granted, so `ctx.http?` narrowing is how an extension
289
+ * detects a denied permission before calling. `settings` and `environment` are always there:
290
+ * they are the extension's own configuration, not privileged host resources.
291
+ */
292
+ /**
293
+ * Reading this extension's own recents. Scoped to what *this* extension recorded — never
294
+ * what the user ran anywhere else. A read, so it sits on every context.
295
+ */
296
+ export interface RecentsReadCapability {
297
+ /** This extension's recents, freshest first. */
298
+ list?(): Promise<CommandItem[]>;
299
+ }
300
+
301
+ /**
302
+ * Reading, plus the effects: `record` files a produced item, `remove` drops one. Effects,
303
+ * so only an `ActionContext` has them. A recorded item is honoured as a scope-entry — the
304
+ * host stamps the extension's own identity and drops any action.
305
+ */
306
+ export interface RecentsCapability extends RecentsReadCapability {
307
+ record?(item: CommandItem): Promise<void>;
308
+ /** Drop a recent by the id it was recorded with. */
309
+ remove?(id: string): Promise<void>;
310
+ }
311
+
312
+ export interface ExtensionContext {
313
+ /** Aborts on cancellation, stale generation, revocation, and deadline. */
314
+ signal: AbortSignal;
315
+ /** Remaining wall-clock budget in milliseconds. */
316
+ deadlineMs: number;
317
+ settings: SettingsSnapshot;
318
+ environment: EnvironmentSnapshot;
319
+ /** Message-catalog lookup for the user's locale; see `i18n.ts`. */
320
+ t: Translate;
321
+ http?: HttpCapability;
322
+ storage?: StorageCapability;
323
+ cache?: CacheCapability;
324
+ secrets?: SecretsCapability;
325
+ /**
326
+ * **The situation that summoned Tapcue** — the frontmost app, the selection, the
327
+ * highlighted files. Invocation-scoped facts, and only those.
328
+ */
329
+ context?: InvocationContextCapability;
330
+ /**
331
+ * **Machine-wide queries.** Facts about the machine rather than about this invocation:
332
+ * true whether or not anyone opened the launcher, and queryable at any time. They are
333
+ * deliberately *not* folded into `context` — "what did you have selected" and "what fonts
334
+ * do you own" are different questions, ask for different consent, and revoke separately.
335
+ */
336
+ fonts?: FontsCapability;
337
+ location?: LocationCapability;
338
+ /**
339
+ * **The places the manifest declared it may read** (spec 016 §2). A read, so it is here rather
340
+ * than on `ActionContext`: an extension whose rows come from a file needs them on a keystroke.
341
+ */
342
+ files?: FilesCapability;
343
+ /**
344
+ * **The binaries the manifest declared it may run** (spec 016 §3). Also a read-tier capability,
345
+ * for the same reason — a `query` that lists a vault has to run something — with the costs paid
346
+ * in concurrency, deadline and output caps rather than by moving it to an action.
347
+ */
348
+ exec?: ExecCapability;
349
+ /**
350
+ * **The statements the manifest declared it may run** against a tool's own database. `exec`'s
351
+ * shape with rows instead of stdout, and read-tier for the same reason.
352
+ */
353
+ queries?: QueriesCapability;
354
+ /**
355
+ * **Read the running processes** — a machine fact, like `fonts`. The effect half — killing a
356
+ * process, bringing one to the front — is a `WritableProcessManager` on `ActionContext`,
357
+ * because a keystroke may ask what is running but must never act on it. `process.list`; argv
358
+ * (`ProcessInfo.command`) needs the separate `process.command` unit.
359
+ */
360
+ process?: ReadonlyProcessManager;
361
+ /** Reading only. The write is an effect, and effects are on `ActionContext`. */
362
+ clipboard?: ClipboardReadCapability;
363
+ /** Reading this extension's own recents. Recording/removing are on `ActionContext`. */
364
+ recents?: RecentsReadCapability;
365
+ }
366
+
367
+ /**
368
+ * **What an extension gets when Tapcue calls it to *do* something.**
369
+ *
370
+ * Handed to a command's `invoke` and a scope's `invoke` — and to nothing else. A person
371
+ * reached for a row and pressed Enter; that is the moment, and the only moment, at which an
372
+ * extension may act on the machine it is running inside.
373
+ *
374
+ * Everything on `ExtensionContext`, plus the three groups that leave a mark:
375
+ *
376
+ * - `screen` — cover the display (§9.4)
377
+ * - `clipboard.write` — change what the next paste produces
378
+ * - `native` — open a URL, reveal a file, post a notification
379
+ *
380
+ * The split used to be a runtime check ("interactive capabilities are actions-only"). It is a
381
+ * type now, which is strictly better: an extension that reaches for the screen from a `query`
382
+ * no longer discovers it in production — it fails to compile.
383
+ */
384
+ /**
385
+ * **Go somewhere in Tapcue** — the one navigation an action can perform.
386
+ *
387
+ * A row that enters a scope has always been able to say so: `scope.entry(...)` stamps the row,
388
+ * and pressing Return on it pushes the scope. That covers everything reachable by *picking a
389
+ * row*, and nothing else — which left an action with no way to lead anywhere. An action that
390
+ * has produced something worth a screen of its own ("why is this process running") had to
391
+ * either fold the answer into the pane it was fired from or throw it away.
392
+ *
393
+ * So this takes the same currency: **an item produced by `scope.entry(...)`**, not a scope type
394
+ * and a bag of options. There is one description of "what it means to enter a scope" and both
395
+ * paths use it — the host materializes the item through the same mapper a row goes through, so
396
+ * a scope reached from an action and the same scope reached from a row are the same scope, with
397
+ * the same subject, scheme, and placeholder.
398
+ *
399
+ * **Needs no permission, and cannot be one.** Entering a scope the extension itself declared is
400
+ * movement inside the extension's own surface — it opens nothing, reads nothing, and leaves no
401
+ * mark; the host refuses an item that enters anything outside this extension's namespace. It is
402
+ * still action-tier, for the reason every effect is: a `query` runs while a person types, and
403
+ * typing must never navigate.
404
+ *
405
+ * Fire-and-forget. The host pushes the scope after the action settles, so an action that
406
+ * navigates and then returns a failure navigates nowhere.
407
+ */
408
+ export interface NavigationCapability {
409
+ /** Enter the scope this item enters. Must be an item from `scope.entry(...)`. */
410
+ enter(item: CommandItem): void;
411
+ }
412
+
413
+ export interface ActionContext extends ExtensionContext {
414
+ /** Reading *and* writing. */
415
+ clipboard?: ClipboardCapability;
416
+ /**
417
+ * File a produced item into Tapcue's unified Recents. An effect, so `ActionContext`
418
+ * only. The item is honoured as a scope-entry: the host stamps it with the extension's
419
+ * own identity and drops any action, so a recorded recent can never forge an execution.
420
+ */
421
+ recents?: RecentsCapability;
422
+ /**
423
+ * Reading *and* the effects — `kill` (host-confirmed) and `activate`. Widens the
424
+ * `ReadonlyProcessManager` a query gets. Present only with `process.kill`, and only here:
425
+ * a keystroke must never be able to end a process.
426
+ */
427
+ process?: WritableProcessManager;
428
+ native?: NativeActionsCapability;
429
+ /** An overlay, its pointer events, and — separately granted — its pixels. */
430
+ screen?: ScreenCapability;
431
+ /**
432
+ * The message bridge to a live `WebViewScene` surface, when one this extension declared is
433
+ * up. `ui.webview`. Present only in an action, and only while the surface exists — the
434
+ * isolate that presented it may be long evicted, so the bridge reconnects to the
435
+ * shell-owned surface rather than being held across the call.
436
+ */
437
+ bridge?: BridgeCapability;
438
+ /**
439
+ * Reading *and* writing the extension's own declared settings. Widens the read-only snapshot
440
+ * every context gets; see `WritableSettings` for why the write is action-tier and why there
441
+ * is exactly one value behind it and the Preferences field.
442
+ */
443
+ settings: WritableSettings;
444
+ /** Enter a scope this extension declared. Ambient — see `NavigationCapability`. */
445
+ navigate?: NavigationCapability;
446
+ }
447
+
448
+ /**
449
+ * One batch of rows, and what it means for the rows already shown.
450
+ *
451
+ * A bare array **adds**: Tapcue upserts by item id and keeps everything shown so far, which is
452
+ * what progressive loading wants — yield the cheap rows first (cached, local, already known) and
453
+ * the expensive ones as they land.
454
+ *
455
+ * `{ replace }` **states the whole set as of now**, so ids missing from it are removed. Anything
456
+ * re-reading a world that changes needs this: upserting alone can say a row appeared or changed
457
+ * but never that it is gone, so a process that exits would keep its row forever.
458
+ *
459
+ * A generator may use both — page in what it has, then settle into refreshing.
460
+ */
461
+ export type QueryBatch<S extends JsonValue = JsonValue> =
462
+ | CommandItem<S>[]
463
+ | { replace: CommandItem<S>[] };
464
+
465
+ /**
466
+ * Everything at once, or a stream.
467
+ *
468
+ * An async iterable (an `async function*` is the natural way) may keep yielding for as long as
469
+ * the scope is shown: **the generator's own scope is the lifetime.** It is aborted when the scope
470
+ * stops being shown, at which point its `finally` runs — so a refresh loop needs no lifecycle
471
+ * hook and no flag, and `await sleep(n)` is only the most obvious trigger. A file watcher, a
472
+ * download's progress, or a socket message all work the same way, and none of them are
473
+ * expressible as a poll interval. A generator that yields once has simply finished.
474
+ *
475
+ * Batches from a superseded generation are dropped, so a stale stream can never overwrite fresh
476
+ * results.
477
+ */
478
+ export type QueryResult<S extends JsonValue = JsonValue> =
479
+ | CommandItem<S>[]
480
+ | AsyncIterable<QueryBatch<S>>;
481
+
482
+ export interface CommandHandlers<S extends JsonValue = JsonValue> {
483
+ query?(request: QueryRequest, ctx: ExtensionContext): QueryResult<S> | Promise<QueryResult<S>>;
484
+ /** The only handler that may act on the machine. See `ActionContext`. */
485
+ invoke?(request: InvokeRequest<S>, ctx: ActionContext): ActionResult | Promise<ActionResult>;
486
+ detail?(itemId: string, ctx: ExtensionContext): Scene | null | Promise<Scene | null>;
487
+ /**
488
+ * An interactive, data-bound view (docs/architecture/extension-ui-interactive.md). Builds a
489
+ * tree of `signal` bindings and declared `action`s; the shell drives the reactive loop. A
490
+ * synchronous, pure builder — the view body runs at query tier, only its actions may act.
491
+ */
492
+ view?(request: QueryRequest, ctx: ExtensionContext): ViewNode | Promise<ViewNode>;
493
+ }
494
+
495
+ export interface ScopeHandlers<S extends JsonValue = JsonValue> {
496
+ /**
497
+ * Rows of the scope. Same contract as a command's `query`. Required for every row layout
498
+ * (`list` / `grid` / `twoColumn`); a `canvas` scope has no rows and provides `surface`
499
+ * instead. The host rejects a scope that has neither, or one that has `items` under a
500
+ * `canvas` layout, or `surface` under a row layout — a handler the shell will never call
501
+ * is a handler an author will believe in.
502
+ */
503
+ items?(request: ScopeQueryRequest<S>, ctx: ExtensionContext): QueryResult | Promise<QueryResult>;
504
+ /**
505
+ * The body of a `canvas`-layout scope: a `WebViewScene` (or a `LoadingScene`/`ErrorScene`
506
+ * while it is coming up or if it cannot). Runs on entry, in the read phase — presenting a
507
+ * surface describes UI, it does not act. Talking to the surface once it is up is an action,
508
+ * over `ctx.bridge` (§12.1).
509
+ */
510
+ surface?(request: ScopeSurfaceRequest<S>, ctx: ExtensionContext): Scene | Promise<Scene>;
511
+ /** The detail pane of a `twoColumn` scope. */
512
+ detail?(request: ScopeDetailRequest<S>, ctx: ExtensionContext): Scene | null | Promise<Scene | null>;
513
+ /** Actions invoked on the scope's own rows — or on a `canvas` scope's footer. See `ActionContext`. */
514
+ invoke?(request: InvokeRequest<S>, ctx: ActionContext): ActionResult | Promise<ActionResult>;
515
+ /**
516
+ * An interactive, data-bound detail pane (docs/architecture/extension-ui-interactive.md): the
517
+ * live alternative to the static `detail`. Rooted at `<Detail>`, it builds a tree of `signal`
518
+ * bindings and declared `action`s over the selected row, and the shell drives the reactive
519
+ * loop — an `<Action>` toggling a unit or refetching — updating the pane by patch, with no full
520
+ * re-fetch. A scope provides `view` **or** the static `detail`, not both.
521
+ */
522
+ view?(request: ScopeDetailRequest<S>, ctx: ExtensionContext): ViewNode | Promise<ViewNode>;
523
+ }
524
+
525
+ /**
526
+ * **What a contribution is asked for** — the rows to add to a scope somebody else owns.
527
+ *
528
+ * The subject is Tapcue's, not the extension's: it is projected from the native scope the user
529
+ * entered and validated against `NativeSubjects` before the extension is called at all. And the
530
+ * extension only ever sees a subject its manifest's `when` already named, so entering an app it
531
+ * did not declare tells it nothing — the call is never made.
532
+ */
533
+ export interface ContributionQueryRequest<
534
+ T extends ContributableScopeType = ContributableScopeType,
535
+ > extends RequestIdentity {
536
+ /** The manifest `contributes[].id` whose handler this is. */
537
+ contributionId: string;
538
+ scopeType: T;
539
+ /** Host-assigned scope identity. Useful as a cache key. */
540
+ scopeId: string;
541
+ subject: NativeSubjects[T];
542
+ /**
543
+ * **What the scope does with the rows you return — and it is not your decision.**
544
+ *
545
+ * `queryMode` belongs to the scope, so a contributor inherits whatever its owner chose
546
+ * (architecture §11.3). `catalog`: `query` arrives empty, return everything, Tapcue filters and
547
+ * ranks. `remote`: `query` is the text the user typed and your rows are shown as returned,
548
+ * unfiltered and unranked — so if you do not narrow them, nothing will.
549
+ *
550
+ * The one rule that is right in both modes: filter by `request.query`, treating empty as
551
+ * "everything". Read this field when you want to know whether ordering is worth the work.
552
+ */
553
+ queryMode: "catalog" | "remote";
554
+ /** Scope-local search text. Empty in `catalog` mode. */
555
+ query: string;
556
+ limit: number;
557
+ }
558
+
559
+ export interface ContributionInvokeRequest<
560
+ T extends ContributableScopeType = ContributableScopeType,
561
+ > extends RequestIdentity {
562
+ contributionId: string;
563
+ scopeType: T;
564
+ scopeId: string;
565
+ subject: NativeSubjects[T];
566
+ actionId: string;
567
+ itemId?: string;
568
+ }
569
+
570
+ /**
571
+ * **Handlers for one manifest `contributes[]` entry**, keyed in `defineExtension` by its id.
572
+ *
573
+ * The same shape a command has, and for the same reason: a contribution is *declared in the
574
+ * manifest and implemented in code*. It has to be declared, because the scope is entered by the
575
+ * user rather than reached through a row the extension produced — so Tapcue must know who to ask
576
+ * without executing anything, exactly as it knows a command's row without running its `query`.
577
+ * A scope the extension *owns* is the opposite case and stays in code (`defineScope`).
578
+ */
579
+ export interface ContributionHandlers<
580
+ T extends ContributableScopeType = ContributableScopeType,
581
+ > {
582
+ /**
583
+ * The rows to add. Required — a contribution with no rows contributes nothing.
584
+ *
585
+ * **There is no `detail` here, on purpose.** A scope's detail pane belongs to whoever owns the
586
+ * scope (`OwnedScope.detail` is one closure, one owner), so no contributor — native or
587
+ * extension — can supply a pane for its own rows; the native `Terminal Here` rows in a folder's
588
+ * scope have the same shape. Give a row a `scope` of its own instead and put the detail there:
589
+ * a screen the extension owns outright beats a pane it would be borrowing. See architecture §23.
590
+ */
591
+ items(
592
+ request: ContributionQueryRequest<T>,
593
+ ctx: ExtensionContext,
594
+ ): QueryResult | Promise<QueryResult>;
595
+ /** Actions on the contributed rows. See `ActionContext`. */
596
+ invoke?(
597
+ request: ContributionInvokeRequest<T>,
598
+ ctx: ActionContext,
599
+ ): ActionResult | Promise<ActionResult>;
600
+ }
601
+
602
+ export interface ScopeSpec<S extends JsonValue = JsonValue> extends ScopeHandlers<S> {
603
+ type: ScopeType;
604
+ scheme: SceneScheme;
605
+ /**
606
+ * `catalog` (the default): return every row and let Tapcue filter and rank them
607
+ * with the same fuzzy engine it uses everywhere — ignore `request.query`.
608
+ * `remote`: the backing service needs the query, so Tapcue debounces, passes it
609
+ * in, and cancels the previous generation.
610
+ */
611
+ queryMode?: "catalog" | "remote";
612
+ }
613
+
614
+ export type { Scene, SceneScheme };