@uniqbit/mate-core 0.17.0-canary.9 → 0.17.1

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 (103) hide show
  1. package/package.json +11 -5
  2. package/src/cli/commands/artifact/finish/index.ts +0 -9
  3. package/src/cli/commands/artifact/finish/openspec.ts +2 -2
  4. package/src/cli/commands/artifact/finish/registry.ts +1 -1
  5. package/src/cli/commands/artifact/pending/discovery.ts +1 -1
  6. package/src/cli/commands/artifact/pending/index.ts +0 -16
  7. package/src/cli/commands/cap/graphify.ts +1 -1
  8. package/src/cli/commands/cap/index-cmd.ts +1 -1
  9. package/src/cli/commands/cap/tokensave.ts +4 -3
  10. package/src/cli/commands/companion/companion.ts +4 -0
  11. package/src/cli/commands/companion/hub.ts +2 -1
  12. package/src/cli/commands/companion/list.ts +2 -1
  13. package/src/cli/commands/companion/prepare.ts +2 -2
  14. package/src/cli/commands/companion/update.ts +334 -0
  15. package/src/cli/commands/doctor.ts +2 -1
  16. package/src/cli/commands/install.ts +59 -5
  17. package/src/cli/commands/launch/shared.ts +2 -1
  18. package/src/cli/commands/plugin/plugin.ts +4 -0
  19. package/src/cli/commands/plugin/verify.ts +25 -0
  20. package/src/cli/commands/report/highlight.ts +1 -4
  21. package/src/cli/commands/report/renderer.ts +0 -2
  22. package/src/cli/commands/studio/access.ts +201 -0
  23. package/src/cli/commands/studio/index.ts +207 -26
  24. package/src/cli/commands/studio/openspec-cli.ts +1 -1
  25. package/src/cli/commands/studio/selection.ts +127 -11
  26. package/src/cli/commands/studio/server.ts +355 -64
  27. package/src/cli/commands/studio/terminal-assets.ts +50 -0
  28. package/src/cli/commands/studio/terminal-launch.ts +71 -0
  29. package/src/cli/commands/studio/terminal.ts +593 -0
  30. package/src/cli/commands/studio/vault.ts +320 -75
  31. package/src/cli/commands/studio/views/client.ts +90 -9
  32. package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
  33. package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
  34. package/src/cli/commands/studio/views/document.tsx +411 -107
  35. package/src/cli/commands/studio/views/model.ts +14 -1
  36. package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
  37. package/src/cli/commands/studio/views/styles.ts +106 -13
  38. package/src/cli/commands/studio/views/terminal.tsx +426 -0
  39. package/src/cli/commands/studio/views/vault/state.ts +42 -0
  40. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  41. package/src/cli/commands/update.ts +11 -8
  42. package/src/cli/companion-link-wizard.tsx +1 -1
  43. package/src/cli/companion-selector.tsx +1 -1
  44. package/src/cli/launch-selector.tsx +1 -1
  45. package/src/cli/main.ts +2 -1
  46. package/src/cli/usage.ts +7 -3
  47. package/src/cli/write-json-stdout.ts +2 -2
  48. package/src/lib/components/spinner.tsx +2 -2
  49. package/src/lib/components/startup-progress.tsx +1 -1
  50. package/src/lib/context-mode-package.ts +1 -1
  51. package/src/lib/opencode-plugin-package.ts +3 -0
  52. package/src/lib/orchestrator/adapters/opencode.ts +53 -13
  53. package/src/lib/orchestrator/companion-hub.ts +3 -3
  54. package/src/lib/orchestrator/companion-registry-store.ts +1 -1
  55. package/src/lib/orchestrator/config-store.ts +2 -2
  56. package/src/lib/orchestrator/editor.ts +4 -12
  57. package/src/lib/orchestrator/engine-guard.ts +9 -1
  58. package/src/lib/orchestrator/projection-entries.ts +1 -1
  59. package/src/lib/orchestrator/projection-record.ts +1 -1
  60. package/src/lib/orchestrator/projection-types.ts +0 -4
  61. package/src/lib/orchestrator/repo-local-registry.ts +0 -1
  62. package/src/lib/orchestrator/root-context.ts +1 -1
  63. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  64. package/src/lib/orchestrator/setup-preflight.ts +1 -1
  65. package/src/lib/orchestrator/types.ts +1 -1
  66. package/src/lib/orchestrator/working-repo-projection.ts +1 -1
  67. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  68. package/src/lib/package-paths.ts +1 -11
  69. package/src/lib/package-root.ts +43 -0
  70. package/src/lib/prebuilt-workspace.ts +1 -1
  71. package/src/lib/public-npm.ts +1 -1
  72. package/src/opencode/companion-hooks.ts +131 -153
  73. package/src/opencode/index.ts +12 -6
  74. package/src/opencode/tui.tsx +46 -49
  75. package/src/playbooks/companion-guidance.ts +1 -28
  76. package/src/runtime/companion-sync.ts +0 -3
  77. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  78. package/src/templates/providers/opencode/opencode.json +6 -4
  79. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
  80. package/src/tools/setup/capabilities/context-mode.ts +45 -25
  81. package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
  82. package/src/tools/setup/capabilities/graphify.ts +2 -2
  83. package/src/tools/setup/capabilities/openspec.ts +2 -7
  84. package/src/tools/setup/capabilities/react-doctor.ts +1 -1
  85. package/src/tools/setup/capabilities/rtk.ts +2 -2
  86. package/src/tools/setup/capabilities/tokensave.ts +98 -12
  87. package/src/tools/setup/context-services.ts +3 -6
  88. package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
  89. package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
  90. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
  91. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  92. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  93. package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
  94. package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
  95. package/src/tools/setup/mate.ts +1 -1
  96. package/src/tools/setup/providers/claude-format.ts +1 -1
  97. package/src/tools/setup/providers/claude.ts +1 -1
  98. package/src/tools/setup/providers/opencode-format.ts +220 -25
  99. package/src/tools/setup/providers/opencode.ts +63 -21
  100. package/src/tools/setup/runtime-documents.ts +9 -11
  101. package/src/tools/setup.ts +0 -4
  102. package/src/cli/commands/studio/views/pairings.tsx +0 -38
  103. package/src/templates/providers/opencode/tui.json +0 -3
@@ -1,6 +1,16 @@
1
1
  /** @jsxImportSource hono/jsx */
2
2
 
3
- import { companionDigest, FILE_PARAM, REFRESH_PARAM, type StudioSelection } from "../selection";
3
+ import {
4
+ companionDigest,
5
+ openFile,
6
+ openFolder,
7
+ refresh,
8
+ type StudioSelection,
9
+ type StudioVaultSelection,
10
+ type StudioView,
11
+ switchView,
12
+ toHref,
13
+ } from "../selection";
4
14
  import { STUDIO_CLIENT_SCRIPT, STUDIO_PREPAINT_SCRIPT } from "./client";
5
15
  import { CompanionPicker } from "./companion-picker";
6
16
  import { CompanionSelector } from "./companion-selector";
@@ -9,9 +19,18 @@ import { CompanionError } from "./error";
9
19
  import { formatCollectedAt, type StudioPage, type StudioVaultPage } from "./model";
10
20
  import type { VaultTreeNode } from "../vault";
11
21
  import { Specs } from "./specs/index";
22
+ import { SelectionFields } from "./selection-fields";
12
23
  import { STUDIO_STYLES } from "./styles";
13
24
  import { Skills } from "./skills/index";
14
25
  import { Workflow } from "./workflow/index";
26
+ import {
27
+ STUDIO_TERMINAL_PREPAINT_SCRIPT,
28
+ STUDIO_TERMINAL_SCRIPT,
29
+ STUDIO_TERMINAL_SIDEBAR_SCRIPT,
30
+ TerminalDrawerButton,
31
+ TerminalSidebar,
32
+ } from "./terminal";
33
+ import { TERMINAL_SCRIPTS, TERMINAL_STYLESHEET } from "../terminal-assets";
15
34
 
16
35
  const STUDIO_TITLE = "Mate Studio";
17
36
 
@@ -46,6 +65,13 @@ const VIEW_DETAILS = {
46
65
  * escape hatch around the renderer's escaping.
47
66
  */
48
67
  export function renderStudioDocument(page: StudioPage): string {
68
+ /** The terminal client is Studio's only library, served by Studio itself and only with the terminal. */
69
+ const terminalHead = page.terminal
70
+ ? `\n<link rel="stylesheet" href="${TERMINAL_STYLESHEET}">\n<script>${STUDIO_TERMINAL_PREPAINT_SCRIPT}</script>`
71
+ : "";
72
+ const terminalBody = page.terminal
73
+ ? `${TERMINAL_SCRIPTS.map((src) => `\n<script src="${src}"></script>`).join("")}\n<script>${STUDIO_TERMINAL_SIDEBAR_SCRIPT}</script>\n<script>${STUDIO_TERMINAL_SCRIPT}</script>`
74
+ : "";
49
75
  return `<!doctype html>
50
76
  <html lang="en">
51
77
  <head>
@@ -53,10 +79,10 @@ export function renderStudioDocument(page: StudioPage): string {
53
79
  <meta name="viewport" content="width=device-width, initial-scale=1">
54
80
  <title>${STUDIO_TITLE}</title>
55
81
  <style>${STUDIO_STYLES}</style>
56
- <script>${STUDIO_PREPAINT_SCRIPT}</script>
82
+ <script>${STUDIO_PREPAINT_SCRIPT}</script>${terminalHead}
57
83
  </head>
58
84
  <body>${String(<StudioShell page={page} />)}
59
- <script>${STUDIO_CLIENT_SCRIPT}</script>
85
+ <script>${STUDIO_CLIENT_SCRIPT}</script>${terminalBody}
60
86
  </body>
61
87
  </html>
62
88
  `;
@@ -72,13 +98,16 @@ function StudioShell({ page }: { page: StudioPage }) {
72
98
  <div
73
99
  className="shell"
74
100
  data-companion={page.companion ? companionDigest(page.companion.path) : undefined}
101
+ data-terminal={page.terminal ? "" : undefined}
75
102
  >
76
103
  <Sidebar page={page} />
77
104
  <main className="main">
78
105
  {page.companion ? <PageHeader page={page} /> : null}
79
106
  <Content page={page} />
80
107
  </main>
108
+ {page.terminal ? <TerminalSidebar terminal={page.terminal} /> : null}
81
109
  </div>
110
+ {page.terminal ? <TerminalDrawerButton /> : null}
82
111
  <div className="toast" id="studio-toast" data-shown="false" />
83
112
  </>
84
113
  );
@@ -91,7 +120,7 @@ function Content({ page }: { page: StudioPage }) {
91
120
  if (page.error) {
92
121
  return <CompanionError companionPath={page.error.companionPath} reason={page.error.reason} />;
93
122
  }
94
- if (page.selection.view === "vault") return <Vault page={page} />;
123
+ if (page.selection.view === "vault") return <Vault page={page} selection={page.selection} />;
95
124
  if (!page.payload) {
96
125
  return (
97
126
  <section className="panel">
@@ -150,49 +179,32 @@ function PageHeader({ page }: { page: StudioPage }) {
150
179
  );
151
180
  }
152
181
 
182
+ const VIEW_NAV: readonly (readonly [StudioView, string])[] = [
183
+ ["dashboard", "Overview"],
184
+ ["vault", "Vault"],
185
+ ["specs", "Specs"],
186
+ ["workflow", "Workflow"],
187
+ ["skills", "Skills"],
188
+ ];
189
+
153
190
  /**
154
- * Submit buttons, so switching a view is a navigation to the URL naming it and
155
- * the browser's history moves between rendered states.
191
+ * One GET form per view, so switching a view is a navigation to the URL naming
192
+ * it and the browser's history moves between rendered states.
156
193
  */
157
194
  function ViewNav({ selection }: { selection: StudioSelection }) {
158
195
  return (
159
196
  <nav className="sidebar-nav" aria-label="Studio views">
160
197
  <span className="sidebar-label">Views</span>
161
- <form method="get" action="/">
162
- <SelectionFields selection={selection} omit="view" dropOpenPath />
163
- <div className="sidebar-nav-list">
164
- <button
165
- type="submit"
166
- name="view"
167
- value="dashboard"
168
- aria-pressed={selection.view === "dashboard"}
169
- >
170
- <span>Overview</span>
171
- </button>
172
- <button type="submit" name="view" value="vault" aria-pressed={selection.view === "vault"}>
173
- <span>Vault</span>
174
- </button>
175
- <button type="submit" name="view" value="specs" aria-pressed={selection.view === "specs"}>
176
- <span>Specs</span>
177
- </button>
178
- <button
179
- type="submit"
180
- name="view"
181
- value="workflow"
182
- aria-pressed={selection.view === "workflow"}
183
- >
184
- <span>Workflow</span>
185
- </button>
186
- <button
187
- type="submit"
188
- name="view"
189
- value="skills"
190
- aria-pressed={selection.view === "skills"}
191
- >
192
- <span>Skills</span>
193
- </button>
194
- </div>
195
- </form>
198
+ <div className="sidebar-nav-list">
199
+ {VIEW_NAV.map(([view, label]) => (
200
+ <form key={view} method="get" action="/" data-studio-view={view}>
201
+ <SelectionFields selection={switchView(selection, view)} />
202
+ <button type="submit" aria-pressed={selection.view === view}>
203
+ <span>{label}</span>
204
+ </button>
205
+ </form>
206
+ ))}
207
+ </div>
196
208
  </nav>
197
209
  );
198
210
  }
@@ -218,10 +230,7 @@ function Metrics({ page }: { page: StudioPage }) {
218
230
  );
219
231
  }
220
232
 
221
- /**
222
- * The refresh control is the only thing that collects again, so it is the only
223
- * control that names the refresh parameter.
224
- */
233
+ /** The refresh control is the only thing that collects again, so only it leads to a refresh. */
225
234
  function Footer({ page }: { page: StudioPage }) {
226
235
  return (
227
236
  <div className="sidebar-footer">
@@ -232,8 +241,7 @@ function Footer({ page }: { page: StudioPage }) {
232
241
  : `state as of ${formatCollectedAt(page.collectedAt)}`}
233
242
  </span>
234
243
  <form method="get" action="/">
235
- <SelectionFields selection={page.selection} />
236
- <input type="hidden" name={REFRESH_PARAM} value="1" />
244
+ <SelectionFields selection={refresh(page.selection)} />
237
245
  <button type="submit">Refresh</button>
238
246
  </form>
239
247
  </div>
@@ -244,32 +252,67 @@ function Footer({ page }: { page: StudioPage }) {
244
252
  );
245
253
  }
246
254
 
247
- /** Carries the parts of the selection a control is not itself changing. */
248
- function SelectionFields({
249
- selection,
250
- omit,
251
- dropOpenPath = false,
252
- }: {
253
- selection: StudioSelection;
254
- omit?: keyof StudioSelection;
255
- dropOpenPath?: boolean;
256
- }) {
257
- return (
255
+ /** The tree and folder listing, fetched by the page when it was served before the listing finished. */
256
+ export function renderVaultView(page: StudioPage): string {
257
+ const { selection, vault } = page;
258
+ if (!vault || selection.view !== "vault") return "";
259
+ return String(
258
260
  <>
259
- {selection.companionDigest && omit !== "companionDigest" ? (
260
- <input type="hidden" name="companion" value={selection.companionDigest} />
261
- ) : null}
262
- {selection.view !== "dashboard" && omit !== "view" ? (
263
- <input type="hidden" name="view" value={selection.view} />
264
- ) : null}
265
- {selection.view === "vault" && selection.openPath && !dropOpenPath ? (
266
- <input type="hidden" name={FILE_PARAM} value={selection.openPath} />
267
- ) : null}
268
- </>
261
+ <VaultTreeSlot vault={vault} selection={selection} />
262
+ {vault.open || vault.refusal || selection.openPath ? null : (
263
+ <VaultListingSlot selection={selection} vault={vault} />
264
+ )}
265
+ </>,
269
266
  );
270
267
  }
271
268
 
272
- function Vault({ page }: { page: StudioPage }) {
269
+ function segments(relative: string): string[] {
270
+ return relative.split(/[\\/]/).filter(Boolean);
271
+ }
272
+
273
+ /** Walks node names, so a requested folder is only ever one the listed tree holds. */
274
+ function findDirectory(tree: VaultTreeNode[], relative: string): VaultTreeNode | null {
275
+ let level = tree;
276
+ let found: VaultTreeNode | null = null;
277
+ for (const name of segments(relative)) {
278
+ found = level.find((node) => node.kind === "directory" && node.name === name) ?? null;
279
+ if (!found) return null;
280
+ level = found.children ?? [];
281
+ }
282
+ return found;
283
+ }
284
+
285
+ function expandedPaths(selection: StudioVaultSelection): Set<string> {
286
+ const expanded = new Set<string>();
287
+ const add = (relative: string, includeLast: boolean) => {
288
+ const parts = segments(relative);
289
+ const count = includeLast ? parts.length : parts.length - 1;
290
+ for (let index = 1; index <= count; index += 1) expanded.add(parts.slice(0, index).join("/"));
291
+ };
292
+ if (selection.openPath) add(selection.openPath, false);
293
+ if (selection.openDir) add(selection.openDir, true);
294
+ return expanded;
295
+ }
296
+
297
+ function byKindThenName(a: VaultTreeNode, b: VaultTreeNode): number {
298
+ if (a.kind !== b.kind) return a.kind === "directory" ? -1 : 1;
299
+ return a.name.localeCompare(b.name);
300
+ }
301
+
302
+ /** Drawn in CSS: one empty element per entry keeps a large tree's markup small. */
303
+ function FolderIcon() {
304
+ return <span className="vault-icon vault-icon-folder" aria-hidden="true" />;
305
+ }
306
+
307
+ function FileIcon() {
308
+ return <span className="vault-icon vault-icon-file" aria-hidden="true" />;
309
+ }
310
+
311
+ function Chevron() {
312
+ return <span className="vault-chevron" aria-hidden="true" />;
313
+ }
314
+
315
+ function Vault({ page, selection }: { page: StudioPage; selection: StudioVaultSelection }) {
273
316
  const vault = page.vault;
274
317
  if (!vault)
275
318
  return (
@@ -277,65 +320,325 @@ function Vault({ page }: { page: StudioPage }) {
277
320
  <p className="empty">No vault state collected.</p>
278
321
  </section>
279
322
  );
323
+ const showListing = !vault.open && !vault.refusal;
280
324
  return (
281
- <section className="vault-layout">
282
- <div className="panel vault-tree-panel">
283
- <div className="section-header">
284
- <div>
285
- <h3>Markdown files</h3>
286
- <p className="section-note">
287
- {vault.tree.length === 0
288
- ? "This companion has no markdown files."
289
- : "Every non-ignored markdown file in this companion."}
290
- </p>
291
- </div>
292
- {!vault.watching ? (
293
- <form method="get" action="/" data-studio-navigation>
294
- <SelectionFields selection={page.selection} />
295
- <input type="hidden" name={REFRESH_PARAM} value="1" />
296
- <button type="submit">Refresh tree</button>
297
- </form>
298
- ) : null}
325
+ <section
326
+ className="vault-layout"
327
+ data-vault-layout
328
+ data-vault-view={vault.tree === null ? toHref(selection, "/api/vault/view") : undefined}
329
+ >
330
+ <aside className="panel vault-tree-panel" id="vault-tree-panel" aria-label="Files">
331
+ <div className="vault-tree-head">
332
+ <strong>Files</strong>
299
333
  </div>
300
- {vault.warning ? (
301
- <p className="note vault-warning">{vault.warning}. The tree may be out of date.</p>
302
- ) : null}
303
- <nav aria-label="Markdown files">
304
- {vault.tree.map((node) => (
305
- <VaultNode key={node.path} node={node} selection={page.selection} />
306
- ))}
307
- </nav>
334
+ <input
335
+ type="search"
336
+ id="vault-filter"
337
+ className="vault-filter"
338
+ placeholder="Go to file"
339
+ aria-label="Go to file"
340
+ hidden
341
+ />
342
+ <VaultTreeSlot vault={vault} selection={selection} />
343
+ </aside>
344
+ <div className="vault-main">
345
+ <div className="vault-bar">
346
+ <button
347
+ type="button"
348
+ id="vault-tree-toggle"
349
+ className="vault-tree-toggle"
350
+ aria-controls="vault-tree-panel"
351
+ aria-expanded="true"
352
+ hidden
353
+ >
354
+ Hide files
355
+ </button>
356
+ <VaultBreadcrumb page={page} selection={selection} />
357
+ </div>
358
+ {showListing ? <VaultListingSlot selection={selection} vault={vault} /> : null}
359
+ {showListing ? null : <VaultEditor page={page} selection={selection} vault={vault} />}
308
360
  </div>
309
- <VaultEditor page={page} vault={vault} />
310
361
  </section>
311
362
  );
312
363
  }
313
364
 
314
- function VaultNode({ node, selection }: { node: VaultTreeNode; selection: StudioSelection }) {
365
+ function RefreshTree({ selection }: { selection: StudioVaultSelection }) {
366
+ return (
367
+ <form method="get" action="/" data-studio-navigation>
368
+ <SelectionFields selection={refresh(selection)} />
369
+ <button type="submit">Refresh tree</button>
370
+ </form>
371
+ );
372
+ }
373
+
374
+ function VaultTreeSlot({
375
+ vault,
376
+ selection,
377
+ }: {
378
+ vault: StudioVaultPage;
379
+ selection: StudioVaultSelection;
380
+ }) {
381
+ if (vault.failure) {
382
+ return (
383
+ <div data-vault-slot="tree">
384
+ <p className="note vault-warning">The files could not be listed: {vault.failure}</p>
385
+ <RefreshTree selection={selection} />
386
+ </div>
387
+ );
388
+ }
389
+ if (vault.tree === null) {
390
+ return (
391
+ <div data-vault-slot="tree" aria-busy="true">
392
+ <p className="empty">Listing files…</p>
393
+ </div>
394
+ );
395
+ }
396
+ const expanded = expandedPaths(selection);
397
+ return (
398
+ <div data-vault-slot="tree">
399
+ {vault.warning ? (
400
+ <p className="note vault-warning">{vault.warning}. The tree may be out of date.</p>
401
+ ) : null}
402
+ {!vault.watching ? <RefreshTree selection={selection} /> : null}
403
+ {vault.tree.length === 0 ? (
404
+ <p className="empty">This companion has no markdown files.</p>
405
+ ) : (
406
+ <nav className="vault-tree" aria-label="Markdown files">
407
+ {vault.tree.toSorted(byKindThenName).map((node) => (
408
+ <VaultNode key={node.path} node={node} selection={selection} expanded={expanded} />
409
+ ))}
410
+ </nav>
411
+ )}
412
+ </div>
413
+ );
414
+ }
415
+
416
+ function VaultNode({
417
+ node,
418
+ selection,
419
+ expanded,
420
+ }: {
421
+ node: VaultTreeNode;
422
+ selection: StudioVaultSelection;
423
+ expanded: Set<string>;
424
+ }) {
425
+ const key = segments(node.path).join("/");
315
426
  if (node.kind === "directory") {
427
+ const open = expanded.has(key);
316
428
  return (
317
- <details open className="vault-directory">
318
- <summary>{node.name}</summary>
429
+ <details
430
+ className="vault-directory"
431
+ open={open}
432
+ data-vault-dir={key}
433
+ data-vault-expanded={open ? "" : undefined}
434
+ >
435
+ <summary
436
+ aria-current={
437
+ selection.openDir && segments(selection.openDir).join("/") === key ? "page" : undefined
438
+ }
439
+ >
440
+ <Chevron />
441
+ <FolderIcon />
442
+ <span>{node.name}</span>
443
+ </summary>
319
444
  <div className="vault-children">
320
- {node.children?.map((child) => (
321
- <VaultNode key={child.path} node={child} selection={selection} />
445
+ {(node.children ?? []).toSorted(byKindThenName).map((child) => (
446
+ <VaultNode key={child.path} node={child} selection={selection} expanded={expanded} />
322
447
  ))}
323
448
  </div>
324
449
  </details>
325
450
  );
326
451
  }
327
452
  return (
328
- <form method="get" action="/" className="vault-file" data-studio-navigation>
329
- <SelectionFields selection={selection} dropOpenPath />
330
- <input type="hidden" name={FILE_PARAM} value={node.path} />
453
+ <form
454
+ method="get"
455
+ action="/"
456
+ className="vault-file"
457
+ data-studio-navigation
458
+ data-vault-entry={key}
459
+ >
460
+ <SelectionFields selection={openFile(selection, node.path)} />
331
461
  <button type="submit" aria-current={selection.openPath === node.path ? "page" : undefined}>
332
- {node.name}
462
+ <FileIcon />
463
+ <span>{node.name}</span>
464
+ </button>
465
+ </form>
466
+ );
467
+ }
468
+
469
+ function OpenFolder({
470
+ selection,
471
+ relative,
472
+ className,
473
+ children,
474
+ }: {
475
+ selection: StudioVaultSelection;
476
+ relative: string | null;
477
+ className?: string;
478
+ children: unknown;
479
+ }) {
480
+ return (
481
+ <form method="get" action="/" className={className} data-studio-navigation>
482
+ <SelectionFields selection={openFolder(selection, relative)} />
483
+ <button type="submit">{children}</button>
484
+ </form>
485
+ );
486
+ }
487
+
488
+ /** Built from the selection alone, so it renders before the tree is listed. */
489
+ function VaultBreadcrumb({
490
+ page,
491
+ selection,
492
+ }: {
493
+ page: StudioPage;
494
+ selection: StudioVaultSelection;
495
+ }) {
496
+ const current = selection.openPath ?? selection.openDir ?? "";
497
+ const parts = segments(current);
498
+ const rootName = page.companion
499
+ ? (segments(page.companion.path).at(-1) ?? "companion")
500
+ : "companion";
501
+ return (
502
+ <nav className="vault-breadcrumb" aria-label="Breadcrumb">
503
+ <ol>
504
+ <li>
505
+ {parts.length === 0 ? (
506
+ <span aria-current="page">{rootName}</span>
507
+ ) : (
508
+ <OpenFolder selection={selection} relative={null} className="vault-crumb">
509
+ {rootName}
510
+ </OpenFolder>
511
+ )}
512
+ </li>
513
+ {parts.map((name, index) => (
514
+ <li key={parts.slice(0, index + 1).join("/")}>
515
+ {index === parts.length - 1 ? (
516
+ <span aria-current="page">{name}</span>
517
+ ) : (
518
+ <OpenFolder
519
+ selection={selection}
520
+ relative={parts.slice(0, index + 1).join("/")}
521
+ className="vault-crumb"
522
+ >
523
+ {name}
524
+ </OpenFolder>
525
+ )}
526
+ </li>
527
+ ))}
528
+ </ol>
529
+ </nav>
530
+ );
531
+ }
532
+
533
+ function VaultListingSlot({
534
+ selection,
535
+ vault,
536
+ }: {
537
+ selection: StudioVaultSelection;
538
+ vault: StudioVaultPage;
539
+ }) {
540
+ if (vault.failure) {
541
+ return (
542
+ <div data-vault-slot="listing" className="panel vault-listing">
543
+ <p className="note vault-warning">The files could not be listed: {vault.failure}</p>
544
+ </div>
545
+ );
546
+ }
547
+ if (vault.tree === null) {
548
+ return (
549
+ <div data-vault-slot="listing" className="panel vault-listing" aria-busy="true">
550
+ <p className="empty">Listing files…</p>
551
+ </div>
552
+ );
553
+ }
554
+ const requested = selection.openDir ? findDirectory(vault.tree, selection.openDir) : null;
555
+ const entries = (requested ? (requested.children ?? []) : vault.tree).toSorted(byKindThenName);
556
+ const parts = requested ? segments(selection.openDir ?? "") : [];
557
+ return (
558
+ <div data-vault-slot="listing" className="panel vault-listing">
559
+ {selection.openDir !== null && requested === null ? (
560
+ <p className="note vault-warning">That folder is not in the tree; showing the root.</p>
561
+ ) : null}
562
+ {entries.length === 0 && parts.length === 0 ? (
563
+ <p className="empty">This companion has no markdown files.</p>
564
+ ) : (
565
+ <VaultListingRows selection={selection} parts={parts} entries={entries} />
566
+ )}
567
+ </div>
568
+ );
569
+ }
570
+
571
+ function VaultListingRows({
572
+ selection,
573
+ parts,
574
+ entries,
575
+ }: {
576
+ selection: StudioVaultSelection;
577
+ parts: string[];
578
+ entries: VaultTreeNode[];
579
+ }) {
580
+ return (
581
+ <ul className="vault-rows" aria-label="Folder contents">
582
+ {parts.length > 0 ? (
583
+ <li>
584
+ <OpenFolder
585
+ selection={selection}
586
+ relative={parts.length > 1 ? parts.slice(0, -1).join("/") : null}
587
+ className="vault-row"
588
+ >
589
+ <FolderIcon />
590
+ <span>..</span>
591
+ </OpenFolder>
592
+ </li>
593
+ ) : null}
594
+ {entries.map((node) => (
595
+ <li key={node.path}>
596
+ <VaultListingEntry selection={selection} node={node} />
597
+ </li>
598
+ ))}
599
+ </ul>
600
+ );
601
+ }
602
+
603
+ function VaultListingEntry({
604
+ selection,
605
+ node,
606
+ }: {
607
+ selection: StudioVaultSelection;
608
+ node: VaultTreeNode;
609
+ }) {
610
+ if (node.kind === "directory") {
611
+ return (
612
+ <OpenFolder
613
+ selection={selection}
614
+ relative={segments(node.path).join("/")}
615
+ className="vault-row"
616
+ >
617
+ <FolderIcon />
618
+ <span>{node.name}</span>
619
+ </OpenFolder>
620
+ );
621
+ }
622
+ return (
623
+ <form method="get" action="/" className="vault-row" data-studio-navigation>
624
+ <SelectionFields selection={openFile(selection, node.path)} />
625
+ <button type="submit">
626
+ <FileIcon />
627
+ <span>{node.name}</span>
333
628
  </button>
334
629
  </form>
335
630
  );
336
631
  }
337
632
 
338
- function VaultEditor({ page, vault }: { page: StudioPage; vault: StudioVaultPage }) {
633
+ function VaultEditor({
634
+ page,
635
+ selection,
636
+ vault,
637
+ }: {
638
+ page: StudioPage;
639
+ selection: StudioVaultSelection;
640
+ vault: StudioVaultPage;
641
+ }) {
339
642
  if (!vault.open) {
340
643
  return (
341
644
  <div className="panel vault-editor-panel">
@@ -354,7 +657,7 @@ function VaultEditor({ page, vault }: { page: StudioPage; vault: StudioVaultPage
354
657
  >
355
658
  <div className="section-header">
356
659
  <div>
357
- <h3>{vault.open.path}</h3>
660
+ <h3>{segments(vault.open.path).at(-1) ?? vault.open.path}</h3>
358
661
  <p className="section-note">
359
662
  {page.writable
360
663
  ? "Changes stay in the editor until you save."
@@ -371,6 +674,7 @@ function VaultEditor({ page, vault }: { page: StudioPage; vault: StudioVaultPage
371
674
  id="vault-editor"
372
675
  data-vault-editor
373
676
  data-vault-path={vault.open.path}
677
+ data-vault-events={toHref(openFile(selection, vault.open.path), "/api/vault/events")}
374
678
  data-vault-token={vault.open.token}
375
679
  {...(page.writable ? {} : { readOnly: true })}
376
680
  spellCheck={false}
@@ -1,6 +1,7 @@
1
1
  import type { StudioInventory, StudioInventoryCompanion } from "../inventory";
2
2
  import type { StudioCompanionPayload } from "../payload";
3
3
  import type { StudioSelection } from "../selection";
4
+ import type { TerminalAgent } from "../terminal";
4
5
  import type { VaultFile, VaultTreeNode } from "../vault";
5
6
 
6
7
  /**
@@ -18,10 +19,22 @@ export interface StudioPage {
18
19
  collectedAt: number | null;
19
20
  writable: boolean;
20
21
  vault: StudioVaultPage | null;
22
+ /** `null` unless the terminal is enabled. */
23
+ terminal: StudioTerminalPage | null;
24
+ }
25
+
26
+ export interface StudioTerminalPage {
27
+ /** The launch companion was pinned at start and ignores the page selection. */
28
+ pinned: boolean;
29
+ target: { path: string; digest: string } | null;
30
+ agents: TerminalAgent[];
21
31
  }
22
32
 
23
33
  export interface StudioVaultPage {
24
- tree: VaultTreeNode[];
34
+ /** `null` while the listing runs; the page then fetches the tree separately. */
35
+ tree: VaultTreeNode[] | null;
36
+ /** Why the listing failed, whether requested in the page or separately. */
37
+ failure?: string | null;
25
38
  open: VaultFile | null;
26
39
  refusal: string | null;
27
40
  incoming: VaultFile | null;