@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/LICENSE +21 -0
- package/README.md +28 -0
- package/package.json +45 -0
- package/src/capabilities.ts +1037 -0
- package/src/components.ts +277 -0
- package/src/define-extension.ts +205 -0
- package/src/errors.ts +90 -0
- package/src/i18n.ts +64 -0
- package/src/index.ts +13 -0
- package/src/json.ts +24 -0
- package/src/jsx-runtime.ts +118 -0
- package/src/manifest.ts +1572 -0
- package/src/overlay-jsx-runtime.ts +72 -0
- package/src/overlay.ts +130 -0
- package/src/permission-units.ts +109 -0
- package/src/reactive.ts +374 -0
- package/src/scene.ts +297 -0
- package/src/testing/http-fake.ts +240 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/test-host.ts +2330 -0
- package/src/types.ts +614 -0
- package/src/view-runtime.ts +270 -0
- package/src/view.ts +116 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed component vocabulary, as JSX components (docs/architecture/extension-ui-interactive.md §5).
|
|
3
|
+
*
|
|
4
|
+
* Each component is an inert tag: the JSX factory reads its `__tag` and builds a `ViewNode`;
|
|
5
|
+
* the function body is never run (except that `<For>`'s child render function and `<Show>`'s
|
|
6
|
+
* structure are read by the factory). You cannot emit a lowercase intrinsic (`<div>`) — the
|
|
7
|
+
* `JSX.IntrinsicElements` set is empty on purpose. The shell renders these natively; an
|
|
8
|
+
* unknown tag is dropped, never guessed at.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { JsonValue } from "./json.js";
|
|
12
|
+
import type { PermissionUnit } from "./manifest.js";
|
|
13
|
+
import type { ActionSpec, Bindable, Signal } from "./reactive.js";
|
|
14
|
+
import type { Chart, IconRef } from "./scene.js";
|
|
15
|
+
import type { CommandItem } from "./types.js";
|
|
16
|
+
import type { ViewNode } from "./view.js";
|
|
17
|
+
|
|
18
|
+
type Node = ViewNode;
|
|
19
|
+
type Children = Node | Node[] | boolean | null | undefined;
|
|
20
|
+
type Str = Bindable<string>;
|
|
21
|
+
|
|
22
|
+
interface Tag<P> {
|
|
23
|
+
(props: P): ViewNode;
|
|
24
|
+
readonly __tag: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function tag<P>(type: string): Tag<P> {
|
|
28
|
+
const component = ((_props: P) => undefined as unknown as ViewNode) as Tag<P> & { __tag: string };
|
|
29
|
+
component.__tag = type;
|
|
30
|
+
return component as Tag<P>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Surfaces ──────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
// The interactive row-scope roots — `<List>` / `<Grid>` / `<TwoColumn>` and their `.Item` /
|
|
36
|
+
// `.Section` — are planned but **not shipped**: the shell renders a `<Detail>` pane, not an
|
|
37
|
+
// interactive row list (extension-ui-interactive.md §5). They return with the shell rendering that
|
|
38
|
+
// draws them and the first extension that needs them, per the grow-on-evidence discipline — a tag
|
|
39
|
+
// the shell would silently drop is the "believe in a handler the shell never calls" trap.
|
|
40
|
+
|
|
41
|
+
export interface DetailProps {
|
|
42
|
+
title?: Str;
|
|
43
|
+
subtitle?: Str;
|
|
44
|
+
icon?: Bindable<IconRef>;
|
|
45
|
+
children?: Children;
|
|
46
|
+
}
|
|
47
|
+
export const Detail = tag<DetailProps>("detail");
|
|
48
|
+
|
|
49
|
+
// ── Content blocks ────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export interface MarkdownProps {
|
|
52
|
+
text: Str;
|
|
53
|
+
}
|
|
54
|
+
export const Markdown = tag<MarkdownProps>("markdown");
|
|
55
|
+
|
|
56
|
+
export interface ChartProps {
|
|
57
|
+
chart: Chart;
|
|
58
|
+
}
|
|
59
|
+
export const ChartView = tag<ChartProps>("chart");
|
|
60
|
+
|
|
61
|
+
export interface MetadataRowProps {
|
|
62
|
+
label: Str;
|
|
63
|
+
value: Str;
|
|
64
|
+
icon?: Bindable<IconRef>;
|
|
65
|
+
}
|
|
66
|
+
export interface MetadataSectionProps {
|
|
67
|
+
title?: Str;
|
|
68
|
+
children?: Children;
|
|
69
|
+
}
|
|
70
|
+
export const Metadata = Object.assign(tag<{ children?: Children }>("metadata"), {
|
|
71
|
+
Section: tag<MetadataSectionProps>("metadata.section"),
|
|
72
|
+
Row: tag<MetadataRowProps>("metadata.row"),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── Inputs ────────────────────────────────────────────────────────────────────
|
|
76
|
+
//
|
|
77
|
+
// These are the first two input widgets, and they ship on evidence: the `fonts` extension needs
|
|
78
|
+
// exactly them — a text field to type a specimen into, and a picker to choose the family. The
|
|
79
|
+
// wider form vocabulary (`TextField`, `Checkbox`, `DatePicker`, `TagPicker`) still waits for an
|
|
80
|
+
// extension that needs it.
|
|
81
|
+
//
|
|
82
|
+
// **Two-way binding costs no IPC.** Bind a `signal` to `value` and the *shell* writes the user's
|
|
83
|
+
// edits straight back into its own copy of the model — the isolate is not woken per keystroke, and
|
|
84
|
+
// is very likely evicted while the user types. That is what makes a live preview affordable: the
|
|
85
|
+
// text and the chosen family are both model cells the shell already holds, so re-rendering as you
|
|
86
|
+
// type is a local operation. An `onChange` action exists for when the extension genuinely needs to
|
|
87
|
+
// *react* (persist a choice, refetch); binding alone needs no action at all.
|
|
88
|
+
|
|
89
|
+
export interface TextAreaProps {
|
|
90
|
+
/** Bind a `signal` for two-way editing; a plain string renders read-only text. */
|
|
91
|
+
value: Bindable<string>;
|
|
92
|
+
placeholder?: Str;
|
|
93
|
+
/** Visible lines. The shell owns the real metrics, this is a hint. */
|
|
94
|
+
rows?: number;
|
|
95
|
+
/**
|
|
96
|
+
* **Render the text in this font family** — the one place the contract lets an extension
|
|
97
|
+
* influence typography, and a deliberate, narrow exception to "the shell owns type"
|
|
98
|
+
* (extension-ui §1).
|
|
99
|
+
*
|
|
100
|
+
* It is content, not styling, in the same sense `color:` is: the family *is* the thing being
|
|
101
|
+
* shown, known only at runtime, and no packaged asset could carry it. The rule that keeps it on
|
|
102
|
+
* the content side of that line: **the family must be one the host reported from
|
|
103
|
+
* `ctx.fonts.list()`.** A family the host never listed is not a styling escape hatch, it is a
|
|
104
|
+
* value the shell refuses. Size, colour, spacing, and accessibility scaling stay the shell's.
|
|
105
|
+
*/
|
|
106
|
+
font?: Bindable<string>;
|
|
107
|
+
fontSize?: Bindable<number>;
|
|
108
|
+
/** 100–900, matching `FontFace.weight`. */
|
|
109
|
+
fontWeight?: Bindable<number>;
|
|
110
|
+
italic?: Bindable<boolean>;
|
|
111
|
+
/**
|
|
112
|
+
* Fired once an edit settles — not per keystroke. The shell coalesces a burst of typing and
|
|
113
|
+
* sends the value it ended on; the bound cell was already written locally, long before this
|
|
114
|
+
* runs, so the preview never waits on it. Omit it for a pure live preview.
|
|
115
|
+
*/
|
|
116
|
+
onChange?: ActionSpec;
|
|
117
|
+
}
|
|
118
|
+
export const TextArea = tag<TextAreaProps>("textarea");
|
|
119
|
+
|
|
120
|
+
export interface SelectOptionProps {
|
|
121
|
+
/** The value written back into the bound cell when this option is chosen. */
|
|
122
|
+
value: Str;
|
|
123
|
+
/** Defaults to `value`. */
|
|
124
|
+
title?: Str;
|
|
125
|
+
icon?: Bindable<IconRef>;
|
|
126
|
+
}
|
|
127
|
+
export interface SelectProps {
|
|
128
|
+
/** Bind a `signal`; the shell writes the chosen option's `value` into it. */
|
|
129
|
+
value: Bindable<string>;
|
|
130
|
+
placeholder?: Str;
|
|
131
|
+
/** Fired after the choice settles. The bound cell is written locally either way. */
|
|
132
|
+
onChange?: ActionSpec;
|
|
133
|
+
children?: Children;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* A single-choice picker. Pair it with `<For>` to build the options from a list the extension
|
|
137
|
+
* fetched — a font picker is `<For each={families}>` emitting one `<Select.Option>` each, and the
|
|
138
|
+
* shell expands it without waking the isolate.
|
|
139
|
+
*/
|
|
140
|
+
export const Select = Object.assign(tag<SelectProps>("select"), {
|
|
141
|
+
Option: tag<SelectOptionProps>("select.option"),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── Actions ───────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export interface ActionProps {
|
|
147
|
+
title: Str;
|
|
148
|
+
onAction: ActionSpec;
|
|
149
|
+
icon?: Bindable<IconRef>;
|
|
150
|
+
/** Overrides the unit inferred from the action; usually omit. */
|
|
151
|
+
requires?: PermissionUnit;
|
|
152
|
+
destructive?: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* **Which menu this action belongs to.** `row` (the default) is ⌘K — the actions menu, which
|
|
155
|
+
* speaks for whatever is selected. `scope` is ⇧⌘K, the scope handle's menu, which speaks for
|
|
156
|
+
* the surface you are *in*.
|
|
157
|
+
*
|
|
158
|
+
* The split is the one the two keystrokes already promise, and it is about the action's
|
|
159
|
+
* subject, not its importance: "Quit Process" acts on the row under the cursor and belongs in
|
|
160
|
+
* ⌘K; "Refresh Every 5 Seconds" acts on the whole table and belongs in ⇧⌘K, next to "Exit
|
|
161
|
+
* Processes". Putting a surface-wide setting in the row menu makes it read as something that
|
|
162
|
+
* happens to the selected row.
|
|
163
|
+
*
|
|
164
|
+
* Only a rowless surface (`pane`) has both menus, so this is where it applies. Elsewhere it is
|
|
165
|
+
* ignored rather than honoured into a menu the shell does not draw.
|
|
166
|
+
*/
|
|
167
|
+
placement?: "row" | "scope";
|
|
168
|
+
}
|
|
169
|
+
export const Action = tag<ActionProps>("action");
|
|
170
|
+
|
|
171
|
+
// ── Non-content states ────────────────────────────────────────────────────────
|
|
172
|
+
// `<Empty>` / `<Error>` are shipped (the shell renders them as pane states). `<Loading>` is planned
|
|
173
|
+
// — the shell has no loading-root render yet, so it waits, like the other unshipped roots above.
|
|
174
|
+
|
|
175
|
+
export interface EmptyProps {
|
|
176
|
+
title: Str;
|
|
177
|
+
description?: Str;
|
|
178
|
+
icon?: Bindable<IconRef>;
|
|
179
|
+
}
|
|
180
|
+
export const Empty = tag<EmptyProps>("empty");
|
|
181
|
+
export interface ErrorProps {
|
|
182
|
+
title: Str;
|
|
183
|
+
description?: Str;
|
|
184
|
+
children?: Children;
|
|
185
|
+
}
|
|
186
|
+
export const ErrorView = tag<ErrorProps>("error");
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A multi-column table of rows the view describes, rather than rows the scope hands back.
|
|
190
|
+
*
|
|
191
|
+
* The scope-level `table` layout (extension-ui §5) is the other way to draw one, and the two are
|
|
192
|
+
* for different things. That one's rows are `CommandItem`s: Tapcue fuzzy-matches them, ranks them,
|
|
193
|
+
* drops actions whose permission was denied, and drives keyboard navigation through them. This one
|
|
194
|
+
* is a view node — the shell draws exactly what is described and searches none of it — so it is for
|
|
195
|
+
* a table that is *part of a pane*, beside other blocks, whose contents the extension owns.
|
|
196
|
+
*
|
|
197
|
+
* `rows` binds a signal, so a `whileVisible` setup writing to it re-renders the table in the shell
|
|
198
|
+
* with no isolate involved. Each row is an object keyed by column id.
|
|
199
|
+
*/
|
|
200
|
+
export interface TableColumnSpec {
|
|
201
|
+
id: string;
|
|
202
|
+
title: Str;
|
|
203
|
+
/** Character-ish width hint; the shell resolves it against the pane. */
|
|
204
|
+
width?: number;
|
|
205
|
+
align?: "leading" | "trailing";
|
|
206
|
+
/** Right-align and sort by value rather than by text, so `9 MB` sorts below `10 MB`. */
|
|
207
|
+
numeric?: boolean;
|
|
208
|
+
/**
|
|
209
|
+
* Draw each row's `icon` before this column's text — for the leading name column. The icon is
|
|
210
|
+
* the row's (`CommandItem.icon`, e.g. `process:<pid>`), resolved and drawn by the shell; the
|
|
211
|
+
* same key the scope-level `table` scheme's columns use.
|
|
212
|
+
*/
|
|
213
|
+
showsIcon?: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface TableProps {
|
|
217
|
+
columns: TableColumnSpec[];
|
|
218
|
+
/**
|
|
219
|
+
* The rows, as the same `CommandItem`s a scope's `items` returns.
|
|
220
|
+
*
|
|
221
|
+
* A row is a row on both paths: `id` gives it identity, `cells` fill the columns, `aliases` add
|
|
222
|
+
* search keys no column shows, and `defaultActionId`/`defaultActionTitle`/`requires` give it an
|
|
223
|
+
* action the shell runs on Return, labels in the footer, and drops when the permission was not
|
|
224
|
+
* granted. None of that is re-invented here — it is the vocabulary rows already had.
|
|
225
|
+
*
|
|
226
|
+
* `undefined` and `[]` are different claims. A signal whose value is still `undefined` is a
|
|
227
|
+
* table whose first data has not arrived — the shell draws it as loading. An empty array is a
|
|
228
|
+
* table with genuinely nothing in it — the shell shows `empty`. A live table (a `whileVisible`
|
|
229
|
+
* setup filling the signal) should start from `undefined`, or its first frame asserts an
|
|
230
|
+
* emptiness nobody has measured yet.
|
|
231
|
+
*/
|
|
232
|
+
rows: Signal<CommandItem[] | undefined> | Signal<CommandItem[]> | CommandItem[];
|
|
233
|
+
/** Column id to sort by when the table first appears; omit to keep the order given. */
|
|
234
|
+
defaultSortColumn?: string;
|
|
235
|
+
/** Sort descending on first appearance. */
|
|
236
|
+
defaultSortDescending?: boolean;
|
|
237
|
+
/** Shown in place of the table when `rows` is empty — genuinely empty, not merely unloaded. */
|
|
238
|
+
empty?: Str;
|
|
239
|
+
/**
|
|
240
|
+
* Filter the rows against what the person typed, matching across every cell.
|
|
241
|
+
*
|
|
242
|
+
* The filtering is the shell's and happens on the model it already holds, so typing costs no
|
|
243
|
+
* round trip and the extension is not woken to answer a keystroke. It is opt-in because a table
|
|
244
|
+
* is not always a list to search — a summary of four figures is not improved by hiding three of
|
|
245
|
+
* them — and because a scope that also declares rows already has search over those.
|
|
246
|
+
*
|
|
247
|
+
* In a `pane`, this is also what puts the query field in the band above it: a pane is otherwise
|
|
248
|
+
* drawn with a titlebar, having nothing to filter. So the one word asks for the filtering and
|
|
249
|
+
* for the thing to type into, and the scope's `placeholder` describes it.
|
|
250
|
+
*/
|
|
251
|
+
search?: boolean;
|
|
252
|
+
}
|
|
253
|
+
export const Table = tag<TableProps>("table");
|
|
254
|
+
|
|
255
|
+
// ── Control flow (compiles to bindings; resolved by the shell, no isolate) ─────
|
|
256
|
+
|
|
257
|
+
export interface ForProps<T> {
|
|
258
|
+
each: Signal<T[]> | T[];
|
|
259
|
+
children: (item: T, index: number) => ViewNode;
|
|
260
|
+
}
|
|
261
|
+
export function For<T>(_props: ForProps<T>): ViewNode {
|
|
262
|
+
return undefined as unknown as ViewNode;
|
|
263
|
+
}
|
|
264
|
+
(For as unknown as { __tag: string }).__tag = "for";
|
|
265
|
+
|
|
266
|
+
export interface ShowProps {
|
|
267
|
+
when: Bindable<boolean>;
|
|
268
|
+
fallback?: ViewNode;
|
|
269
|
+
children?: Children;
|
|
270
|
+
}
|
|
271
|
+
export function Show(_props: ShowProps): ViewNode {
|
|
272
|
+
return undefined as unknown as ViewNode;
|
|
273
|
+
}
|
|
274
|
+
(Show as unknown as { __tag: string }).__tag = "show";
|
|
275
|
+
|
|
276
|
+
/** Grouping with no wrapper node; the factory splices its children into the parent. */
|
|
277
|
+
export const Fragment = tag<{ children?: Children }>("fragment");
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineExtension` — the entry point an extension's `main.js` default-exports.
|
|
3
|
+
*
|
|
4
|
+
* Top-level code must do nothing but define the extension: the host evaluates
|
|
5
|
+
* the module under a load deadline and expects no I/O, no side effects, and no
|
|
6
|
+
* capability use before the first invocation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ExtensionError } from "./errors.js";
|
|
10
|
+
import type { JsonValue } from "./json.js";
|
|
11
|
+
import type {
|
|
12
|
+
CommandHandlers,
|
|
13
|
+
CommandItem,
|
|
14
|
+
ContributionHandlers,
|
|
15
|
+
ExtensionContext,
|
|
16
|
+
ItemScope,
|
|
17
|
+
ScopeSpec,
|
|
18
|
+
} from "./types.js";
|
|
19
|
+
import { applySettingChange, settingSignal } from "./reactive.js";
|
|
20
|
+
import { applyAction, driveView, renderView } from "./view-runtime.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Narrow a capability the manifest declares as **required**.
|
|
24
|
+
*
|
|
25
|
+
* A required capability cannot be missing at runtime — denying it means the
|
|
26
|
+
* extension was never enabled — but the SDK's types cannot read your manifest, so
|
|
27
|
+
* they hand you `ctx.http | undefined` regardless. This asserts the manifest's
|
|
28
|
+
* promise. If it ever throws, the host is broken, not your extension.
|
|
29
|
+
*
|
|
30
|
+
* Do not use it on an *optional* capability: there, absence is a state you must
|
|
31
|
+
* handle, and `if (!ctx.clipboard?.write)` is the whole check.
|
|
32
|
+
*/
|
|
33
|
+
export function requireCapability<T>(capability: T | undefined, name: string): T {
|
|
34
|
+
if (capability === undefined) {
|
|
35
|
+
throw new ExtensionError({
|
|
36
|
+
code: "permission-denied",
|
|
37
|
+
message: `${name} is declared required but was not granted`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return capability;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A registered scope plus the type-safe way to build items that enter it.
|
|
45
|
+
*
|
|
46
|
+
* The `type` string and the subject type `S` are linked here, so an item built
|
|
47
|
+
* with `scope.entry(...)` can never carry a subject the scope's handlers cannot
|
|
48
|
+
* read — which a bare `scope: { type, subject }` object literal could.
|
|
49
|
+
*/
|
|
50
|
+
export interface DefinedScope<S extends JsonValue> {
|
|
51
|
+
readonly spec: ScopeSpec<S>;
|
|
52
|
+
readonly type: string;
|
|
53
|
+
/** Build a scope-face item: no `defaultActionId`, so Enter enters the scope. */
|
|
54
|
+
entry(
|
|
55
|
+
item: Omit<CommandItem<S>, "scope" | "defaultActionId">,
|
|
56
|
+
subject: S,
|
|
57
|
+
options?: Omit<ItemScope<S>, "type" | "subject">,
|
|
58
|
+
): CommandItem<S>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function defineScope<S extends JsonValue>(spec: ScopeSpec<S>): DefinedScope<S> {
|
|
62
|
+
return {
|
|
63
|
+
spec,
|
|
64
|
+
type: spec.type,
|
|
65
|
+
entry(item, subject, options) {
|
|
66
|
+
return {
|
|
67
|
+
...item,
|
|
68
|
+
// The scheme travels with the row: an extension declares its scopes in code, so the
|
|
69
|
+
// shell only learns a scope's layout from an item that enters it. A `canvas` scope must
|
|
70
|
+
// reach the shell as `canvas`, or it is rendered as the wrong thing; a `table` scope must
|
|
71
|
+
// carry its `columns` and `live` flag the same way. `queryMode` travels for the same reason
|
|
72
|
+
// — the shell only knows whether to filter the scope's rows locally (`catalog`, the default)
|
|
73
|
+
// or forward the query to the scope (`remote`) from the row that enters it, since the scope
|
|
74
|
+
// is declared in code, not the manifest.
|
|
75
|
+
scope: {
|
|
76
|
+
type: spec.type,
|
|
77
|
+
subject,
|
|
78
|
+
scheme: spec.scheme,
|
|
79
|
+
queryMode: spec.queryMode ?? "catalog",
|
|
80
|
+
...options,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ExtensionSpec {
|
|
88
|
+
/**
|
|
89
|
+
* `CommandHandlers<any>`, not a union of `CommandHandlers<never> | CommandHandlers<JsonValue>`:
|
|
90
|
+
* TypeScript gives no contextual types to a parameter whose target is a union, so a
|
|
91
|
+
* union here would silently turn every inline `query(request, ctx)` into
|
|
92
|
+
* `query(request: any, ctx: any)` and stop checking the return type — under
|
|
93
|
+
* `strict`, it does not even compile. Commands are keyed by id and each carries its
|
|
94
|
+
* own subject type, so there is nothing for one shared type parameter to say.
|
|
95
|
+
*/
|
|
96
|
+
commands: Record<string, CommandHandlers<any>>;
|
|
97
|
+
/** Scopes this extension's items can enter. Declared in code, not in the manifest. */
|
|
98
|
+
scopes?: DefinedScope<any>[];
|
|
99
|
+
/**
|
|
100
|
+
* **Rows this extension adds to scopes it does not own**, keyed by the `contributes[].id` the
|
|
101
|
+
* manifest declared (architecture §11.3).
|
|
102
|
+
*
|
|
103
|
+
* Keyed like `commands` rather than listed like `scopes`, because that is what a contribution
|
|
104
|
+
* is: declared in the manifest — the user enters the scope, so Tapcue has to know who to ask
|
|
105
|
+
* without running anything — and implemented here. **Both halves are required and the host
|
|
106
|
+
* checks them against each other at load:** a manifest entry with no handler promises rows
|
|
107
|
+
* nobody wrote, and a handler with no manifest entry is code that will never be called. Either
|
|
108
|
+
* way somebody wrote half a contribution, and half of one failing loudly beats it failing in
|
|
109
|
+
* silence.
|
|
110
|
+
*
|
|
111
|
+
* `ContributionHandlers<any>` for the reason `commands` is `CommandHandlers<any>`: a union
|
|
112
|
+
* target gives inline handler parameters no contextual type, so each entry carries its own
|
|
113
|
+
* scope type instead of one shared parameter.
|
|
114
|
+
*/
|
|
115
|
+
contributions?: Record<string, ContributionHandlers<any>>;
|
|
116
|
+
/**
|
|
117
|
+
* **Slots this extension fills**, keyed by the `provides[].id` the manifest declared.
|
|
118
|
+
*
|
|
119
|
+
* The mirror image of `contributions`: there, Tapcue owns a place and the extension adds rows to
|
|
120
|
+
* it; here, Tapcue owns a *question* and the extension adds an answer to it. Both halves are
|
|
121
|
+
* required and checked against each other at load, for the same reason.
|
|
122
|
+
*
|
|
123
|
+
* A slot handler is a **query, never an effect** — it is handed an `ExtensionContext`, not an
|
|
124
|
+
* `ActionContext`, because nobody pressed anything. Tapcue asks when the launcher opens and
|
|
125
|
+
* caches the answer; a slow one is late, not felt.
|
|
126
|
+
*/
|
|
127
|
+
provisions?: Record<string, ProvisionHandlers>;
|
|
128
|
+
/** Called before the isolate is dropped. Best-effort: a forced termination skips it. */
|
|
129
|
+
dispose?(reason: string): void | Promise<void>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What Tapcue asks when it needs a slot filled. */
|
|
133
|
+
export interface SlotRequest {
|
|
134
|
+
provisionId: string;
|
|
135
|
+
slot: string;
|
|
136
|
+
/** The most answers Tapcue will keep. Returning more is not an error; the tail is dropped. */
|
|
137
|
+
limit: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* One project folder the user works in.
|
|
142
|
+
*
|
|
143
|
+
* `path` is absolute, and the host still checks it: it must exist, be a directory, and sit under
|
|
144
|
+
* the user's home. An extension that names a folder is making a claim about where the user's files
|
|
145
|
+
* are, not being handed the right to read them — nothing here opens anything, and every row Tapcue
|
|
146
|
+
* builds from one shows the full path it landed on.
|
|
147
|
+
*/
|
|
148
|
+
export interface WorkspaceRoot {
|
|
149
|
+
path: string;
|
|
150
|
+
/** What to call it, when the tool knows a better name than the folder's own. */
|
|
151
|
+
name?: string;
|
|
152
|
+
/** Seconds since the epoch, for ordering. Freshest first is the useful order. */
|
|
153
|
+
usedAt?: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The handler shape per slot. A union of one today; adding a slot adds a member, and the manifest's
|
|
158
|
+
* `slot` field is what selects between them.
|
|
159
|
+
*/
|
|
160
|
+
export interface ProvisionHandlers {
|
|
161
|
+
roots?(request: SlotRequest, ctx: ExtensionContext): Promise<WorkspaceRoot[]> | WorkspaceRoot[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface ExtensionDefinition {
|
|
165
|
+
commands: Record<string, CommandHandlers<any>>;
|
|
166
|
+
scopes: DefinedScope<any>[];
|
|
167
|
+
/**
|
|
168
|
+
* Optional on the *definition*, required in nothing: a bundle built against an SDK that
|
|
169
|
+
* predates §11.3 has no contributions at all, and the glue that reads this must load it
|
|
170
|
+
* anyway — the manifest's `api` range is a compatibility promise, so an absent key here has
|
|
171
|
+
* to mean "declares none" rather than "malformed".
|
|
172
|
+
*/
|
|
173
|
+
contributions?: Record<string, ContributionHandlers<any>>;
|
|
174
|
+
/** Absent means "declares none", for the reason `contributions` is optional here. */
|
|
175
|
+
provisions?: Record<string, ProvisionHandlers>;
|
|
176
|
+
dispose?(reason: string): void | Promise<void>;
|
|
177
|
+
/**
|
|
178
|
+
* The interactive-view reactive loop (view-runtime.ts), attached here so the host glue
|
|
179
|
+
* (bootstrap.js) can drive a `view` handler without importing the SDK: the SDK is bundled
|
|
180
|
+
* into the extension, so this is how the two meet. Not part of the authored surface.
|
|
181
|
+
*/
|
|
182
|
+
runtime: {
|
|
183
|
+
renderView: typeof renderView;
|
|
184
|
+
applyAction: typeof applyAction;
|
|
185
|
+
driveView: typeof driveView;
|
|
186
|
+
/**
|
|
187
|
+
* The settings-as-state pair (reactive.ts). The host glue builds `ctx.settings`, so it is the
|
|
188
|
+
* glue that has to create a settings-following cell and to announce a write — and the glue
|
|
189
|
+
* cannot import the SDK. Same reason `driveView` is here.
|
|
190
|
+
*/
|
|
191
|
+
settingSignal: typeof settingSignal;
|
|
192
|
+
applySettingChange: typeof applySettingChange;
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function defineExtension(spec: ExtensionSpec): ExtensionDefinition {
|
|
197
|
+
return {
|
|
198
|
+
commands: spec.commands as Record<string, CommandHandlers<any>>,
|
|
199
|
+
scopes: (spec.scopes ?? []) as DefinedScope<any>[],
|
|
200
|
+
contributions: (spec.contributions ?? {}) as Record<string, ContributionHandlers<any>>,
|
|
201
|
+
provisions: spec.provisions ?? {},
|
|
202
|
+
dispose: spec.dispose,
|
|
203
|
+
runtime: { renderView, applyAction, driveView, settingSignal, applySettingChange },
|
|
204
|
+
};
|
|
205
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured, user-safe errors. Never put secrets, authorization headers, host
|
|
3
|
+
* paths, or raw service responses in `message` — the shell shows it to the user
|
|
4
|
+
* and the host records it in diagnostics.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Closed vocabulary for the codes the host itself raises. Extensions may add
|
|
9
|
+
* their own namespaced codes (`weather.no-such-place`), so the type stays open.
|
|
10
|
+
*/
|
|
11
|
+
export type ErrorCode =
|
|
12
|
+
| "permission-denied"
|
|
13
|
+
| "cancelled"
|
|
14
|
+
| "deadline-exceeded"
|
|
15
|
+
| "quota-exceeded"
|
|
16
|
+
| "invalid-request"
|
|
17
|
+
| "invalid-result"
|
|
18
|
+
| "network"
|
|
19
|
+
| "unavailable"
|
|
20
|
+
| "not-found"
|
|
21
|
+
| (string & {});
|
|
22
|
+
|
|
23
|
+
export interface ExtensionErrorInit {
|
|
24
|
+
code: ErrorCode;
|
|
25
|
+
message: string;
|
|
26
|
+
/** Whether retrying the same invocation could plausibly succeed. */
|
|
27
|
+
retryable?: boolean;
|
|
28
|
+
/** Optional recovery the shell may offer. */
|
|
29
|
+
recovery?: ExtensionErrorRecovery;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A recovery is always **a command of this same package, run again** — either the
|
|
34
|
+
* same command with different arguments, or a sibling command. An extension
|
|
35
|
+
* cannot name a host action: it must not be able to put Tapcue's own permission
|
|
36
|
+
* or settings UI behind a title it wrote.
|
|
37
|
+
*
|
|
38
|
+
* Permission recovery therefore has no place here by design. A required
|
|
39
|
+
* permission cannot be denied at runtime (the extension would not be enabled),
|
|
40
|
+
* and a denied *optional* permission is something the extension degrades around
|
|
41
|
+
* — turning it back on is the shell's and settings' business, not an error's.
|
|
42
|
+
*/
|
|
43
|
+
export interface ExtensionErrorRecovery {
|
|
44
|
+
title: string;
|
|
45
|
+
/** Must be a command declared in this extension's manifest. */
|
|
46
|
+
commandId: string;
|
|
47
|
+
arguments?: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ExtensionError extends Error {
|
|
51
|
+
readonly code: ErrorCode;
|
|
52
|
+
readonly retryable: boolean;
|
|
53
|
+
readonly recovery?: ExtensionErrorRecovery;
|
|
54
|
+
|
|
55
|
+
constructor(init: ExtensionErrorInit) {
|
|
56
|
+
super(init.message);
|
|
57
|
+
this.name = "ExtensionError";
|
|
58
|
+
this.code = init.code;
|
|
59
|
+
this.retryable = init.retryable ?? false;
|
|
60
|
+
this.recovery = init.recovery;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isExtensionError(value: unknown): value is ExtensionError {
|
|
65
|
+
return value instanceof ExtensionError;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The host raises this (and rejects pending capability calls) on cancellation. */
|
|
69
|
+
export function cancelledError(reason = "Invocation was cancelled"): ExtensionError {
|
|
70
|
+
return new ExtensionError({ code: "cancelled", message: reason, retryable: false });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* **The code behind a rejection, without reading the prose.**
|
|
75
|
+
*
|
|
76
|
+
* The host tags what it refuses — `deadline-exceeded: exec "op-item-list" ran past its deadline` —
|
|
77
|
+
* and an extension that has to tell those apart by matching English will get it wrong. The wrong
|
|
78
|
+
* answer is rarely harmless: a *timeout* read as a missing binary is how somebody with the tool
|
|
79
|
+
* installed gets told to install it, while the dialog they were meant to answer is still on screen.
|
|
80
|
+
*
|
|
81
|
+
* Returns `undefined` for an error that carries no code, which is not the same as a code you do
|
|
82
|
+
* not recognise: an unknown code should degrade to the generic path, never to the wrong one.
|
|
83
|
+
*/
|
|
84
|
+
export function errorCode(error: unknown): ErrorCode | undefined {
|
|
85
|
+
if (error instanceof ExtensionError) return error.code;
|
|
86
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
87
|
+
// `<code>: <message>`, the shape every coded host refusal has.
|
|
88
|
+
const match = /^([a-z][a-z-]*[a-z]):\s/.exec(message);
|
|
89
|
+
return match?.[1];
|
|
90
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message catalogs.
|
|
3
|
+
*
|
|
4
|
+
* Tapcue's own UI is localized; extension rows sitting next to it in English are
|
|
5
|
+
* not the extension author's fault, they are a missing platform mechanism. So the
|
|
6
|
+
* package ships catalogs as data:
|
|
7
|
+
*
|
|
8
|
+
* locales/en.json { "command.title": "Weather", … }
|
|
9
|
+
* locales/zh-CN.json { "command.title": "天气", … }
|
|
10
|
+
*
|
|
11
|
+
* Manifest strings reference a key with a leading `@` (`"title": "@command.title"`);
|
|
12
|
+
* a string without one is a literal. Runtime strings go through `ctx.t("key")`.
|
|
13
|
+
* Catalogs are data files covered by `checksums.json`, so they are reviewable and
|
|
14
|
+
* translatable without touching the bundle.
|
|
15
|
+
*
|
|
16
|
+
* Numbers, dates, and plurals are `Intl`'s job — it is available in the isolate.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type LocaleCatalog = Record<string, string>;
|
|
20
|
+
export type LocaleCatalogs = Record<string, LocaleCatalog>;
|
|
21
|
+
|
|
22
|
+
export type Translate = (key: string, params?: Record<string, string | number>) => string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fallback chain: exact tag (`zh-CN`) → language (`zh`) → the package's
|
|
26
|
+
* `defaultLocale` → the key itself. A missing key never throws: a scene with a
|
|
27
|
+
* raw key in it is bad, a scene that fails to render is worse.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveCatalog(
|
|
30
|
+
catalogs: LocaleCatalogs,
|
|
31
|
+
locale: string,
|
|
32
|
+
defaultLocale: string,
|
|
33
|
+
): LocaleCatalog {
|
|
34
|
+
const language = locale.split("-")[0];
|
|
35
|
+
return {
|
|
36
|
+
...(catalogs[defaultLocale] ?? {}),
|
|
37
|
+
...(catalogs[language] ?? {}),
|
|
38
|
+
...(catalogs[locale] ?? {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createTranslator(catalog: LocaleCatalog, onMissing?: (key: string) => void): Translate {
|
|
43
|
+
return (key, params) => {
|
|
44
|
+
const template = catalog[key];
|
|
45
|
+
if (template === undefined) {
|
|
46
|
+
onMissing?.(key);
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
if (!params) return template;
|
|
50
|
+
return template.replace(/\{(\w+)\}/g, (whole, name: string) =>
|
|
51
|
+
name in params ? String(params[name]) : whole,
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `"@command.title"` → `"command.title"`; a literal string → `undefined`. */
|
|
57
|
+
export function catalogKey(value: string): string | undefined {
|
|
58
|
+
return value.startsWith("@") ? value.slice(1) : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function localizeString(value: string, translate: Translate): string {
|
|
62
|
+
const key = catalogKey(value);
|
|
63
|
+
return key === undefined ? value : translate(key);
|
|
64
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from "./json.js";
|
|
2
|
+
export * from "./errors.js";
|
|
3
|
+
export * from "./i18n.js";
|
|
4
|
+
export * from "./scene.js";
|
|
5
|
+
export * from "./capabilities.js";
|
|
6
|
+
export * from "./types.js";
|
|
7
|
+
export * from "./define-extension.js";
|
|
8
|
+
export * from "./manifest.js";
|
|
9
|
+
export * from "./permission-units.js";
|
|
10
|
+
export * from "./view.js";
|
|
11
|
+
export * from "./reactive.js";
|
|
12
|
+
export * from "./components.js";
|
|
13
|
+
export * from "./view-runtime.js";
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value vocabulary that may cross the guest ↔ host boundary.
|
|
3
|
+
*
|
|
4
|
+
* Everything an extension hands back to Tapcue — items, scenes, scope subjects,
|
|
5
|
+
* settings — must be structurally clonable JSON. Host paths, native pointers and
|
|
6
|
+
* other extensions' handles never cross the boundary.
|
|
7
|
+
*
|
|
8
|
+
* Two TypeScript notes for extension authors:
|
|
9
|
+
*
|
|
10
|
+
* - An `interface` is NOT assignable to `JsonValue` (interfaces get no implicit
|
|
11
|
+
* index signature). Declare payload shapes with `type X = { … }`.
|
|
12
|
+
* - Optional properties are allowed and are dropped on serialization, which is
|
|
13
|
+
* why the object branch admits `undefined` — otherwise `{ region?: string }`
|
|
14
|
+
* would not satisfy `JsonValue`.
|
|
15
|
+
*/
|
|
16
|
+
export type JsonValue =
|
|
17
|
+
| string
|
|
18
|
+
| number
|
|
19
|
+
| boolean
|
|
20
|
+
| null
|
|
21
|
+
| JsonValue[]
|
|
22
|
+
| JsonObject;
|
|
23
|
+
|
|
24
|
+
export type JsonObject = { [key: string]: JsonValue | undefined };
|