@animalabs/connectome-host 0.3.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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Context panel — the makeup of the agent's current compiled context.
3
+ *
4
+ * Reads GET /debug/context/makeup (transparent: previewActivation +
5
+ * count_tokens, no inference / no writes) and shows the segment breakdown the
6
+ * autobiographical strategy produced: head window, raw middle, summaries by
7
+ * level (L1/L2/L3), and the recent verbatim tail — as a proportional bar plus
8
+ * a per-segment table, with an exact total token count.
9
+ */
10
+
11
+ import { createSignal, onMount, For, Show } from 'solid-js';
12
+
13
+ interface Seg { messages: number; tokens: number }
14
+ interface Stats {
15
+ head: Seg;
16
+ tail: Seg;
17
+ middleRaw: Seg;
18
+ summaries: { l1: { count: number; tokens: number }; l2: { count: number; tokens: number }; l3: { count: number; tokens: number } };
19
+ pending: { chunks: number; merges: number };
20
+ total: Seg;
21
+ }
22
+ interface Makeup {
23
+ agent: string;
24
+ stats: Stats | null;
25
+ exactTotalTokens: number | null;
26
+ countModel: string;
27
+ countSource: string;
28
+ error?: string;
29
+ }
30
+
31
+ type Row = { key: string; label: string; messages: number; tokens: number; color: string };
32
+
33
+ const fmt = (n: number) => n.toLocaleString();
34
+
35
+ function rowsOf(s: Stats): Row[] {
36
+ return [
37
+ { key: 'head', label: 'Head (oldest, verbatim)', messages: s.head.messages, tokens: s.head.tokens, color: '#64748b' },
38
+ { key: 'l1', label: 'Summaries · L1', messages: s.summaries.l1.count, tokens: s.summaries.l1.tokens, color: '#06b6d4' },
39
+ { key: 'l2', label: 'Summaries · L2', messages: s.summaries.l2.count, tokens: s.summaries.l2.tokens, color: '#3b82f6' },
40
+ { key: 'l3', label: 'Summaries · L3', messages: s.summaries.l3.count, tokens: s.summaries.l3.tokens, color: '#6366f1' },
41
+ { key: 'middleRaw', label: 'Middle (raw, verbatim)', messages: s.middleRaw.messages, tokens: s.middleRaw.tokens, color: '#f59e0b' },
42
+ { key: 'tail', label: 'Recent tail (verbatim)', messages: s.tail.messages, tokens: s.tail.tokens, color: '#10b981' },
43
+ ].filter((r) => r.tokens > 0 || r.messages > 0);
44
+ }
45
+
46
+ export function ContextPanel(props: { agent?: string }) {
47
+ const [data, setData] = createSignal<Makeup | null>(null);
48
+ const [loading, setLoading] = createSignal(false);
49
+ const [err, setErr] = createSignal<string | null>(null);
50
+
51
+ const load = async () => {
52
+ setLoading(true);
53
+ setErr(null);
54
+ try {
55
+ const q = props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
56
+ const res = await fetch(`/debug/context/makeup${q}`, { credentials: 'same-origin' });
57
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
58
+ const j = (await res.json()) as Makeup;
59
+ if (j.error) throw new Error(j.error);
60
+ setData(j);
61
+ } catch (e) {
62
+ setErr(e instanceof Error ? e.message : String(e));
63
+ } finally {
64
+ setLoading(false);
65
+ }
66
+ };
67
+
68
+ onMount(load);
69
+
70
+ const estTotal = () => data()?.stats?.total.tokens ?? 0;
71
+ const rows = () => {
72
+ const s = data()?.stats;
73
+ return s ? rowsOf(s) : [];
74
+ };
75
+
76
+ return (
77
+ <div class="p-2 text-[11px] font-mono text-neutral-300 space-y-3">
78
+ <div class="flex items-center justify-between">
79
+ <span class="text-neutral-400">Context makeup{data()?.agent ? ` · ${data()!.agent}` : ''}</span>
80
+ <button
81
+ type="button"
82
+ class="px-2 py-0.5 text-neutral-400 hover:text-neutral-100 border border-neutral-700 rounded"
83
+ onClick={load}
84
+ disabled={loading()}
85
+ >
86
+ {loading() ? '…' : 'refresh'}
87
+ </button>
88
+ </div>
89
+
90
+ <Show when={err()}>
91
+ <div class="text-rose-400">error: {err()}</div>
92
+ </Show>
93
+
94
+ <Show when={data()?.stats} fallback={<Show when={!err()}><div class="text-neutral-500">loading…</div></Show>}>
95
+ {/* headline totals */}
96
+ <div class="flex gap-4">
97
+ <div>
98
+ <div class="text-2xl text-neutral-100">{fmt(data()!.exactTotalTokens ?? estTotal())}</div>
99
+ <div class="text-neutral-500">
100
+ {data()!.exactTotalTokens != null ? 'tokens (exact)' : 'tokens (est)'}
101
+ </div>
102
+ </div>
103
+ <div class="self-end text-neutral-500">
104
+ {fmt(data()!.stats!.total.messages)} msgs · est {fmt(estTotal())}
105
+ </div>
106
+ </div>
107
+
108
+ {/* proportional bar */}
109
+ <div class="flex h-4 w-full overflow-hidden rounded border border-neutral-800">
110
+ <For each={rows()}>
111
+ {(r) => (
112
+ <div
113
+ style={{ width: `${(r.tokens / Math.max(1, estTotal())) * 100}%`, background: r.color }}
114
+ title={`${r.label}: ${fmt(r.tokens)} tok`}
115
+ />
116
+ )}
117
+ </For>
118
+ </div>
119
+
120
+ {/* per-segment table */}
121
+ <table class="w-full">
122
+ <thead>
123
+ <tr class="text-neutral-500 text-left">
124
+ <th class="font-normal py-1">segment</th>
125
+ <th class="font-normal text-right">msgs</th>
126
+ <th class="font-normal text-right">tokens</th>
127
+ <th class="font-normal text-right">%</th>
128
+ </tr>
129
+ </thead>
130
+ <tbody>
131
+ <For each={rows()}>
132
+ {(r) => (
133
+ <tr class="border-t border-neutral-900">
134
+ <td class="py-1">
135
+ <span class="inline-block w-2 h-2 mr-2 rounded-sm align-middle" style={{ background: r.color }} />
136
+ {r.label}
137
+ </td>
138
+ <td class="text-right text-neutral-400">{fmt(r.messages)}</td>
139
+ <td class="text-right text-neutral-200">{fmt(r.tokens)}</td>
140
+ <td class="text-right text-neutral-500">{((r.tokens / Math.max(1, estTotal())) * 100).toFixed(1)}%</td>
141
+ </tr>
142
+ )}
143
+ </For>
144
+ </tbody>
145
+ </table>
146
+
147
+ <div class="text-neutral-500 space-y-0.5">
148
+ <div>
149
+ pending compression: {data()!.stats!.pending.chunks} chunk(s), {data()!.stats!.pending.merges} merge(s)
150
+ </div>
151
+ <div>
152
+ exact via count_tokens ({data()!.countModel}; same tokenizer) · per-segment tokens are strategy estimates
153
+ </div>
154
+ </div>
155
+ </Show>
156
+ </div>
157
+ );
158
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * ContextDocument — the agent's compiled context rendered in the MAIN pane, as
3
+ * a readable, navigable document.
4
+ *
5
+ * Combines GET /debug/context (the flat compiled messages) with
6
+ * /debug/context/makeup (segment token/message counts) to partition the flat
7
+ * list into Head | Middle | Recent zones (render order is head, then the folded
8
+ * middle, then the verbatim tail — the makeup counts give the boundaries). A
9
+ * sticky timeline at the top is a clickable minimap: each zone is sized by its
10
+ * token share and scrolls the document to that section. Summary recall-pairs in
11
+ * the middle are styled distinctly.
12
+ */
13
+
14
+ import { createSignal, onMount, For, Show } from 'solid-js';
15
+
16
+ interface Msg { participant?: string; role?: string; content: unknown }
17
+ interface Seg { messages: number; tokens: number }
18
+ interface Stats {
19
+ head: Seg; tail: Seg; middleRaw: Seg;
20
+ summaries: { l1: { count: number; tokens: number }; l2: { count: number; tokens: number }; l3: { count: number; tokens: number } };
21
+ total: Seg;
22
+ }
23
+
24
+ const fmt = (n: number) => n.toLocaleString();
25
+ const estTokens = (s: string) => Math.round(s.length / 3.6);
26
+ const SUMMARY_LABELS = ['What do you remember', 'Context Manager'];
27
+
28
+ function textOf(c: unknown): string {
29
+ if (Array.isArray(c)) return c.map((b) => (b && typeof b === 'object' && (b as { type?: string }).type === 'text' ? (b as { text: string }).text : (b && (b as { type?: string }).type === 'image' ? '[image]' : ''))).join('');
30
+ return String(c ?? '');
31
+ }
32
+
33
+ export function ContextDocument(props: { agent?: string; scrollRoot?: () => HTMLElement | undefined }) {
34
+ const [msgs, setMsgs] = createSignal<Msg[]>([]);
35
+ const [stats, setStats] = createSignal<Stats | null>(null);
36
+ const [exact, setExact] = createSignal<number | null>(null);
37
+ const [err, setErr] = createSignal<string | null>(null);
38
+ const [loading, setLoading] = createSignal(false);
39
+
40
+ const load = async () => {
41
+ setLoading(true); setErr(null);
42
+ try {
43
+ const q = props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
44
+ const [ctxRes, mkRes] = await Promise.all([
45
+ fetch(`/debug/context${q}`, { credentials: 'same-origin' }),
46
+ fetch(`/debug/context/makeup${q}`, { credentials: 'same-origin' }),
47
+ ]);
48
+ if (!ctxRes.ok) throw new Error(`context HTTP ${ctxRes.status}`);
49
+ const ctx = await ctxRes.json();
50
+ setMsgs((ctx?.request?.messages ?? []) as Msg[]);
51
+ if (mkRes.ok) { const mk = await mkRes.json(); setStats(mk.stats); setExact(mk.exactTotalTokens); }
52
+ } catch (e) {
53
+ setErr(e instanceof Error ? e.message : String(e));
54
+ } finally { setLoading(false); }
55
+ };
56
+ onMount(load);
57
+
58
+ // Zone boundaries from the makeup counts (render order: head | middle | tail).
59
+ const headN = () => stats()?.head.messages ?? 0;
60
+ const tailN = () => stats()?.tail.messages ?? 0;
61
+ const midStart = () => headN();
62
+ const midEnd = () => Math.max(headN(), msgs().length - tailN());
63
+ const zoneOf = (i: number): 'head' | 'middle' | 'tail' =>
64
+ i < headN() ? 'head' : i >= midEnd() ? 'tail' : 'middle';
65
+
66
+ const zones = () => {
67
+ const s = stats(); if (!s) return [];
68
+ const midTok = s.middleRaw.tokens + s.summaries.l1.tokens + s.summaries.l2.tokens + s.summaries.l3.tokens;
69
+ const tot = Math.max(1, s.total.tokens);
70
+ return [
71
+ { key: 'head', label: 'Head', tokens: s.head.tokens, color: '#64748b', pct: (s.head.tokens / tot) * 100 },
72
+ { key: 'middle', label: 'Middle (summaries + raw)', tokens: midTok, color: '#06b6d4', pct: (midTok / tot) * 100 },
73
+ { key: 'tail', label: 'Recent', tokens: s.tail.tokens, color: '#10b981', pct: (s.tail.tokens / tot) * 100 },
74
+ ];
75
+ };
76
+
77
+ const scrollTo = (zone: string) => {
78
+ const el = document.getElementById(`ctxseg-${zone}`);
79
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
80
+ };
81
+
82
+ const isSummary = (m: Msg) => {
83
+ const who = m.participant ?? '';
84
+ const t = textOf(m.content);
85
+ return SUMMARY_LABELS.some((l) => who.includes('Context Manager') || t.startsWith(l));
86
+ };
87
+
88
+ return (
89
+ <div class="text-sm text-neutral-300">
90
+ {/* sticky timeline minimap */}
91
+ <div class="sticky top-0 z-10 bg-neutral-950/95 backdrop-blur border-b border-neutral-800 pb-2 mb-3 -mx-1 px-1">
92
+ <div class="flex items-center justify-between text-[11px] font-mono text-neutral-500 mb-1">
93
+ <span>
94
+ context · {fmt(exact() ?? stats()?.total.tokens ?? 0)} tokens{exact() != null ? ' (exact)' : ''} · {fmt(msgs().length)} msgs
95
+ </span>
96
+ <button type="button" class="px-2 py-0.5 border border-neutral-700 rounded hover:text-neutral-200" onClick={load} disabled={loading()}>
97
+ {loading() ? '…' : 'refresh'}
98
+ </button>
99
+ </div>
100
+ <Show when={stats()}>
101
+ <div class="flex h-5 w-full overflow-hidden rounded border border-neutral-800 cursor-pointer text-[10px] font-mono">
102
+ <For each={zones()}>
103
+ {(z) => (
104
+ <div
105
+ class="flex items-center justify-center text-neutral-900 hover:brightness-125 transition"
106
+ style={{ width: `${Math.max(3, z.pct)}%`, background: z.color }}
107
+ title={`${z.label}: ${fmt(z.tokens)} tok — click to jump`}
108
+ onClick={() => scrollTo(z.key)}
109
+ >
110
+ <Show when={z.pct > 8}>{z.label}</Show>
111
+ </div>
112
+ )}
113
+ </For>
114
+ </div>
115
+ </Show>
116
+ </div>
117
+
118
+ <Show when={err()}><div class="text-rose-400 font-mono text-xs px-1">error: {err()}</div></Show>
119
+
120
+ {/* the document */}
121
+ <div class="space-y-2 px-1">
122
+ <For each={msgs()}>
123
+ {(m, i) => {
124
+ const zone = zoneOf(i());
125
+ const firstOfZone = i() === 0 || zoneOf(i() - 1) !== zone;
126
+ const summary = isSummary(m);
127
+ const who = m.participant ?? m.role ?? '?';
128
+ const t = textOf(m.content);
129
+ return (
130
+ <>
131
+ <Show when={firstOfZone}>
132
+ <div id={`ctxseg-${zone}`} class="pt-3 pb-1 text-[10px] font-mono uppercase tracking-wider text-neutral-600 border-t border-neutral-800/60">
133
+ {zone === 'head' ? 'Head — oldest, verbatim' : zone === 'tail' ? 'Recent — verbatim tail' : 'Middle — summaries + raw'}
134
+ </div>
135
+ </Show>
136
+ <div class={`rounded border px-3 py-2 ${summary ? 'border-cyan-900/60 bg-cyan-950/20' : 'border-neutral-800 bg-neutral-900/30'}`}>
137
+ <div class="flex items-center justify-between text-[10px] font-mono mb-1">
138
+ <span class={summary ? 'text-cyan-400' : 'text-neutral-400'}>{summary ? '◆ summary' : who}</span>
139
+ <span class="text-neutral-600">~{fmt(estTokens(t))} tok</span>
140
+ </div>
141
+ <div class="whitespace-pre-wrap text-[13px] leading-relaxed text-neutral-300">{t.slice(0, 4000)}{t.length > 4000 ? '…' : ''}</div>
142
+ </div>
143
+ </>
144
+ );
145
+ }}
146
+ </For>
147
+ </div>
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Files panel — workspace-aware browser for the parent process's mounts.
3
+ *
4
+ * Lists mounts, expands one at a time on click, builds a hierarchical tree
5
+ * from the flat workspace `ls -r` output, and lets the operator click a
6
+ * file to view its content in a centered modal.
7
+ *
8
+ * Reads only — write/edit/delete aren't surfaced here. Operators with that
9
+ * intent should be on the host shell anyway.
10
+ */
11
+
12
+ import { createSignal, For, Show } from 'solid-js';
13
+ import { ScopePicker } from './Lessons';
14
+
15
+ export interface Mount {
16
+ name: string;
17
+ path: string;
18
+ mode: string;
19
+ }
20
+
21
+ export interface FlatEntry {
22
+ path: string;
23
+ size: number;
24
+ }
25
+
26
+ export interface FileViewer {
27
+ path: string;
28
+ totalLines: number;
29
+ fromLine: number;
30
+ toLine: number;
31
+ content: string;
32
+ truncated: boolean;
33
+ }
34
+
35
+ export function FilesPanel(props: {
36
+ loaded: boolean;
37
+ moduleLoaded: boolean;
38
+ mounts: Mount[];
39
+ /** Currently-loaded tree, keyed by mount name. Populated lazily on
40
+ * expand-click — the parent component maintains the cache. */
41
+ treesByMount: Map<string, FlatEntry[]>;
42
+ /** Mounts that have been expanded at least once. */
43
+ expandedMounts: Set<string>;
44
+ /** Currently-selected scope ('local' or fleet child name). */
45
+ scope: string;
46
+ /** Selectable scopes — always includes 'local', plus every fleet child. */
47
+ scopes: Array<{ id: string; label: string }>;
48
+ onScopeChange(scope: string): void;
49
+ onRefreshMounts(): void;
50
+ onExpandMount(name: string): void;
51
+ onCollapseMount(name: string): void;
52
+ onOpenFile(path: string): void;
53
+ }) {
54
+ return (
55
+ <div class="h-full overflow-y-auto px-3 py-2 text-xs">
56
+ <div class="flex items-center gap-2 mb-2">
57
+ <span class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold">
58
+ workspace
59
+ </span>
60
+ <span class="text-neutral-600 text-[10px]">{props.mounts.length} mounts</span>
61
+ <button
62
+ type="button"
63
+ class="ml-auto px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
64
+ onClick={() => props.onRefreshMounts()}
65
+ >
66
+ refresh
67
+ </button>
68
+ </div>
69
+ <ScopePicker scope={props.scope} scopes={props.scopes} onChange={props.onScopeChange} />
70
+
71
+ <Show when={!props.loaded}>
72
+ <div class="text-neutral-600 italic">Loading…</div>
73
+ </Show>
74
+
75
+ <Show when={props.loaded && !props.moduleLoaded}>
76
+ <div class="text-neutral-600 italic">
77
+ WorkspaceModule not loaded in this recipe.
78
+ </div>
79
+ </Show>
80
+
81
+ <Show when={props.loaded && props.moduleLoaded && props.mounts.length === 0}>
82
+ <div class="text-neutral-600 italic">No mounts configured.</div>
83
+ </Show>
84
+
85
+ <div class="space-y-1">
86
+ <For each={props.mounts}>{(mount) => (
87
+ <MountSection
88
+ mount={mount}
89
+ expanded={props.expandedMounts.has(mount.name)}
90
+ entries={props.treesByMount.get(mount.name) ?? []}
91
+ onToggle={() => {
92
+ if (props.expandedMounts.has(mount.name)) props.onCollapseMount(mount.name);
93
+ else props.onExpandMount(mount.name);
94
+ }}
95
+ onOpenFile={props.onOpenFile}
96
+ />
97
+ )}</For>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ function MountSection(props: {
104
+ mount: Mount;
105
+ expanded: boolean;
106
+ entries: FlatEntry[];
107
+ onToggle(): void;
108
+ onOpenFile(path: string): void;
109
+ }) {
110
+ const tree = (): TreeNode => buildTree(props.entries);
111
+ return (
112
+ <div class="border border-neutral-800 rounded">
113
+ <button
114
+ type="button"
115
+ class="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-neutral-900/50"
116
+ onClick={() => props.onToggle()}
117
+ >
118
+ <span class="text-neutral-500">{props.expanded ? '▾' : '▸'}</span>
119
+ <span class="font-mono text-cyan-300 truncate">{props.mount.name}</span>
120
+ <span class="text-[10px] text-neutral-600">{props.mount.mode}</span>
121
+ <span class="ml-auto text-[10px] text-neutral-600 truncate" title={props.mount.path}>
122
+ {props.mount.path}
123
+ </span>
124
+ </button>
125
+ <Show when={props.expanded}>
126
+ <div class="px-2 py-1 border-t border-neutral-800 bg-neutral-950/40">
127
+ <Show when={props.entries.length === 0}>
128
+ <div class="text-[11px] text-neutral-600 italic">empty</div>
129
+ </Show>
130
+ <For each={tree().children}>{(child) => (
131
+ <TreeRow node={child} depth={0} mountName={props.mount.name} onOpenFile={props.onOpenFile} />
132
+ )}</For>
133
+ </div>
134
+ </Show>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ interface TreeNode {
140
+ /** Empty string for the synthetic root. */
141
+ name: string;
142
+ /** Full path rooted at the mount (so the row can pass it to onOpenFile). */
143
+ fullPath: string;
144
+ isDir: boolean;
145
+ size?: number;
146
+ children: TreeNode[];
147
+ }
148
+
149
+ function buildTree(entries: FlatEntry[]): TreeNode {
150
+ const root: TreeNode = { name: '', fullPath: '', isDir: true, children: [] };
151
+ for (const entry of entries) {
152
+ const parts = entry.path.split('/').filter(p => p.length > 0);
153
+ let cursor = root;
154
+ for (let i = 0; i < parts.length; i++) {
155
+ const part = parts[i]!;
156
+ const isLast = i === parts.length - 1;
157
+ let next = cursor.children.find(c => c.name === part);
158
+ if (!next) {
159
+ next = {
160
+ name: part,
161
+ fullPath: parts.slice(0, i + 1).join('/'),
162
+ isDir: !isLast,
163
+ ...(isLast ? { size: entry.size } : {}),
164
+ children: [],
165
+ };
166
+ cursor.children.push(next);
167
+ }
168
+ cursor = next;
169
+ }
170
+ }
171
+ // Sort children: dirs first, then files, alpha within each.
172
+ const sortNode = (node: TreeNode): void => {
173
+ node.children.sort((a, b) => {
174
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
175
+ return a.name.localeCompare(b.name);
176
+ });
177
+ for (const c of node.children) sortNode(c);
178
+ };
179
+ sortNode(root);
180
+ return root;
181
+ }
182
+
183
+ function TreeRow(props: { node: TreeNode; depth: number; mountName: string; onOpenFile(path: string): void }) {
184
+ const [expanded, setExpanded] = createSignal(props.depth < 1);
185
+ const indent = (): string => `${0.25 + props.depth * 0.75}rem`;
186
+ const fullMountPath = (): string => `${props.mountName}/${props.node.fullPath}`;
187
+ return (
188
+ <div>
189
+ <Show when={props.node.isDir} fallback={
190
+ <button
191
+ type="button"
192
+ class="w-full flex items-center gap-2 py-0.5 hover:bg-neutral-900/60 text-left rounded"
193
+ style={{ 'padding-left': indent() }}
194
+ onClick={() => props.onOpenFile(fullMountPath())}
195
+ title={fullMountPath()}
196
+ >
197
+ <span class="text-neutral-500 w-3 inline-block" />
198
+ <span class="font-mono text-neutral-300 truncate">{props.node.name}</span>
199
+ <Show when={typeof props.node.size === 'number'}>
200
+ <span class="ml-auto text-[10px] text-neutral-600">{fmtSize(props.node.size!)}</span>
201
+ </Show>
202
+ </button>
203
+ }>
204
+ <button
205
+ type="button"
206
+ class="w-full flex items-center gap-2 py-0.5 hover:bg-neutral-900/60 text-left rounded"
207
+ style={{ 'padding-left': indent() }}
208
+ onClick={() => setExpanded(s => !s)}
209
+ >
210
+ <span class="text-neutral-500 w-3 inline-block">{expanded() ? '▾' : '▸'}</span>
211
+ <span class="font-mono text-cyan-200 truncate">{props.node.name}/</span>
212
+ </button>
213
+ <Show when={expanded()}>
214
+ <For each={props.node.children}>{(child) => (
215
+ <TreeRow node={child} depth={props.depth + 1} mountName={props.mountName} onOpenFile={props.onOpenFile} />
216
+ )}</For>
217
+ </Show>
218
+ </Show>
219
+ </div>
220
+ );
221
+ }
222
+
223
+ export function FileViewerModal(props: {
224
+ file: FileViewer | null;
225
+ loading: boolean;
226
+ onClose(): void;
227
+ }) {
228
+ return (
229
+ <Show when={props.loading || props.file}>
230
+ <div class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => props.onClose()}>
231
+ <div class="bg-neutral-950 border border-neutral-700 rounded-lg shadow-2xl w-[80vw] h-[80vh] m-4 flex flex-col" onClick={(e) => e.stopPropagation()}>
232
+ <div class="border-b border-neutral-800 px-3 py-2 flex items-center gap-2">
233
+ <span class="text-[10px] uppercase tracking-wider text-neutral-500 font-semibold">file</span>
234
+ <span class="font-mono text-sm text-neutral-200 truncate">
235
+ {props.file?.path ?? 'loading…'}
236
+ </span>
237
+ <Show when={props.file}>
238
+ <span class="text-[10px] text-neutral-600">
239
+ lines {props.file!.fromLine}–{props.file!.toLine} of {props.file!.totalLines}
240
+ <Show when={props.file!.truncated}>
241
+ <span class="ml-2 text-amber-400">truncated</span>
242
+ </Show>
243
+ </span>
244
+ </Show>
245
+ <button
246
+ type="button"
247
+ class="ml-auto px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded text-xs"
248
+ onClick={() => props.onClose()}
249
+ >
250
+ close
251
+ </button>
252
+ </div>
253
+ <div class="flex-1 overflow-auto px-3 py-2 font-mono text-[11px] whitespace-pre text-neutral-200 bg-neutral-950">
254
+ <Show when={props.loading}>
255
+ <div class="text-neutral-600 italic">Loading…</div>
256
+ </Show>
257
+ <Show when={props.file && !props.loading}>
258
+ {props.file!.content}
259
+ </Show>
260
+ </div>
261
+ </div>
262
+ </div>
263
+ </Show>
264
+ );
265
+ }
266
+
267
+ function fmtSize(bytes: number): string {
268
+ if (bytes < 1024) return `${bytes}B`;
269
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
270
+ return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
271
+ }