@openforge-app/plugin-sdk 0.1.0 → 0.2.3

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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/browserSurfaces.d.ts +78 -0
  4. package/dist/browserSurfaces.js +17 -0
  5. package/dist/browserSurfacesTesting.d.ts +60 -0
  6. package/dist/browserSurfacesTesting.js +240 -0
  7. package/dist/domain.d.ts +74 -15
  8. package/dist/domain.js +5 -1
  9. package/dist/fileIcons.d.ts +6 -0
  10. package/dist/fileIcons.js +84 -0
  11. package/dist/frontend.d.ts +5 -2
  12. package/dist/frontend.js +1 -0
  13. package/dist/index.d.ts +5 -3
  14. package/dist/index.js +2 -1
  15. package/dist/manifest.d.ts +29 -0
  16. package/dist/manifest.js +37 -0
  17. package/dist/markdown.d.ts +5 -1
  18. package/dist/markdown.js +67 -18
  19. package/dist/openforgePackageMetadataSchema.json +40 -1
  20. package/dist/pluginIcons.d.ts +7 -0
  21. package/dist/pluginIcons.js +104 -0
  22. package/dist/publicUiExports.d.mts +16 -0
  23. package/dist/publicUiExports.mjs +59 -0
  24. package/dist/svelteHostRuntimeContract.d.mts +1 -0
  25. package/dist/svelteHostRuntimeContract.mjs +16 -0
  26. package/dist/testing/backendServicesFake.d.ts +19 -0
  27. package/dist/testing/backendServicesFake.js +87 -0
  28. package/dist/testing/commonApiFake.d.ts +22 -0
  29. package/dist/testing/commonApiFake.js +274 -0
  30. package/dist/testing/contracts.d.ts +231 -0
  31. package/dist/testing/contracts.js +1 -0
  32. package/dist/testing/frontendContributionFake.d.ts +36 -0
  33. package/dist/testing/frontendContributionFake.js +195 -0
  34. package/dist/testing/registryFake.d.ts +40 -0
  35. package/dist/testing/registryFake.js +124 -0
  36. package/dist/testing/support.d.ts +40 -0
  37. package/dist/testing/support.js +242 -0
  38. package/dist/testing.d.ts +3 -223
  39. package/dist/testing.js +2 -625
  40. package/dist/types.d.ts +193 -7
  41. package/dist/types.js +8 -0
  42. package/dist/ui/Button.svelte +49 -0
  43. package/dist/ui/Checkbox.svelte +137 -0
  44. package/dist/ui/FileTypeIcon.svelte +37 -0
  45. package/dist/ui/MarkdownContent.svelte +71 -11
  46. package/dist/ui/Modal.svelte +157 -0
  47. package/dist/ui/PluginPageHeader.svelte +26 -0
  48. package/dist/ui/PluginViewState.svelte +76 -0
  49. package/dist/ui/ResizablePanel.svelte +10 -0
  50. package/dist/ui/icons/README.md +9 -0
  51. package/dist/ui/icons/c.svg +1 -0
  52. package/dist/ui/icons/console.svg +1 -0
  53. package/dist/ui/icons/cpp.svg +1 -0
  54. package/dist/ui/icons/csharp.svg +1 -0
  55. package/dist/ui/icons/css.svg +3 -0
  56. package/dist/ui/icons/database.svg +1 -0
  57. package/dist/ui/icons/docker.svg +1 -0
  58. package/dist/ui/icons/document.svg +4 -0
  59. package/dist/ui/icons/file.svg +1 -0
  60. package/dist/ui/icons/folder-open.svg +1 -0
  61. package/dist/ui/icons/folder.svg +1 -0
  62. package/dist/ui/icons/git.svg +3 -0
  63. package/dist/ui/icons/go.svg +1 -0
  64. package/dist/ui/icons/graphql.svg +20 -0
  65. package/dist/ui/icons/h.svg +3 -0
  66. package/dist/ui/icons/html.svg +3 -0
  67. package/dist/ui/icons/image.svg +3 -0
  68. package/dist/ui/icons/java.svg +6 -0
  69. package/dist/ui/icons/javascript.svg +3 -0
  70. package/dist/ui/icons/json.svg +3 -0
  71. package/dist/ui/icons/kotlin.svg +1 -0
  72. package/dist/ui/icons/lock.svg +3 -0
  73. package/dist/ui/icons/markdown.svg +6 -0
  74. package/dist/ui/icons/nodejs.svg +6 -0
  75. package/dist/ui/icons/npm.svg +3 -0
  76. package/dist/ui/icons/pdf.svg +1 -0
  77. package/dist/ui/icons/php.svg +1 -0
  78. package/dist/ui/icons/python.svg +1 -0
  79. package/dist/ui/icons/react.svg +8 -0
  80. package/dist/ui/icons/react_ts.svg +8 -0
  81. package/dist/ui/icons/readme.svg +4 -0
  82. package/dist/ui/icons/ruby.svg +1 -0
  83. package/dist/ui/icons/rust.svg +1 -0
  84. package/dist/ui/icons/sass.svg +3 -0
  85. package/dist/ui/icons/settings.svg +4 -0
  86. package/dist/ui/icons/svelte.svg +1 -0
  87. package/dist/ui/icons/svg.svg +3 -0
  88. package/dist/ui/icons/swift.svg +1 -0
  89. package/dist/ui/icons/tune.svg +11 -0
  90. package/dist/ui/icons/typescript-def.svg +6 -0
  91. package/dist/ui/icons/typescript.svg +3 -0
  92. package/dist/ui/icons/vue.svg +1 -0
  93. package/dist/ui/icons/xml.svg +1 -0
  94. package/dist/ui/icons/yaml.svg +3 -0
  95. package/dist/ui/icons/zip.svg +3 -0
  96. package/dist/vite.js +5 -2
  97. package/package.json +34 -7
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Component } from 'svelte';
2
- import type { AgentSession, FileContent, FileEntry, Project, ProjectAttention, Task, TaskWorkspaceInfo, WritableBoardStatus } from './domain';
2
+ import type { BrowserSurfacesAPI } from './browserSurfaces';
3
+ import type { AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, Task, TaskWorkspaceInfo, WritableBoardStatus } from './domain';
3
4
  export type SupportedOpenForgeApiVersion = 1;
4
5
  export declare const SUPPORTED_OPENFORGE_API_VERSIONS: readonly [1, ...1[]];
5
6
  export declare const OPENFORGE_PLUGIN_API_VERSION: SupportedOpenForgeApiVersion;
@@ -16,7 +17,7 @@ export interface ValidationError {
16
17
  path: string;
17
18
  message: string;
18
19
  }
19
- export type OpenForgePluginCapability = 'commands' | 'events' | 'views' | 'taskPane' | 'settings' | 'background' | 'backend' | 'storage' | 'context' | 'navigation' | 'tasks' | 'projects' | 'fs' | 'shell' | 'notifications' | 'attention' | 'system.openUrl' | 'config' | 'projectConfig';
20
+ export type OpenForgePluginCapability = 'commands' | 'events' | 'views' | 'taskPane' | 'taskStart' | 'settings' | 'background' | 'backend' | 'storage' | 'context' | 'navigation' | 'tasks' | 'projects' | 'fs' | 'shell' | 'notifications' | 'attention' | 'system.openUrl' | 'system.writeClipboardText' | 'config' | 'projectConfig' | 'browserSurfaces' | 'taskLinks';
20
21
  export interface OpenForgePackageMetadata {
21
22
  id: string;
22
23
  apiVersion: SupportedOpenForgeApiVersion;
@@ -24,6 +25,7 @@ export interface OpenForgePackageMetadata {
24
25
  description: string;
25
26
  icon?: string;
26
27
  frontend?: string;
28
+ frontendStyles?: string[];
27
29
  backend?: string;
28
30
  requires?: OpenForgePluginCapability[];
29
31
  }
@@ -61,11 +63,23 @@ export interface OpenForgeNavigationRequest {
61
63
  viewId?: string;
62
64
  projectId?: string | null;
63
65
  taskId?: string | null;
66
+ /** Plugin-local Task UI tab id. Requires a non-null taskId. */
67
+ taskViewId?: string;
64
68
  }
65
69
  export interface NavigationAPI {
66
70
  get(): OpenForgeNavigationSnapshot;
67
71
  navigate(request: OpenForgeNavigationRequest): Promise<OpenForgeNavigationSnapshot>;
68
72
  }
73
+ export interface TaskLinkOpenRequest {
74
+ taskId: string;
75
+ url: string;
76
+ }
77
+ export type TaskLinkHandlerResult = 'handled' | 'declined';
78
+ export type TaskLinkHandler = (request: TaskLinkOpenRequest) => Promise<TaskLinkHandlerResult>;
79
+ export interface TaskLinksAPI {
80
+ open(request: TaskLinkOpenRequest): Promise<void>;
81
+ registerHandler(handler: TaskLinkHandler): Disposable;
82
+ }
69
83
  export interface OpenForgePluginContext {
70
84
  pluginId: string;
71
85
  apiVersion: SupportedOpenForgeApiVersion;
@@ -89,6 +103,33 @@ export type CommandShortcutMetadata = string | {
89
103
  scope?: 'global' | 'project' | 'task';
90
104
  when?: string;
91
105
  };
106
+ export interface AgentCommandMetadata {
107
+ /** Concise guidance explaining when and why an agent should use this command. */
108
+ description: string;
109
+ /** Example plugin-owned JSON inputs. Task and Project context are supplied separately. */
110
+ examples?: JsonValue[];
111
+ /** Whether the command appears in the routine agent catalog. Defaults to true. */
112
+ discoverable?: boolean;
113
+ }
114
+ export type AgentCommandRuntime = 'backend' | 'frontend';
115
+ /** Serializable agent-facing projection of a Plugin Command. Never contains its handler. */
116
+ export interface AgentCommandDescriptor {
117
+ qualifiedId: string;
118
+ pluginId: string;
119
+ runtime: AgentCommandRuntime;
120
+ description: string;
121
+ examples: JsonValue[];
122
+ discoverable: boolean;
123
+ input?: JsonSchema;
124
+ output?: JsonSchema;
125
+ }
126
+ export type PluginCommandInvocationSource = 'agent-cli' | 'plugin';
127
+ /** Host-resolved targeting information supplied separately from plugin-owned command input. */
128
+ export interface PluginCommandInvocationContext {
129
+ taskId: string | null;
130
+ projectId: string | null;
131
+ source: PluginCommandInvocationSource;
132
+ }
92
133
  export interface CommandRegistration<TInput = unknown, TOutput = unknown> {
93
134
  id: string;
94
135
  title: string;
@@ -96,9 +137,11 @@ export interface CommandRegistration<TInput = unknown, TOutput = unknown> {
96
137
  shortcut?: CommandShortcutMetadata;
97
138
  /** Whether this command should appear in user-facing discovery surfaces such as the Command Palette. Defaults to true. */
98
139
  discoverable?: boolean;
140
+ /** Explicitly opts this command into agent access. Omitted commands are unavailable to agents. */
141
+ agent?: AgentCommandMetadata;
99
142
  input?: JsonSchema;
100
143
  output?: JsonSchema;
101
- handler(input: TInput): MaybePromise<TOutput>;
144
+ handler(input: TInput, context: PluginCommandInvocationContext): MaybePromise<TOutput>;
102
145
  }
103
146
  export interface CommandDescriptor {
104
147
  id: string;
@@ -116,7 +159,18 @@ export interface CommandRegistry {
116
159
  register<TInput = unknown, TOutput = unknown>(registration: CommandRegistration<TInput, TOutput>): Disposable;
117
160
  invoke<TOutput = unknown>(id: string, payload?: unknown): Promise<TOutput>;
118
161
  invokeGlobal<TOutput = unknown>(qualifiedId: string, payload?: unknown): Promise<TOutput>;
162
+ /** Plugin-registered commands (this and other enabled plugins). */
119
163
  list(): Promise<CommandDescriptor[]>;
164
+ /**
165
+ * The host's Claude skills/commands catalog for the given project (skills from
166
+ * `~/.claude`/`.agents`, builtin commands, and plugin-provided commands), as the
167
+ * app's own injectable/autocomplete surfaces see it. Omitting `projectId` (or
168
+ * passing null) yields only project-independent entries. Distinct from `list()`,
169
+ * which returns plugin-registered commands.
170
+ */
171
+ listCatalog(request?: {
172
+ projectId?: string | null;
173
+ }): Promise<CommandInfo[]>;
120
174
  }
121
175
  export type EventHandler<TPayload = unknown> = (payload: TPayload) => void;
122
176
  export interface EventRegistry {
@@ -140,14 +194,33 @@ export interface PluginTaskPaneProps extends Record<string, unknown> {
140
194
  taskId: string;
141
195
  projectId: string | null;
142
196
  }
197
+ export interface PluginTaskUISectionProps extends PluginTaskPaneProps {
198
+ /** True while the host is running a task-scoped action represented by the section. */
199
+ taskActionPending: boolean;
200
+ }
143
201
  export interface PluginSettingsSectionProps extends Record<string, unknown> {
144
202
  api: FrontendOpenForgeAPI;
145
203
  context: OpenForgeContextSnapshot;
146
204
  }
205
+ /**
206
+ * Inline SVG geometry for a plugin view icon.
207
+ *
208
+ * The host sanitizes the markup, strips root sizing and accessibility metadata,
209
+ * renders it decoratively at the navigation surface's size, and uses the view
210
+ * title as the accessible navigation label. Plugins own the `viewBox`, paths,
211
+ * and paint. Use `currentColor` when the icon should follow host navigation
212
+ * states; literal safe colors remain plugin-owned and do not change with state.
213
+ */
214
+ export interface PluginSvgIcon {
215
+ type: 'svg';
216
+ svg: string;
217
+ }
218
+ /** A host-registered icon name or inline SVG geometry. */
219
+ export type PluginIcon = string | PluginSvgIcon;
147
220
  export interface PluginViewRegistration {
148
221
  id: string;
149
222
  title: string;
150
- icon: string;
223
+ icon: PluginIcon;
151
224
  /**
152
225
  * Where the host surfaces the view's nav entry. `'rail'` (default) places it on
153
226
  * the icon rail; `'sidebar'` places it in the left projects sidebar. Either way
@@ -165,15 +238,72 @@ export interface PluginTaskPaneTabRegistration {
165
238
  order?: number;
166
239
  component: PluginComponentLoader<PluginTaskPaneProps> | PluginComponent<PluginTaskPaneProps>;
167
240
  }
241
+ export interface PluginTaskUISectionRegistration {
242
+ id: string;
243
+ order?: number;
244
+ component: PluginComponentLoader<PluginTaskUISectionProps> | PluginComponent<PluginTaskUISectionProps>;
245
+ }
246
+ /**
247
+ * Where a settings section is surfaced. `'project'` (the default) renders it on the
248
+ * per-project settings page, scoped to the active project. `'global'` renders it
249
+ * inside the plugin's own card on the global settings page — for configuration that
250
+ * is one value for the whole app (an API key, a credential) rather than per-project.
251
+ */
252
+ export type PluginSettingsSectionScope = 'project' | 'global';
168
253
  export interface PluginSettingsSectionRegistration {
169
254
  id: string;
170
255
  title: string;
171
256
  order?: number;
257
+ /** Defaults to `'project'` when omitted. */
258
+ scope?: PluginSettingsSectionScope;
172
259
  component: PluginComponentLoader<PluginSettingsSectionProps> | PluginComponent<PluginSettingsSectionProps>;
173
260
  }
174
261
  export interface FrontendViewRegistry {
175
262
  register(registration: PluginViewRegistration): Disposable;
176
263
  }
264
+ export type InjectionPointLocation = 'createTaskPrompt' | 'agentSession' | 'backlogPrompt';
265
+ export interface PluginInjectionPointProps extends Record<string, unknown> {
266
+ api: FrontendOpenForgeAPI;
267
+ context: OpenForgeContextSnapshot;
268
+ location: InjectionPointLocation;
269
+ projectId: string | null;
270
+ taskId: string | null;
271
+ onInsert: (text: string) => void;
272
+ }
273
+ export interface PluginInjectionPointRegistration {
274
+ id: string;
275
+ location: InjectionPointLocation;
276
+ component: PluginComponentLoader<PluginInjectionPointProps> | PluginComponent<PluginInjectionPointProps>;
277
+ }
278
+ export interface TaskStartPrefixContext {
279
+ /** The task being started, or null when the caller is authoring a new one. */
280
+ taskId: string | null;
281
+ projectId: string | null;
282
+ }
283
+ export interface TaskStartPrefixProviderRegistration {
284
+ id: string;
285
+ /** Menu label shown on the surface offering this provider. */
286
+ title: string;
287
+ /** Lower sorts first. Defaults to 0. */
288
+ order?: number;
289
+ /**
290
+ * Asks the user for a prefix. Returning null means they cancelled, and the
291
+ * task is not started. The host — not the provider — starts the task, so the
292
+ * diverged-branch gate, starting spinner and terminal handling all still run.
293
+ */
294
+ provide(context: TaskStartPrefixContext): MaybePromise<string | null>;
295
+ }
296
+ export interface FrontendTaskStartRegistry {
297
+ registerPrefixProvider(registration: TaskStartPrefixProviderRegistration): Disposable;
298
+ }
299
+ export interface FrontendInjectionPointRegistry {
300
+ register(registration: PluginInjectionPointRegistration): Disposable;
301
+ }
302
+ export interface FrontendTaskUIRegistry {
303
+ registerTab(registration: PluginTaskPaneTabRegistration): Disposable;
304
+ registerSection(registration: PluginTaskUISectionRegistration): Disposable;
305
+ }
306
+ /** @deprecated Use `FrontendTaskUIRegistry.registerTab` through `openforge.taskUI`. */
177
307
  export interface FrontendTaskPaneRegistry {
178
308
  registerTab(registration: PluginTaskPaneTabRegistration): Disposable;
179
309
  }
@@ -223,6 +353,7 @@ export interface FileSystemAPI {
223
353
  limit?: number;
224
354
  }): Promise<string[]>;
225
355
  }
356
+ export type TerminalImageProtocol = 'iterm2';
226
357
  export interface ShellSessionRequest {
227
358
  taskId: string;
228
359
  terminalIndex: number;
@@ -231,6 +362,7 @@ export interface ShellSpawnRequest extends ShellSessionRequest {
231
362
  cwd: string;
232
363
  cols: number;
233
364
  rows: number;
365
+ terminalImageProtocol?: TerminalImageProtocol | null;
234
366
  }
235
367
  export interface ShellWriteRequest extends ShellSessionRequest {
236
368
  data: string;
@@ -252,10 +384,27 @@ export interface CreateTaskRequest {
252
384
  dependsOn?: string[];
253
385
  labelNames?: string[];
254
386
  }
387
+ export interface ComposeTaskRequest {
388
+ projectId: string;
389
+ /** Seeds the dialog's prompt field; the user edits it before saving. */
390
+ initialPrompt: string;
391
+ sourceTicketUrl?: string | null;
392
+ title?: string | null;
393
+ }
394
+ export interface ComposeTaskResult {
395
+ task: Task;
396
+ /** True when the user chose Start Task rather than plain Create. */
397
+ started: boolean;
398
+ }
255
399
  export interface StartPromptContribution {
400
+ /** Host-assigned identity of the plugin that owns this persisted contribution. */
401
+ readonly ownerPluginId?: string;
256
402
  id: string;
257
403
  enabled: boolean;
258
- /** Prompt text injected before OpenForge's task prompt. The host substitutes {{taskId}} and {{task_id}}. */
404
+ /**
405
+ * Prompt text injected before OpenForge's task prompt.
406
+ * The host substitutes {{taskId}} and {{task_id}}.
407
+ */
259
408
  content: string;
260
409
  /** Lower values are injected first. Defaults to 0. */
261
410
  order?: number;
@@ -271,17 +420,47 @@ export interface ImplementationRun {
271
420
  sessionId: string;
272
421
  workspacePath: string;
273
422
  }
423
+ export type TaskFollowUpDisposition = 'delivered' | 'queued';
424
+ export interface SendTaskFollowUpRequest {
425
+ taskId: string;
426
+ message: string;
427
+ }
428
+ export interface TaskFollowUpReceipt {
429
+ taskId: string;
430
+ sessionId: string;
431
+ disposition: TaskFollowUpDisposition;
432
+ }
433
+ export type TaskFollowUpErrorCode = 'NO_SESSION' | 'DELIVERY_FAILED';
434
+ export declare class TaskFollowUpError extends Error {
435
+ readonly code: TaskFollowUpErrorCode;
436
+ constructor(code: TaskFollowUpErrorCode, message: string);
437
+ }
274
438
  export interface TasksAPI {
439
+ /**
440
+ * Lists tasks, optionally scoped to a project. By default done tasks are
441
+ * excluded (matching the app board's active-only view); pass
442
+ * `includeDone: true` to include tasks in the terminal `done` state. The
443
+ * unscoped listing (no `projectId`) always returns all states, so
444
+ * `includeDone` only affects the project-scoped path.
445
+ */
275
446
  list(request?: {
276
447
  projectId?: string | null;
448
+ includeDone?: boolean;
277
449
  }): Promise<Task[]>;
278
- get(taskId: string): Promise<Task>;
450
+ get(taskId: string): Promise<Task | null>;
279
451
  create(request: CreateTaskRequest): Promise<Task>;
280
- updateSummary(taskId: string, summary: string): Promise<void>;
452
+ /**
453
+ * Opens the host's create-task dialog pre-filled, letting the user edit the
454
+ * prompt — including anything contributed at that injection point —
455
+ * before the task exists.
456
+ * Resolves null if they dismiss it.
457
+ */
458
+ compose(request: ComposeTaskRequest): Promise<ComposeTaskResult | null>;
281
459
  updateStatus(taskId: string, status: WritableBoardStatus): Promise<void>;
282
460
  listStartPromptContributions(projectId: string): Promise<StartPromptContribution[]>;
283
461
  configureStartPromptContribution(request: ConfigureStartPromptContributionRequest): Promise<StartPromptContribution[]>;
284
462
  startImplementation(request: StartTaskImplementationRequest): Promise<ImplementationRun>;
463
+ sendFollowUp(request: SendTaskFollowUpRequest): Promise<TaskFollowUpReceipt>;
285
464
  getWorkspace(taskId: string): Promise<TaskWorkspaceInfo | null>;
286
465
  getLatestSession(taskId: string): Promise<AgentSession | null>;
287
466
  }
@@ -302,6 +481,7 @@ export interface AttentionAPI {
302
481
  }
303
482
  export interface SystemAPI {
304
483
  openUrl(url: string): Promise<void>;
484
+ writeClipboardText(text: string): Promise<void>;
305
485
  }
306
486
  export interface KeyValueConfigAPI {
307
487
  get<T extends JsonValue = JsonValue>(key: string, projectId?: string): Promise<T | null>;
@@ -325,11 +505,17 @@ export interface OpenForgeCommonAPI {
325
505
  projectConfig: KeyValueConfigAPI;
326
506
  }
327
507
  export interface FrontendOpenForgeAPI extends OpenForgeCommonAPI {
508
+ browserSurfaces: BrowserSurfacesAPI;
509
+ taskLinks: TaskLinksAPI;
328
510
  navigation: NavigationAPI;
329
511
  views: FrontendViewRegistry;
512
+ taskUI: FrontendTaskUIRegistry;
513
+ /** @deprecated Use `taskUI.registerTab(...)`. */
330
514
  taskPane: FrontendTaskPaneRegistry;
331
515
  settings: FrontendSettingsRegistry;
332
516
  backend: FrontendBackendBridge;
517
+ injectionPoints: FrontendInjectionPointRegistry;
518
+ taskStart: FrontendTaskStartRegistry;
333
519
  }
334
520
  export interface BackendOpenForgeAPI extends OpenForgeCommonAPI {
335
521
  backend: BackendMethodRegistry;
package/dist/types.js CHANGED
@@ -10,6 +10,14 @@ export const SUPPORTED_OPENFORGE_API_VERSIONS = Object.freeze(readSupportedOpenF
10
10
  export const OPENFORGE_PLUGIN_API_VERSION = SUPPORTED_OPENFORGE_API_VERSIONS[0];
11
11
  export const MIN_SUPPORTED_API_VERSION = Math.min(...SUPPORTED_OPENFORGE_API_VERSIONS);
12
12
  export const MAX_SUPPORTED_API_VERSION = Math.max(...SUPPORTED_OPENFORGE_API_VERSIONS);
13
+ export class TaskFollowUpError extends Error {
14
+ code;
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.name = 'TaskFollowUpError';
18
+ this.code = code;
19
+ }
20
+ }
13
21
  export function makePluginViewKey(pluginId, viewId) {
14
22
  return `plugin:${pluginId}:${viewId}`;
15
23
  }
@@ -0,0 +1,49 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import type { HTMLButtonAttributes } from 'svelte/elements'
4
+
5
+ type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'error'
6
+ type ButtonSize = 'xs' | 'sm' | 'md' | 'lg'
7
+
8
+ interface Props extends HTMLButtonAttributes {
9
+ children: Snippet
10
+ variant?: ButtonVariant
11
+ size?: ButtonSize
12
+ }
13
+
14
+ const variantClasses: Record<ButtonVariant, string> = {
15
+ primary: 'btn-primary',
16
+ secondary: 'btn-secondary',
17
+ outline: 'btn-outline',
18
+ ghost: 'btn-ghost',
19
+ error: 'btn-error',
20
+ }
21
+
22
+ const sizeClasses: Record<ButtonSize, string> = {
23
+ xs: 'btn-xs',
24
+ sm: 'btn-sm',
25
+ md: 'btn-md',
26
+ lg: 'btn-lg',
27
+ }
28
+
29
+ let {
30
+ children,
31
+ variant = 'primary',
32
+ size = 'md',
33
+ class: className,
34
+ disabled = false,
35
+ onclick,
36
+ ...attributes
37
+ }: Props = $props()
38
+ </script>
39
+
40
+ <button
41
+ {...attributes}
42
+ class={['btn', variantClasses[variant], sizeClasses[size], className]}
43
+ {disabled}
44
+ onclick={(event) => {
45
+ if (!disabled) onclick?.(event)
46
+ }}
47
+ >
48
+ {@render children()}
49
+ </button>
@@ -0,0 +1,137 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements'
3
+
4
+ type CheckboxSize = 'xs' | 'sm' | 'md'
5
+
6
+ interface Props extends Omit<HTMLInputAttributes, 'checked' | 'size' | 'type'> {
7
+ checked?: boolean
8
+ indeterminate?: boolean
9
+ size?: CheckboxSize
10
+ }
11
+
12
+ let {
13
+ checked = $bindable(false),
14
+ indeterminate = false,
15
+ size = 'sm',
16
+ class: className,
17
+ onchange,
18
+ ...attributes
19
+ }: Props = $props()
20
+
21
+ let input = $state<HTMLInputElement | null>(null)
22
+
23
+ $effect(() => {
24
+ if (input) input.indeterminate = indeterminate
25
+ })
26
+ </script>
27
+
28
+ <input
29
+ {...attributes}
30
+ bind:this={input}
31
+ bind:checked
32
+ type="checkbox"
33
+ class={["of-checkbox", className]}
34
+ data-size={size}
35
+ aria-checked={indeterminate ? 'mixed' : undefined}
36
+ {onchange}
37
+ />
38
+
39
+ <style>
40
+ .of-checkbox {
41
+ width: 1.25rem;
42
+ height: 1.25rem;
43
+ flex: none;
44
+ appearance: none;
45
+ display: inline-grid;
46
+ place-content: center;
47
+ margin: 0;
48
+ border: 2px solid color-mix(in oklab, var(--color-base-content) 55%, transparent);
49
+ border-radius: 0.25rem;
50
+ background: var(--color-base-100);
51
+ color: var(--color-primary-content);
52
+ cursor: pointer;
53
+ transition:
54
+ background-color 150ms ease,
55
+ border-color 150ms ease,
56
+ box-shadow 150ms ease;
57
+ }
58
+
59
+ .of-checkbox::before {
60
+ width: 0.75rem;
61
+ height: 0.75rem;
62
+ background: currentColor;
63
+ clip-path: polygon(14% 44%, 0 59%, 40% 100%, 100% 19%, 84% 4%, 39% 73%);
64
+ content: '';
65
+ transform: scale(0);
66
+ transform-origin: center;
67
+ transition: transform 120ms ease-out;
68
+ }
69
+
70
+ .of-checkbox:hover:not(:disabled) {
71
+ border-color: var(--color-primary);
72
+ }
73
+
74
+ .of-checkbox:checked,
75
+ .of-checkbox:indeterminate {
76
+ border-color: var(--color-primary);
77
+ background: var(--color-primary);
78
+ }
79
+
80
+ .of-checkbox:checked::before {
81
+ transform: scale(1);
82
+ }
83
+
84
+ .of-checkbox:indeterminate::before {
85
+ width: 0.625rem;
86
+ height: 0.125rem;
87
+ clip-path: none;
88
+ transform: scale(1);
89
+ }
90
+
91
+ .of-checkbox:focus-visible {
92
+ outline: 2px solid var(--color-primary);
93
+ outline-offset: 2px;
94
+ }
95
+
96
+ .of-checkbox:disabled {
97
+ cursor: not-allowed;
98
+ opacity: 0.45;
99
+ }
100
+
101
+ .of-checkbox[data-size='xs'] {
102
+ width: 1rem;
103
+ height: 1rem;
104
+ }
105
+
106
+ .of-checkbox[data-size='xs']::before {
107
+ width: 0.625rem;
108
+ height: 0.625rem;
109
+ }
110
+
111
+ .of-checkbox[data-size='md'] {
112
+ width: 1.5rem;
113
+ height: 1.5rem;
114
+ }
115
+
116
+ .of-checkbox[data-size='md']::before {
117
+ width: 0.875rem;
118
+ height: 0.875rem;
119
+ }
120
+
121
+ @media (prefers-reduced-motion: reduce) {
122
+ .of-checkbox,
123
+ .of-checkbox::before {
124
+ transition: none;
125
+ }
126
+ }
127
+
128
+ @media (forced-colors: active) {
129
+ .of-checkbox {
130
+ appearance: auto;
131
+ }
132
+
133
+ .of-checkbox::before {
134
+ display: none;
135
+ }
136
+ }
137
+ </style>
@@ -0,0 +1,37 @@
1
+ <script lang="ts">
2
+ import { getFileIconName, getFolderIconName } from '../fileIcons'
3
+
4
+ interface Props {
5
+ filename?: string
6
+ folder?: boolean
7
+ open?: boolean
8
+ class?: string
9
+ }
10
+
11
+ let { filename, folder = false, open = false, class: className = '' }: Props = $props()
12
+
13
+ // Curated, vendored set only (~45 files) — eager raw is fine and avoids runtime asset URLs.
14
+ const icons = import.meta.glob('./icons/*.svg', {
15
+ query: '?raw',
16
+ import: 'default',
17
+ eager: true,
18
+ }) as Record<string, string>
19
+
20
+ let iconName = $derived(folder ? getFolderIconName(open) : getFileIconName(filename ?? ''))
21
+ let svg = $derived(icons[`./icons/${iconName}.svg`] ?? icons['./icons/file.svg'] ?? '')
22
+ </script>
23
+
24
+ <span
25
+ class="file-type-icon inline-flex items-center justify-center shrink-0 {className}"
26
+ data-icon={iconName}
27
+ aria-hidden="true"
28
+ >
29
+ {@html svg}
30
+ </span>
31
+
32
+ <style>
33
+ .file-type-icon :global(svg) {
34
+ width: 100%;
35
+ height: 100%;
36
+ }
37
+ </style>
@@ -1,30 +1,90 @@
1
1
  <script lang="ts">
2
- import { renderMarkdownHtml } from '../markdown'
2
+ import { onDestroy } from 'svelte'
3
+ import { getMarkdownRepositoryLinkSuffix, renderMarkdownHtml, resolveMarkdownRepositoryPath } from '../markdown'
3
4
 
4
5
  interface Props {
5
6
  content: string
6
7
  imageBaseUrl?: string | null
8
+ markdownFilePath?: string | null
9
+ resolveRepositoryImage?: (repositoryPath: string) => Promise<string | null>
10
+ onOpenRepositoryPath?: (repositoryPath: string, suffix: string) => void | Promise<void>
7
11
  onOpenUrl?: (url: string) => void | Promise<void>
8
12
  }
9
13
 
10
- let { content, imageBaseUrl = null, onOpenUrl }: Props = $props()
14
+ let {
15
+ content,
16
+ imageBaseUrl = null,
17
+ markdownFilePath = null,
18
+ resolveRepositoryImage,
19
+ onOpenRepositoryPath,
20
+ onOpenUrl,
21
+ }: Props = $props()
11
22
 
12
- let html = $derived(renderMarkdownHtml(content, { imageBaseUrl }))
23
+ let root = $state<HTMLDivElement | null>(null)
24
+ let imageResolutionId = 0
25
+ let html = $derived(renderMarkdownHtml(content, {
26
+ imageBaseUrl: resolveRepositoryImage ? null : imageBaseUrl,
27
+ markdownFilePath,
28
+ deferRepositoryImages: Boolean(resolveRepositoryImage),
29
+ }))
13
30
 
14
- function handleClick(e: MouseEvent) {
15
- if (!(e.target instanceof Element)) return
31
+ async function resolveRepositoryImages(runId: number) {
32
+ if (!root || !markdownFilePath || !resolveRepositoryImage) return
16
33
 
17
- const anchor = e.target.closest('a')
18
- if (anchor?.href) {
19
- e.preventDefault()
20
- if (onOpenUrl) {
21
- void onOpenUrl(anchor.href)
34
+ const images = Array.from(root.querySelectorAll('img[data-markdown-repository-path]'))
35
+ await Promise.all(images.map(async (image) => {
36
+ const repositoryPath = image.getAttribute('data-markdown-repository-path')
37
+ if (!repositoryPath) return
38
+
39
+ try {
40
+ const resolvedSrc = await resolveRepositoryImage(repositoryPath)
41
+ if (runId !== imageResolutionId || !resolvedSrc) return
42
+ image.setAttribute('src', resolvedSrc)
43
+ image.removeAttribute('data-markdown-repository-path')
44
+ } catch {
45
+ // Leave the image inert when an asset is missing or cannot be previewed.
46
+ }
47
+ }))
48
+ }
49
+
50
+ $effect(() => {
51
+ const runId = ++imageResolutionId
52
+ void html
53
+ if (!root || !markdownFilePath || !resolveRepositoryImage) return
54
+
55
+ void resolveRepositoryImages(runId)
56
+ })
57
+
58
+ onDestroy(() => {
59
+ imageResolutionId++
60
+ })
61
+
62
+ function handleClick(event: MouseEvent) {
63
+ if (!(event.target instanceof Element)) return
64
+
65
+ const anchor = event.target.closest('a')
66
+ const href = anchor?.getAttribute('href')
67
+ if (!anchor || !href) return
68
+
69
+ if (markdownFilePath) {
70
+ const repositoryPath = resolveMarkdownRepositoryPath(href, markdownFilePath)
71
+ if (repositoryPath) {
72
+ event.preventDefault()
73
+ void onOpenRepositoryPath?.(repositoryPath, getMarkdownRepositoryLinkSuffix(href))
74
+ return
22
75
  }
23
76
  }
77
+
78
+ if (href.startsWith('#')) return
79
+
80
+ event.preventDefault()
81
+ if (onOpenUrl && anchor.href) {
82
+ void onOpenUrl(href.startsWith('//') ? `https:${href}` : anchor.href)
83
+ }
24
84
  }
25
85
  </script>
26
86
 
27
87
  <!-- svelte-ignore a11y_click_events_have_key_events -->
28
- <div role="presentation" class="markdown-body" onclick={handleClick}>
88
+ <div bind:this={root} role="presentation" class="markdown-body" onclick={handleClick}>
29
89
  {@html html}
30
90
  </div>