@ouraihub/aether-ui 0.1.0 → 0.1.2

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 (90) hide show
  1. package/package.json +3 -3
  2. package/src/chat/ApprovalCard.svelte +8 -2
  3. package/src/chat/ChatComposer.svelte +319 -0
  4. package/src/chat/ChatMessage.svelte +262 -0
  5. package/src/chat/ChatPanel.css +386 -0
  6. package/src/chat/ChatPanel.svelte +276 -1880
  7. package/src/chat/ChatPanelApprovals.svelte +28 -0
  8. package/src/chat/ChatPanelComposer.svelte +336 -0
  9. package/src/chat/ChatPanelDialogs.svelte +242 -0
  10. package/src/chat/ChatPanelHeader.svelte +108 -0
  11. package/src/chat/ChatPanelMessages.svelte +166 -0
  12. package/src/chat/ChatPanelMode.svelte +190 -0
  13. package/src/chat/ChatPanelState.svelte.ts +1 -0
  14. package/src/chat/ChatPanelToolbar.svelte +436 -0
  15. package/src/chat/ChatSurface.svelte +168 -0
  16. package/src/chat/ContextUsageStatus.svelte +97 -0
  17. package/src/chat/MessageItem.svelte +111 -214
  18. package/src/chat/ModelEffortPicker.svelte +326 -0
  19. package/src/chat/PickerEffort.svelte +71 -0
  20. package/src/chat/ReasoningControl.svelte +184 -0
  21. package/src/chat/ReasoningSlider.svelte +174 -0
  22. package/src/chat/SkillPicker.svelte +14 -6
  23. package/src/chat/UsageControl.svelte +223 -0
  24. package/src/chat/UsageDetails.svelte +44 -90
  25. package/src/chat/chat-attachments.svelte.ts +78 -0
  26. package/src/chat/chat-panel-history.svelte.ts +430 -0
  27. package/src/chat/chat-panel-models.svelte.ts +97 -0
  28. package/src/chat/chat-panel-props.ts +38 -0
  29. package/src/chat/context-status.ts +101 -0
  30. package/src/chat/icons/LICENSE-REMIX +186 -0
  31. package/src/chat/icons/LICENSE-SIMPLE-ICONS +30 -0
  32. package/src/chat/icons/PathIcon.svelte +8 -0
  33. package/src/chat/icons/effort-icons.ts +65 -0
  34. package/src/chat/icons/status-icons.ts +105 -0
  35. package/src/chat/index.ts +25 -1
  36. package/src/chat/model-effort.ts +177 -0
  37. package/src/chat/run-session-history.ts +56 -0
  38. package/src/chat/run-session-types.ts +69 -0
  39. package/src/chat/run-session.svelte.ts +73 -135
  40. package/src/chat/test-support/chat-fixture.ts +194 -0
  41. package/src/chat/test-support/conversation-fixtures.ts +32 -0
  42. package/src/chat/test-support/run-session-fixture.ts +112 -0
  43. package/src/dev/Demo.svelte +3 -1
  44. package/src/i18n/en-chat.d.ts +1 -0
  45. package/src/i18n/en-chat.js +201 -0
  46. package/src/i18n/en.ts +58 -180
  47. package/src/i18n/index.ts +7 -2
  48. package/src/i18n/zh-CN-chat.d.ts +1 -0
  49. package/src/i18n/zh-CN-chat.js +190 -0
  50. package/src/i18n/zh-CN.ts +8 -153
  51. package/src/logs/LogPanel.svelte +9 -3
  52. package/src/logs/LogSettings.svelte +3 -1
  53. package/src/mcp/McpClientEditor.svelte +204 -0
  54. package/src/mcp/McpClientSettings.svelte +57 -130
  55. package/src/mcp/McpServerSettings.svelte +47 -11
  56. package/src/mcp/input.ts +2 -1
  57. package/src/ports/contract.ts +11 -2
  58. package/src/ports/fake-optional.ts +81 -0
  59. package/src/ports/fake.ts +90 -86
  60. package/src/ports/search.ts +11 -2
  61. package/src/ports/test-support/fake-fixture.ts +63 -0
  62. package/src/providers/EndpointKeysDialog.svelte +27 -13
  63. package/src/providers/EndpointModels.svelte +311 -0
  64. package/src/providers/EndpointPanel.svelte +33 -258
  65. package/src/providers/ModelPickerDialog.svelte +59 -16
  66. package/src/providers/ModelPricing.svelte +125 -0
  67. package/src/providers/ModelSelect.svelte +22 -8
  68. package/src/providers/ModelSettingsDialog.svelte +61 -126
  69. package/src/providers/ProviderDialogs.svelte +120 -0
  70. package/src/providers/ProviderList.svelte +159 -0
  71. package/src/providers/ProvidersSettings.svelte +67 -207
  72. package/src/providers/RequestConfigDialog.svelte +40 -12
  73. package/src/providers/SecretInput.svelte +11 -3
  74. package/src/providers/draft.ts +39 -13
  75. package/src/providers/edit-session.ts +31 -7
  76. package/src/providers/reasoning.ts +18 -4
  77. package/src/providers/revision.svelte.ts +3 -1
  78. package/src/providers/test-support/providers-fixture.ts +120 -0
  79. package/src/search/SearchAdvancedTools.svelte +182 -0
  80. package/src/search/SearchProviderCard.svelte +195 -0
  81. package/src/search/SearchSettings.svelte +62 -265
  82. package/src/search/settings-flow.ts +8 -3
  83. package/src/skills/SkillAdd.svelte +51 -18
  84. package/src/skills/SkillAuthoring.svelte +69 -20
  85. package/src/skills/SkillEditor.svelte +13 -3
  86. package/src/skills/SkillPreviewDialog.svelte +19 -5
  87. package/src/skills/SkillsSettings.svelte +11 -4
  88. package/src/skills/test-support/skills-fixture.ts +139 -0
  89. package/src/tauri/index.ts +23 -7
  90. package/src/web/index.ts +90 -25
@@ -68,7 +68,9 @@
68
68
  invalidate()
69
69
  })
70
70
 
71
- async function act(work: (signal: AbortSignal, current: () => boolean) => Promise<void>): Promise<void> {
71
+ async function act(
72
+ work: (signal: AbortSignal, current: () => boolean) => Promise<void>,
73
+ ): Promise<void> {
72
74
  invalidate()
73
75
  const id = generation
74
76
  const controller = new AbortController()
@@ -130,7 +132,9 @@
130
132
  function scan(pick: boolean): Promise<void> {
131
133
  return act(async (signal, current) => {
132
134
  if (!sources) return
133
- const found = await (pick ? sources.pickSearchDirectory({ signal }) : sources.discover({ signal }))
135
+ const found = await (pick
136
+ ? sources.pickSearchDirectory({ signal })
137
+ : sources.discover({ signal }))
134
138
  if (current() && found !== null) {
135
139
  system = found
136
140
  searched = true
@@ -155,7 +159,9 @@
155
159
  input.value = ''
156
160
  if (!picked) return
157
161
  void act(async (_signal, current) => {
158
- const next: Source = { bytes: { fileName: picked.name, bytes: [...new Uint8Array(await picked.arrayBuffer())] } }
162
+ const next: Source = {
163
+ bytes: { fileName: picked.name, bytes: [...new Uint8Array(await picked.arrayBuffer())] },
164
+ }
159
165
  await showPreview(next, picked.name, current)
160
166
  })
161
167
  }
@@ -195,7 +201,9 @@
195
201
  <DropdownMenu.Trigger>
196
202
  {#snippet child({ props })}
197
203
  <Button {...props} {disabled}
198
- ><Plus data-icon="inline-start" />{t('skills.add.title')}<ChevronDown data-icon="inline-end" /></Button
204
+ ><Plus data-icon="inline-start" />{t('skills.add.title')}<ChevronDown
205
+ data-icon="inline-end"
206
+ /></Button
199
207
  >
200
208
  {/snippet}
201
209
  </DropdownMenu.Trigger>
@@ -203,13 +211,20 @@
203
211
  <DropdownMenu.Group>
204
212
  {#if sources}
205
213
  {#if sources.authoring && ports.providers}
206
- <DropdownMenu.Item onSelect={() => open('agent')}><Bot />{t('skills.add.agent')}</DropdownMenu.Item>
214
+ <DropdownMenu.Item onSelect={() => open('agent')}
215
+ ><Bot />{t('skills.add.agent')}</DropdownMenu.Item
216
+ >
207
217
  {/if}
208
- <DropdownMenu.Item onSelect={() => open('online')}><Search />{t('skills.add.online')}</DropdownMenu.Item>
209
- <DropdownMenu.Item onSelect={() => open('system')}><FolderSearch />{t('skills.add.system')}</DropdownMenu.Item
218
+ <DropdownMenu.Item onSelect={() => open('online')}
219
+ ><Search />{t('skills.add.online')}</DropdownMenu.Item
220
+ >
221
+ <DropdownMenu.Item onSelect={() => open('system')}
222
+ ><FolderSearch />{t('skills.add.system')}</DropdownMenu.Item
210
223
  >
211
224
  {/if}
212
- <DropdownMenu.Item onSelect={() => open('local')}><Import />{t('skills.add.local')}</DropdownMenu.Item>
225
+ <DropdownMenu.Item onSelect={() => open('local')}
226
+ ><Import />{t('skills.add.local')}</DropdownMenu.Item
227
+ >
213
228
  </DropdownMenu.Group>
214
229
  </DropdownMenu.Content>
215
230
  </DropdownMenu.Root>
@@ -265,10 +280,15 @@
265
280
  <div class="flex min-w-0 flex-1 flex-col gap-1">
266
281
  <strong class="text-sm">{candidate.name}</strong>
267
282
  <p class="text-muted-foreground text-xs">{candidate.description}</p>
268
- <p class="text-muted-foreground break-all text-xs">{candidate.origin} · {candidate.url}</p>
283
+ <p class="text-muted-foreground break-all text-xs">
284
+ {candidate.origin} · {candidate.url}
285
+ </p>
269
286
  </div>
270
- <Button size="sm" variant="outline" disabled={busy} onclick={() => void prepare(candidate)}
271
- >{t('skills.add.preview')}</Button
287
+ <Button
288
+ size="sm"
289
+ variant="outline"
290
+ disabled={busy}
291
+ onclick={() => void prepare(candidate)}>{t('skills.add.preview')}</Button
272
292
  >
273
293
  </li>
274
294
  {/each}
@@ -276,9 +296,13 @@
276
296
  {:else if mode === 'system'}
277
297
  <p class="text-muted-foreground text-sm">{t('skills.add.system_hint')}</p>
278
298
  <div class="flex flex-wrap gap-2">
279
- <Button variant="outline" disabled={busy} onclick={() => void scan(false)}>{t('skills.add.rescan')}</Button>
299
+ <Button variant="outline" disabled={busy} onclick={() => void scan(false)}
300
+ >{t('skills.add.rescan')}</Button
301
+ >
280
302
  <Button variant="outline" disabled={busy} onclick={() => void scan(true)}
281
- ><FolderSearch data-icon="inline-start" />{t('skills.add.pick_search_directory')}</Button
303
+ ><FolderSearch data-icon="inline-start" />{t(
304
+ 'skills.add.pick_search_directory',
305
+ )}</Button
282
306
  >
283
307
  </div>
284
308
  <ul class="flex flex-col gap-2" aria-label={t('skills.add.results')}>
@@ -286,10 +310,15 @@
286
310
  <li class="flex items-start gap-3 rounded-md border p-3">
287
311
  <div class="flex min-w-0 flex-1 flex-col gap-1">
288
312
  <strong class="text-sm">{candidate.name}</strong>
289
- <p class="text-muted-foreground break-all text-xs">{candidate.origin} · {candidate.path}</p>
313
+ <p class="text-muted-foreground break-all text-xs">
314
+ {candidate.origin} · {candidate.path}
315
+ </p>
290
316
  </div>
291
- <Button size="sm" variant="outline" disabled={busy} onclick={() => void prepareSystem(candidate)}
292
- >{t('skills.add.preview')}</Button
317
+ <Button
318
+ size="sm"
319
+ variant="outline"
320
+ disabled={busy}
321
+ onclick={() => void prepareSystem(candidate)}>{t('skills.add.preview')}</Button
293
322
  >
294
323
  </li>
295
324
  {/each}
@@ -305,12 +334,16 @@
305
334
  >
306
335
  </div>
307
336
  {/if}
308
- {#if busy}<p role="status" class="text-muted-foreground text-sm">{t('skills.add.working')}</p>{/if}
337
+ {#if busy}<p role="status" class="text-muted-foreground text-sm">
338
+ {t('skills.add.working')}
339
+ </p>{/if}
309
340
  {#if !busy && searched && ((mode === 'online' && candidates.length === 0) || (mode === 'system' && system.length === 0))}
310
341
  <p role="status" class="text-muted-foreground text-sm">{t('skills.add.no_results')}</p>
311
342
  {/if}
312
343
  </div>
313
- <Dialog.Footer><Button variant="outline" onclick={close}>{t('common.cancel')}</Button></Dialog.Footer>
344
+ <Dialog.Footer
345
+ ><Button variant="outline" onclick={close}>{t('common.cancel')}</Button></Dialog.Footer
346
+ >
314
347
  </Dialog.Content>
315
348
  </Dialog.Root>
316
349
 
@@ -6,7 +6,13 @@
6
6
  import * as Field from '#lib/components/ui/field/index.js'
7
7
  import { Textarea } from '#lib/components/ui/textarea/index.js'
8
8
  import { getKitContext } from '../context'
9
- import { KitClientError, type ModelRef, type PreparedSkill, type RunEvent, type SkillDto } from '../ports'
9
+ import {
10
+ KitClientError,
11
+ type ModelRef,
12
+ type PreparedSkill,
13
+ type RunEvent,
14
+ type SkillDto,
15
+ } from '../ports'
10
16
  import ModelSelect from '../providers/ModelSelect.svelte'
11
17
  import ApprovalCard from '../chat/ApprovalCard.svelte'
12
18
  import ToolCallItem from '../chat/ToolCallItem.svelte'
@@ -94,7 +100,9 @@
94
100
  await cleanup
95
101
  if (!alive || epoch !== currentEpoch || !active) return
96
102
  const skills = await ports.skills?.list()
97
- const builtin = skills?.find((skill) => skill.origin === 'builtin' && skill.name === 'skill-creator')
103
+ const builtin = skills?.find(
104
+ (skill) => skill.origin === 'builtin' && skill.name === 'skill-creator',
105
+ )
98
106
  if (!builtin || !ports.skills)
99
107
  throw new KitClientError({ code: 'needs_config', message: 'skill-creator unavailable' })
100
108
  const full = await ports.skills.get(builtin.id)
@@ -158,7 +166,8 @@
158
166
  const currentEpoch = epoch
159
167
  const controller = new AbortController()
160
168
  operation = controller
161
- const current = (): boolean => alive && epoch === currentEpoch && session === id && !controller.signal.aborted
169
+ const current = (): boolean =>
170
+ alive && epoch === currentEpoch && session === id && !controller.signal.aborted
162
171
  const turnId = ++nextTurn
163
172
  turns.push({ id: turnId, input: input.trim(), text: '', tools: [], status: 'running' })
164
173
  // The pushed literal is wrapped by the $state proxy; mutate the proxied entry.
@@ -170,7 +179,8 @@
170
179
  let ended = false
171
180
  const receive = (event: RunEvent): void => {
172
181
  if (!current() || ended) return
173
- const tool = (callId: string): ToolItem | undefined => turn.tools.find((item) => item.callId === callId)
182
+ const tool = (callId: string): ToolItem | undefined =>
183
+ turn.tools.find((item) => item.callId === callId)
174
184
  switch (event.kind) {
175
185
  case 'text_delta':
176
186
  turn.text += event.text
@@ -202,19 +212,25 @@
202
212
  }
203
213
  case 'tool_result': {
204
214
  const item = tool(event.callId)
205
- if (item) Object.assign(item, { pending: null, outcome: event.outcome, summary: event.summary })
215
+ if (item)
216
+ Object.assign(item, { pending: null, outcome: event.outcome, summary: event.summary })
206
217
  break
207
218
  }
208
219
  case 'end':
209
220
  ended = true
210
221
  finish(turn, event.status)
211
222
  if (event.status === 'failed')
212
- error = errorText(t, new KitClientError(event.error ?? { code: 'internal', message: '' }))
223
+ error = errorText(
224
+ t,
225
+ new KitClientError(event.error ?? { code: 'internal', message: '' }),
226
+ )
213
227
  break
214
228
  }
215
229
  }
216
230
  try {
217
- const accepted = await authoring.send(id, turn.input, model, receive, { signal: controller.signal })
231
+ const accepted = await authoring.send(id, turn.input, model, receive, {
232
+ signal: controller.signal,
233
+ })
218
234
  if (!current()) {
219
235
  if (!ended) await authoring.cancel(id, accepted.runId).catch(report)
220
236
  if (alive && epoch === currentEpoch && session === id && controller.signal.aborted) {
@@ -233,7 +249,15 @@
233
249
  }
234
250
  }
235
251
  async function approve(tool: ToolItem, allow: boolean): Promise<void> {
236
- if (!authoring || !session || !runId || !running || tool.pending === null || answering.includes(tool.callId)) return
252
+ if (
253
+ !authoring ||
254
+ !session ||
255
+ !runId ||
256
+ !running ||
257
+ tool.pending === null ||
258
+ answering.includes(tool.callId)
259
+ )
260
+ return
237
261
  const id = session
238
262
  const run = runId
239
263
  const currentEpoch = epoch
@@ -244,7 +268,8 @@
244
268
  } catch (cause) {
245
269
  if (alive && epoch === currentEpoch && runId === run) error = errorText(t, cause)
246
270
  } finally {
247
- if (alive && epoch === currentEpoch) answering = answering.filter((callId) => callId !== tool.callId)
271
+ if (alive && epoch === currentEpoch)
272
+ answering = answering.filter((callId) => callId !== tool.callId)
248
273
  }
249
274
  }
250
275
  async function stop(): Promise<void> {
@@ -277,7 +302,8 @@
277
302
  error = null
278
303
  try {
279
304
  const prepared = await authoring.prepare(id, { signal: controller.signal })
280
- if (alive && epoch === currentEpoch && session === id && !controller.signal.aborted) await onprepared(prepared)
305
+ if (alive && epoch === currentEpoch && session === id && !controller.signal.aborted)
306
+ await onprepared(prepared)
281
307
  } catch (cause) {
282
308
  if (alive && epoch === currentEpoch) error = errorText(t, cause)
283
309
  } finally {
@@ -305,7 +331,9 @@
305
331
  {#if !session}
306
332
  <p class="text-sm">{t('skills.add.creator_review')}</p>
307
333
  {#if creator}
308
- <Button disabled={loading} onclick={() => (creatorReview = true)}>{t('skills.add.review_creator')}</Button>
334
+ <Button disabled={loading} onclick={() => (creatorReview = true)}
335
+ >{t('skills.add.review_creator')}</Button
336
+ >
309
337
  {/if}
310
338
  {:else}
311
339
  <Field.Group>
@@ -332,33 +360,54 @@
332
360
  </fieldset>
333
361
  {:else}<ToolCallItem {tool} />{/if}
334
362
  {/each}
335
- {#if turn.status === 'running'}<p role="status" class="text-muted-foreground text-xs">
363
+ {#if turn.status === 'running'}<p
364
+ role="status"
365
+ class="text-muted-foreground text-xs"
366
+ >
336
367
  {t('skills.add.working')}
337
368
  </p>{/if}
338
- {#if turn.status === 'cancelled'}<p role="status" class="text-muted-foreground text-xs">
369
+ {#if turn.status === 'cancelled'}<p
370
+ role="status"
371
+ class="text-muted-foreground text-xs"
372
+ >
339
373
  {t('chat.stopped')}
340
374
  </p>{/if}
341
375
  </li>
342
376
  {/each}
343
377
  </ol>
344
378
  <Field.Field>
345
- <Field.Label for="skill-authoring-message">{t('skills.add.authoring_input')}</Field.Label>
346
- <Textarea id="skill-authoring-message" bind:value={input} disabled={running || loading} rows={3} />
379
+ <Field.Label for="skill-authoring-message"
380
+ >{t('skills.add.authoring_input')}</Field.Label
381
+ >
382
+ <Textarea
383
+ id="skill-authoring-message"
384
+ bind:value={input}
385
+ disabled={running || loading}
386
+ rows={3}
387
+ />
347
388
  </Field.Field>
348
389
  <div class="flex flex-wrap gap-2">
349
390
  <Button disabled={running || loading || !input.trim()} onclick={() => void send()}
350
391
  ><Bot data-icon="inline-start" />{t('chat.send')}</Button
351
392
  >
352
- {#if running}<Button variant="outline" onclick={() => void stop()}>{t('chat.stop')}</Button>{/if}
353
- <Button variant="outline" disabled={running || loading || turns.length === 0} onclick={() => void prepare()}
354
- >{t('skills.add.preview_artifact')}</Button
393
+ {#if running}<Button variant="outline" onclick={() => void stop()}
394
+ >{t('chat.stop')}</Button
395
+ >{/if}
396
+ <Button
397
+ variant="outline"
398
+ disabled={running || loading || turns.length === 0}
399
+ onclick={() => void prepare()}>{t('skills.add.preview_artifact')}</Button
355
400
  >
356
401
  </div>
357
402
  </Field.Group>
358
403
  {/if}
359
- {#if loading}<p role="status" class="text-muted-foreground text-sm">{t('skills.add.working')}</p>{/if}
404
+ {#if loading}<p role="status" class="text-muted-foreground text-sm">
405
+ {t('skills.add.working')}
406
+ </p>{/if}
360
407
  </div>
361
- <Dialog.Footer><Button variant="outline" onclick={onclose}>{t('common.cancel')}</Button></Dialog.Footer>
408
+ <Dialog.Footer
409
+ ><Button variant="outline" onclick={onclose}>{t('common.cancel')}</Button></Dialog.Footer
410
+ >
362
411
  </Dialog.Content>
363
412
  </Dialog.Root>
364
413
 
@@ -62,7 +62,9 @@
62
62
  <Dialog.Root bind:open>
63
63
  <Dialog.Content class="flex max-h-[85vh] flex-col sm:max-w-2xl">
64
64
  <Dialog.Header>
65
- <Dialog.Title>{skill ? t('skills.edit_title', { name: skill.name }) : t('skills.new')}</Dialog.Title>
65
+ <Dialog.Title
66
+ >{skill ? t('skills.edit_title', { name: skill.name }) : t('skills.new')}</Dialog.Title
67
+ >
66
68
  </Dialog.Header>
67
69
  <Field.Group class="min-h-0 overflow-y-auto">
68
70
  <div class="flex gap-3">
@@ -72,7 +74,12 @@
72
74
  </Field.Field>
73
75
  <Field.Field class="w-32">
74
76
  <Field.Label for="kit-skill-version">{t('skills.version')}</Field.Label>
75
- <Input id="kit-skill-version" maxlength={32} placeholder={t('skills.unversioned')} bind:value={version} />
77
+ <Input
78
+ id="kit-skill-version"
79
+ maxlength={32}
80
+ placeholder={t('skills.unversioned')}
81
+ bind:value={version}
82
+ />
76
83
  </Field.Field>
77
84
  </div>
78
85
  <Field.Field>
@@ -86,7 +93,10 @@
86
93
  </Field.Group>
87
94
  <Dialog.Footer>
88
95
  <Button variant="outline" onclick={() => (open = false)}>{t('common.cancel')}</Button>
89
- <Button disabled={saving || !name.trim() || !description.trim() || !body.trim()} onclick={() => void save()}>
96
+ <Button
97
+ disabled={saving || !name.trim() || !description.trim() || !body.trim()}
98
+ onclick={() => void save()}
99
+ >
90
100
  {t('skills.save')}
91
101
  </Button>
92
102
  </Dialog.Footer>
@@ -8,7 +8,10 @@
8
8
  import type { Preview, PreviewCurrent } from '../ports'
9
9
 
10
10
  /** What both a Preview and a stored SkillDto carry. */
11
- type SkillText = Pick<Preview, 'name' | 'description' | 'version' | 'body' | 'license' | 'extra' | 'files'>
11
+ type SkillText = Pick<
12
+ Preview,
13
+ 'name' | 'description' | 'version' | 'body' | 'license' | 'extra' | 'files'
14
+ >
12
15
 
13
16
  let {
14
17
  open = $bindable(false),
@@ -82,14 +85,19 @@
82
85
  </Alert.Root>
83
86
  {/if}
84
87
  {#if files.length > 0}
85
- <section class="flex flex-col gap-1 text-xs" aria-label={t('skills.files', { count: files.length })}>
88
+ <section
89
+ class="flex flex-col gap-1 text-xs"
90
+ aria-label={t('skills.files', { count: files.length })}
91
+ >
86
92
  <h3 class="font-medium">{t('skills.files', { count: files.length })}</h3>
87
93
  <ul class="max-h-40 overflow-y-auto font-mono">
88
94
  {#each files as file (file.path)}
89
95
  <li class="flex gap-3">
90
96
  <span class="min-w-0 flex-1 truncate">{file.path}</span>
91
97
  <span class="text-muted-foreground">{t(`skills.file_kind.${file.kind}`)}</span>
92
- <span class="text-muted-foreground">{t('skills.file_size', { size: file.size })}</span>
98
+ <span class="text-muted-foreground"
99
+ >{t('skills.file_size', { size: file.size })}</span
100
+ >
93
101
  </li>
94
102
  {/each}
95
103
  </ul>
@@ -106,14 +114,20 @@
106
114
  {#if onconfirm && current}
107
115
  <label class="flex items-center gap-2 text-sm">
108
116
  <input type="checkbox" class="accent-primary" bind:checked={replace} />
109
- {t('skills.replace', { version: current.version ?? t('skills.unversioned'), revision: current.revision })}
117
+ {t('skills.replace', {
118
+ version: current.version ?? t('skills.unversioned'),
119
+ revision: current.revision,
120
+ })}
110
121
  </label>
111
122
  {/if}
112
123
  {/if}
113
124
  <Dialog.Footer>
114
125
  <Button variant="outline" onclick={() => (open = false)}>{t('common.cancel')}</Button>
115
126
  {#if onconfirm}
116
- <Button disabled={busy || !skill || (current !== null && !replace)} onclick={() => onconfirm(replace)}>
127
+ <Button
128
+ disabled={busy || !skill || (current !== null && !replace)}
129
+ onclick={() => onconfirm(replace)}
130
+ >
117
131
  {confirmLabel ?? t('skills.install')}
118
132
  </Button>
119
133
  {/if}
@@ -92,12 +92,16 @@
92
92
  <span class="flex min-w-0 items-center gap-2">
93
93
  <span class="truncate font-mono text-sm">{skill.name}</span>
94
94
  {#if skill.hasScripts}
95
- <span class="shrink-0 rounded border px-1.5 text-xs">{t('skills.has_scripts')}</span>
95
+ <span class="shrink-0 rounded border px-1.5 text-xs"
96
+ >{t('skills.has_scripts')}</span
97
+ >
96
98
  {/if}
97
99
  </span>
98
100
  <span class="text-muted-foreground truncate text-xs">
99
101
  {skill.version ?? t('skills.unversioned')} · {t(`skills.origin.${skill.origin}`)} ·
100
- {t('skills.revision', { revision: skill.revision })} · {t('skills.chars', { count: skill.bodyChars })}
102
+ {t('skills.revision', { revision: skill.revision })} · {t('skills.chars', {
103
+ count: skill.bodyChars,
104
+ })}
101
105
  {#if skill.fileCount > 0}· {t('skills.file_count', { count: skill.fileCount })}{/if}
102
106
  </span>
103
107
  </div>
@@ -126,8 +130,11 @@
126
130
  <Button size="sm" variant="outline" disabled={busy} onclick={() => edit(skill.id)}
127
131
  >{t('skills.edit')}</Button
128
132
  >
129
- <Button size="sm" variant="destructive" disabled={busy} onclick={() => void remove(skill)}
130
- >{t('skills.delete')}</Button
133
+ <Button
134
+ size="sm"
135
+ variant="destructive"
136
+ disabled={busy}
137
+ onclick={() => void remove(skill)}>{t('skills.delete')}</Button
131
138
  >
132
139
  {/if}
133
140
  </li>
@@ -0,0 +1,139 @@
1
+ import { render, screen, fireEvent } from '@testing-library/svelte'
2
+ import { createI18n } from '../../i18n'
3
+ import {
4
+ createFakePorts,
5
+ type FakePortsOptions,
6
+ type Preview,
7
+ type RunEvent,
8
+ type SkillAuthoringPort,
9
+ type SkillDto,
10
+ type SkillMeta,
11
+ type Source,
12
+ } from '../../ports'
13
+ import { vi } from 'vitest'
14
+ import Harness from '../Harness.test.svelte'
15
+
16
+ export const meta = (
17
+ revision: number,
18
+ files: Pick<SkillMeta, 'hasScripts' | 'fileCount'> = { hasScripts: false, fileCount: 0 },
19
+ ): SkillMeta => ({
20
+ id: 's1',
21
+ name: 'writer',
22
+ description: 'Writes',
23
+ version: null,
24
+ origin: 'imported',
25
+ revision,
26
+ bodyChars: 4,
27
+ updatedAt: 0,
28
+ ...files,
29
+ })
30
+
31
+ export const preview = (body: string): Preview => ({
32
+ digest: `d-${body}`,
33
+ name: 'writer',
34
+ description: 'Writes',
35
+ version: '1.0.0',
36
+ body,
37
+ license: null,
38
+ extra: {},
39
+ files: [],
40
+ action: 'install',
41
+ current: null,
42
+ })
43
+
44
+ export function mount(options: FakePortsOptions, skills?: { id: string; revision: number }[]) {
45
+ const ports = createFakePorts({
46
+ providers: {
47
+ modelOptions: () =>
48
+ Promise.resolve([
49
+ {
50
+ endpointId: 'e1',
51
+ modelId: 'm1',
52
+ label: 'm1',
53
+ name: null,
54
+ modelType: 'chat',
55
+ supportsTools: null,
56
+ reasoning: null,
57
+ nativeWebSearch: false,
58
+ canVision: false,
59
+ },
60
+ ]),
61
+ },
62
+ ...options,
63
+ })
64
+ const notify = { toast: vi.fn(), confirm: () => Promise.resolve(true) }
65
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }
66
+ const view = render(Harness, {
67
+ kit: { ports, i18n: createI18n('en'), notify, logger },
68
+ ...(skills ? { skills } : {}),
69
+ })
70
+ return { ...notify, unmount: view.unmount }
71
+ }
72
+
73
+ export async function addFrom(name: string) {
74
+ await fireEvent.click(screen.getByRole('button', { name: 'Add skill' }))
75
+ await fireEvent.click(await screen.findByRole('menuitem', { name }))
76
+ }
77
+
78
+ export async function selectAuthoringModel() {
79
+ await fireEvent.click(await screen.findByLabelText('Select a model'))
80
+ await fireEvent.click(await screen.findByRole('option', { name: /m1/ }))
81
+ }
82
+
83
+ export const creator: SkillDto = {
84
+ ...meta(1),
85
+ id: 'builtin-creator',
86
+ name: 'skill-creator',
87
+ origin: 'builtin',
88
+ body: 'Complete creator instructions: capture intent, draft, evaluate, improve, package.',
89
+ license: null,
90
+ extra: {},
91
+ files: [{ path: 'scripts/quick_validate.py', kind: 'script', size: 10 }],
92
+ }
93
+
94
+ export function authoring(overrides: Partial<SkillAuthoringPort> = {}): SkillAuthoringPort {
95
+ return {
96
+ create: vi.fn(async () => 'authoring-1'),
97
+ send: vi.fn<SkillAuthoringPort['send']>(async (_session, _input, _model, emit) => {
98
+ emit({ kind: 'end', status: 'completed' })
99
+ return { runId: 'authoring-run', conversationId: null, turnId: null }
100
+ }),
101
+ approve: vi.fn(async () => {}),
102
+ cancel: vi.fn(async () => {}),
103
+ prepare: vi.fn(async () => ({
104
+ source: { bytes: { fileName: 'writer.zip', bytes: [1] } },
105
+ origin: 'Authoring workspace',
106
+ })),
107
+ close: vi.fn(async () => {}),
108
+ ...overrides,
109
+ }
110
+ }
111
+
112
+ export function mountAuthoring(api: SkillAuthoringPort, options: FakePortsOptions = {}) {
113
+ return mount({
114
+ ...options,
115
+ skillSources: { ...options.skillSources, authoring: api },
116
+ skills: {
117
+ list: async () => [creator],
118
+ get: async () => creator,
119
+ ...options.skills,
120
+ },
121
+ })
122
+ }
123
+
124
+ export async function startAuthoring() {
125
+ await addFrom('Create with Agent')
126
+ await fireEvent.click(await screen.findByRole('button', { name: 'Review skill-creator' }))
127
+ await screen.findByText(creator.body)
128
+ await fireEvent.click(screen.getByRole('button', { name: 'Confirm skill-creator and start' }))
129
+ await screen.findByLabelText('Message to the skill creation assistant')
130
+ }
131
+
132
+ export async function authorMessage(text: string) {
133
+ await fireEvent.input(screen.getByLabelText('Message to the skill creation assistant'), {
134
+ target: { value: text },
135
+ })
136
+ await fireEvent.click(screen.getByRole('button', { name: /^Send$/ }))
137
+ }
138
+
139
+ export type { Preview, RunEvent, SkillAuthoringPort, Source }
@@ -39,7 +39,9 @@ async function call<T>(command: string, args: Record<string, unknown> = {}): Pro
39
39
  error !== null &&
40
40
  typeof (error as Partial<KitError>).code === 'string' &&
41
41
  typeof (error as Partial<KitError>).message === 'string'
42
- throw new KitClientError(isKitError ? (error as KitError) : { code: 'internal', message: String(error) })
42
+ throw new KitClientError(
43
+ isKitError ? (error as KitError) : { code: 'internal', message: String(error) },
44
+ )
43
45
  }
44
46
  }
45
47
 
@@ -103,14 +105,19 @@ export function createTauriPorts(
103
105
  const search: SearchPort = {
104
106
  settings: (capability, o) => searchCall('search_settings', { capability }, o),
105
107
  save: (input, o) => searchCall('search_save', { input }, o),
106
- setDefault: (capability, provider, o) => searchCall('search_set_default', { capability, provider }, o),
108
+ setDefault: (capability, provider, o) =>
109
+ searchCall('search_set_default', { capability, provider }, o),
107
110
  savePreferences: (input, o) => searchCall('search_save_preferences', { input }, o),
108
111
  saveKey: (capability, provider, value, keyId, o) =>
109
112
  searchCall('search_save_key', { capability, provider, value, keyId: keyId ?? null }, o),
110
113
  removeKey: (capability, provider, keyId, o) =>
111
114
  searchCall('search_remove_key', { capability, provider, keyId }, o),
112
115
  reveal: (capability, provider, keyId, currentPassword, o) =>
113
- searchCall('search_reveal', { capability, provider, keyId, currentPassword: currentPassword ?? null }, o),
116
+ searchCall(
117
+ 'search_reveal',
118
+ { capability, provider, keyId, currentPassword: currentPassword ?? null },
119
+ o,
120
+ ),
114
121
  check: (capability, provider, o) => searchCall('search_check', { capability, provider }, o),
115
122
  search: (query, o) => searchCall('search_query', { query }, o),
116
123
  fetch: (url, o) => searchCall('search_fetch', { url }, o),
@@ -183,7 +190,8 @@ export function createTauriPorts(
183
190
  get: (id) => call('skills_get', { id }),
184
191
  save: (input) => call('skills_save', { input }),
185
192
  preview: (source) => call('skills_preview', { source }),
186
- install: (source, confirmedDigest, replace) => call('skills_install', { source, confirmedDigest, replace }),
193
+ install: (source, confirmedDigest, replace) =>
194
+ call('skills_install', { source, confirmedDigest, replace }),
187
195
  duplicate: (id, newName) => call('skills_duplicate', { id, newName }),
188
196
  reorder: (ids) => call('skills_reorder', { ids }),
189
197
  remove: (id) => call('skills_delete', { id }),
@@ -218,7 +226,9 @@ export function createTauriPorts(
218
226
  let ended = false
219
227
  const cancel = () => {
220
228
  if (accepted && !ended)
221
- void call('skills_authoring_cancel', { sessionId, runId: accepted.runId }).catch(() => undefined)
229
+ void call('skills_authoring_cancel', { sessionId, runId: accepted.runId }).catch(
230
+ () => undefined,
231
+ )
222
232
  }
223
233
  signal?.addEventListener('abort', cancel, { once: true })
224
234
  const channel = new Channel<RunEvent>((event) => {
@@ -230,7 +240,12 @@ export function createTauriPorts(
230
240
  onEvent(event)
231
241
  })
232
242
  try {
233
- accepted = await call<RunAccepted>('skills_authoring_send', { sessionId, input, model, onEvent: channel })
243
+ accepted = await call<RunAccepted>('skills_authoring_send', {
244
+ sessionId,
245
+ input,
246
+ model,
247
+ onEvent: channel,
248
+ })
234
249
  if (signal?.aborted) cancel()
235
250
  return accepted
236
251
  } catch (error) {
@@ -245,7 +260,8 @@ export function createTauriPorts(
245
260
  cancel: async (sessionId, runId) => {
246
261
  await call('skills_authoring_cancel', { sessionId, runId })
247
262
  },
248
- prepare: (sessionId, o) => sourceCall<PreparedSkill>('skills_authoring_prepare', { sessionId }, o),
263
+ prepare: (sessionId, o) =>
264
+ sourceCall<PreparedSkill>('skills_authoring_prepare', { sessionId }, o),
249
265
  close: async (sessionId) => {
250
266
  await call('skills_authoring_close', { sessionId })
251
267
  },