@ruckc/truss-chat 0.0.0-stage → 1.12.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.
package/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Curtis Ruck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ The bundled fonts, Inter and JetBrains Mono, are licensed separately under the
24
+ SIL Open Font License 1.1; see src/lib/fonts/LICENSE-*.txt.
@@ -0,0 +1,73 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { Button, Textarea } from '@ruckc/truss-ui';
5
+
6
+ let {
7
+ value = $bindable(''),
8
+ onsend,
9
+ busy = false,
10
+ sendOn = 'enter',
11
+ placeholder = 'Message',
12
+ maxRows = 8,
13
+ disabled = false,
14
+ label = 'Message',
15
+ sendLabel = 'Send',
16
+ actions,
17
+ class: klass = '',
18
+ ...rest
19
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'onkeydown'> & {
20
+ /** The draft text. Bindable; cleared after a send. */
21
+ value?: string;
22
+ /** Called with the trimmed text when the user sends. Not called while `busy` or when the draft is blank. */
23
+ onsend?: (text: string) => void;
24
+ /** A reply is in progress: the send button shows a spinner and is disabled, and sending does nothing. The text stays editable. */
25
+ busy?: boolean;
26
+ /** `enter`: Enter sends and Shift+Enter adds a newline. `mod-enter`: Ctrl/Cmd+Enter sends and Enter adds a newline. Enter never sends while an IME composition is in progress. */
27
+ sendOn?: 'enter' | 'mod-enter';
28
+ placeholder?: string;
29
+ /** The field grows with its content up to this many rows, then scrolls. */
30
+ maxRows?: number;
31
+ /** Disables the field and the send button. */
32
+ disabled?: boolean;
33
+ /** Accessible name of the text field. */
34
+ label?: string;
35
+ /** Accessible name of the send button. */
36
+ sendLabel?: string;
37
+ /** Extra buttons placed between the field and the send button (attach, stop, voice, ...). */
38
+ actions?: Snippet;
39
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
40
+ class?: string;
41
+ } = $props();
42
+
43
+ const empty = $derived(!value.trim());
44
+
45
+ function send() {
46
+ if (busy || disabled || empty) return;
47
+ const text = value.trim();
48
+ value = '';
49
+ onsend?.(text);
50
+ }
51
+
52
+ function onkeydown(e: KeyboardEvent) {
53
+ // keyCode 229 is the IME's "processing" key on browsers that report isComposing too late
54
+ if (e.key !== 'Enter' || e.isComposing || e.keyCode === 229) return;
55
+ const wanted = sendOn === 'enter' ? !e.shiftKey : e.ctrlKey || e.metaKey;
56
+ if (!wanted) return;
57
+ e.preventDefault();
58
+ send();
59
+ }
60
+ </script>
61
+
62
+ <div {...rest} class={['composer', klass]} style:--rows={maxRows} aria-busy={busy}>
63
+ <Textarea bind:value rows={1} autosize {placeholder} {disabled} aria-label={label} {onkeydown} class="field" />
64
+ {#if actions}<div class="actions">{@render actions()}</div>{/if}
65
+ <Button icon="send" aria-label={sendLabel} loading={busy} disabled={disabled || busy || empty} onclick={send} />
66
+ </div>
67
+
68
+ <style>
69
+ .composer { display: flex; align-items: flex-end; gap: var(--tr-space-2); min-width: 0; }
70
+ .composer :global(.field) { flex: 1; min-width: 0; }
71
+ .composer :global(textarea) { max-height: calc(var(--rows) * 1.4em); overflow-y: auto; }
72
+ .actions { display: flex; align-items: center; gap: var(--tr-space-1); flex: none; }
73
+ </style>
@@ -0,0 +1,28 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'onkeydown'> & {
4
+ /** The draft text. Bindable; cleared after a send. */
5
+ value?: string;
6
+ /** Called with the trimmed text when the user sends. Not called while `busy` or when the draft is blank. */
7
+ onsend?: (text: string) => void;
8
+ /** A reply is in progress: the send button shows a spinner and is disabled, and sending does nothing. The text stays editable. */
9
+ busy?: boolean;
10
+ /** `enter`: Enter sends and Shift+Enter adds a newline. `mod-enter`: Ctrl/Cmd+Enter sends and Enter adds a newline. Enter never sends while an IME composition is in progress. */
11
+ sendOn?: 'enter' | 'mod-enter';
12
+ placeholder?: string;
13
+ /** The field grows with its content up to this many rows, then scrolls. */
14
+ maxRows?: number;
15
+ /** Disables the field and the send button. */
16
+ disabled?: boolean;
17
+ /** Accessible name of the text field. */
18
+ label?: string;
19
+ /** Accessible name of the send button. */
20
+ sendLabel?: string;
21
+ /** Extra buttons placed between the field and the send button (attach, stop, voice, ...). */
22
+ actions?: Snippet;
23
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
24
+ class?: string;
25
+ };
26
+ declare const ChatComposer: import("svelte").Component<$$ComponentProps, {}, "value">;
27
+ type ChatComposer = ReturnType<typeof ChatComposer>;
28
+ export default ChatComposer;
@@ -0,0 +1,144 @@
1
+ <script lang="ts">
2
+ import { untrack, type Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { Icon } from '@ruckc/truss-ui';
5
+ import { Markdown } from '@ruckc/truss-markdown';
6
+ import { useThread } from './internal/chat-context.js';
7
+
8
+ let {
9
+ role = 'assistant',
10
+ text,
11
+ content,
12
+ plain = false,
13
+ markdown,
14
+ streaming = false,
15
+ typing = true,
16
+ announce = false,
17
+ label,
18
+ onfinish,
19
+ tools,
20
+ reasoning,
21
+ showReasoning = $bindable(),
22
+ class: klass = '',
23
+ children,
24
+ ...rest
25
+ }: Omit<HTMLAttributes<HTMLElement>, 'children' | 'class'> & {
26
+ /** `user` messages are right-aligned bubbles; `assistant` messages are full-width prose. */
27
+ role?: 'user' | 'assistant';
28
+ /** The message text. Assistant messages render it as Markdown (paragraphs, headings, emphasis, code, lists, quotes, http/https/mailto links; nothing is injected as HTML), user messages as plain text with whitespace and line breaks preserved. See `plain` and `markdown`. Used when neither `content` nor `children` is given. */
29
+ text?: string;
30
+ /** The message body in place of `text`, chosen per message: `{#snippet content()}<Markdown text={m.text} />{/snippet}`. Takes precedence over `children`, `plain` and `markdown`. */
31
+ content?: Snippet;
32
+ /** The reply is still arriving. Sets `aria-busy`. When it turns `false` the message is finished: `onfinish` fires and, inside a `ChatThread`, the reply is announced once to screen readers. */
33
+ streaming?: boolean;
34
+ /** Show a typing indicator while `streaming`. */
35
+ typing?: boolean;
36
+ /** Announce the message when it first appears (inside a `ChatThread`), for a reply delivered whole instead of streamed. */
37
+ announce?: boolean;
38
+ /** Accessible name, and the speaker named in the announcement. Defaults to "You" or "Assistant". */
39
+ label?: string;
40
+ /** Called once when `streaming` changes from `true` to `false`. */
41
+ onfinish?: () => void;
42
+ /** Tool calls (typically `ToolCall` rows) shown under the text. */
43
+ tools?: Snippet;
44
+ /**
45
+ * The model's reasoning or thinking, streamed incrementally like `text` (pass the text so far). Rendered as Markdown in a subdued block (muted colour, left border, smaller text) above the answer,
46
+ * behind a "Show reasoning" / "Hide reasoning" button. It is hidden by default (see `showReasoning`), and while it is hidden and `streaming` the button shows "Thinking…". Reasoning is never announced by the thread's live region, only the answer is.
47
+ */
48
+ reasoning?: string;
49
+ /**
50
+ * Whether the reasoning is shown. Bindable. Leave it `undefined` (the default) to follow the thread: `ChatThread`'s `showReasoning`, which is hidden unless set, and which this message's button updates for every message.
51
+ * Set it to `true` or `false` to control this message alone: the button then changes only this value. Precedence: this prop, then the thread's setting, then hidden.
52
+ */
53
+ showReasoning?: boolean;
54
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<article>`. `class` is added to it. */
55
+ class?: string;
56
+ /** Show `text` as plain text even for an assistant message. */
57
+ plain?: boolean;
58
+ /** Render `text` as Markdown even for a user message (they are plain text by default, since people type literal asterisks and underscores). Ignored when `plain` is set. */
59
+ markdown?: boolean;
60
+ /** The message body in place of `text`, for anything it cannot express. Same as `content`. */
61
+ children?: Snippet;
62
+ } = $props();
63
+
64
+ const thread = useThread();
65
+ let body: HTMLDivElement;
66
+ const rich = $derived(!plain && (markdown ?? role === 'assistant'));
67
+ let localShow = $state(false);
68
+ const shown = $derived(showReasoning ?? (thread ? thread.showReasoning : localShow));
69
+ function toggleReasoning() {
70
+ const next = !shown;
71
+ if (showReasoning !== undefined) showReasoning = next;
72
+ else if (thread) thread.setShowReasoning(next);
73
+ else localShow = next;
74
+ }
75
+ const rid = $props.id();
76
+ const name = $derived(label ?? (role === 'user' ? 'You' : 'Assistant'));
77
+
78
+ /** At most this many characters are read out; the full reply stays in the log. */
79
+ const MAX = 300;
80
+ function speak() {
81
+ const said = body.textContent!.replace(/\s+/g, ' ').trim();
82
+ thread?.announce(`${name}: ${said.length > MAX ? `${said.slice(0, MAX)}…` : said}`);
83
+ }
84
+
85
+ let was = false;
86
+ $effect(() => {
87
+ const now = streaming;
88
+ if (was && !now)
89
+ untrack(() => {
90
+ onfinish?.();
91
+ if (role === 'assistant') speak();
92
+ });
93
+ was = now;
94
+ });
95
+ $effect(() => {
96
+ if (announce) untrack(speak);
97
+ });
98
+ </script>
99
+
100
+ <article {...rest} class={['msg', klass]} data-role={role} data-streaming={streaming} aria-label={name} aria-busy={streaming}>
101
+ {#if reasoning}
102
+ <div class="reasoning" data-open={shown}>
103
+ <button type="button" class="rtoggle" aria-expanded={shown} aria-controls="{rid}-reasoning" onclick={toggleReasoning}>
104
+ <span class="chev" data-open={shown}><Icon name="chevron-down" /></span>
105
+ {shown ? 'Hide reasoning' : 'Show reasoning'}
106
+ {#if streaming && !shown}<span class="thinking">Thinking…</span>{/if}
107
+ </button>
108
+ <div class="rbody" id="{rid}-reasoning" hidden={!shown}>{#if shown}<Markdown text={reasoning} />{/if}</div>
109
+ </div>
110
+ {/if}
111
+ <div class="body" bind:this={body}>
112
+ {#if content}{@render content()}{:else if children}{@render children()}{:else if text}{#if rich}<Markdown {text} />{:else}<div class="plain">{text}</div>{/if}{/if}
113
+ {#if streaming && typing}<span class="typing" aria-hidden="true"><i></i><i></i><i></i></span>{/if}
114
+ </div>
115
+ {#if tools}<div class="tools">{@render tools()}</div>{/if}
116
+ </article>
117
+
118
+ <style>
119
+ .msg { display: flex; flex-direction: column; gap: var(--tr-space-2); min-width: 0; max-width: 100%; }
120
+ .msg[data-role='user'] { align-self: flex-end; align-items: flex-end; max-width: min(85%, 60ch); }
121
+ .body { min-width: 0; max-width: 100%; overflow-wrap: anywhere; line-height: 1.5; color: var(--tr-on-surface); }
122
+ [data-role='user'] .body { padding: var(--tr-space-2) var(--tr-space-3); border-radius: var(--tr-radius-lg) var(--tr-radius-lg) var(--tr-radius-sm) var(--tr-radius-lg); background: var(--tr-primary-container); color: var(--tr-on-primary-container); }
123
+ [data-role='assistant'] .body { width: 100%; }
124
+ [data-role='user'] .body :global(a) { color: inherit; }
125
+ .plain { white-space: pre-wrap; }
126
+ .reasoning { min-width: 0; max-width: 100%; width: 100%; font-size: var(--tr-text-sm); color: var(--tr-on-surface-variant); }
127
+ .rtoggle { display: inline-flex; align-items: center; gap: var(--tr-space-1); min-height: var(--tr-control-h-sm); max-width: 100%; padding: 0 var(--tr-space-1); border: 0; border-radius: var(--tr-radius-sm); background: none; color: inherit; font: inherit; cursor: pointer; }
128
+ .rtoggle:hover { color: var(--tr-on-surface); }
129
+ .rtoggle:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
130
+ .chev { display: inline-flex; transition: transform var(--tr-dur) var(--tr-ease); }
131
+ .chev[data-open='true'] { transform: rotate(180deg); }
132
+ .thinking { font-style: italic; opacity: 0.85; animation: pulse 1.6s infinite ease-in-out; }
133
+ .rbody { margin-top: var(--tr-space-1); padding-inline-start: var(--tr-space-3); border-inline-start: 2px solid var(--tr-outline-variant); min-width: 0; overflow-wrap: anywhere; line-height: 1.5; }
134
+ @keyframes pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
135
+ @media (prefers-reduced-motion: reduce) { .thinking { animation: none; } }
136
+ .tools { display: flex; flex-direction: column; gap: var(--tr-space-1); width: 100%; min-width: 0; }
137
+
138
+ .typing { display: inline-flex; gap: 4px; align-items: center; height: 1.5em; vertical-align: bottom; }
139
+ .typing i { width: 6px; height: 6px; border-radius: 50%; background: var(--tr-on-surface-variant); animation: bounce 1.2s infinite ease-in-out; }
140
+ .typing i:nth-child(2) { animation-delay: 0.15s; }
141
+ .typing i:nth-child(3) { animation-delay: 0.3s; }
142
+ @keyframes bounce { 0%, 60%, 100% { opacity: 0.35; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } }
143
+ @media (prefers-reduced-motion: reduce) { .typing i { animation: none; opacity: 0.7; } }
144
+ </style>
@@ -0,0 +1,43 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'class'> & {
4
+ /** `user` messages are right-aligned bubbles; `assistant` messages are full-width prose. */
5
+ role?: 'user' | 'assistant';
6
+ /** The message text. Assistant messages render it as Markdown (paragraphs, headings, emphasis, code, lists, quotes, http/https/mailto links; nothing is injected as HTML), user messages as plain text with whitespace and line breaks preserved. See `plain` and `markdown`. Used when neither `content` nor `children` is given. */
7
+ text?: string;
8
+ /** The message body in place of `text`, chosen per message: `{#snippet content()}<Markdown text={m.text} />{/snippet}`. Takes precedence over `children`, `plain` and `markdown`. */
9
+ content?: Snippet;
10
+ /** The reply is still arriving. Sets `aria-busy`. When it turns `false` the message is finished: `onfinish` fires and, inside a `ChatThread`, the reply is announced once to screen readers. */
11
+ streaming?: boolean;
12
+ /** Show a typing indicator while `streaming`. */
13
+ typing?: boolean;
14
+ /** Announce the message when it first appears (inside a `ChatThread`), for a reply delivered whole instead of streamed. */
15
+ announce?: boolean;
16
+ /** Accessible name, and the speaker named in the announcement. Defaults to "You" or "Assistant". */
17
+ label?: string;
18
+ /** Called once when `streaming` changes from `true` to `false`. */
19
+ onfinish?: () => void;
20
+ /** Tool calls (typically `ToolCall` rows) shown under the text. */
21
+ tools?: Snippet;
22
+ /**
23
+ * The model's reasoning or thinking, streamed incrementally like `text` (pass the text so far). Rendered as Markdown in a subdued block (muted colour, left border, smaller text) above the answer,
24
+ * behind a "Show reasoning" / "Hide reasoning" button. It is hidden by default (see `showReasoning`), and while it is hidden and `streaming` the button shows "Thinking…". Reasoning is never announced by the thread's live region, only the answer is.
25
+ */
26
+ reasoning?: string;
27
+ /**
28
+ * Whether the reasoning is shown. Bindable. Leave it `undefined` (the default) to follow the thread: `ChatThread`'s `showReasoning`, which is hidden unless set, and which this message's button updates for every message.
29
+ * Set it to `true` or `false` to control this message alone: the button then changes only this value. Precedence: this prop, then the thread's setting, then hidden.
30
+ */
31
+ showReasoning?: boolean;
32
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<article>`. `class` is added to it. */
33
+ class?: string;
34
+ /** Show `text` as plain text even for an assistant message. */
35
+ plain?: boolean;
36
+ /** Render `text` as Markdown even for a user message (they are plain text by default, since people type literal asterisks and underscores). Ignored when `plain` is set. */
37
+ markdown?: boolean;
38
+ /** The message body in place of `text`, for anything it cannot express. Same as `content`. */
39
+ children?: Snippet;
40
+ };
41
+ declare const ChatMessage: import("svelte").Component<$$ComponentProps, {}, "showReasoning">;
42
+ type ChatMessage = ReturnType<typeof ChatMessage>;
43
+ export default ChatMessage;
@@ -0,0 +1,35 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { Icon } from '@ruckc/truss-ui';
5
+
6
+ let {
7
+ label,
8
+ variant = 'progress',
9
+ class: klass = '',
10
+ children,
11
+ ...rest
12
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'role'> & {
13
+ /** The status text, e.g. "Starting…" or "Thinking…". Use `children` for richer content. */
14
+ label?: string;
15
+ /** `progress` shows a spinner and is announced politely (`role="status"`); `error` shows an error icon and is announced at once (`role="alert"`). */
16
+ variant?: 'progress' | 'error';
17
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
18
+ class?: string;
19
+ /** Status content, shown after `label`. */
20
+ children?: Snippet;
21
+ } = $props();
22
+ </script>
23
+
24
+ <div {...rest} class={['cs', klass]} data-variant={variant} role={variant === 'error' ? 'alert' : 'status'}>
25
+ <span class="icon"><Icon name={variant === 'error' ? 'error' : 'refresh'} spin={variant === 'progress'} /></span>
26
+ <span class="text">{label}{@render children?.()}</span>
27
+ </div>
28
+
29
+ <style>
30
+ .cs { display: inline-flex; align-items: center; gap: var(--tr-space-2); max-width: 100%; color: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); }
31
+ .icon { display: inline-flex; flex: none; color: var(--tr-primary); }
32
+ .text { min-width: 0; overflow-wrap: anywhere; }
33
+ [data-variant='error'] { color: var(--tr-danger); }
34
+ [data-variant='error'] .icon { color: var(--tr-danger); }
35
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'role'> & {
4
+ /** The status text, e.g. "Starting…" or "Thinking…". Use `children` for richer content. */
5
+ label?: string;
6
+ /** `progress` shows a spinner and is announced politely (`role="status"`); `error` shows an error icon and is announced at once (`role="alert"`). */
7
+ variant?: 'progress' | 'error';
8
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
9
+ class?: string;
10
+ /** Status content, shown after `label`. */
11
+ children?: Snippet;
12
+ };
13
+ declare const ChatStatus: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type ChatStatus = ReturnType<typeof ChatStatus>;
15
+ export default ChatStatus;
@@ -0,0 +1,124 @@
1
+ <script lang="ts">
2
+ import { onDestroy, onMount, type Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { Button } from '@ruckc/truss-ui';
5
+ import { provideThread } from './internal/chat-context.js';
6
+
7
+ let {
8
+ label = 'Conversation',
9
+ jumpLabel = 'Jump to latest',
10
+ follow = $bindable(true),
11
+ showReasoning = $bindable(false),
12
+ reasoningStorageKey,
13
+ class: klass = '',
14
+ children,
15
+ ...rest
16
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'role'> & {
17
+ /** Accessible name of the scrolling log. */
18
+ label?: string;
19
+ /** Text of the button shown once the reader has scrolled away from the latest message. */
20
+ jumpLabel?: string;
21
+ /** Whether the thread is following the bottom. It sticks to the bottom while content grows, turns `false` when the reader scrolls up and `true` again at the bottom or on "Jump to latest". Bindable: set it to `true` to scroll to the latest message. */
22
+ follow?: boolean;
23
+ /**
24
+ * Whether the messages' reasoning blocks are shown. Bindable. Hidden (`false`) by default. It is the default for every `ChatMessage` in the thread, and the Show/Hide reasoning button on any message updates it,
25
+ * so one choice applies to the whole thread. A message that sets its own `showReasoning` overrides it. Precedence: the message's `showReasoning`, then this, then hidden.
26
+ */
27
+ showReasoning?: boolean;
28
+ /**
29
+ * A `localStorage` key that remembers the reader's choice across visits. When set, a stored `"true"` or `"false"` replaces `showReasoning` after mount (so server-rendered HTML shows the default), and every toggle by the reader is stored.
30
+ * Unavailable, blocked or throwing storage and unrecognised stored values are ignored. Changing `showReasoning` from code does not write to storage.
31
+ */
32
+ reasoningStorageKey?: string;
33
+ /** Other attributes (`data-testid`, `id`, `style`, ...) go to the root `<div>`. `class` is added to it. */
34
+ class?: string;
35
+ /** The messages: `ChatMessage`, `ChatStatus` and anything else. A `ChatMessage` inside reports its finished reply to the thread's live region. */
36
+ children: Snippet;
37
+ } = $props();
38
+
39
+ let log: HTMLDivElement;
40
+ let content: HTMLDivElement;
41
+ let live = $state('');
42
+ let timer: ReturnType<typeof setTimeout>;
43
+
44
+ /** Clears, then sets, so the same text can be announced twice in a row. */
45
+ provideThread({
46
+ announce(text) {
47
+ live = '';
48
+ clearTimeout(timer);
49
+ timer = setTimeout(() => (live = text), 50);
50
+ },
51
+ get showReasoning() {
52
+ return showReasoning;
53
+ },
54
+ setShowReasoning(show) {
55
+ showReasoning = show;
56
+ try {
57
+ if (reasoningStorageKey) localStorage.setItem(reasoningStorageKey, String(show));
58
+ } catch {
59
+ /* storage unavailable or full: the choice still applies to this visit */
60
+ }
61
+ }
62
+ });
63
+ onMount(() => {
64
+ try {
65
+ const stored = reasoningStorageKey ? localStorage.getItem(reasoningStorageKey) : null;
66
+ if (stored === 'true' || stored === 'false') showReasoning = stored === 'true';
67
+ } catch {
68
+ /* storage unavailable: keep the default */
69
+ }
70
+ });
71
+ onDestroy(() => clearTimeout(timer));
72
+
73
+ // the handlers take their element as an argument: a scroll or resize that is already queued can arrive after the thread is gone
74
+ // Only a scroll *up* means the reader left the bottom. A scroll event is delivered after the fact, so the one caused by
75
+ // pinning can arrive once streamed content has already grown the log: judged by position alone it would look like
76
+ // "not at the bottom" and stop following mid-stream.
77
+ let lastTop = 0;
78
+ const pin = (el: HTMLElement) => {
79
+ el.scrollTop = el.scrollHeight;
80
+ lastTop = el.scrollTop;
81
+ };
82
+ const onscroll = (e: Event & { currentTarget: HTMLElement }) => {
83
+ const el = e.currentTarget;
84
+ const top = el.scrollTop;
85
+ if (el.scrollHeight - top - el.clientHeight <= 8) follow = true;
86
+ else if (top < lastTop) follow = false;
87
+ lastTop = top;
88
+ };
89
+ const jump = () => {
90
+ follow = true;
91
+ pin(log);
92
+ };
93
+
94
+ // following: scroll now (mount, jump, `follow` set from outside) and again whenever the content or the viewport resizes
95
+ $effect(() => {
96
+ if (follow) pin(log);
97
+ });
98
+ $effect(() => {
99
+ const el = log;
100
+ const ro = new ResizeObserver(() => follow && pin(el));
101
+ ro.observe(content);
102
+ ro.observe(el);
103
+ return () => ro.disconnect();
104
+ });
105
+ </script>
106
+
107
+ <div {...rest} class={['thread', klass]}>
108
+ <!-- aria-live="off": a log is implicitly polite, which would announce every streamed token; finished replies go through the live region below instead -->
109
+ <div class="log" role="log" aria-live="off" aria-label={label} bind:this={log} {onscroll}>
110
+ <div class="content" bind:this={content}>{@render children()}</div>
111
+ </div>
112
+ {#if !follow}
113
+ <div class="jump"><Button size="sm" variant="tonal" icon="arrow-down" onclick={jump}>{jumpLabel}</Button></div>
114
+ {/if}
115
+ <div class="tr-visually-hidden" role="status" aria-live="polite" aria-atomic="true">{live}</div>
116
+ </div>
117
+
118
+ <style>
119
+ .thread { position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
120
+ .log { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; border-radius: var(--tr-radius-md); }
121
+ .content { display: flex; flex-direction: column; gap: var(--tr-space-4); padding: var(--tr-space-3); min-width: 0; }
122
+ .jump { position: absolute; inset-inline: 0; bottom: var(--tr-space-3); display: flex; justify-content: center; pointer-events: none; }
123
+ .jump :global(.tr-btn) { pointer-events: auto; box-shadow: var(--tr-elevation-2); }
124
+ </style>
@@ -0,0 +1,27 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'role'> & {
4
+ /** Accessible name of the scrolling log. */
5
+ label?: string;
6
+ /** Text of the button shown once the reader has scrolled away from the latest message. */
7
+ jumpLabel?: string;
8
+ /** Whether the thread is following the bottom. It sticks to the bottom while content grows, turns `false` when the reader scrolls up and `true` again at the bottom or on "Jump to latest". Bindable: set it to `true` to scroll to the latest message. */
9
+ follow?: boolean;
10
+ /**
11
+ * Whether the messages' reasoning blocks are shown. Bindable. Hidden (`false`) by default. It is the default for every `ChatMessage` in the thread, and the Show/Hide reasoning button on any message updates it,
12
+ * so one choice applies to the whole thread. A message that sets its own `showReasoning` overrides it. Precedence: the message's `showReasoning`, then this, then hidden.
13
+ */
14
+ showReasoning?: boolean;
15
+ /**
16
+ * A `localStorage` key that remembers the reader's choice across visits. When set, a stored `"true"` or `"false"` replaces `showReasoning` after mount (so server-rendered HTML shows the default), and every toggle by the reader is stored.
17
+ * Unavailable, blocked or throwing storage and unrecognised stored values are ignored. Changing `showReasoning` from code does not write to storage.
18
+ */
19
+ reasoningStorageKey?: string;
20
+ /** Other attributes (`data-testid`, `id`, `style`, ...) go to the root `<div>`. `class` is added to it. */
21
+ class?: string;
22
+ /** The messages: `ChatMessage`, `ChatStatus` and anything else. A `ChatMessage` inside reports its finished reply to the thread's live region. */
23
+ children: Snippet;
24
+ };
25
+ declare const ChatThread: import("svelte").Component<$$ComponentProps, {}, "showReasoning" | "follow">;
26
+ type ChatThread = ReturnType<typeof ChatThread>;
27
+ export default ChatThread;
@@ -0,0 +1,203 @@
1
+ <script lang="ts" module>
2
+ /** One question: a prompt with options to pick from. */
3
+ export type Question = {
4
+ /** A short topic shown above the question, e.g. "Auth method". */
5
+ header?: string;
6
+ question: string;
7
+ options: { label: string; description?: string }[];
8
+ /** Several options can be chosen. Otherwise exactly one. */
9
+ multiSelect?: boolean;
10
+ /** The question can be passed without an answer. */
11
+ optional?: boolean;
12
+ };
13
+ /** What was answered to one question. `selected` holds option labels; `other` is the free-text answer. */
14
+ export type QuestionAnswer = { selected: string[]; other?: string };
15
+ </script>
16
+
17
+ <script lang="ts">
18
+ import { tick, untrack } from 'svelte';
19
+ import type { HTMLAttributes } from 'svelte/elements';
20
+ import { Button, Checkbox, RadioGroup, Textarea } from '@ruckc/truss-ui';
21
+
22
+ let {
23
+ questions,
24
+ allowOther = true,
25
+ onsubmit,
26
+ onskip,
27
+ submitLabel = 'Submit',
28
+ class: klass = '',
29
+ ...rest
30
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'onkeydown' | 'onsubmit'> & {
31
+ /** The questions, shown one per page. Read once when the form mounts: to ask a different set, mount a new form (for example in a `{#key}`). */
32
+ questions: Question[];
33
+ /** Offer a free-text "Other" answer. For a single-choice question it replaces the chosen option; for `multiSelect` it is added to the chosen ones. */
34
+ allowOther?: boolean;
35
+ /** Called from the review page with one answer per question, in order: `{ selected: string[]; other?: string }`. `other` is left out when empty. */
36
+ onsubmit?: (answers: QuestionAnswer[]) => void;
37
+ /** Called when the user skips the whole form (the Skip button or Escape). Without it there is no Skip button and Escape does nothing. */
38
+ onskip?: () => void;
39
+ /** Text of the final button on the review page. */
40
+ submitLabel?: string;
41
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
42
+ class?: string;
43
+ } = $props();
44
+
45
+ const uid = $props.id();
46
+ const qs = untrack(() => questions);
47
+ const total = qs.length;
48
+ let page = $state(0); // `total` is the review page
49
+ let editing = $state(false);
50
+ let picked = $state<string[][]>(qs.map(() => []));
51
+ let others = $state<string[]>(qs.map(() => ''));
52
+ let head = $state<HTMLElement>();
53
+
54
+ const review = $derived(page === total);
55
+ const q = $derived(qs[page]);
56
+ const isAnswered = (i: number) => picked[i].length > 0 || others[i].trim() !== '';
57
+ const canNext = $derived(isAnswered(page) || !!q.optional);
58
+
59
+ async function go(p: number) {
60
+ page = p;
61
+ await tick();
62
+ head?.focus();
63
+ }
64
+ const next = () => {
65
+ if (!canNext) return;
66
+ if (editing) {
67
+ editing = false;
68
+ go(total);
69
+ } else go(page + 1);
70
+ };
71
+ const back = () => {
72
+ editing = false;
73
+ go(page - 1);
74
+ };
75
+ const edit = (i: number) => {
76
+ editing = true;
77
+ go(i);
78
+ };
79
+
80
+ const order = (i: number, labels: string[]) => qs[i].options.map((o) => o.label).filter((l) => labels.includes(l));
81
+ const setSingle = (i: number, v: string) => {
82
+ picked[i] = [v];
83
+ others[i] = '';
84
+ };
85
+ const toggle = (i: number, label: string, on: boolean) => {
86
+ picked[i] = order(i, on ? [...picked[i], label] : picked[i].filter((l) => l !== label));
87
+ };
88
+ const setOther = (i: number, v: string) => {
89
+ others[i] = v;
90
+ if (!qs[i].multiSelect && v.trim()) picked[i] = [];
91
+ };
92
+
93
+ const answers = $derived(
94
+ qs.map((_, i): QuestionAnswer => {
95
+ const other = others[i].trim();
96
+ return other ? { selected: picked[i], other } : { selected: picked[i] };
97
+ })
98
+ );
99
+ const summary = (a: QuestionAnswer) => [...a.selected, ...(a.other ? [`Other: ${a.other}`] : [])].join(', ') || 'No answer';
100
+ const submit = () => onsubmit?.($state.snapshot(answers));
101
+
102
+ function onkeydown(e: KeyboardEvent) {
103
+ if (e.key === 'Escape') {
104
+ if (!onskip) return;
105
+ e.preventDefault();
106
+ onskip();
107
+ return;
108
+ }
109
+ if (e.key !== 'Enter' || e.isComposing || e.keyCode === 229 || e.shiftKey) return;
110
+ const t = e.target as HTMLElement;
111
+ // buttons activate themselves; a multi-line "Other" answer keeps its Enter for the newline
112
+ if (t.closest('button') || (t instanceof HTMLTextAreaElement && t.value.includes('\n'))) return;
113
+ e.preventDefault();
114
+ if (review) submit();
115
+ else next();
116
+ }
117
+
118
+ /** Checkboxes have no arrow-key behaviour of their own: Up/Down (and Left/Right) move between the options. */
119
+ function arrows(node: HTMLElement) {
120
+ const onArrow = (e: KeyboardEvent) => {
121
+ const move = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key];
122
+ const t = e.target as HTMLElement;
123
+ if (!move || !t.matches('input[type="checkbox"]')) return;
124
+ const boxes = [...node.querySelectorAll<HTMLElement>('input[type="checkbox"]')];
125
+ e.preventDefault();
126
+ boxes[(boxes.indexOf(t) + move + boxes.length) % boxes.length].focus();
127
+ };
128
+ node.addEventListener('keydown', onArrow);
129
+ return () => node.removeEventListener('keydown', onArrow);
130
+ }
131
+ </script>
132
+
133
+ <!-- the keydown handler lives on the wrapper so Enter and Escape work from any control inside -->
134
+ <div {...rest} class={['qf', klass]} role="group" aria-label="Questions" {onkeydown}>
135
+ {#if review}
136
+ <p class="step">Review</p>
137
+ <h3 class="title" tabindex="-1" bind:this={head}>Review your answers</h3>
138
+ <ol class="review">
139
+ {#each qs as item, i (i)}
140
+ <li>
141
+ <div class="ra">
142
+ <span class="rq">{item.question}</span>
143
+ <span class="rv">{summary(answers[i])}</span>
144
+ </div>
145
+ <Button size="sm" variant="minimal" onclick={() => edit(i)} aria-label={`Edit answer: ${item.question}`}>Edit</Button>
146
+ </li>
147
+ {/each}
148
+ </ol>
149
+ {:else}
150
+ {#key page}
151
+ <p class="step">Question {page + 1} of {total}{#if q.optional} · optional{/if}</p>
152
+ {#if q.header}<p class="header">{q.header}</p>{/if}
153
+ <h3 class="title" id="{uid}-q" tabindex="-1" bind:this={head}>{q.question}</h3>
154
+ {#if q.multiSelect}
155
+ <fieldset class="opts" aria-labelledby="{uid}-q" {@attach arrows}>
156
+ {#each q.options as o (o.label)}
157
+ <Checkbox label={o.label} description={o.description} bind:checked={() => picked[page].includes(o.label), (on) => toggle(page, o.label, on)} />
158
+ {/each}
159
+ </fieldset>
160
+ {:else}
161
+ <RadioGroup
162
+ aria-labelledby="{uid}-q"
163
+ options={q.options.map((o) => ({ value: o.label, label: o.label, description: o.description }))}
164
+ bind:value={() => picked[page][0], (v) => setSingle(page, v as string)}
165
+ />
166
+ {/if}
167
+ {#if allowOther}
168
+ <Textarea label="Other" placeholder="Type your own answer" rows={1} autosize bind:value={() => others[page], (v) => setOther(page, v)} />
169
+ {/if}
170
+ {/key}
171
+ {/if}
172
+
173
+ <div class="nav">
174
+ {#if onskip}<Button variant="minimal" onclick={onskip}>Skip</Button>{/if}
175
+ <span class="grow"></span>
176
+ {#if review}
177
+ <Button variant="outlined" onclick={() => go(total - 1)}>Back</Button>
178
+ <Button onclick={submit}>{submitLabel}</Button>
179
+ {:else}
180
+ <Button variant="outlined" disabled={page === 0} onclick={back}>Back</Button>
181
+ <Button disabled={!canNext} onclick={next}>Next</Button>
182
+ {/if}
183
+ </div>
184
+ </div>
185
+
186
+ <style>
187
+ .qf { display: flex; flex-direction: column; gap: var(--tr-space-3); min-width: 0; max-width: 100%; padding: var(--tr-space-4); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-lg); background: var(--tr-surface-container-low); }
188
+ .step { margin: 0; color: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); font-variant-numeric: tabular-nums; }
189
+ .header { margin: 0; color: var(--tr-primary-text, var(--tr-primary)); font-size: var(--tr-text-sm); font-weight: 600; }
190
+ .title { margin: 0; font-size: var(--tr-text-lg); line-height: 1.3; overflow-wrap: anywhere; }
191
+ .title:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); border-radius: var(--tr-radius-sm); }
192
+ .opts { display: flex; flex-direction: column; gap: var(--tr-space-1); min-width: 0; margin: 0; padding: 0; border: 0; }
193
+ /* long labels wrap inside the narrowest column instead of widening the form */
194
+ .qf :global(.cb), .qf :global(.opt) { max-width: 100%; }
195
+ .qf :global(.text) { min-width: 0; overflow-wrap: anywhere; }
196
+ .review { display: flex; flex-direction: column; gap: var(--tr-space-2); margin: 0; padding: 0; list-style: none; }
197
+ .review li { display: flex; align-items: center; justify-content: space-between; gap: var(--tr-space-2); padding-bottom: var(--tr-space-2); border-bottom: 1px solid var(--tr-outline-variant); }
198
+ .ra { display: flex; flex-direction: column; min-width: 0; overflow-wrap: anywhere; }
199
+ .rq { font-weight: 600; }
200
+ .rv { color: var(--tr-on-surface-variant); }
201
+ .nav { display: flex; flex-wrap: wrap; align-items: center; gap: var(--tr-space-2); margin-top: var(--tr-space-1); }
202
+ .grow { flex: 1; }
203
+ </style>
@@ -0,0 +1,37 @@
1
+ /** One question: a prompt with options to pick from. */
2
+ export type Question = {
3
+ /** A short topic shown above the question, e.g. "Auth method". */
4
+ header?: string;
5
+ question: string;
6
+ options: {
7
+ label: string;
8
+ description?: string;
9
+ }[];
10
+ /** Several options can be chosen. Otherwise exactly one. */
11
+ multiSelect?: boolean;
12
+ /** The question can be passed without an answer. */
13
+ optional?: boolean;
14
+ };
15
+ /** What was answered to one question. `selected` holds option labels; `other` is the free-text answer. */
16
+ export type QuestionAnswer = {
17
+ selected: string[];
18
+ other?: string;
19
+ };
20
+ import type { HTMLAttributes } from 'svelte/elements';
21
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'onkeydown' | 'onsubmit'> & {
22
+ /** The questions, shown one per page. Read once when the form mounts: to ask a different set, mount a new form (for example in a `{#key}`). */
23
+ questions: Question[];
24
+ /** Offer a free-text "Other" answer. For a single-choice question it replaces the chosen option; for `multiSelect` it is added to the chosen ones. */
25
+ allowOther?: boolean;
26
+ /** Called from the review page with one answer per question, in order: `{ selected: string[]; other?: string }`. `other` is left out when empty. */
27
+ onsubmit?: (answers: QuestionAnswer[]) => void;
28
+ /** Called when the user skips the whole form (the Skip button or Escape). Without it there is no Skip button and Escape does nothing. */
29
+ onskip?: () => void;
30
+ /** Text of the final button on the review page. */
31
+ submitLabel?: string;
32
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
33
+ class?: string;
34
+ };
35
+ declare const QuestionForm: import("svelte").Component<$$ComponentProps, {}, "">;
36
+ type QuestionForm = ReturnType<typeof QuestionForm>;
37
+ export default QuestionForm;
@@ -0,0 +1,201 @@
1
+ <script lang="ts">
2
+ import { onDestroy, type Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { Button, Dialog, Icon, Tooltip } from '@ruckc/truss-ui';
5
+ import { formatValue, previewInput } from './chat.js';
6
+
7
+ type Status = 'pending' | 'in_progress' | 'completed' | 'failed';
8
+
9
+ let {
10
+ name,
11
+ status = 'pending',
12
+ variant = 'row',
13
+ formatName,
14
+ labels,
15
+ input,
16
+ output,
17
+ error,
18
+ open = $bindable(false),
19
+ details,
20
+ class: klass = '',
21
+ ...rest
22
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class'> & {
23
+ /** The tool's display name. Pass a raw identifier together with `formatName` to tidy it. */
24
+ name: string;
25
+ /** Where the call is. Shown as an icon (a spinner while `in_progress`) and always as text (the row's label, the chip's accessible name and tooltip), never by colour alone. */
26
+ status?: Status;
27
+ /** `row` (default) is a full-width line with the status text, for a list of calls. `chip` is a compact pill coloured by status with a tooltip, for many calls side by side (wrap them in `ToolCalls`). */
28
+ variant?: 'row' | 'chip';
29
+ /** Turns `name` into what is displayed, e.g. `formatToolName`, which strips an `mcp__server__` prefix and replaces underscores with spaces (`mcp__github__create_issue` becomes `create issue`). */
30
+ formatName?: (name: string) => string;
31
+ /** Replaces the status texts, e.g. for another language. Missing keys keep the defaults: Pending, Running, Done, Failed. */
32
+ labels?: Partial<Record<Status, string>>;
33
+ /** What the tool was called with: a string or any JSON-able value. Shown as a one-line preview in the chip's tooltip (see `previewInput`) and in full, pretty-printed, in the details. */
34
+ input?: unknown;
35
+ /** What the tool returned: a string or any JSON-able value. Pretty-printed in the details. */
36
+ output?: unknown;
37
+ /** Why the call failed. Shown in the details as an "Error" section. */
38
+ error?: string;
39
+ /** Whether the details are shown (the dialog is open, or the inline block is unfolded). Bindable. Only used when there is something to show: `input`, `output`, `error` or a `details` snippet. */
40
+ open?: boolean;
41
+ /**
42
+ * Either where the details appear, or a snippet that is the details.
43
+ * - `'dialog'` opens a modal `Dialog` (in the browser's top layer, so it stacks above a modal `Drawer`, sheet or bottom sheet). The default for `variant="chip"`.
44
+ * - `'inline'` unfolds them under the chip or row, which becomes a disclosure button with `aria-expanded`. The default for `variant="row"`.
45
+ * - A `Snippet` is a custom body (shown after the status line, before the Input, Output and Error sections) and uses the default mode for the variant.
46
+ */
47
+ details?: 'dialog' | 'inline' | Snippet;
48
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
49
+ class?: string;
50
+ } = $props();
51
+
52
+ const uid = $props.id();
53
+ const DEFAULTS: Record<Status, string> = { pending: 'Pending', in_progress: 'Running', completed: 'Done', failed: 'Failed' };
54
+ const ICONS = { pending: 'clock', in_progress: 'refresh', completed: 'success', failed: 'error' } as const;
55
+ const shown = $derived(formatName ? formatName(name) : name);
56
+ const statusText = $derived(labels?.[status] ?? DEFAULTS[status]);
57
+
58
+ const custom = $derived(typeof details === 'function' ? details : undefined);
59
+ const mode = $derived(typeof details === 'string' ? details : variant === 'chip' ? 'dialog' : 'inline');
60
+ const present = (v: unknown) => v !== undefined && v !== null;
61
+ const sections = $derived(
62
+ [
63
+ present(input) && { key: 'input', title: 'Input', text: formatValue(input) },
64
+ present(output) && { key: 'output', title: 'Output', text: formatValue(output) },
65
+ !!error && { key: 'error', title: 'Error', text: error }
66
+ ].filter((s) => !!s)
67
+ );
68
+ const hasBody = $derived(!!custom || sections.length > 0);
69
+ const preview = $derived(previewInput(input));
70
+ const tip = $derived(preview ? `${statusText}: ${preview}` : statusText);
71
+
72
+ // the dialog is created on first use, so a long list of calls does not render every call's details up front
73
+ let mounted = $state(false);
74
+ $effect(() => {
75
+ if (open) mounted = true;
76
+ });
77
+
78
+ let copied = $state<{ key: string; ok: boolean } | undefined>();
79
+ let timer: ReturnType<typeof setTimeout>;
80
+ onDestroy(() => clearTimeout(timer));
81
+ async function copy(key: string, text: string) {
82
+ let ok = true;
83
+ try {
84
+ await navigator.clipboard.writeText(text);
85
+ } catch {
86
+ ok = false;
87
+ }
88
+ copied = { key, ok };
89
+ clearTimeout(timer);
90
+ timer = setTimeout(() => (copied = undefined), 2000);
91
+ }
92
+
93
+ /** A scrolling region must be reachable by keyboard so it can be scrolled with the arrow keys. */
94
+ const focusable = (node: HTMLElement) => void (node.tabIndex = 0);
95
+ </script>
96
+
97
+ {#snippet icon()}
98
+ <span class="icon"><Icon name={ICONS[status]} spin={status === 'in_progress'} /></span>
99
+ {/snippet}
100
+
101
+ {#snippet body()}
102
+ <p class="line">Status: <strong>{statusText}</strong></p>
103
+ {#if custom}{@render custom()}{/if}
104
+ {#each sections as s (s.key)}
105
+ <section class="sec" data-section={s.key}>
106
+ <div class="sec-head">
107
+ <span class="sec-title">{s.title}</span>
108
+ <Button variant="minimal" size="sm" icon={copied?.key === s.key && copied.ok ? 'tick' : 'copy'} aria-label="Copy {s.title.toLowerCase()}" onclick={() => copy(s.key, s.text)} />
109
+ </div>
110
+ <pre {@attach focusable} role="region" aria-label={s.title}>{s.text}</pre>
111
+ </section>
112
+ {/each}
113
+ <span class="tr-visually-hidden" role="status" aria-live="polite">{copied ? (copied.ok ? 'Copied' : 'Copy failed') : ''}</span>
114
+ {/snippet}
115
+
116
+ <div {...rest} class={['tool', klass]} data-status={status} data-variant={variant} data-open={open && hasBody && mode === 'inline'}>
117
+ {#if variant === 'chip'}
118
+ <Tooltip text={tip}>
119
+ {#if hasBody}
120
+ <button
121
+ type="button"
122
+ class="chip"
123
+ aria-label="{shown}, {statusText}"
124
+ aria-haspopup={mode === 'dialog' ? 'dialog' : undefined}
125
+ aria-expanded={mode === 'inline' ? open : undefined}
126
+ aria-controls={mode === 'inline' ? `${uid}-details` : undefined}
127
+ onclick={() => (open = !open)}
128
+ >
129
+ {@render icon()}<span class="name">{shown}</span>
130
+ </button>
131
+ {:else}
132
+ <span class="chip" role="group" aria-label="{shown}, {statusText}">{@render icon()}<span class="name">{shown}</span></span>
133
+ {/if}
134
+ </Tooltip>
135
+ {:else if hasBody}
136
+ <button
137
+ type="button"
138
+ class="row"
139
+ aria-haspopup={mode === 'dialog' ? 'dialog' : undefined}
140
+ aria-expanded={mode === 'inline' ? open : undefined}
141
+ aria-controls={mode === 'inline' ? `${uid}-details` : undefined}
142
+ onclick={() => (open = !open)}
143
+ >
144
+ {@render icon()}<span class="name">{shown}</span><span class="status">{statusText}</span>
145
+ {#if mode === 'inline'}<span class="chev" data-open={open}><Icon name="chevron-down" /></span>{/if}
146
+ </button>
147
+ {:else}
148
+ <div class="row">{@render icon()}<span class="name">{shown}</span><span class="status">{statusText}</span></div>
149
+ {/if}
150
+ {#if hasBody && mode === 'inline' && open}
151
+ <div class="details" id="{uid}-details">{@render body()}</div>
152
+ {/if}
153
+ </div>
154
+ {#if hasBody && mode === 'dialog' && mounted}
155
+ <Dialog bind:open title={shown} size="lg" data-testid="tool-dialog">{@render body()}</Dialog>
156
+ {/if}
157
+
158
+ <style>
159
+ .tool { max-width: 100%; min-width: 0; font-size: var(--tr-text-sm); }
160
+ .tool[data-variant='row'] { border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--tr-surface-container-low); }
161
+ .tool[data-variant='chip'] { display: inline-flex; flex-direction: column; align-items: flex-start; }
162
+ .tool[data-variant='chip'][data-open='true'] { flex: 1 1 100%; }
163
+ /* the Tooltip's host span sits between the root and the chip: let it shrink so a long name is cut instead of overflowing */
164
+ .tool[data-variant='chip'] > :global(span) { max-width: 100%; min-width: 0; }
165
+ .row { display: flex; align-items: center; gap: var(--tr-space-2); width: 100%; min-height: var(--tr-control-h-sm); padding: 2px var(--tr-space-2); box-sizing: border-box; border: 0; background: none; color: var(--tr-on-surface); font: inherit; text-align: start; }
166
+ button.row { cursor: pointer; border-radius: var(--tr-radius-md); }
167
+ button.row:hover { background: var(--tr-surface-container); }
168
+ button.row:focus-visible, button.chip:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
169
+ .icon { display: inline-flex; flex: none; color: var(--tr-on-surface-variant); }
170
+ [data-status='completed'] .icon { color: var(--tr-success); }
171
+ [data-status='failed'] .icon { color: var(--tr-danger); }
172
+ [data-status='in_progress'] .icon { color: var(--tr-primary); }
173
+ .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
174
+ .status { flex: none; color: var(--tr-on-surface-variant); }
175
+ [data-status='failed'] .status { color: var(--tr-danger); font-weight: 600; }
176
+ .chev { display: inline-flex; flex: none; color: var(--tr-on-surface-variant); transition: transform var(--tr-dur) var(--tr-ease); }
177
+ .chev[data-open='true'] { transform: rotate(180deg); }
178
+ .details { width: 100%; box-sizing: border-box; padding: var(--tr-space-2) var(--tr-space-3) var(--tr-space-3); overflow-x: auto; overflow-wrap: anywhere; }
179
+ .tool[data-variant='row'] .details { border-top: 1px solid var(--tr-outline-variant); }
180
+ .tool[data-variant='chip'] .details { margin-top: var(--tr-space-1); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--tr-surface-container-low); }
181
+
182
+ /* chip: a pill tinted by status; the icon, the name and the accessible status text carry the meaning, the colour only reinforces it */
183
+ .chip { display: inline-flex; align-items: center; gap: var(--tr-space-1); max-width: 100%; min-width: 0; box-sizing: border-box; min-height: var(--tr-control-h-sm); padding: 0 var(--tr-space-2); border: 1px solid var(--tr-outline-variant); border-radius: 999px; background: var(--tr-surface-container); color: var(--tr-on-surface); font: inherit; }
184
+ button.chip { cursor: pointer; }
185
+ button.chip:hover { filter: brightness(0.96); }
186
+ .chip .name { flex: 0 1 auto; max-width: 24ch; font-weight: 500; }
187
+ .chip .icon { color: inherit; }
188
+ [data-status='in_progress'] .chip { background: var(--tr-primary-container); color: var(--tr-on-primary-container); border-color: transparent; }
189
+ [data-status='completed'] .chip { background: var(--tr-success-container); color: var(--tr-on-success-container); border-color: transparent; }
190
+ [data-status='failed'] .chip { background: var(--tr-danger-container); color: var(--tr-on-danger-container); border-color: transparent; }
191
+ [data-status='pending'] .chip { color: var(--tr-on-surface-variant); border-style: dashed; }
192
+
193
+ .line { margin: 0 0 var(--tr-space-2); color: var(--tr-on-surface-variant); }
194
+ .line strong { color: var(--tr-on-surface); }
195
+ .sec { margin-top: var(--tr-space-2); }
196
+ .sec-head { display: flex; align-items: center; justify-content: space-between; gap: var(--tr-space-2); }
197
+ .sec-title { font-weight: 600; font-size: var(--tr-text-sm); }
198
+ pre { margin: 0; max-width: 100%; max-height: min(40vh, 320px); overflow: auto; box-sizing: border-box; padding: var(--tr-space-2) var(--tr-space-3); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--tr-surface-container); color: var(--tr-on-surface); font-family: var(--tr-font-mono); font-size: var(--tr-text-sm); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 2; }
199
+ pre:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
200
+ [data-section='error'] pre { border-color: var(--tr-danger); }
201
+ </style>
@@ -0,0 +1,35 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type Status = 'pending' | 'in_progress' | 'completed' | 'failed';
4
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class'> & {
5
+ /** The tool's display name. Pass a raw identifier together with `formatName` to tidy it. */
6
+ name: string;
7
+ /** Where the call is. Shown as an icon (a spinner while `in_progress`) and always as text (the row's label, the chip's accessible name and tooltip), never by colour alone. */
8
+ status?: Status;
9
+ /** `row` (default) is a full-width line with the status text, for a list of calls. `chip` is a compact pill coloured by status with a tooltip, for many calls side by side (wrap them in `ToolCalls`). */
10
+ variant?: 'row' | 'chip';
11
+ /** Turns `name` into what is displayed, e.g. `formatToolName`, which strips an `mcp__server__` prefix and replaces underscores with spaces (`mcp__github__create_issue` becomes `create issue`). */
12
+ formatName?: (name: string) => string;
13
+ /** Replaces the status texts, e.g. for another language. Missing keys keep the defaults: Pending, Running, Done, Failed. */
14
+ labels?: Partial<Record<Status, string>>;
15
+ /** What the tool was called with: a string or any JSON-able value. Shown as a one-line preview in the chip's tooltip (see `previewInput`) and in full, pretty-printed, in the details. */
16
+ input?: unknown;
17
+ /** What the tool returned: a string or any JSON-able value. Pretty-printed in the details. */
18
+ output?: unknown;
19
+ /** Why the call failed. Shown in the details as an "Error" section. */
20
+ error?: string;
21
+ /** Whether the details are shown (the dialog is open, or the inline block is unfolded). Bindable. Only used when there is something to show: `input`, `output`, `error` or a `details` snippet. */
22
+ open?: boolean;
23
+ /**
24
+ * Either where the details appear, or a snippet that is the details.
25
+ * - `'dialog'` opens a modal `Dialog` (in the browser's top layer, so it stacks above a modal `Drawer`, sheet or bottom sheet). The default for `variant="chip"`.
26
+ * - `'inline'` unfolds them under the chip or row, which becomes a disclosure button with `aria-expanded`. The default for `variant="row"`.
27
+ * - A `Snippet` is a custom body (shown after the status line, before the Input, Output and Error sections) and uses the default mode for the variant.
28
+ */
29
+ details?: 'dialog' | 'inline' | Snippet;
30
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
31
+ class?: string;
32
+ };
33
+ declare const ToolCall: import("svelte").Component<$$ComponentProps, {}, "open">;
34
+ type ToolCall = ReturnType<typeof ToolCall>;
35
+ export default ToolCall;
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ let {
6
+ label = 'Tool calls',
7
+ class: klass = '',
8
+ children,
9
+ ...rest
10
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class'> & {
11
+ /** Accessible name of the group. */
12
+ label?: string;
13
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
14
+ class?: string;
15
+ /** The `ToolCall`s. Chips wrap onto new lines, so many of them fit a narrow sidebar or a phone. */
16
+ children: Snippet;
17
+ } = $props();
18
+ </script>
19
+
20
+ <div {...rest} class={['calls', klass]} role="group" aria-label={label}>{@render children()}</div>
21
+
22
+ <style>
23
+ .calls { display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--tr-space-1); width: 100%; min-width: 0; max-width: 100%; }
24
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class'> & {
4
+ /** Accessible name of the group. */
5
+ label?: string;
6
+ /** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
7
+ class?: string;
8
+ /** The `ToolCall`s. Chips wrap onto new lines, so many of them fit a narrow sidebar or a phone. */
9
+ children: Snippet;
10
+ };
11
+ declare const ToolCalls: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type ToolCalls = ReturnType<typeof ToolCalls>;
13
+ export default ToolCalls;
package/dist/chat.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Turns a raw tool identifier into a readable name: drops an `mcp__server__` prefix and replaces underscores with spaces.
3
+ * `mcp__github__create_issue` becomes `create issue`. Pass it as `ToolCall`'s `formatName`.
4
+ */
5
+ export declare function formatToolName(name: string): string;
6
+ /**
7
+ * A single-line preview of a tool's input, for tooltips. Strings have their whitespace collapsed; JSON text and other values are compacted
8
+ * (no indentation); anything longer than `max` characters is cut with an ellipsis, so the result is never longer than `max`.
9
+ * `undefined` and `null` give an empty string.
10
+ */
11
+ export declare function previewInput(input: unknown, max?: number): string;
12
+ /** The full text of a tool's input, output or error: strings as they are, everything else pretty-printed with two-space indentation. */
13
+ export declare function formatValue(v: unknown): string;
package/dist/chat.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Turns a raw tool identifier into a readable name: drops an `mcp__server__` prefix and replaces underscores with spaces.
3
+ * `mcp__github__create_issue` becomes `create issue`. Pass it as `ToolCall`'s `formatName`.
4
+ */
5
+ export function formatToolName(name) {
6
+ return name.replace(/^mcp__.+?__/, '').replace(/_+/g, ' ').trim();
7
+ }
8
+ /** JSON text of `v`, or `String(v)` for what JSON cannot express (circular objects, functions, symbols, bigints). */
9
+ function stringify(v, indent) {
10
+ try {
11
+ return JSON.stringify(v, null, indent) ?? String(v);
12
+ }
13
+ catch {
14
+ return String(v);
15
+ }
16
+ }
17
+ /**
18
+ * A single-line preview of a tool's input, for tooltips. Strings have their whitespace collapsed; JSON text and other values are compacted
19
+ * (no indentation); anything longer than `max` characters is cut with an ellipsis, so the result is never longer than `max`.
20
+ * `undefined` and `null` give an empty string.
21
+ */
22
+ export function previewInput(input, max = 80) {
23
+ if (input === undefined || input === null)
24
+ return '';
25
+ let s;
26
+ if (typeof input === 'string') {
27
+ s = input;
28
+ if (/^\s*[[{]/.test(s))
29
+ try {
30
+ s = JSON.stringify(JSON.parse(s));
31
+ }
32
+ catch {
33
+ /* not JSON: keep the text */
34
+ }
35
+ s = s.replace(/\s+/g, ' ').trim();
36
+ }
37
+ else
38
+ s = stringify(input);
39
+ return s.length > max ? `${s.slice(0, Math.max(0, max - 1)).trimEnd()}…` : s;
40
+ }
41
+ /** The full text of a tool's input, output or error: strings as they are, everything else pretty-printed with two-space indentation. */
42
+ export function formatValue(v) {
43
+ if (v === undefined || v === null)
44
+ return '';
45
+ return typeof v === 'string' ? v : stringify(v, 2);
46
+ }
@@ -0,0 +1,9 @@
1
+ export { default as ChatThread } from './ChatThread.svelte';
2
+ export { default as ChatMessage } from './ChatMessage.svelte';
3
+ export { default as ToolCall } from './ToolCall.svelte';
4
+ export { default as ToolCalls } from './ToolCalls.svelte';
5
+ export { default as ChatComposer } from './ChatComposer.svelte';
6
+ export { default as ChatStatus } from './ChatStatus.svelte';
7
+ export { default as QuestionForm } from './QuestionForm.svelte';
8
+ export type { Question, QuestionAnswer } from './QuestionForm.svelte';
9
+ export { formatToolName, previewInput, formatValue } from './chat.js';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { default as ChatThread } from './ChatThread.svelte';
2
+ export { default as ChatMessage } from './ChatMessage.svelte';
3
+ export { default as ToolCall } from './ToolCall.svelte';
4
+ export { default as ToolCalls } from './ToolCalls.svelte';
5
+ export { default as ChatComposer } from './ChatComposer.svelte';
6
+ export { default as ChatStatus } from './ChatStatus.svelte';
7
+ export { default as QuestionForm } from './QuestionForm.svelte';
8
+ export { formatToolName, previewInput, formatValue } from './chat.js';
@@ -0,0 +1,11 @@
1
+ /** What a ChatThread offers the messages inside it. */
2
+ export type ChatThreadContext = {
3
+ /** Queue text for the thread's polite live region. */
4
+ announce: (text: string) => void;
5
+ /** The thread-wide default for showing reasoning blocks. */
6
+ readonly showReasoning: boolean;
7
+ /** The user chose to show or hide reasoning: update the thread setting (and its storage). */
8
+ setShowReasoning: (show: boolean) => void;
9
+ };
10
+ export declare const provideThread: (c: ChatThreadContext) => ChatThreadContext;
11
+ export declare const useThread: () => ChatThreadContext | undefined;
@@ -0,0 +1,4 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ const KEY = Symbol('truss-chat-thread');
3
+ export const provideThread = (c) => setContext(KEY, c);
4
+ export const useThread = () => getContext(KEY);
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
- "name": "@ruckc/truss-chat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@ruckc/truss-chat",
3
+ "version": "1.12.0",
4
+ "description": "Chat building blocks for Truss UI: thread, messages, tool calls, composer, status line and a question form",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "sideEffects": [
11
+ "**/*.css"
12
+ ],
13
+ "svelte": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "svelte": "./dist/index.js"
19
+ }
20
+ },
21
+ "peerDependencies": {
22
+ "svelte": "^5.20.0",
23
+ "@ruckc/truss-ui": "^1.0.0"
24
+ },
25
+ "dependencies": {
26
+ "@ruckc/truss-markdown": "^1.12.0"
27
+ },
28
+ "author": "Curtis Ruck",
29
+ "homepage": "https://truss.ruck.io",
30
+ "bugs": {
31
+ "url": "https://github.com/ruckc/truss-ui/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/ruckc/truss-ui.git",
39
+ "directory": "packages/chat"
40
+ },
41
+ "keywords": [
42
+ "svelte",
43
+ "sveltekit",
44
+ "truss",
45
+ "chat",
46
+ "agent",
47
+ "ai"
48
+ ]
49
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.