@windowkit/agent-chat 0.0.0-stage → 0.3.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 +21 -0
- package/README.md +147 -2
- package/dist/core/driver.d.ts +183 -0
- package/dist/core/driver.d.ts.map +1 -0
- package/dist/core/driver.js +12 -0
- package/dist/core/driver.js.map +1 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +11 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/markdown.d.ts +7 -0
- package/dist/core/markdown.d.ts.map +1 -0
- package/dist/core/markdown.js +53 -0
- package/dist/core/markdown.js.map +1 -0
- package/dist/core/policy.d.ts +18 -0
- package/dist/core/policy.d.ts.map +1 -0
- package/dist/core/policy.js +67 -0
- package/dist/core/policy.js.map +1 -0
- package/dist/core/reducer.d.ts +61 -0
- package/dist/core/reducer.d.ts.map +1 -0
- package/dist/core/reducer.js +391 -0
- package/dist/core/reducer.js.map +1 -0
- package/dist/core/session.d.ts +105 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/session.js +648 -0
- package/dist/core/session.js.map +1 -0
- package/dist/core/tools.d.ts +120 -0
- package/dist/core/tools.d.ts.map +1 -0
- package/dist/core/tools.js +120 -0
- package/dist/core/tools.js.map +1 -0
- package/dist/core/types.d.ts +295 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +21 -0
- package/dist/core/types.js.map +1 -0
- package/dist/drivers/claude/index.d.ts +20 -0
- package/dist/drivers/claude/index.d.ts.map +1 -0
- package/dist/drivers/claude/index.js +727 -0
- package/dist/drivers/claude/index.js.map +1 -0
- package/dist/drivers/claude/input.d.ts +7 -0
- package/dist/drivers/claude/input.d.ts.map +1 -0
- package/dist/drivers/claude/input.js +46 -0
- package/dist/drivers/claude/input.js.map +1 -0
- package/dist/drivers/claude/peers.d.ts +16 -0
- package/dist/drivers/claude/peers.d.ts.map +1 -0
- package/dist/drivers/claude/peers.js +137 -0
- package/dist/drivers/claude/peers.js.map +1 -0
- package/dist/drivers/claude/schema.d.ts +5 -0
- package/dist/drivers/claude/schema.d.ts.map +1 -0
- package/dist/drivers/claude/schema.js +77 -0
- package/dist/drivers/claude/schema.js.map +1 -0
- package/dist/drivers/claude/sdk.d.ts +86 -0
- package/dist/drivers/claude/sdk.d.ts.map +1 -0
- package/dist/drivers/claude/sdk.js +47 -0
- package/dist/drivers/claude/sdk.js.map +1 -0
- package/dist/drivers/claude/tools.d.ts +18 -0
- package/dist/drivers/claude/tools.d.ts.map +1 -0
- package/dist/drivers/claude/tools.js +119 -0
- package/dist/drivers/claude/tools.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/testing/fake.d.ts +63 -0
- package/dist/testing/fake.d.ts.map +1 -0
- package/dist/testing/fake.js +270 -0
- package/dist/testing/fake.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/view/AgentChat.d.ts +53 -0
- package/dist/view/AgentChat.d.ts.map +1 -0
- package/dist/view/AgentChat.js +87 -0
- package/dist/view/AgentChat.js.map +1 -0
- package/dist/view/CodeBlock.d.ts +9 -0
- package/dist/view/CodeBlock.d.ts.map +1 -0
- package/dist/view/CodeBlock.js +67 -0
- package/dist/view/CodeBlock.js.map +1 -0
- package/dist/view/Composer.d.ts +24 -0
- package/dist/view/Composer.d.ts.map +1 -0
- package/dist/view/Composer.js +144 -0
- package/dist/view/Composer.js.map +1 -0
- package/dist/view/Header.d.ts +3 -0
- package/dist/view/Header.d.ts.map +1 -0
- package/dist/view/Header.js +30 -0
- package/dist/view/Header.js.map +1 -0
- package/dist/view/MessageList.d.ts +9 -0
- package/dist/view/MessageList.d.ts.map +1 -0
- package/dist/view/MessageList.js +111 -0
- package/dist/view/MessageList.js.map +1 -0
- package/dist/view/QuestionCard.d.ts +6 -0
- package/dist/view/QuestionCard.d.ts.map +1 -0
- package/dist/view/QuestionCard.js +39 -0
- package/dist/view/QuestionCard.js.map +1 -0
- package/dist/view/TextBlock.d.ts +9 -0
- package/dist/view/TextBlock.d.ts.map +1 -0
- package/dist/view/TextBlock.js +25 -0
- package/dist/view/TextBlock.js.map +1 -0
- package/dist/view/ToolCard.d.ts +7 -0
- package/dist/view/ToolCard.d.ts.map +1 -0
- package/dist/view/ToolCard.js +155 -0
- package/dist/view/ToolCard.js.map +1 -0
- package/dist/view/Transcript.d.ts +5 -0
- package/dist/view/Transcript.d.ts.map +1 -0
- package/dist/view/Transcript.js +56 -0
- package/dist/view/Transcript.js.map +1 -0
- package/dist/view/context.d.ts +21 -0
- package/dist/view/context.d.ts.map +1 -0
- package/dist/view/context.js +37 -0
- package/dist/view/context.js.map +1 -0
- package/dist/view/fences.d.ts +13 -0
- package/dist/view/fences.d.ts.map +1 -0
- package/dist/view/fences.js +59 -0
- package/dist/view/fences.js.map +1 -0
- package/dist/view/files.d.ts +11 -0
- package/dist/view/files.d.ts.map +1 -0
- package/dist/view/files.js +109 -0
- package/dist/view/files.js.map +1 -0
- package/dist/view/look.d.ts +25 -0
- package/dist/view/look.d.ts.map +1 -0
- package/dist/view/look.js +47 -0
- package/dist/view/look.js.map +1 -0
- package/dist/view/types.d.ts +50 -0
- package/dist/view/types.d.ts.map +1 -0
- package/dist/view/types.js +14 -0
- package/dist/view/types.js.map +1 -0
- package/dist/view/use-agent.d.ts +11 -0
- package/dist/view/use-agent.d.ts.map +1 -0
- package/dist/view/use-agent.js +33 -0
- package/dist/view/use-agent.js.map +1 -0
- package/package.json +92 -4
- package/src/core/driver.ts +191 -0
- package/src/core/index.ts +54 -0
- package/src/core/markdown.ts +56 -0
- package/src/core/policy.ts +77 -0
- package/src/core/reducer.ts +490 -0
- package/src/core/session.ts +824 -0
- package/src/core/tools.ts +233 -0
- package/src/core/types.ts +321 -0
- package/src/drivers/claude/index.ts +825 -0
- package/src/drivers/claude/input.ts +47 -0
- package/src/drivers/claude/peers.ts +153 -0
- package/src/drivers/claude/schema.ts +99 -0
- package/src/drivers/claude/sdk.ts +138 -0
- package/src/drivers/claude/tools.ts +141 -0
- package/src/index.ts +51 -0
- package/src/testing/fake.ts +374 -0
- package/src/testing/index.ts +11 -0
- package/src/view/AgentChat.tsx +204 -0
- package/src/view/CodeBlock.tsx +115 -0
- package/src/view/Composer.tsx +230 -0
- package/src/view/Header.tsx +73 -0
- package/src/view/MessageList.tsx +290 -0
- package/src/view/QuestionCard.tsx +107 -0
- package/src/view/TextBlock.tsx +61 -0
- package/src/view/ToolCard.tsx +283 -0
- package/src/view/Transcript.tsx +91 -0
- package/src/view/context.ts +70 -0
- package/src/view/dom-globals.d.ts +10 -0
- package/src/view/fences.ts +73 -0
- package/src/view/files.ts +111 -0
- package/src/view/look.ts +70 -0
- package/src/view/types.ts +71 -0
- package/src/view/use-agent.ts +40 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// The message box: `<RichTextEditor>` as the hybrid composer
|
|
2
|
+
// (`@react-x11/components` 0.38). The value is exactly what was typed —
|
|
3
|
+
// `2 * 3` is sent as `2 * 3` — and markdown is drawn over it as it is typed:
|
|
4
|
+
// fences in the code face and coloured, inline code on a tint, emphasis,
|
|
5
|
+
// headings, quotes and lists with their markers kept and dimmed.
|
|
6
|
+
//
|
|
7
|
+
// - Enter sends; Shift+Enter breaks the line; Enter inside an open fence is
|
|
8
|
+
// a new line, and on its closing line it sends.
|
|
9
|
+
// - `@` lists the files under the agent's working directory, and inserts one
|
|
10
|
+
// as a mention, written `@path` — what Claude Code reads a file by.
|
|
11
|
+
// - `/` at the start of a line lists the agent's commands.
|
|
12
|
+
// - A paste past ten lines or 2,000 characters is an attachment, written as
|
|
13
|
+
// its token (`[Pasted text #1 +312 lines]`) and sent beside the text; a
|
|
14
|
+
// pasted image is one too.
|
|
15
|
+
// - Up on the first line brings back what was sent, newest first.
|
|
16
|
+
// - Escape stops a running turn.
|
|
17
|
+
|
|
18
|
+
import { useEffect, useMemo, useRef, type ReactElement } from 'react';
|
|
19
|
+
import { Button, type KeyboardEvent } from 'react-x11';
|
|
20
|
+
import { XK_ESCAPE } from 'react-x11/keysyms';
|
|
21
|
+
import {
|
|
22
|
+
RichTextEditor,
|
|
23
|
+
composerParts,
|
|
24
|
+
composerSchema,
|
|
25
|
+
type ComposerAtom,
|
|
26
|
+
type RichTextEditorHandle,
|
|
27
|
+
type Suggester,
|
|
28
|
+
} from '@react-x11/components/rich-text-editor';
|
|
29
|
+
|
|
30
|
+
import type { AgentSession } from '../core/session.js';
|
|
31
|
+
import type { Attachment } from '../core/types.js';
|
|
32
|
+
import { useChat } from './context.js';
|
|
33
|
+
import { listFiles, matchFiles } from './files.js';
|
|
34
|
+
import { useLook } from './look.js';
|
|
35
|
+
import { useSessionSnapshot } from './use-agent.js';
|
|
36
|
+
|
|
37
|
+
/** How the composer behaves: `<AgentChat composer={…}>`. */
|
|
38
|
+
export interface ComposerOptions {
|
|
39
|
+
/** What is drawn over the text as it is typed. Default `'markdown'`:
|
|
40
|
+
* everything below plus emphasis, headings, quotes and lists. `'code'`
|
|
41
|
+
* draws fences and inline code alone. Nothing changes what is sent. */
|
|
42
|
+
markup?: 'none' | 'code' | 'markdown';
|
|
43
|
+
/** `@` lists the files under the working directory. Default true. */
|
|
44
|
+
files?: boolean;
|
|
45
|
+
/** `/` at the start of a line lists the agent's commands. Default true. */
|
|
46
|
+
commands?: boolean;
|
|
47
|
+
/** A long paste, and a pasted image, become attachments. Default true. */
|
|
48
|
+
pasteAsAttachment?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ComposerProps {
|
|
52
|
+
placeholder?: string;
|
|
53
|
+
autoFocus?: boolean;
|
|
54
|
+
options?: ComposerOptions;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function Composer({
|
|
58
|
+
placeholder,
|
|
59
|
+
autoFocus,
|
|
60
|
+
options = {},
|
|
61
|
+
}: ComposerProps): ReactElement {
|
|
62
|
+
const { session } = useChat();
|
|
63
|
+
const snap = useSessionSnapshot(session);
|
|
64
|
+
const look = useLook();
|
|
65
|
+
const editor = useRef<RichTextEditorHandle | null>(null);
|
|
66
|
+
|
|
67
|
+
const busy =
|
|
68
|
+
snap.status === 'running' ||
|
|
69
|
+
snap.status === 'waiting' ||
|
|
70
|
+
snap.status === 'paused' ||
|
|
71
|
+
snap.status === 'starting';
|
|
72
|
+
const disabled = snap.status === 'closed';
|
|
73
|
+
|
|
74
|
+
// what `stop()` took back out of the queue comes back to the box
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (snap.restored === undefined) return;
|
|
77
|
+
const text = session.takeRestored();
|
|
78
|
+
if (text) editor.current?.setValue(text);
|
|
79
|
+
}, [snap.restored, session]);
|
|
80
|
+
|
|
81
|
+
// Up brings back what the transcript says was sent, so a remount keeps it
|
|
82
|
+
const recall = useMemo(() => sentTexts(snap.messages), [snap.messages]);
|
|
83
|
+
|
|
84
|
+
const cwd = snap.info.cwd;
|
|
85
|
+
const files = options.files !== false;
|
|
86
|
+
const commands = options.commands !== false;
|
|
87
|
+
const suggestions = useMemo(
|
|
88
|
+
() => [
|
|
89
|
+
...(files && cwd ? [fileSuggester(cwd)] : []),
|
|
90
|
+
...(commands ? [commandSuggester(session)] : []),
|
|
91
|
+
],
|
|
92
|
+
[files, commands, cwd, session],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const submit = () => {
|
|
96
|
+
const handle = editor.current;
|
|
97
|
+
if (!handle) return;
|
|
98
|
+
const { text, attachments } = composerParts(handle.state.doc);
|
|
99
|
+
if (!text.trim() && !attachments.length) return;
|
|
100
|
+
session.send({ text, attachments: attachments.map(attachmentOf) });
|
|
101
|
+
handle.setValue('');
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const onKeyDown = (ev: KeyboardEvent) => {
|
|
105
|
+
if (ev.keysym === XK_ESCAPE && busy) {
|
|
106
|
+
ev.preventDefault();
|
|
107
|
+
void session.stop();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const canPause = snap.capabilities.gate === 'every-call';
|
|
112
|
+
return (
|
|
113
|
+
<box
|
|
114
|
+
style={{
|
|
115
|
+
flexDirection: 'row',
|
|
116
|
+
alignItems: 'flex-end',
|
|
117
|
+
gap: 8,
|
|
118
|
+
padding: 10,
|
|
119
|
+
borderTopWidth: 1,
|
|
120
|
+
borderTopColor: look.border,
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<RichTextEditor
|
|
124
|
+
ref={editor}
|
|
125
|
+
format="text"
|
|
126
|
+
schema={composerSchema}
|
|
127
|
+
markup={options.markup ?? 'markdown'}
|
|
128
|
+
pasteAsAttachment={options.pasteAsAttachment ?? true}
|
|
129
|
+
recall={recall}
|
|
130
|
+
suggestions={suggestions}
|
|
131
|
+
submitOnEnter
|
|
132
|
+
onSubmit={submit}
|
|
133
|
+
onKeyDown={onKeyDown}
|
|
134
|
+
placeholder={
|
|
135
|
+
placeholder ?? 'Ask the agent… @ for a file, / for a command'
|
|
136
|
+
}
|
|
137
|
+
autoFocus={autoFocus ?? true}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
aria-label="Message"
|
|
140
|
+
data-testname="agent-chat-composer"
|
|
141
|
+
style={{
|
|
142
|
+
flexGrow: 1,
|
|
143
|
+
flexShrink: 1,
|
|
144
|
+
maxHeight: 220,
|
|
145
|
+
borderWidth: 1,
|
|
146
|
+
borderColor: look.border,
|
|
147
|
+
borderRadius: 8,
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
<box style={{ flexDirection: 'row', gap: 6 }}>
|
|
151
|
+
{busy && canPause ? (
|
|
152
|
+
<Button
|
|
153
|
+
label={snap.paused ? 'Resume' : 'Pause'}
|
|
154
|
+
variant="outline"
|
|
155
|
+
onPress={() => (snap.paused ? session.resume() : session.pause())}
|
|
156
|
+
/>
|
|
157
|
+
) : null}
|
|
158
|
+
{busy ? (
|
|
159
|
+
<Button label="Stop" onPress={() => void session.stop()} />
|
|
160
|
+
) : (
|
|
161
|
+
<Button label="Send" primary disabled={disabled} onPress={submit} />
|
|
162
|
+
)}
|
|
163
|
+
</box>
|
|
164
|
+
</box>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** What was sent, oldest first: the composer's `recall`. */
|
|
169
|
+
export function sentTexts(
|
|
170
|
+
messages: AgentSession['snapshot']['messages'],
|
|
171
|
+
): string[] {
|
|
172
|
+
const out: string[] = [];
|
|
173
|
+
for (const m of messages) {
|
|
174
|
+
if (m.role !== 'user' || m.queued) continue;
|
|
175
|
+
const text = m.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
|
|
176
|
+
if (text && out[out.length - 1] !== text) out.push(text);
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function fileSuggester(cwd: string): Suggester {
|
|
182
|
+
return {
|
|
183
|
+
char: '@',
|
|
184
|
+
items: async ({ query, state }) =>
|
|
185
|
+
matchFiles(await listFiles(cwd), query).map((file) => ({
|
|
186
|
+
label: file,
|
|
187
|
+
insert: state.schema.nodes.mention!.create({
|
|
188
|
+
kind: 'file',
|
|
189
|
+
id: file,
|
|
190
|
+
label: file,
|
|
191
|
+
}),
|
|
192
|
+
})),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function commandSuggester(session: AgentSession): Suggester {
|
|
197
|
+
return {
|
|
198
|
+
char: '/',
|
|
199
|
+
startOfLine: true,
|
|
200
|
+
// read as asked: the harness names its commands once it is up
|
|
201
|
+
items: ({ query }) =>
|
|
202
|
+
session.snapshot.info.commands
|
|
203
|
+
.filter((c) => c.name.toLowerCase().startsWith(query.toLowerCase()))
|
|
204
|
+
.slice(0, 20)
|
|
205
|
+
.map((c) => ({
|
|
206
|
+
label: `/${c.name}`,
|
|
207
|
+
...(c.description ? { detail: c.description } : {}),
|
|
208
|
+
insert: `/${c.name} `,
|
|
209
|
+
})),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function attachmentOf(atom: ComposerAtom): Attachment {
|
|
214
|
+
if (atom.kind === 'image' && atom.data instanceof Uint8Array) {
|
|
215
|
+
return {
|
|
216
|
+
kind: 'image',
|
|
217
|
+
label: atom.label,
|
|
218
|
+
mime: 'image/png',
|
|
219
|
+
data: Buffer.from(atom.data).toString('base64'),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (atom.kind === 'file' && typeof atom.data === 'string') {
|
|
223
|
+
return { kind: 'file', label: atom.label, path: atom.data };
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
kind: 'paste',
|
|
227
|
+
label: atom.label,
|
|
228
|
+
data: typeof atom.data === 'string' ? atom.data : String(atom.data ?? ''),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The session's line: which agent and model, the mode and model pickers
|
|
2
|
+
// where the harness has them, what the session has cost, and Copy for the
|
|
3
|
+
// whole transcript.
|
|
4
|
+
|
|
5
|
+
import type { ReactElement } from 'react';
|
|
6
|
+
import { Button, Select, useClipboard } from 'react-x11';
|
|
7
|
+
|
|
8
|
+
import { useChat } from './context.js';
|
|
9
|
+
import { useLook } from './look.js';
|
|
10
|
+
import { useSessionSnapshot } from './use-agent.js';
|
|
11
|
+
|
|
12
|
+
export function Header(): ReactElement {
|
|
13
|
+
const { session } = useChat();
|
|
14
|
+
const snap = useSessionSnapshot(session);
|
|
15
|
+
const look = useLook();
|
|
16
|
+
const clipboard = useClipboard();
|
|
17
|
+
const { info, capabilities } = snap;
|
|
18
|
+
const cost = info.usage?.costUsd;
|
|
19
|
+
return (
|
|
20
|
+
<box
|
|
21
|
+
style={{
|
|
22
|
+
flexDirection: 'row',
|
|
23
|
+
alignItems: 'center',
|
|
24
|
+
gap: 8,
|
|
25
|
+
paddingLeft: 12,
|
|
26
|
+
paddingRight: 8,
|
|
27
|
+
paddingTop: 6,
|
|
28
|
+
paddingBottom: 6,
|
|
29
|
+
borderBottomWidth: 1,
|
|
30
|
+
borderBottomColor: look.border,
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<text style={{ color: look.text, fontWeight: 'bold' }}>
|
|
34
|
+
{agentName(info.agent)}
|
|
35
|
+
</text>
|
|
36
|
+
{info.model && !(capabilities.models && info.models.length) ? (
|
|
37
|
+
<text style={{ color: look.muted, fontSize: 12 }}>{info.model}</text>
|
|
38
|
+
) : null}
|
|
39
|
+
<box style={{ flexGrow: 1 }} />
|
|
40
|
+
{capabilities.models && info.models.length ? (
|
|
41
|
+
<Select
|
|
42
|
+
value={info.model}
|
|
43
|
+
options={info.models.map((m) => ({ value: m.id, label: m.label }))}
|
|
44
|
+
onChange={(ev) => void session.setModel(String(ev.value))}
|
|
45
|
+
/>
|
|
46
|
+
) : null}
|
|
47
|
+
{capabilities.modes && info.modes.length ? (
|
|
48
|
+
<Select
|
|
49
|
+
value={info.mode}
|
|
50
|
+
options={info.modes.map((m) => ({ value: m.id, label: m.label }))}
|
|
51
|
+
onChange={(ev) => void session.setMode(String(ev.value))}
|
|
52
|
+
/>
|
|
53
|
+
) : null}
|
|
54
|
+
{cost !== undefined ? (
|
|
55
|
+
<text
|
|
56
|
+
style={{ color: look.muted, fontSize: 12 }}
|
|
57
|
+
>{`$${cost.toFixed(2)}`}</text>
|
|
58
|
+
) : null}
|
|
59
|
+
<Button
|
|
60
|
+
size="small"
|
|
61
|
+
variant="ghost"
|
|
62
|
+
label="Copy chat"
|
|
63
|
+
onPress={() => void clipboard.writeText(session.toMarkdown())}
|
|
64
|
+
/>
|
|
65
|
+
</box>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function agentName(agent: string): string {
|
|
70
|
+
return agent === 'claude'
|
|
71
|
+
? 'Claude'
|
|
72
|
+
: agent.charAt(0).toUpperCase() + agent.slice(1);
|
|
73
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Messages, and the parts inside them. Each message is memoized by
|
|
2
|
+
// identity: the reducer replaces only the message a change touched, so a
|
|
3
|
+
// streamed token re-renders one message.
|
|
4
|
+
|
|
5
|
+
import { memo, useState, type ReactElement } from 'react';
|
|
6
|
+
import { Button, Icon, useClipboard } from 'react-x11';
|
|
7
|
+
|
|
8
|
+
import { messageMarkdown, sourceLabel } from '../core/markdown.js';
|
|
9
|
+
import type { AgentMessage, Part, PlanPart } from '../core/types.js';
|
|
10
|
+
import { useLook, type ChatLook } from './look.js';
|
|
11
|
+
import { QuestionCard } from './QuestionCard.js';
|
|
12
|
+
import { TextBlock } from './TextBlock.js';
|
|
13
|
+
import { ToolCard } from './ToolCard.js';
|
|
14
|
+
|
|
15
|
+
export function MessageList({
|
|
16
|
+
messages,
|
|
17
|
+
}: {
|
|
18
|
+
messages: readonly AgentMessage[];
|
|
19
|
+
}): ReactElement {
|
|
20
|
+
return (
|
|
21
|
+
<box style={{ flexDirection: 'column', gap: 14 }}>
|
|
22
|
+
{messages.map((m) => (
|
|
23
|
+
<MessageView key={m.id} message={m} />
|
|
24
|
+
))}
|
|
25
|
+
</box>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const MessageView = memo(function MessageView({
|
|
30
|
+
message,
|
|
31
|
+
}: {
|
|
32
|
+
message: AgentMessage;
|
|
33
|
+
}): ReactElement {
|
|
34
|
+
const look = useLook();
|
|
35
|
+
return message.role === 'user' ? (
|
|
36
|
+
<UserMessage message={message} look={look} />
|
|
37
|
+
) : (
|
|
38
|
+
<AssistantMessage message={message} look={look} />
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function UserMessage({
|
|
43
|
+
message,
|
|
44
|
+
look,
|
|
45
|
+
}: {
|
|
46
|
+
message: AgentMessage;
|
|
47
|
+
look: ChatLook;
|
|
48
|
+
}): ReactElement {
|
|
49
|
+
// the user's own on the right, filled; anyone else's on the left, framed
|
|
50
|
+
// and named, so a message another session sent is never read as the
|
|
51
|
+
// user's
|
|
52
|
+
const from = message.from;
|
|
53
|
+
return (
|
|
54
|
+
<box
|
|
55
|
+
style={{
|
|
56
|
+
flexDirection: 'column',
|
|
57
|
+
alignItems: from ? 'flex-start' : 'flex-end',
|
|
58
|
+
gap: 4,
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<box
|
|
62
|
+
style={{
|
|
63
|
+
flexDirection: 'column',
|
|
64
|
+
gap: 4,
|
|
65
|
+
maxWidth: '85%',
|
|
66
|
+
paddingLeft: 12,
|
|
67
|
+
paddingRight: 12,
|
|
68
|
+
paddingTop: 8,
|
|
69
|
+
paddingBottom: 8,
|
|
70
|
+
borderRadius: 10,
|
|
71
|
+
...(from
|
|
72
|
+
? { borderWidth: 1, borderColor: look.border }
|
|
73
|
+
: { backgroundColor: look.bubble }),
|
|
74
|
+
opacity: message.queued ? 0.6 : 1,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{from ? (
|
|
78
|
+
<text style={{ color: look.muted, fontSize: 11, fontWeight: 'bold' }}>
|
|
79
|
+
{`${sourceLabel(from)} · ${from.kind === 'peer' ? 'another session' : 'channel'}`}
|
|
80
|
+
</text>
|
|
81
|
+
) : null}
|
|
82
|
+
{message.parts.map((p) =>
|
|
83
|
+
// what was typed as markdown is drawn as markdown once it is sent,
|
|
84
|
+
// with the same fences, components and code actions as a reply
|
|
85
|
+
p.type === 'text' ? (
|
|
86
|
+
<TextBlock
|
|
87
|
+
key={p.id}
|
|
88
|
+
text={p.text}
|
|
89
|
+
streaming={false}
|
|
90
|
+
message={message}
|
|
91
|
+
/>
|
|
92
|
+
) : p.type === 'file' && !textOf(message).includes(`[${p.label}]`) ? (
|
|
93
|
+
// an attachment the composer made is already in the text as its
|
|
94
|
+
// token; one sent some other way gets a line of its own
|
|
95
|
+
<text key={p.id} style={{ color: look.muted, fontSize: 11 }}>
|
|
96
|
+
{`[${p.label}]`}
|
|
97
|
+
</text>
|
|
98
|
+
) : null,
|
|
99
|
+
)}
|
|
100
|
+
</box>
|
|
101
|
+
<box style={{ flexDirection: 'row', gap: 4, alignItems: 'center' }}>
|
|
102
|
+
{message.queued ? (
|
|
103
|
+
<text style={{ color: look.muted, fontSize: 11 }}>Queued</text>
|
|
104
|
+
) : null}
|
|
105
|
+
<CopyButton message={message} />
|
|
106
|
+
</box>
|
|
107
|
+
</box>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function AssistantMessage({
|
|
112
|
+
message,
|
|
113
|
+
look,
|
|
114
|
+
}: {
|
|
115
|
+
message: AgentMessage;
|
|
116
|
+
look: ChatLook;
|
|
117
|
+
}): ReactElement {
|
|
118
|
+
const hasText = message.parts.some((p) => p.type === 'text');
|
|
119
|
+
return (
|
|
120
|
+
<box style={{ flexDirection: 'column', gap: 8 }}>
|
|
121
|
+
{message.parts.map((p) => (
|
|
122
|
+
<PartView key={p.id} part={p} message={message} look={look} />
|
|
123
|
+
))}
|
|
124
|
+
{message.done && hasText ? (
|
|
125
|
+
<box style={{ flexDirection: 'row' }}>
|
|
126
|
+
<CopyButton message={message} />
|
|
127
|
+
</box>
|
|
128
|
+
) : null}
|
|
129
|
+
</box>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function PartView({
|
|
134
|
+
part,
|
|
135
|
+
message,
|
|
136
|
+
look,
|
|
137
|
+
}: {
|
|
138
|
+
part: Part;
|
|
139
|
+
message: AgentMessage;
|
|
140
|
+
look: ChatLook;
|
|
141
|
+
}): ReactElement | null {
|
|
142
|
+
switch (part.type) {
|
|
143
|
+
case 'text':
|
|
144
|
+
return (
|
|
145
|
+
<TextBlock
|
|
146
|
+
text={part.text}
|
|
147
|
+
streaming={part.streaming}
|
|
148
|
+
message={message}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
case 'reasoning':
|
|
152
|
+
return (
|
|
153
|
+
<Reasoning text={part.text} streaming={part.streaming} look={look} />
|
|
154
|
+
);
|
|
155
|
+
case 'tool':
|
|
156
|
+
return <ToolCard part={part} />;
|
|
157
|
+
case 'question':
|
|
158
|
+
return <QuestionCard part={part} />;
|
|
159
|
+
case 'plan':
|
|
160
|
+
return <Plan part={part} look={look} />;
|
|
161
|
+
case 'notice':
|
|
162
|
+
return (
|
|
163
|
+
<text
|
|
164
|
+
style={{
|
|
165
|
+
color:
|
|
166
|
+
part.level === 'error'
|
|
167
|
+
? look.danger
|
|
168
|
+
: part.level === 'warning'
|
|
169
|
+
? look.warning
|
|
170
|
+
: look.muted,
|
|
171
|
+
fontSize: 12,
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
{part.text}
|
|
175
|
+
</text>
|
|
176
|
+
);
|
|
177
|
+
case 'file':
|
|
178
|
+
return (
|
|
179
|
+
<text
|
|
180
|
+
style={{ color: look.muted, fontSize: 11 }}
|
|
181
|
+
>{`[${part.label}]`}</text>
|
|
182
|
+
);
|
|
183
|
+
case 'data':
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function Reasoning({
|
|
189
|
+
text,
|
|
190
|
+
streaming,
|
|
191
|
+
look,
|
|
192
|
+
}: {
|
|
193
|
+
text: string;
|
|
194
|
+
streaming: boolean;
|
|
195
|
+
look: ChatLook;
|
|
196
|
+
}): ReactElement | null {
|
|
197
|
+
const [open, setOpen] = useState(false);
|
|
198
|
+
// A harness may send a thought with its text left out (Claude's
|
|
199
|
+
// `display: 'omitted'`): while it streams it is still news that the agent
|
|
200
|
+
// is thinking; once it is over there is nothing to open, so nothing is drawn.
|
|
201
|
+
if (!text.trim()) {
|
|
202
|
+
return streaming ? (
|
|
203
|
+
<text style={{ color: look.muted, fontSize: 12 }}>Thinking…</text>
|
|
204
|
+
) : null;
|
|
205
|
+
}
|
|
206
|
+
return (
|
|
207
|
+
<box style={{ flexDirection: 'column', gap: 4 }}>
|
|
208
|
+
<box
|
|
209
|
+
style={{
|
|
210
|
+
flexDirection: 'row',
|
|
211
|
+
gap: 6,
|
|
212
|
+
alignItems: 'center',
|
|
213
|
+
cursor: 'pointer',
|
|
214
|
+
}}
|
|
215
|
+
onClick={() => setOpen((o) => !o)}
|
|
216
|
+
>
|
|
217
|
+
<Icon
|
|
218
|
+
name={open ? 'chevronDown' : 'chevronRight'}
|
|
219
|
+
size={12}
|
|
220
|
+
color={look.muted}
|
|
221
|
+
/>
|
|
222
|
+
<text style={{ color: look.muted, fontSize: 12 }}>
|
|
223
|
+
{streaming ? 'Thinking…' : 'Thought'}
|
|
224
|
+
</text>
|
|
225
|
+
</box>
|
|
226
|
+
{open ? (
|
|
227
|
+
<text style={{ color: look.muted, fontSize: 12, paddingLeft: 18 }}>
|
|
228
|
+
{text}
|
|
229
|
+
</text>
|
|
230
|
+
) : null}
|
|
231
|
+
</box>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function Plan({
|
|
236
|
+
part,
|
|
237
|
+
look,
|
|
238
|
+
}: {
|
|
239
|
+
part: PlanPart;
|
|
240
|
+
look: ChatLook;
|
|
241
|
+
}): ReactElement {
|
|
242
|
+
return (
|
|
243
|
+
<box style={{ flexDirection: 'column', gap: 2 }}>
|
|
244
|
+
{part.entries.map((e, i) => (
|
|
245
|
+
<box
|
|
246
|
+
key={i}
|
|
247
|
+
style={{ flexDirection: 'row', gap: 6, alignItems: 'center' }}
|
|
248
|
+
>
|
|
249
|
+
{e.status === 'completed' ? (
|
|
250
|
+
<Icon name="check" size={12} color={look.success} />
|
|
251
|
+
) : e.status === 'in_progress' ? (
|
|
252
|
+
<Icon name="dot" size={12} color={look.accent} />
|
|
253
|
+
) : (
|
|
254
|
+
<Icon name="dash" size={12} color={look.muted} />
|
|
255
|
+
)}
|
|
256
|
+
<text
|
|
257
|
+
style={{
|
|
258
|
+
color: e.status === 'completed' ? look.muted : look.text,
|
|
259
|
+
fontWeight: e.status === 'in_progress' ? 'bold' : 'normal',
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
{e.text}
|
|
263
|
+
</text>
|
|
264
|
+
</box>
|
|
265
|
+
))}
|
|
266
|
+
</box>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function CopyButton({ message }: { message: AgentMessage }): ReactElement {
|
|
271
|
+
const clipboard = useClipboard();
|
|
272
|
+
const [copied, setCopied] = useState(false);
|
|
273
|
+
return (
|
|
274
|
+
<Button
|
|
275
|
+
size="small"
|
|
276
|
+
variant="ghost"
|
|
277
|
+
label={copied ? 'Copied' : 'Copy'}
|
|
278
|
+
onPress={() => {
|
|
279
|
+
void clipboard.writeText(messageMarkdown(message)).then(() => {
|
|
280
|
+
setCopied(true);
|
|
281
|
+
setTimeout(() => setCopied(false), 1500);
|
|
282
|
+
});
|
|
283
|
+
}}
|
|
284
|
+
/>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function textOf(message: AgentMessage): string {
|
|
289
|
+
return message.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
|
|
290
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// A question the agent asked: options as buttons, one or several a
|
|
2
|
+
// question, and Submit once every question has an answer.
|
|
3
|
+
|
|
4
|
+
import { memo, useState, type ReactElement } from 'react';
|
|
5
|
+
import { Button } from 'react-x11';
|
|
6
|
+
|
|
7
|
+
import type { QuestionPart } from '../core/types.js';
|
|
8
|
+
import { useChat } from './context.js';
|
|
9
|
+
import { useLook } from './look.js';
|
|
10
|
+
|
|
11
|
+
export const QuestionCard = memo(function QuestionCard({
|
|
12
|
+
part,
|
|
13
|
+
}: {
|
|
14
|
+
part: QuestionPart;
|
|
15
|
+
}): ReactElement {
|
|
16
|
+
const { session } = useChat();
|
|
17
|
+
const look = useLook();
|
|
18
|
+
const [chosen, setChosen] = useState<Record<string, readonly string[]>>({});
|
|
19
|
+
const open = part.state === 'open';
|
|
20
|
+
const complete = part.questions.every(
|
|
21
|
+
(q) => (chosen[q.question]?.length ?? 0) > 0,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const pick = (question: string, label: string, multi: boolean) =>
|
|
25
|
+
setChosen((c) => {
|
|
26
|
+
const now = c[question] ?? [];
|
|
27
|
+
const next = multi
|
|
28
|
+
? now.includes(label)
|
|
29
|
+
? now.filter((l) => l !== label)
|
|
30
|
+
: [...now, label]
|
|
31
|
+
: [label];
|
|
32
|
+
return { ...c, [question]: next };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<box
|
|
37
|
+
style={{
|
|
38
|
+
flexDirection: 'column',
|
|
39
|
+
gap: 10,
|
|
40
|
+
padding: 10,
|
|
41
|
+
borderWidth: 1,
|
|
42
|
+
borderColor: open ? look.accent : look.border,
|
|
43
|
+
borderRadius: 6,
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
{part.questions.map((q) => (
|
|
47
|
+
<box key={q.question} style={{ flexDirection: 'column', gap: 6 }}>
|
|
48
|
+
{q.header ? (
|
|
49
|
+
<text style={{ color: look.muted, fontSize: 11 }}>{q.header}</text>
|
|
50
|
+
) : null}
|
|
51
|
+
<text style={{ color: look.text, fontWeight: 'bold' }}>
|
|
52
|
+
{q.question}
|
|
53
|
+
</text>
|
|
54
|
+
{open ? (
|
|
55
|
+
<box style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
|
56
|
+
{q.options.map((o) => {
|
|
57
|
+
const on = chosen[q.question]?.includes(o.label) ?? false;
|
|
58
|
+
return (
|
|
59
|
+
<Button
|
|
60
|
+
key={o.label}
|
|
61
|
+
size="small"
|
|
62
|
+
label={o.label}
|
|
63
|
+
variant={on ? 'solid' : 'outline'}
|
|
64
|
+
primary={on}
|
|
65
|
+
onPress={() => pick(q.question, o.label, q.multiSelect)}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</box>
|
|
70
|
+
) : (
|
|
71
|
+
<text style={{ color: look.muted }}>
|
|
72
|
+
{part.answers?.[q.question] ??
|
|
73
|
+
(part.state === 'cancelled' ? 'Dismissed' : '')}
|
|
74
|
+
</text>
|
|
75
|
+
)}
|
|
76
|
+
</box>
|
|
77
|
+
))}
|
|
78
|
+
{open ? (
|
|
79
|
+
<box style={{ flexDirection: 'row', gap: 6 }}>
|
|
80
|
+
<Button
|
|
81
|
+
size="small"
|
|
82
|
+
primary
|
|
83
|
+
label="Submit"
|
|
84
|
+
disabled={!complete}
|
|
85
|
+
onPress={() =>
|
|
86
|
+
session.answer(
|
|
87
|
+
part.id,
|
|
88
|
+
Object.fromEntries(
|
|
89
|
+
part.questions.map((q) => [
|
|
90
|
+
q.question,
|
|
91
|
+
(chosen[q.question] ?? []).join(', '),
|
|
92
|
+
]),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
/>
|
|
97
|
+
<Button
|
|
98
|
+
size="small"
|
|
99
|
+
variant="ghost"
|
|
100
|
+
label="Dismiss"
|
|
101
|
+
onPress={() => session.answer(part.id, null)}
|
|
102
|
+
/>
|
|
103
|
+
</box>
|
|
104
|
+
) : null}
|
|
105
|
+
</box>
|
|
106
|
+
);
|
|
107
|
+
});
|