anentrypoint-design 0.0.374 → 0.0.376

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.374",
3
+ "version": "0.0.376",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -17,6 +17,7 @@ import { Btn, Icon } from './shell.js';
17
17
  import { BreadcrumbPath } from './files.js';
18
18
  import { SplitPanel } from './editor-primitives.js';
19
19
  import { initializeCachesEagerly } from '../markdown-cache.js';
20
+ import { ChatMinimap } from './chat-minimap.js';
20
21
 
21
22
  const h = webjsx.createElement;
22
23
 
@@ -224,6 +225,17 @@ export function AgentChat(props = {}) {
224
225
  onPasteFiles, onDropFiles, onEmoji,
225
226
  shownMessages, onShowEarlier,
226
227
  streamingSince, detectAttachment,
228
+ // @-mention file autocomplete in the composer — a flat list of file paths
229
+ // the host already has (e.g. its Files tab data source). Purely forwarded
230
+ // to ChatComposer; omitting it keeps every existing caller unchanged (no
231
+ // mention affordance appears without it).
232
+ mentionFiles,
233
+ // Optional scroll-position minimap alongside the thread (ChatMinimap).
234
+ // false/omitted keeps every existing caller byte-identical (no minimap
235
+ // column at all). true renders the strip using this component's own
236
+ // thread ref via a shared getter — no extra DOM wiring needed from the
237
+ // host beyond passing showMinimap.
238
+ showMinimap = false,
227
239
  // Optional inline content viewer beside the thread (a docstudio-cue
228
240
  // addition: its chat view keeps a live document/PDF preview open next to
229
241
  // the conversation instead of forcing a separate tab/window). The host
@@ -414,6 +426,7 @@ export function AgentChat(props = {}) {
414
426
  onEmoji,
415
427
  streamingSince,
416
428
  detectAttachment,
429
+ mentionFiles,
417
430
  });
418
431
 
419
432
  // Contextual follow-up chips below the last SETTLED assistant turn (claude.ai/
@@ -472,8 +485,18 @@ export function AgentChat(props = {}) {
472
485
  : null)
473
486
  : null;
474
487
 
488
+ // ChatMinimap needs a getter that resolves the live thread element lazily
489
+ // (it may mount before the thread's own ref fires). A holder object keeps
490
+ // the element across re-renders without introducing component state.
491
+ const threadElHolder = { el: null };
492
+ const combinedThreadRef = (el) => {
493
+ threadElHolder.el = el;
494
+ const dispose = threadRef(messages.length)(el);
495
+ return dispose;
496
+ };
497
+
475
498
  const threadBody = h('div', { class: 'agentchat-thread-wrap' },
476
- h('div', { class: 'agentchat-thread', ref: threadRef(messages.length), role: 'log', 'aria-label': 'conversation', 'aria-live': 'polite', 'aria-relevant': 'additions' },
499
+ h('div', { class: 'agentchat-thread', ref: combinedThreadRef, role: 'log', 'aria-label': 'conversation', 'aria-live': 'polite', 'aria-relevant': 'additions' },
477
500
  emptyState,
478
501
  earlierRow,
479
502
  ...rows.filter(Boolean),
@@ -488,7 +511,12 @@ export function AgentChat(props = {}) {
488
511
  // stateless chrome, so the host needn't thread scroll state through state.
489
512
  h('button', { class: 'agentchat-jump', type: 'button', 'aria-label': 'jump to latest', title: 'jump to latest',
490
513
  onclick: (e) => scrollThreadToBottom(e.currentTarget) },
491
- Icon('arrow-down', { size: 16 }), h('span', { class: 'agentchat-jump-label' }, 'latest')));
514
+ Icon('arrow-down', { size: 16 }), h('span', { class: 'agentchat-jump-label' }, 'latest')),
515
+ // Optional scroll-position overview strip, sharing the same live thread
516
+ // element the auto-scroll/jump logic already resolves via threadElHolder.
517
+ showMinimap
518
+ ? ChatMinimap({ messages, getThreadEl: () => threadElHolder.el })
519
+ : null);
492
520
 
493
521
  const mainColumn = h('div', { class: 'agentchat-main-col' },
494
522
  h('div', { class: 'agentchat-head' },
@@ -14,9 +14,12 @@ import { register as registerDebug, unregister as unregisterDebug } from '../deb
14
14
  import { queueMessage, watchReconnect, isOnline } from '../idb-outbox.js';
15
15
  import { ChatMessage, ChatComposer } from './chat.js';
16
16
  import { fmtTime, fmtAgo } from './sessions.js';
17
- import { createVirtualizer, measureRef } from '../virtual-scroll.js';
17
+ import { ModelsConfig } from './models-config.js';
18
+ import { SkillsConfig } from './skills-config.js';
19
+ import { PluginsConfig } from './plugins-config.js';
18
20
  import { GitStatusPanel, GitDiffView } from './git-status.js';
19
21
  import { WorktreeSwitcher } from './worktree-switcher.js';
22
+ import { AgentChat } from './agent-chat.js';
20
23
 
21
24
  const h = webjsx.createElement;
22
25
 
@@ -43,10 +46,6 @@ const TRUNC_PROMPT = 50; // batch prompt cells
43
46
  const truncSpan = (s, n) => { const t = trunc(s, n); return h('span', { title: t.title }, t.text); };
44
47
  // Cap a raw JSON dump for an inline table cell without losing the data via tooltip.
45
48
  const truncJson = (v, n = TRUNC_TITLE) => truncSpan(JSON.stringify(v), n);
46
- // Autoscroll a thread only when the user is already near the bottom, so
47
- // scrolling up to read history is not yanked back down on the next render.
48
- const stickyScroll = (el) => { if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; if (nearBottom) el.scrollTop = el.scrollHeight; };
49
-
50
49
  // ---- home ------------------------------------------------------------------
51
50
 
52
51
  export const home = makePage((ctx) => {
@@ -106,98 +105,196 @@ export const home = makePage((ctx) => {
106
105
 
107
106
  // ---- chat ------------------------------------------------------------------
108
107
 
109
- // Below this thread length, mount every message directly -- virtualization's
110
- // scroll-listener + spacer-div overhead only pays for itself once the DOM
111
- // node count it would otherwise mount is large enough to matter.
112
- const VIRTUALIZE_THRESHOLD = 60;
108
+ // Parse a fetch Response body as a Server-Sent-Events frame stream. There is
109
+ // no EventSource-over-POST in browsers (EventSource only does GET, no custom
110
+ // headers/body), so a POST-based SSE consumer has to manually decode the
111
+ // ReadableStream and split on blank-line-terminated `event: X\ndata: Y\n\n`
112
+ // frames. No existing SSE-parsing utility exists elsewhere in this SDK
113
+ // (checked idb-outbox.js and grepped src/ for `text/event-stream`) -- this is
114
+ // the first, generic enough (event name + JSON.parse'd data) to reuse for any
115
+ // future SSE endpoint, not freddie-chat-specific in shape.
116
+ async function* parseSseStream(response) {
117
+ const reader = response.body.getReader();
118
+ const decoder = new TextDecoder();
119
+ let buf = '';
120
+ try {
121
+ for (;;) {
122
+ const { done, value } = await reader.read();
123
+ if (done) break;
124
+ buf += decoder.decode(value, { stream: true });
125
+ // Frames are separated by a blank line; a frame may itself contain
126
+ // multiple `field: value` lines (event/data/id/retry) but this
127
+ // server only ever emits one `event:` + one `data:` line per frame.
128
+ let sep;
129
+ while ((sep = buf.indexOf('\n\n')) !== -1) {
130
+ const frame = buf.slice(0, sep);
131
+ buf = buf.slice(sep + 2);
132
+ let event = 'message', dataLines = [];
133
+ for (const line of frame.split('\n')) {
134
+ if (line.startsWith('event:')) event = line.slice(6).trim();
135
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
136
+ }
137
+ if (!dataLines.length) continue;
138
+ let data;
139
+ try { data = JSON.parse(dataLines.join('\n')); } catch { data = dataLines.join('\n'); }
140
+ yield { event, data };
141
+ }
142
+ }
143
+ } finally {
144
+ try { reader.releaseLock(); } catch { /* stream already closed/errored */ }
145
+ }
146
+ }
113
147
 
114
- export const chat = makePage((ctx) => {
115
- Object.assign(ctx.state, { loading: false, messages: [], draft: '', sending: false });
116
- const virtualizer = createVirtualizer();
117
- let threadEl = null;
118
- let range = { startIndex: 0, endIndex: 0, topSpacerPx: 0, bottomSpacerPx: 0 };
119
- function recomputeRange() {
120
- if (!threadEl) return;
121
- virtualizer.setCount(ctx.state.messages.length);
122
- range = virtualizer.computeRange(threadEl.scrollTop, threadEl.clientHeight);
148
+ // Map a freddie tool_progress SSE payload ({name, args, partial}) to an
149
+ // AgentChat tool part. There is no matching id to correlate a later
150
+ // "done" state (freddie's stream has no discrete tool-start/tool-end pair --
151
+ // tool_progress fires zero-or-more times per call while it runs, and the
152
+ // authoritative role:'tool' result only arrives batched in the final
153
+ // `message`/`done` events) so every progress part renders as 'running'; the
154
+ // turn-settle pass below promotes matching parts to 'done'/'error' once the
155
+ // real tool_call_id/content pairs are known.
156
+ function toolProgressPart(payload) {
157
+ return { kind: 'tool', name: payload.name || 'tool', args: payload.args || {}, status: 'running' };
158
+ }
159
+
160
+ // After `done`, freddie's persisted message list is the source of truth:
161
+ // walk it and rebuild the assistant turn's parts as interleaved
162
+ // text/tool/tool_result, replacing the provisional tool_progress-only parts
163
+ // accumulated during streaming. assistant messages with tool_calls become
164
+ // running tool parts (by call id); role:'tool' messages settle the matching
165
+ // part to done/error by tool_call_id.
166
+ function partsFromMessages(assistantAndToolMessages) {
167
+ const parts = [];
168
+ const byId = new Map();
169
+ for (const m of assistantAndToolMessages) {
170
+ if (m.role === 'assistant') {
171
+ if (m.content) parts.push({ kind: 'md', text: m.content });
172
+ for (const tc of (m.tool_calls || [])) {
173
+ const part = { kind: 'tool', _id: tc.id, name: tc.name || tc.function?.name || 'tool', args: tc.arguments || tc.function?.arguments || {}, status: 'running' };
174
+ parts.push(part);
175
+ if (tc.id) byId.set(tc.id, part);
176
+ }
177
+ } else if (m.role === 'tool') {
178
+ const target = m.tool_call_id ? byId.get(m.tool_call_id) : null;
179
+ let content = m.content;
180
+ let isError = false;
181
+ try { const parsed = JSON.parse(content); if (parsed && parsed.error) { isError = true; } } catch { /* not JSON, leave as-is */ }
182
+ if (target) { target.result = content; target.status = isError ? 'error' : 'done'; target.error = isError || undefined; }
183
+ else parts.push({ kind: 'tool_result', name: 'result', result: content, error: isError || undefined, status: isError ? 'error' : 'done' });
184
+ }
123
185
  }
186
+ return parts;
187
+ }
188
+
189
+ export const chat = makePage((ctx) => {
190
+ Object.assign(ctx.state, { loading: false, messages: [], draft: '', busy: false, error: null, abort: null });
191
+
124
192
  // Offline outbox: a prompt sent while genuinely offline queues to
125
193
  // IndexedDB and auto-flushes on the real 'online' event, rather than
126
194
  // surfacing a hard error the user can't act on. True offline LLM
127
195
  // response generation is impossible by definition -- a queued message
128
- // only gets a reply once connectivity actually returns.
129
- async function sendToServer(body) {
196
+ // only gets a reply once connectivity actually returns. Reconnect-flush
197
+ // still goes through the single-shot JSON path (no live UI to stream
198
+ // into for a message sent while this page may not even be mounted).
199
+ async function sendQueuedToServer(body) {
130
200
  const r = await api('/api/chat', { method: 'POST', body });
131
201
  const reply = r.result || r.content || r.message || (r.messages && r.messages.at(-1)?.content) || JSON.stringify(r);
132
- ctx.state.messages.push({ role: 'assistant', text: String(reply), time: formatTime(Date.now()) });
202
+ ctx.state.messages.push({ id: 'a' + Date.now(), role: 'assistant', content: String(reply), time: formatTime(Date.now()) });
203
+ ctx.rerender();
133
204
  }
134
- watchReconnect('chat', sendToServer);
205
+ watchReconnect('chat', sendQueuedToServer);
206
+
135
207
  async function send(text) {
136
- const t = (text || ctx.state.draft || '').trim();
137
- if (!t || ctx.state.sending) return;
138
- ctx.state.messages.push({ role: 'user', text: t, time: formatTime(Date.now()) });
139
- ctx.set({ draft: '', sending: true });
208
+ const t = (typeof text === 'string' ? text : ctx.state.draft || '').trim();
209
+ if (!t || ctx.state.busy) return;
210
+ const userMsg = { id: 'u' + Date.now(), role: 'user', content: t, time: formatTime(Date.now()) };
211
+ const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: formatTime(Date.now()), parts: [] };
212
+ ctx.state.messages = [...ctx.state.messages, userMsg, curMsg];
213
+ ctx.set({ draft: '', busy: true, error: null });
214
+
140
215
  if (!isOnline()) {
141
216
  await queueMessage('chat', { prompt: t });
142
- ctx.state.messages.push({ role: 'assistant', text: '(offline -- queued, will send when connection returns)', time: formatTime(Date.now()) });
143
- ctx.set({ sending: false });
217
+ ctx.state.messages = ctx.state.messages.slice(0, -1);
218
+ ctx.state.messages.push({ id: curMsg.id, role: 'assistant', content: '(offline -- queued, will send when connection returns)', time: formatTime(Date.now()) });
219
+ ctx.set({ busy: false });
144
220
  return;
145
221
  }
222
+
223
+ const ctrl = new AbortController();
224
+ ctx.state.abort = ctrl;
225
+ const cur = ctx.state.messages[ctx.state.messages.length - 1];
146
226
  try {
147
- await sendToServer({ prompt: t });
227
+ const res = await fetch('/api/chat', {
228
+ method: 'POST',
229
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
230
+ body: JSON.stringify({ prompt: t, sessionId: ctx.state.sessionId || undefined }),
231
+ signal: ctrl.signal,
232
+ });
233
+ if (!res.ok || !res.body) {
234
+ const txt = await res.text().catch(() => '');
235
+ throw new Error(txt || ('HTTP ' + res.status));
236
+ }
237
+ let finalMessages = null;
238
+ for await (const { event, data } of parseSseStream(res)) {
239
+ if (ctrl.signal.aborted) break;
240
+ if (event === 'start') {
241
+ if (data && data.sessionId) ctx.state.sessionId = data.sessionId;
242
+ } else if (event === 'tool_progress') {
243
+ cur.parts.push(toolProgressPart(data || {}));
244
+ ctx.rerender();
245
+ } else if (event === 'message') {
246
+ // Buffered per-message events land right before `done` -- accumulate
247
+ // rather than rerender per-message; the final rebuild below is O(1)
248
+ // extra work and avoids a flurry of rerenders in the same tick.
249
+ (finalMessages || (finalMessages = [])).push(data);
250
+ } else if (event === 'error') {
251
+ cur.error = String((data && data.error) || 'stream error');
252
+ ctx.rerender();
253
+ } else if (event === 'done') {
254
+ if (finalMessages && finalMessages.length) {
255
+ cur.parts = partsFromMessages(finalMessages);
256
+ // Prefer the settled assistant text as plain content when the
257
+ // rebuilt parts carry exactly one md part (the common no-tool-call
258
+ // case) -- keeps AgentChat's md-vs-content dedup path simple.
259
+ cur.content = '';
260
+ } else if (data && data.result) {
261
+ cur.content = String(data.result);
262
+ }
263
+ ctx.rerender();
264
+ }
265
+ }
148
266
  } catch (e) {
149
- ctx.state.messages.push({ role: 'assistant', text: 'Error: ' + String(e.message || e), time: formatTime(Date.now()) });
267
+ if (e && e.name === 'AbortError') {
268
+ cur.stopped = true;
269
+ } else {
270
+ cur.error = String(e && e.message || e);
271
+ }
272
+ } finally {
273
+ ctx.state.abort = null;
274
+ ctx.set({ busy: false });
150
275
  }
151
- ctx.set({ sending: false });
152
- }
153
- // Combines the existing bottom-pin auto-scroll ref (stickyScroll) with the
154
- // virtualizer's range recompute on every scroll tick. Both run off the
155
- // SAME element -- one ref callback wiring both keeps a single listener
156
- // registration instead of two refs fighting over the same node.
157
- function threadRef(el) {
158
- if (!el) { threadEl = null; return; }
159
- threadEl = el;
160
- stickyScroll(el);
161
- if (!el.__vsScrollWired) {
162
- el.__vsScrollWired = true;
163
- el.addEventListener('scroll', () => { recomputeRange(); ctx.rerender(); }, { passive: true });
164
- }
165
- recomputeRange();
276
+ }
277
+
278
+ function stop() {
279
+ if (ctx.state.abort) { try { ctx.state.abort.abort(); } catch { /* already settled */ } }
166
280
  }
167
281
 
168
282
  return () => {
169
283
  const s = ctx.state;
170
- const virtualized = s.messages.length > VIRTUALIZE_THRESHOLD;
171
- let threadChildren;
172
- if (!s.messages.length) {
173
- threadChildren = [emptyState('send a prompt to start', Icon('forum'))];
174
- } else if (!virtualized) {
175
- threadChildren = s.messages.map((m, i) => ChatMessage({ ...m, key: i }));
176
- } else {
177
- recomputeRange();
178
- const { startIndex, endIndex, topSpacerPx, bottomSpacerPx } = range;
179
- threadChildren = [
180
- h('div', { key: '_top_spacer', style: `height:${topSpacerPx}px` }),
181
- ...s.messages.slice(startIndex, endIndex).map((m, i) => {
182
- const realIndex = startIndex + i;
183
- return h('div', { key: 'vm' + realIndex, ref: measureRef(virtualizer, realIndex) }, ChatMessage({ ...m, key: realIndex }));
184
- }),
185
- h('div', { key: '_bottom_spacer', style: `height:${bottomSpacerPx}px` }),
186
- ];
187
- }
188
284
  return h('div', { class: 'fd-chat' },
189
- PageHeader({ eyebrow: 'freddie', title: 'chat', lede: 'one-shot agent turns · POST /api/chat' }),
190
- liveRegion(s.sending ? 'waiting for assistant reply' : ''),
191
- h('div', { class: 'chat-thread fd-chat-thread', role: 'log', 'aria-label': 'chat messages',
192
- ref: threadRef },
193
- ...threadChildren,
194
- s.sending ? ChatMessage({ role: 'assistant', typing: true, key: '_typing' }) : null),
195
- ChatComposer({
196
- value: s.draft,
197
- placeholder: s.sending ? 'waiting for reply…' : 'message…',
198
- disabled: s.sending,
285
+ AgentChat({
286
+ messages: s.messages,
287
+ busy: s.busy,
288
+ draft: s.draft,
289
+ status: s.busy ? 'streaming…' : 'ready',
290
+ agentName: 'freddie',
291
+ placeholder: s.busy ? 'waiting for reply…' : 'message…',
292
+ showMinimap: true,
293
+ banners: s.error ? [noteAlert({ kind: 'error', msg: s.error })] : [],
199
294
  onInput: (v) => { s.draft = v; },
200
295
  onSend: send,
296
+ onStop: stop,
297
+ onNewChat: () => ctx.set({ messages: [], draft: '', error: null, sessionId: null }),
201
298
  }));
202
299
  };
203
300
  });
@@ -364,44 +461,34 @@ export const analytics = makePage((ctx) => {
364
461
  // ---- models ----------------------------------------------------------------
365
462
 
366
463
  export const models = makePage((ctx) => {
367
- Object.assign(ctx.state, { discovering: false });
464
+ Object.assign(ctx.state, { rebuilding: false, selectedProviderId: null, selectedModel: null });
465
+ // GET /api/models/availability — the real per-(provider x model x mode)
466
+ // availability matrix (plugins/gui-models-discover), per freddie's AGENTS.md
467
+ // "Model availability matrix" section. 404 with {error,hint} when the
468
+ // matrix file hasn't been built yet — ModelsConfig itself renders that
469
+ // as an empty state with a "build availability matrix" action.
368
470
  async function load() {
369
- try {
370
- const [providers, cached, sampler] = await Promise.all([
371
- api('/api/models/providers').catch(() => []),
372
- api('/api/models/cached').catch(() => ({})),
373
- api('/api/models/sampler').catch(() => ({})),
374
- ]);
375
- ctx.set({ loading: false, providers, cached, sampler, error: null });
376
- } catch (e) { ctx.set({ loading: false, error: e }); }
471
+ try { ctx.set({ loading: false, data: await api('/api/models/availability'), error: null }); }
472
+ catch (e) { ctx.set({ loading: false, data: null, error: (e && e.body) || e }); }
377
473
  }
378
- async function discover() {
379
- if (ctx.state.discovering) return;
380
- ctx.set({ discovering: true });
381
- try { await api('/api/models/discover', { method: 'POST', body: {} }); await load(); }
382
- catch (e) { ctx.set({ error: e }); }
383
- ctx.set({ discovering: false });
474
+ async function rebuild() {
475
+ if (ctx.state.rebuilding) return;
476
+ ctx.set({ rebuilding: true, rebuildError: null });
477
+ try { await api('/api/models/availability/rebuild', { method: 'POST', body: {} }); await load(); }
478
+ catch (e) { ctx.set({ rebuildError: e }); }
479
+ ctx.set({ rebuilding: false });
384
480
  }
385
481
  load();
386
482
  return () => {
387
483
  const s = ctx.state;
388
- if (s.loading) return loadingState('loading models…');
389
- if (s.error && !s.providers) return errorState(s.error, load);
390
- const providers = Array.isArray(s.providers) ? s.providers : [];
391
- const cached = s.cached || {};
392
- const status = s.sampler?.status || {};
393
484
  return [
394
- PageHeader({ eyebrow: 'freddie', title: 'models', lede: providers.length + ' providers', right: Btn({ variant: 'primary', disabled: s.discovering, children: s.discovering ? 'discovering…' : 'discover', onClick: discover }) }),
395
- liveRegion(s.discovering ? 'discovering models' : ''),
396
- section('providers', providers.length ? Table({
397
- headers: ['provider', 'sampler', 'cached models'],
398
- rows: providers.map(p => {
399
- const name = typeof p === 'string' ? p : p.id || p.provider;
400
- const st = status[name];
401
- const cm = (cached[name] || []);
402
- return [name, st ? (st.available === false ? Chip({ tone: 'miss', children: 'down' }) : Chip({ tone: 'ok', children: 'up' })) : '—', Array.isArray(cm) ? cm.length : '—'];
403
- }),
404
- }) : emptyState('no providers; set provider API keys')),
485
+ PageHeader({ eyebrow: 'freddie', title: 'models', lede: s.data ? (s.data.summary?.total_models ?? 0) + ' models across ' + (s.data.summary?.total_providers ?? 0) + ' providers' : 'model availability matrix' }),
486
+ ModelsConfig({
487
+ data: s.data, loading: s.loading, error: s.error,
488
+ selectedProviderId: s.selectedProviderId, onSelectProvider: (id) => ctx.set({ selectedProviderId: id, selectedModel: null }),
489
+ selectedModel: s.selectedModel, onSelectModel: (m) => ctx.set({ selectedModel: m }),
490
+ onRefresh: load, onRebuild: rebuild, rebuilding: s.rebuilding, rebuildError: s.rebuildError,
491
+ }),
405
492
  ];
406
493
  };
407
494
  });
@@ -444,22 +531,59 @@ export const cron = makePage((ctx) => {
444
531
  // ---- skills ----------------------------------------------------------------
445
532
 
446
533
  export const skills = makePage((ctx) => {
447
- Object.assign(ctx.state, { open: null });
534
+ Object.assign(ctx.state, { selected: null, query: '', busyName: null });
448
535
  async function load() { try { ctx.set({ loading: false, list: await api('/api/skills'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
449
536
  load();
450
537
  return () => {
451
538
  const s = ctx.state;
452
- if (s.loading) return loadingState('loading skills…');
453
- if (s.error) return errorState(s.error, load);
454
- const list = Array.isArray(s.list) ? s.list : (s.list?.skills || []);
539
+ // GET /api/skills returns {home:[...], bundled:[...], skillState} —
540
+ // two source lists (user ~/.freddie/skills vs bundled skills/ dirs)
541
+ // plus a per-skill enabled/disabled state map, not a flat array.
542
+ // Concat both sources (home overrides bundled on name collision,
543
+ // matching src/skills/index.js's own findSkill() precedence) and
544
+ // resolve enabled state from skillState (default true when absent).
545
+ const raw = s.list && typeof s.list === 'object' ? s.list : {};
546
+ const rawList = Array.isArray(raw) ? raw : [...(raw.bundled || []), ...(raw.home || [])];
547
+ const skillState = raw.skillState || {};
548
+ const mapped = rawList.map((sk) => ({
549
+ file: sk.file || sk.path || sk.name,
550
+ name: sk.name,
551
+ description: sk.description || (sk.frontmatter && sk.frontmatter.description) || '',
552
+ platforms: sk.platforms || (sk.frontmatter && sk.frontmatter.platforms),
553
+ enabled: skillState[sk.name] !== false,
554
+ }));
455
555
  return [
456
- PageHeader({ eyebrow: 'freddie', title: 'skills', lede: list.length + ' skills' }),
457
- section('skills', list.length ? list.map((sk, i) => h('div', { key: i },
458
- Row({ code: (sk.source || 'fs').slice(0, 3), title: sk.name, sub: trunc(sk.description, TRUNC_DESC).text,
459
- onClick: () => ctx.set({ open: s.open === i ? null : i }), active: s.open === i }),
460
- s.open === i ? h('pre', { class: 'fd-pre fd-skill-body' }, sk.body || sk.content || '(no body)') : null,
461
- )) : emptyState('no skills')),
462
- ].filter(Boolean);
556
+ PageHeader({ eyebrow: 'freddie', title: 'skills', lede: mapped.length + ' skills' }),
557
+ SkillsConfig({
558
+ skills: mapped, selected: s.selected, loading: s.loading, error: s.error,
559
+ busyName: s.busyName, query: s.query, onQuery: (q) => ctx.set({ query: q }),
560
+ onSelect: (name) => ctx.set({ selected: s.selected === name ? null : name }),
561
+ }),
562
+ ];
563
+ };
564
+ });
565
+
566
+ // ---- plugins -----------------------------------------------------------------
567
+
568
+ export const plugins = makePage((ctx) => {
569
+ Object.assign(ctx.state, { selected: null });
570
+ // GET /api/plugins — flat {name,version,surfaces,requires,source,enabled}
571
+ // list, per plugins/gui-plugins-list/plugin.js (distinct from
572
+ // /api/plugin-graph's D3 {nodes,edges} shape built for the dependency
573
+ // visualization, not a flat list UI).
574
+ async function load() { try { ctx.set({ loading: false, list: await api('/api/plugins'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
575
+ load();
576
+ return () => {
577
+ const s = ctx.state;
578
+ const list = Array.isArray(s.list) ? s.list : (s.list?.plugins || []);
579
+ return [
580
+ PageHeader({ eyebrow: 'freddie', title: 'plugins', lede: list.length + ' plugins loaded' }),
581
+ PluginsConfig({
582
+ plugins: list, selected: s.selected, loading: s.loading, error: s.error,
583
+ onSelect: (name) => ctx.set({ selected: s.selected === name ? null : name }),
584
+ onReload: load,
585
+ }),
586
+ ];
463
587
  };
464
588
  });
465
589
 
@@ -950,7 +1074,7 @@ export const git = makePage((ctx) => {
950
1074
 
951
1075
  export const FREDDIE_PAGES = {
952
1076
  home, chat, voice, sessions, projects, agents, analytics,
953
- models, cron, skills, config, env, tools, batch, gateway, chains,
1077
+ models, cron, skills, plugins, config, env, tools, batch, gateway, chains,
954
1078
  machines, health, debug, logs, git,
955
1079
  };
956
1080