@signal9/era-ui 3.12.4 → 3.13.0

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.
@@ -16,4 +16,5 @@
16
16
  * Exports are flat and prefixed by app (`Notes`, `NotesStore`, `NoteEditor`) so
17
17
  * a second app can land without a namespace churn.
18
18
  */
19
+ export * from './llm-shell/index.js';
19
20
  export * from './notes/index.js';
@@ -16,4 +16,5 @@
16
16
  * Exports are flat and prefixed by app (`Notes`, `NotesStore`, `NoteEditor`) so
17
17
  * a second app can land without a namespace churn.
18
18
  */
19
+ export * from './llm-shell/index.js';
19
20
  export * from './notes/index.js';
@@ -0,0 +1,2 @@
1
+ export { default as LlmShell } from './llm-shell.svelte';
2
+ export type { LlmBlock, LlmConversation, LlmMessage, LlmToolChip, LlmUsage } from './types.js';
@@ -0,0 +1 @@
1
+ export { default as LlmShell } from './llm-shell.svelte';
@@ -0,0 +1,291 @@
1
+ <script lang="ts">
2
+ /**
3
+ * LLM Shell — a streaming chat console, assembled entirely from the AI suite.
4
+ *
5
+ * A conversation rail (rename / star / delete / time buckets), a run-status
6
+ * strip, reasoning and tool steps with structured output and a runtime feed,
7
+ * an editable approval gate, an error panel, a compact chip row, a markdown
8
+ * response with a stream cursor, and a per-turn usage footer.
9
+ *
10
+ * The app owns PRESENTATION and interaction wiring only — it is deliberately
11
+ * backend-free. The host owns the model: it passes conversations in, and gets
12
+ * `onSend` / `onNewChat` / `onDelete` / `onStop` back. That is the same split
13
+ * Notes draws with its adapter, and it is why the docs demo can drive this
14
+ * with a canned stream while a real app drives it from an SDK without
15
+ * touching the component.
16
+ *
17
+ * Every presentation the suite offers is reachable through the LlmBlock union
18
+ * (see types.ts), so a host maps its stream into blocks rather than composing
19
+ * AI parts by hand.
20
+ */
21
+ import type { Snippet } from 'svelte';
22
+ import * as AI from '../../ai/index.js';
23
+ import { Button } from '../../ui/button/index.js';
24
+ import Copy from '@lucide/svelte/icons/copy';
25
+ import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
26
+ import Sparkles from '@lucide/svelte/icons/sparkles';
27
+ import Plus from '@lucide/svelte/icons/plus';
28
+ import { cn } from '../../utils/index.js';
29
+ import type { ChatStatus, LlmBlock, LlmConversation, LlmMessage, RunState } from './types.js';
30
+
31
+ let {
32
+ conversations = $bindable([]),
33
+ activeId = $bindable(''),
34
+ status = 'ready',
35
+ runStatus = 'idle',
36
+ suggestions = [],
37
+ /** Consumer owns the clock — the time bucketer takes it as an argument. */
38
+ now = Date.now(),
39
+ emptyTitle = 'LLM Shell',
40
+ emptyDescription = 'Ask something to get started.',
41
+ approvalState = $bindable('requested'),
42
+ placeholder = 'Send a message…',
43
+ onSend,
44
+ onNewChat,
45
+ onDelete,
46
+ onStop,
47
+ empty,
48
+ class: className
49
+ }: {
50
+ conversations?: LlmConversation[];
51
+ activeId?: string;
52
+ status?: ChatStatus;
53
+ runStatus?: RunState;
54
+ suggestions?: string[];
55
+ now?: number;
56
+ emptyTitle?: string;
57
+ emptyDescription?: string;
58
+ approvalState?: AI.Confirmation.ApprovalState;
59
+ placeholder?: string;
60
+ onSend?: (text: string) => void;
61
+ onNewChat?: () => void;
62
+ onDelete?: (id: string) => void;
63
+ onStop?: () => void;
64
+ /** Replaces the built-in empty state for a conversation with no messages. */
65
+ empty?: Snippet;
66
+ class?: string;
67
+ } = $props();
68
+
69
+ const active = $derived(conversations.find((c) => c.id === activeId) ?? conversations[0]);
70
+ const groups = $derived(AI.Conversations.bucketConversations(conversations, now));
71
+
72
+ // One open chip per row is plenty — the expansion area is shared, so tracking
73
+ // more than the current index would render two bodies into one slot.
74
+ let openChip = $state<string | null>(null);
75
+
76
+ function copyText(text: string) {
77
+ void navigator.clipboard?.writeText(text);
78
+ }
79
+ </script>
80
+
81
+ <div class={cn('flex h-full min-h-0', className)}>
82
+ <!-- Conversation rail. The gutter (p-gutter) matches the inter-row gap so the
83
+ rows sit an even gap from every edge — the same treatment the docs Library
84
+ window gets via its pane bodyClass. -->
85
+ <aside class="hidden w-64 shrink-0 flex-col border-r border-divider p-gutter md:flex">
86
+ <AI.Conversations.Root>
87
+ {#snippet header()}
88
+ <Button class="w-full justify-start" onclick={() => onNewChat?.()}>
89
+ <Plus class="size-xs" aria-hidden="true" />
90
+ New chat
91
+ </Button>
92
+ {/snippet}
93
+ {#each groups as group (group.bucket)}
94
+ <AI.Conversations.Group label={group.label}>
95
+ {#each group.items as entry (entry.id)}
96
+ {@const convo = conversations.find((c) => c.id === entry.id)}
97
+ {#if convo}
98
+ <AI.Conversations.Item
99
+ bind:title={convo.title}
100
+ bind:starred={convo.starred}
101
+ status={convo.status}
102
+ active={convo.id === active?.id}
103
+ onSelect={() => (activeId = convo.id)}
104
+ onDelete={() => onDelete?.(convo.id)}
105
+ />
106
+ {/if}
107
+ {/each}
108
+ </AI.Conversations.Group>
109
+ {/each}
110
+ </AI.Conversations.Root>
111
+ </aside>
112
+
113
+ <!-- Thread -->
114
+ <div class="flex min-w-0 flex-1 flex-col">
115
+ {#if runStatus !== 'idle'}
116
+ <div class="shrink-0 border-b border-divider px-(--era-pad-md) py-gutter">
117
+ <AI.RunStatus.Root
118
+ status={runStatus}
119
+ tools={{
120
+ complete: runStatus === 'running' ? 1 : 0,
121
+ total: 1,
122
+ running: runStatus === 'running' ? 0 : 1
123
+ }}
124
+ latest={{
125
+ tone: 'muted',
126
+ text: runStatus === 'running' ? 'streaming response…' : 'thinking…'
127
+ }}
128
+ oncancel={() => onStop?.()}
129
+ />
130
+ </div>
131
+ {/if}
132
+
133
+ <AI.Conversation.Root>
134
+ <AI.Conversation.Content>
135
+ {#if !active || active.messages.length === 0}
136
+ {#if empty}
137
+ {@render empty()}
138
+ {:else}
139
+ <AI.Conversation.EmptyState title={emptyTitle} description={emptyDescription}>
140
+ {#snippet icon()}
141
+ <Sparkles class="size-md text-muted" />
142
+ {/snippet}
143
+ </AI.Conversation.EmptyState>
144
+ {/if}
145
+ {:else}
146
+ {#each active.messages as message (message.id)}
147
+ {@render messageView(message)}
148
+ {/each}
149
+ {/if}
150
+ </AI.Conversation.Content>
151
+ <AI.Conversation.ScrollButton />
152
+ </AI.Conversation.Root>
153
+
154
+ <div class="mx-auto w-full max-w-2xl shrink-0 p-(--era-pad-md) pt-0">
155
+ {#if active && active.messages.length === 0 && suggestions.length > 0}
156
+ <div class="pb-(--era-gap)">
157
+ <AI.Suggestions.Root>
158
+ {#each suggestions as s (s)}
159
+ <AI.Suggestions.Suggestion suggestion={s} onSelect={(t) => onSend?.(t)} />
160
+ {/each}
161
+ </AI.Suggestions.Root>
162
+ </div>
163
+ {/if}
164
+ <AI.PromptInput.Root accept="image/*" onSubmit={(m) => onSend?.(m.text)}>
165
+ <AI.PromptInput.Attachments />
166
+ <AI.PromptInput.Toolbar>
167
+ <AI.PromptInput.AddAttachments />
168
+ <AI.PromptInput.Textarea {placeholder} />
169
+ <AI.PromptInput.Submit {status} onStop={() => onStop?.()} />
170
+ </AI.PromptInput.Toolbar>
171
+ </AI.PromptInput.Root>
172
+ </div>
173
+ </div>
174
+ </div>
175
+
176
+ <!-- ── Render snippets ─────────────────────────────────────────────────── -->
177
+
178
+ {#snippet messageView(message: LlmMessage)}
179
+ {#if message.role === 'user'}
180
+ <AI.Message.Root from="user">
181
+ <AI.Message.Content>{message.text}</AI.Message.Content>
182
+ </AI.Message.Root>
183
+ {:else}
184
+ <AI.Message.Root from="assistant">
185
+ {#each message.blocks ?? [] as block, i (i)}
186
+ {@render blockView(block)}
187
+ {/each}
188
+ </AI.Message.Root>
189
+ {/if}
190
+ {/snippet}
191
+
192
+ {#snippet blockView(block: LlmBlock)}
193
+ {#if block.kind === 'reasoning'}
194
+ <AI.Reasoning.Root streaming={block.streaming} bind:duration={block.duration}>
195
+ <AI.Reasoning.Trigger streaming={block.streaming} duration={block.duration} />
196
+ <AI.Reasoning.Content>
197
+ <p class="text-body leading-relaxed text-muted">{block.text}</p>
198
+ </AI.Reasoning.Content>
199
+ </AI.Reasoning.Root>
200
+ {:else if block.kind === 'tool'}
201
+ <AI.Tool.Root state={block.state}>
202
+ <AI.Tool.Header type={block.name} />
203
+ <AI.Tool.Content>
204
+ <AI.Tool.Input input={block.input} />
205
+ {#if block.feed}
206
+ <AI.Tool.Section label="trace">
207
+ <AI.RuntimeFeed.Root items={block.feed} />
208
+ </AI.Tool.Section>
209
+ {/if}
210
+ {#if block.output !== undefined}
211
+ <AI.Tool.Section label="output">
212
+ <AI.StructuredValue.Root value={block.output} />
213
+ </AI.Tool.Section>
214
+ {/if}
215
+ </AI.Tool.Content>
216
+ </AI.Tool.Root>
217
+ {:else if block.kind === 'tool-row'}
218
+ <AI.Tool.Chips>
219
+ {#each block.tools as tool (tool.id)}
220
+ <AI.Tool.Chip
221
+ state={tool.state}
222
+ active={openChip === tool.id}
223
+ icon={tool.icon}
224
+ onclick={() => (openChip = openChip === tool.id ? null : tool.id)}
225
+ >
226
+ {tool.name}
227
+ </AI.Tool.Chip>
228
+ {/each}
229
+ {#snippet expanded()}
230
+ {@const tool = block.tools.find((t) => t.id === openChip)}
231
+ {#if tool}
232
+ <div
233
+ class="flex min-w-0 flex-col gap-(--era-pad-sm) rounded-md bg-well p-(--era-pad-sm) shadow-well"
234
+ >
235
+ <AI.Tool.Input input={tool.input} />
236
+ <AI.Tool.Output output={tool.output} />
237
+ </div>
238
+ {/if}
239
+ {/snippet}
240
+ </AI.Tool.Chips>
241
+ {:else if block.kind === 'exec'}
242
+ <AI.Tool.Root state={block.exitCode ? 'output-error' : 'output-available'}>
243
+ <AI.Tool.Header type={block.command} />
244
+ <AI.Tool.Content>
245
+ <AI.Tool.Exec
246
+ command={block.command}
247
+ stdout={block.stdout}
248
+ stderr={block.stderr}
249
+ exitCode={block.exitCode}
250
+ />
251
+ </AI.Tool.Content>
252
+ </AI.Tool.Root>
253
+ {:else if block.kind === 'approval'}
254
+ <AI.Confirmation.Root state={approvalState}>
255
+ Approve <span class="font-mono text-fg">{block.toolName}</span>? Review the arguments before
256
+ it runs.
257
+ <AI.Confirmation.Args controller={block.controller} />
258
+ <AI.Confirmation.Actions>
259
+ <Button variant="link" onclick={() => (approvalState = 'rejected')}>Reject</Button>
260
+ <Button tone="accent" onclick={() => (approvalState = 'approved')}>Approve</Button>
261
+ </AI.Confirmation.Actions>
262
+ </AI.Confirmation.Root>
263
+ {:else if block.kind === 'error'}
264
+ <AI.ErrorPanel.Root error={block.error} />
265
+ {:else if block.kind === 'answer'}
266
+ {#if block.text || block.streaming}
267
+ <AI.Message.Content>
268
+ <AI.Response content={block.text} streaming={block.streaming} />
269
+ </AI.Message.Content>
270
+ {/if}
271
+ {#if !block.streaming && block.text}
272
+ <AI.Message.Toolbar>
273
+ <AI.Actions.Action tooltip="Copy" onclick={() => copyText(block.text)}>
274
+ <Copy class="size-xs" />
275
+ </AI.Actions.Action>
276
+ <AI.Actions.Action tooltip="Regenerate" onclick={() => copyText(block.text)}>
277
+ <RotateCcw class="size-xs" />
278
+ </AI.Actions.Action>
279
+ </AI.Message.Toolbar>
280
+ {#if block.usage}
281
+ <AI.Message.Usage
282
+ tokensIn={block.usage.tokensIn}
283
+ tokensOut={block.usage.tokensOut}
284
+ model={block.usage.model}
285
+ cost={block.usage.cost}
286
+ breakdown={block.usage.breakdown}
287
+ />
288
+ {/if}
289
+ {/if}
290
+ {/if}
291
+ {/snippet}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * LLM Shell — a streaming chat console, assembled entirely from the AI suite.
3
+ *
4
+ * A conversation rail (rename / star / delete / time buckets), a run-status
5
+ * strip, reasoning and tool steps with structured output and a runtime feed,
6
+ * an editable approval gate, an error panel, a compact chip row, a markdown
7
+ * response with a stream cursor, and a per-turn usage footer.
8
+ *
9
+ * The app owns PRESENTATION and interaction wiring only — it is deliberately
10
+ * backend-free. The host owns the model: it passes conversations in, and gets
11
+ * `onSend` / `onNewChat` / `onDelete` / `onStop` back. That is the same split
12
+ * Notes draws with its adapter, and it is why the docs demo can drive this
13
+ * with a canned stream while a real app drives it from an SDK without
14
+ * touching the component.
15
+ *
16
+ * Every presentation the suite offers is reachable through the LlmBlock union
17
+ * (see types.ts), so a host maps its stream into blocks rather than composing
18
+ * AI parts by hand.
19
+ */
20
+ import type { Snippet } from 'svelte';
21
+ import * as AI from '../../ai/index.js';
22
+ import type { ChatStatus, LlmConversation, RunState } from './types.js';
23
+ type $$ComponentProps = {
24
+ conversations?: LlmConversation[];
25
+ activeId?: string;
26
+ status?: ChatStatus;
27
+ runStatus?: RunState;
28
+ suggestions?: string[];
29
+ now?: number;
30
+ emptyTitle?: string;
31
+ emptyDescription?: string;
32
+ approvalState?: AI.Confirmation.ApprovalState;
33
+ placeholder?: string;
34
+ onSend?: (text: string) => void;
35
+ onNewChat?: () => void;
36
+ onDelete?: (id: string) => void;
37
+ onStop?: () => void;
38
+ /** Replaces the built-in empty state for a conversation with no messages. */
39
+ empty?: Snippet;
40
+ class?: string;
41
+ };
42
+ declare const LlmShell: import("svelte").Component<$$ComponentProps, {}, "conversations" | "activeId" | "approvalState">;
43
+ type LlmShell = ReturnType<typeof LlmShell>;
44
+ export default LlmShell;
@@ -0,0 +1,79 @@
1
+ import type { Component } from 'svelte';
2
+ import type { IconProps } from '@lucide/svelte';
3
+ import type { ChatStatus, ConversationStatus, RunState, ToolState, UsageBreakdown } from '../../ai/index.js';
4
+ import type { ApprovalController } from '../../ai/confirmation/index.js';
5
+ import type { RuntimeFeedItemData } from '../../ai/runtime-feed/index.js';
6
+ /** Per-turn cost footer under a finished answer. */
7
+ export type LlmUsage = {
8
+ tokensIn: number;
9
+ tokensOut: number;
10
+ model: string;
11
+ cost: number;
12
+ breakdown?: UsageBreakdown;
13
+ };
14
+ /** One tool in a compact chip row — the projectMessages "tool burst" shape. */
15
+ export type LlmToolChip = {
16
+ id: string;
17
+ name: string;
18
+ state: ToolState;
19
+ icon?: Component<IconProps>;
20
+ input?: unknown;
21
+ output?: unknown;
22
+ };
23
+ /**
24
+ * Everything an assistant turn can contain.
25
+ *
26
+ * The union is the app's contract: it covers every presentation the AI suite
27
+ * has, so a host maps its own stream into these and never renders AI parts by
28
+ * hand. `tool-row` is the compact chips view (one shared expansion area);
29
+ * `tool` is the full expanded step with input, trace and output.
30
+ */
31
+ export type LlmBlock = {
32
+ kind: 'reasoning';
33
+ text: string;
34
+ duration: number;
35
+ streaming: boolean;
36
+ } | {
37
+ kind: 'tool';
38
+ name: string;
39
+ input: unknown;
40
+ output?: unknown;
41
+ state: ToolState;
42
+ feed?: RuntimeFeedItemData[];
43
+ } | {
44
+ kind: 'tool-row';
45
+ tools: LlmToolChip[];
46
+ } | {
47
+ kind: 'exec';
48
+ command: string;
49
+ stdout?: string;
50
+ stderr?: string;
51
+ exitCode?: number;
52
+ } | {
53
+ kind: 'approval';
54
+ toolName: string;
55
+ controller: ApprovalController;
56
+ } | {
57
+ kind: 'error';
58
+ error: unknown;
59
+ } | {
60
+ kind: 'answer';
61
+ text: string;
62
+ streaming: boolean;
63
+ usage?: LlmUsage;
64
+ };
65
+ export type LlmMessage = {
66
+ id: number | string;
67
+ role: 'user' | 'assistant';
68
+ text?: string;
69
+ blocks?: LlmBlock[];
70
+ };
71
+ export type LlmConversation = {
72
+ id: string;
73
+ title: string;
74
+ updatedAt: number;
75
+ starred?: boolean;
76
+ status?: ConversationStatus;
77
+ messages: LlmMessage[];
78
+ };
79
+ export type { ChatStatus, RunState };
@@ -0,0 +1 @@
1
+ export {};
@@ -566,14 +566,6 @@ Make any element draggable with @neodrag/svelte v3.
566
566
 
567
567
  <!-- end: draggable -->
568
568
 
569
- <!-- begin: llm-shell -->
570
-
571
- # LLM Shell
572
-
573
- A full chat console — conversation rail, streaming, tools, approvals.
574
-
575
- <!-- end: llm-shell -->
576
-
577
569
  <!-- begin: os -->
578
570
 
579
571
  # OS Shell
@@ -590,6 +582,14 @@ Release history — every version, grouped by change type.
590
582
 
591
583
  <!-- end: changelog -->
592
584
 
585
+ <!-- begin: llm-shell -->
586
+
587
+ # LLM Shell
588
+
589
+ A full chat console — conversation rail, streaming, tools, approvals.
590
+
591
+ <!-- end: llm-shell -->
592
+
593
593
  <!-- begin: notes -->
594
594
 
595
595
  # Notes
@@ -31,7 +31,7 @@ Full concatenated reference: `{{ORIGIN}}/llms-full.txt`.
31
31
 
32
32
  ## Components
33
33
 
34
- getting-started, spacing, surfaces, measurements, text, utilities, draggable, llm-shell, os, changelog, notes, aspect-ratio, badge, avatar, bar, button, button-group, pane, card, chip, code-block, copy-button, cycle, kv, separator, sheet, skeleton, switch, toggle, accordion, alert-dialog, calendar, checkbox, collapsible, combobox, command, command-bar, step, context-menu, date-field, date-picker, date-range-field, date-range-picker, dialog, dropdown-menu, file-upload, scroll-area, input, link-preview, label, logo, menu, menubar, meter, navigation-menu, mode, pagination, pin-input, popover, progress, range-calendar, radio-group, rating-group, select, slider, table, tabs, time-field, time-range-field, toggle-group, toolbar, video-player, tooltip
34
+ getting-started, spacing, surfaces, measurements, text, utilities, draggable, os, changelog, llm-shell, notes, aspect-ratio, badge, avatar, bar, button, button-group, pane, card, chip, code-block, copy-button, cycle, kv, separator, sheet, skeleton, switch, toggle, accordion, alert-dialog, calendar, checkbox, collapsible, combobox, command, command-bar, step, context-menu, date-field, date-picker, date-range-field, date-range-picker, dialog, dropdown-menu, file-upload, scroll-area, input, link-preview, label, logo, menu, menubar, meter, navigation-menu, mode, pagination, pin-input, popover, progress, range-calendar, radio-group, rating-group, select, slider, table, tabs, time-field, time-range-field, toggle-group, toolbar, video-player, tooltip
35
35
 
36
36
  ## CSS utilities
37
37
 
@@ -76,14 +76,6 @@
76
76
  "sections": [],
77
77
  "file": "draggable.md"
78
78
  },
79
- {
80
- "slug": "llm-shell",
81
- "title": "LLM Shell",
82
- "summary": "A full chat console — conversation rail, streaming, tools, approvals.",
83
- "tokenEstimate": 21,
84
- "sections": [],
85
- "file": "llm-shell.md"
86
- },
87
79
  {
88
80
  "slug": "os",
89
81
  "title": "OS Shell",
@@ -100,6 +92,14 @@
100
92
  "sections": [],
101
93
  "file": "changelog.md"
102
94
  },
95
+ {
96
+ "slug": "llm-shell",
97
+ "title": "LLM Shell",
98
+ "summary": "A full chat console — conversation rail, streaming, tools, approvals.",
99
+ "tokenEstimate": 21,
100
+ "sections": [],
101
+ "file": "llm-shell.md"
102
+ },
103
103
  {
104
104
  "slug": "notes",
105
105
  "title": "Notes",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "3.12.4",
3
+ "version": "3.13.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",
@@ -68,6 +68,11 @@
68
68
  "svelte": "./dist/apps/index.js",
69
69
  "default": "./dist/apps/index.js"
70
70
  },
71
+ "./apps/llm-shell": {
72
+ "types": "./dist/apps/llm-shell/index.d.ts",
73
+ "svelte": "./dist/apps/llm-shell/index.js",
74
+ "default": "./dist/apps/llm-shell/index.js"
75
+ },
71
76
  "./apps/notes": {
72
77
  "types": "./dist/apps/notes/index.d.ts",
73
78
  "svelte": "./dist/apps/notes/index.js",