@windowkit/agent-chat 0.0.0-stage → 0.1.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 +60 -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 +22 -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 +47 -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 +18 -0
- package/dist/view/look.d.ts.map +1 -0
- package/dist/view/look.js +37 -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 +108 -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 +58 -0
- package/src/view/ToolCard.tsx +283 -0
- package/src/view/Transcript.tsx +81 -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 +53 -0
- package/src/view/types.ts +71 -0
- package/src/view/use-agent.ts +40 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Assistant text: markdown, with each top-level fence drawn as a code block
|
|
2
|
+
// that carries actions, or by the app's renderer for its language.
|
|
3
|
+
|
|
4
|
+
import { memo, useMemo, type ReactElement } from 'react';
|
|
5
|
+
import { Markdown } from '@react-x11/components/markdown';
|
|
6
|
+
|
|
7
|
+
import type { AgentMessage } from '../core/types.js';
|
|
8
|
+
import { CodeBlock } from './CodeBlock.js';
|
|
9
|
+
import { useChat } from './context.js';
|
|
10
|
+
import { splitFences } from './fences.js';
|
|
11
|
+
|
|
12
|
+
export interface TextBlockProps {
|
|
13
|
+
text: string;
|
|
14
|
+
streaming: boolean;
|
|
15
|
+
message: AgentMessage;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const TextBlock = memo(function TextBlock({
|
|
19
|
+
text,
|
|
20
|
+
streaming,
|
|
21
|
+
message,
|
|
22
|
+
}: TextBlockProps): ReactElement {
|
|
23
|
+
const { fences, components, onLink, fontSize } = useChat();
|
|
24
|
+
const segments = useMemo(
|
|
25
|
+
() => splitFences(text, streaming),
|
|
26
|
+
[text, streaming],
|
|
27
|
+
);
|
|
28
|
+
const link = useMemo(
|
|
29
|
+
() => (onLink ? (href: string) => onLink(href) : undefined),
|
|
30
|
+
[onLink],
|
|
31
|
+
);
|
|
32
|
+
return (
|
|
33
|
+
<box style={{ flexDirection: 'column', gap: 8 }}>
|
|
34
|
+
{segments.map((s, i) => {
|
|
35
|
+
if (s.type === 'prose') {
|
|
36
|
+
return (
|
|
37
|
+
<Markdown
|
|
38
|
+
key={i}
|
|
39
|
+
source={s.text}
|
|
40
|
+
partial={streaming && i === segments.length - 1}
|
|
41
|
+
{...(fences ? { fences } : {})}
|
|
42
|
+
{...(components ? { components } : {})}
|
|
43
|
+
{...(link ? { onLink: link } : {})}
|
|
44
|
+
{...(fontSize ? { fontSize } : {})}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const fence = { lang: s.lang, text: s.text, partial: s.partial };
|
|
49
|
+
const custom = fences?.[s.lang];
|
|
50
|
+
return custom ? (
|
|
51
|
+
<box key={i}>{custom(fence)}</box>
|
|
52
|
+
) : (
|
|
53
|
+
<CodeBlock key={i} fence={fence} message={message} />
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</box>
|
|
57
|
+
);
|
|
58
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// One tool call: a line saying what it is and how it went, the approval row
|
|
2
|
+
// while it waits for the user, and its output when opened.
|
|
3
|
+
//
|
|
4
|
+
// Approval is drawn here, on the call, not in a dialog: what is being
|
|
5
|
+
// approved is right above the buttons.
|
|
6
|
+
|
|
7
|
+
import { memo, useState, type ReactElement, type ReactNode } from 'react';
|
|
8
|
+
import { Button, Icon, type IconName } from 'react-x11';
|
|
9
|
+
import { Code } from '@react-x11/components/code';
|
|
10
|
+
import { Markdown } from '@react-x11/components/markdown';
|
|
11
|
+
import { TerminalOutput } from '@react-x11/components/terminal-output';
|
|
12
|
+
|
|
13
|
+
import type { ToolOutput, ToolPart } from '../core/types.js';
|
|
14
|
+
import { useChat } from './context.js';
|
|
15
|
+
import { clipLines, useLook, type ChatLook } from './look.js';
|
|
16
|
+
import { MessageList } from './MessageList.js';
|
|
17
|
+
|
|
18
|
+
const STATE_LABEL: Readonly<Record<ToolPart['state'], string>> = {
|
|
19
|
+
'input-streaming': 'preparing',
|
|
20
|
+
pending: 'queued',
|
|
21
|
+
'awaiting-approval': 'needs approval',
|
|
22
|
+
running: 'running',
|
|
23
|
+
done: '',
|
|
24
|
+
error: 'failed',
|
|
25
|
+
denied: 'denied',
|
|
26
|
+
cancelled: 'stopped',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface ToolCardProps {
|
|
30
|
+
part: ToolPart;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const ToolCard = memo(function ToolCard({
|
|
34
|
+
part,
|
|
35
|
+
}: ToolCardProps): ReactElement {
|
|
36
|
+
const { session, renderTool } = useChat();
|
|
37
|
+
const look = useLook();
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
|
|
40
|
+
const value = part.output.find((o) => o.type === 'value');
|
|
41
|
+
const render =
|
|
42
|
+
session.tools.get(part.label)?.render ??
|
|
43
|
+
renderTool[part.label] ??
|
|
44
|
+
renderTool['*'];
|
|
45
|
+
const custom = render
|
|
46
|
+
? (render({
|
|
47
|
+
part,
|
|
48
|
+
input: part.input,
|
|
49
|
+
output: value?.type === 'value' ? value.value : undefined,
|
|
50
|
+
}) as ReactNode)
|
|
51
|
+
: null;
|
|
52
|
+
const waiting = part.state === 'awaiting-approval';
|
|
53
|
+
const expanded = open || waiting;
|
|
54
|
+
const [glyph, colour] = glyphOf(part.state, look);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<box
|
|
58
|
+
style={{
|
|
59
|
+
flexDirection: 'column',
|
|
60
|
+
borderWidth: 1,
|
|
61
|
+
borderColor: waiting ? look.warning : look.border,
|
|
62
|
+
borderRadius: 6,
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<box
|
|
66
|
+
style={{
|
|
67
|
+
flexDirection: 'row',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
gap: 6,
|
|
70
|
+
paddingLeft: 8,
|
|
71
|
+
paddingRight: 8,
|
|
72
|
+
paddingTop: 4,
|
|
73
|
+
paddingBottom: 4,
|
|
74
|
+
cursor: 'pointer',
|
|
75
|
+
}}
|
|
76
|
+
onClick={() => setOpen((o) => !o)}
|
|
77
|
+
>
|
|
78
|
+
<Icon
|
|
79
|
+
name={expanded ? 'chevronDown' : 'chevronRight'}
|
|
80
|
+
size={12}
|
|
81
|
+
color={look.muted}
|
|
82
|
+
/>
|
|
83
|
+
<Icon name={glyph} size={12} color={colour} />
|
|
84
|
+
<text
|
|
85
|
+
style={{ color: look.text, flexGrow: 1, flexShrink: 1, maxLines: 1 }}
|
|
86
|
+
>
|
|
87
|
+
{part.title}
|
|
88
|
+
</text>
|
|
89
|
+
{STATE_LABEL[part.state] ? (
|
|
90
|
+
<text style={{ color: look.muted, fontSize: 11 }}>
|
|
91
|
+
{STATE_LABEL[part.state]}
|
|
92
|
+
</text>
|
|
93
|
+
) : null}
|
|
94
|
+
</box>
|
|
95
|
+
|
|
96
|
+
{custom ? (
|
|
97
|
+
<box style={{ padding: 8, paddingTop: 0 }}>{custom}</box>
|
|
98
|
+
) : null}
|
|
99
|
+
|
|
100
|
+
{expanded && !custom ? (
|
|
101
|
+
<box
|
|
102
|
+
style={{ flexDirection: 'column', gap: 6, padding: 8, paddingTop: 0 }}
|
|
103
|
+
>
|
|
104
|
+
{waiting || !part.output.length ? <InputPreview part={part} /> : null}
|
|
105
|
+
{part.output.map((o, i) => (
|
|
106
|
+
<Output key={i} output={o} look={look} />
|
|
107
|
+
))}
|
|
108
|
+
{part.error ? (
|
|
109
|
+
<text style={{ color: look.danger }}>
|
|
110
|
+
{clipLines(part.error, 40)}
|
|
111
|
+
</text>
|
|
112
|
+
) : null}
|
|
113
|
+
</box>
|
|
114
|
+
) : null}
|
|
115
|
+
|
|
116
|
+
{part.children?.length && expanded ? (
|
|
117
|
+
<box
|
|
118
|
+
style={{
|
|
119
|
+
paddingLeft: 12,
|
|
120
|
+
marginLeft: 8,
|
|
121
|
+
borderLeftWidth: 2,
|
|
122
|
+
borderLeftColor: look.border,
|
|
123
|
+
marginBottom: 8,
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
<MessageList messages={part.children} />
|
|
127
|
+
</box>
|
|
128
|
+
) : null}
|
|
129
|
+
|
|
130
|
+
{waiting && part.approval ? (
|
|
131
|
+
<box
|
|
132
|
+
style={{
|
|
133
|
+
flexDirection: 'row',
|
|
134
|
+
gap: 6,
|
|
135
|
+
padding: 8,
|
|
136
|
+
borderTopWidth: 1,
|
|
137
|
+
borderTopColor: look.border,
|
|
138
|
+
alignItems: 'center',
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
{part.approval.options.map((option, i) => (
|
|
142
|
+
<Button
|
|
143
|
+
key={option.id}
|
|
144
|
+
size="small"
|
|
145
|
+
label={option.label}
|
|
146
|
+
primary={i === 0}
|
|
147
|
+
variant={
|
|
148
|
+
option.kind === 'deny' ? 'ghost' : i === 0 ? 'solid' : 'outline'
|
|
149
|
+
}
|
|
150
|
+
onPress={() => session.respond(part.id, option.id)}
|
|
151
|
+
/>
|
|
152
|
+
))}
|
|
153
|
+
</box>
|
|
154
|
+
) : null}
|
|
155
|
+
</box>
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
function glyphOf(state: ToolPart['state'], look: ChatLook): [IconName, string] {
|
|
160
|
+
switch (state) {
|
|
161
|
+
case 'done':
|
|
162
|
+
return ['check', look.success];
|
|
163
|
+
case 'error':
|
|
164
|
+
case 'denied':
|
|
165
|
+
return ['close', look.danger];
|
|
166
|
+
case 'cancelled':
|
|
167
|
+
return ['dash', look.muted];
|
|
168
|
+
case 'awaiting-approval':
|
|
169
|
+
return ['dot', look.warning];
|
|
170
|
+
default:
|
|
171
|
+
return ['dot', look.accent];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** What the call will do, for the user deciding whether it may. */
|
|
176
|
+
function InputPreview({ part }: { part: ToolPart }): ReactElement | null {
|
|
177
|
+
const input = (
|
|
178
|
+
part.input && typeof part.input === 'object' ? part.input : {}
|
|
179
|
+
) as Record<string, unknown>;
|
|
180
|
+
if (part.label === 'ExitPlanMode' && typeof input.plan === 'string') {
|
|
181
|
+
return <Markdown source={input.plan} partial={false} />;
|
|
182
|
+
}
|
|
183
|
+
if (part.kind === 'execute' && typeof input.command === 'string') {
|
|
184
|
+
return <Code source={input.command} lang="sh" wrap />;
|
|
185
|
+
}
|
|
186
|
+
if (part.kind === 'edit' && typeof input.file_path === 'string') {
|
|
187
|
+
if (
|
|
188
|
+
typeof input.old_string === 'string' ||
|
|
189
|
+
typeof input.new_string === 'string'
|
|
190
|
+
) {
|
|
191
|
+
return (
|
|
192
|
+
<Diff
|
|
193
|
+
output={{
|
|
194
|
+
type: 'diff',
|
|
195
|
+
path: input.file_path,
|
|
196
|
+
oldText: String(input.old_string ?? ''),
|
|
197
|
+
newText: String(input.new_string ?? ''),
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (typeof input.content === 'string') {
|
|
203
|
+
return (
|
|
204
|
+
<Diff
|
|
205
|
+
output={{
|
|
206
|
+
type: 'diff',
|
|
207
|
+
path: input.file_path,
|
|
208
|
+
oldText: null,
|
|
209
|
+
newText: input.content,
|
|
210
|
+
}}
|
|
211
|
+
/>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (part.inputText === '' && part.input === undefined) return null;
|
|
216
|
+
return <Code source={clipLines(json(part.input), 60)} lang="json" wrap />;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function Output({
|
|
220
|
+
output,
|
|
221
|
+
look,
|
|
222
|
+
}: {
|
|
223
|
+
output: ToolOutput;
|
|
224
|
+
look: ChatLook;
|
|
225
|
+
}): ReactElement | null {
|
|
226
|
+
switch (output.type) {
|
|
227
|
+
case 'text':
|
|
228
|
+
return output.progress ? (
|
|
229
|
+
<text style={{ color: look.muted }}>{output.text}</text>
|
|
230
|
+
) : (
|
|
231
|
+
<Code source={clipLines(output.text)} wrap />
|
|
232
|
+
);
|
|
233
|
+
case 'terminal':
|
|
234
|
+
return <TerminalOutput data={clipLines(output.text)} mode="flow" />;
|
|
235
|
+
case 'diff':
|
|
236
|
+
return <Diff output={output} />;
|
|
237
|
+
case 'image':
|
|
238
|
+
return <text style={{ color: look.muted }}>[image, {output.mime}]</text>;
|
|
239
|
+
case 'value':
|
|
240
|
+
return (
|
|
241
|
+
<Code source={clipLines(json(output.value), 80)} lang="json" wrap />
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A change as lines in red and green: what Claude Code's own edit shows. */
|
|
247
|
+
function Diff({
|
|
248
|
+
output,
|
|
249
|
+
}: {
|
|
250
|
+
output: Extract<ToolOutput, { type: 'diff' }>;
|
|
251
|
+
}): ReactElement {
|
|
252
|
+
const look = useLook();
|
|
253
|
+
const removed = output.oldText === null ? [] : output.oldText.split('\n');
|
|
254
|
+
const added = output.newText.split('\n');
|
|
255
|
+
const lines = [
|
|
256
|
+
...removed.map((l) => ['-', l] as const),
|
|
257
|
+
...added.map((l) => ['+', l] as const),
|
|
258
|
+
].slice(0, 300);
|
|
259
|
+
return (
|
|
260
|
+
<box style={{ flexDirection: 'column' }}>
|
|
261
|
+
<text style={{ color: look.muted, fontSize: 11 }}>{output.path}</text>
|
|
262
|
+
{lines.map(([sign, line], i) => (
|
|
263
|
+
<text
|
|
264
|
+
key={i}
|
|
265
|
+
style={{
|
|
266
|
+
fontFamily: look.mono,
|
|
267
|
+
color: sign === '-' ? look.danger : look.success,
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
{`${sign} ${line}`}
|
|
271
|
+
</text>
|
|
272
|
+
))}
|
|
273
|
+
</box>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function json(value: unknown): string {
|
|
278
|
+
try {
|
|
279
|
+
return JSON.stringify(value, null, 2) ?? '';
|
|
280
|
+
} catch {
|
|
281
|
+
return String(value);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The scrolling transcript. It follows the newest message while the reader
|
|
2
|
+
// is at the bottom, and stops following once they scroll up to read.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
type ReactElement,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import type { ScrollableNode, ScrollEvent } from 'react-x11';
|
|
11
|
+
|
|
12
|
+
import { useChat } from './context.js';
|
|
13
|
+
import { useLook } from './look.js';
|
|
14
|
+
import { MessageList } from './MessageList.js';
|
|
15
|
+
import { useSessionSnapshot } from './use-agent.js';
|
|
16
|
+
|
|
17
|
+
/** Within this many pixels of the end counts as at the end. */
|
|
18
|
+
const STICK_PX = 24;
|
|
19
|
+
|
|
20
|
+
export function Transcript({ empty }: { empty?: ReactNode }): ReactElement {
|
|
21
|
+
const { session } = useChat();
|
|
22
|
+
const snap = useSessionSnapshot(session);
|
|
23
|
+
const look = useLook();
|
|
24
|
+
const pane = useRef<ScrollableNode | null>(null);
|
|
25
|
+
const stick = useRef(true);
|
|
26
|
+
|
|
27
|
+
useLayoutEffect(() => {
|
|
28
|
+
// a layout effect: `scrollTo(Infinity)` here follows the new rows to the
|
|
29
|
+
// end in the frame that shows them (react-x11's ScrollableNode)
|
|
30
|
+
if (stick.current) pane.current?.scrollTo(Infinity);
|
|
31
|
+
}, [snap.messages, snap.status]);
|
|
32
|
+
|
|
33
|
+
const onScroll = (ev: ScrollEvent) => {
|
|
34
|
+
stick.current =
|
|
35
|
+
ev.scrollY + ev.viewportHeight >= ev.contentHeight - STICK_PX;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const activity = activityOf(snap.status, snap.messages);
|
|
39
|
+
return (
|
|
40
|
+
<box
|
|
41
|
+
ref={pane}
|
|
42
|
+
onScroll={onScroll}
|
|
43
|
+
style={{ flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: 'scroll' }}
|
|
44
|
+
data-testname="agent-chat-transcript"
|
|
45
|
+
>
|
|
46
|
+
<box style={{ flexDirection: 'column', gap: 14, padding: 14 }}>
|
|
47
|
+
{snap.messages.length ? (
|
|
48
|
+
<MessageList messages={snap.messages} />
|
|
49
|
+
) : (
|
|
50
|
+
(empty ?? null)
|
|
51
|
+
)}
|
|
52
|
+
{activity ? (
|
|
53
|
+
<text style={{ color: look.muted, fontSize: 12 }}>{activity}</text>
|
|
54
|
+
) : null}
|
|
55
|
+
</box>
|
|
56
|
+
</box>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function activityOf(
|
|
61
|
+
status: string,
|
|
62
|
+
messages: ReturnType<typeof useSessionSnapshot>['messages'],
|
|
63
|
+
): string | null {
|
|
64
|
+
switch (status) {
|
|
65
|
+
case 'starting':
|
|
66
|
+
return 'Starting the agent…';
|
|
67
|
+
case 'paused':
|
|
68
|
+
return 'Paused. The agent stops at its next action until you resume.';
|
|
69
|
+
case 'waiting':
|
|
70
|
+
return 'Waiting for you.';
|
|
71
|
+
case 'running': {
|
|
72
|
+
const last = messages[messages.length - 1];
|
|
73
|
+
const streaming = last?.parts.some(
|
|
74
|
+
(p) => (p.type === 'text' || p.type === 'reasoning') && p.streaming,
|
|
75
|
+
);
|
|
76
|
+
return streaming ? null : 'Working…';
|
|
77
|
+
}
|
|
78
|
+
default:
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// What every part of the view reads: the session, the app's renderers and
|
|
2
|
+
// actions, normalized once with stable identities.
|
|
3
|
+
//
|
|
4
|
+
// Stable identities matter: `<Markdown>` keeps a block cache keyed on its
|
|
5
|
+
// `fences` and `components` maps, and a new map per render would re-parse
|
|
6
|
+
// every message on every token.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createContext,
|
|
10
|
+
useContext,
|
|
11
|
+
type ComponentType,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
} from 'react';
|
|
14
|
+
import type { FenceInfo } from '@react-x11/components/markdown';
|
|
15
|
+
|
|
16
|
+
import type { AgentSession } from '../core/session.js';
|
|
17
|
+
import type {
|
|
18
|
+
CodeAction,
|
|
19
|
+
ComponentEntry,
|
|
20
|
+
FenceEntry,
|
|
21
|
+
ToolRenderer,
|
|
22
|
+
} from './types.js';
|
|
23
|
+
|
|
24
|
+
export interface ChatContextValue {
|
|
25
|
+
session: AgentSession;
|
|
26
|
+
/** For `<Markdown fences>`: language → renderer. */
|
|
27
|
+
fences: Record<string, (fence: FenceInfo) => ReactNode> | undefined;
|
|
28
|
+
/** For `<Markdown components>`. */
|
|
29
|
+
components:
|
|
30
|
+
Record<string, ComponentType<Record<string, unknown>>> | undefined;
|
|
31
|
+
renderTool: Readonly<Record<string, ToolRenderer>>;
|
|
32
|
+
codeActions: readonly CodeAction[];
|
|
33
|
+
onLink: ((href: string) => void) | undefined;
|
|
34
|
+
/** Base text size, the theme's unless the app said. */
|
|
35
|
+
fontSize: number | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const ChatContext = createContext<ChatContextValue | null>(null);
|
|
39
|
+
|
|
40
|
+
export function useChat(): ChatContextValue {
|
|
41
|
+
const value = useContext(ChatContext);
|
|
42
|
+
if (!value) throw new Error('A chat part was rendered outside <AgentChat>.');
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function fenceMap(
|
|
47
|
+
fences: Readonly<Record<string, FenceEntry>> | undefined,
|
|
48
|
+
): Record<string, (fence: FenceInfo) => ReactNode> | undefined {
|
|
49
|
+
if (!fences) return undefined;
|
|
50
|
+
const out: Record<string, (fence: FenceInfo) => ReactNode> = {};
|
|
51
|
+
for (const [lang, entry] of Object.entries(fences)) {
|
|
52
|
+
out[lang.toLowerCase()] =
|
|
53
|
+
typeof entry === 'function' ? entry : entry.render;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function componentMap(
|
|
59
|
+
components: Readonly<Record<string, ComponentEntry>> | undefined,
|
|
60
|
+
): Record<string, ComponentType<Record<string, unknown>>> | undefined {
|
|
61
|
+
if (!components) return undefined;
|
|
62
|
+
const out: Record<string, ComponentType<Record<string, unknown>>> = {};
|
|
63
|
+
for (const [name, entry] of Object.entries(components)) {
|
|
64
|
+
out[name] =
|
|
65
|
+
typeof entry === 'object' && entry !== null && 'component' in entry
|
|
66
|
+
? entry.component
|
|
67
|
+
: (entry as ComponentType<Record<string, unknown>>);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `@react-x11/components/rich-text-editor`'s declarations name four DOM
|
|
2
|
+
// globals through ProseMirror's types, and this project type-checks with
|
|
3
|
+
// `skipLibCheck` off and no `"dom"` lib. These are the four, as that package
|
|
4
|
+
// ships them (its `src/rich-text-editor/dom-globals.d.ts`). Ambient and
|
|
5
|
+
// compile-time only: nothing emitted names them.
|
|
6
|
+
interface ShadowRoot {}
|
|
7
|
+
interface MutationRecord {}
|
|
8
|
+
interface HTMLElementEventMap {}
|
|
9
|
+
/** Only ever read as `InstanceType<typeof window.Node>`. */
|
|
10
|
+
declare var window: { readonly Node: new () => object };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// A reply split at its top-level fences, so each code block can carry a
|
|
2
|
+
// header of actions.
|
|
3
|
+
//
|
|
4
|
+
// `<Markdown>` draws a fence with no hook for a button on it, and adding one
|
|
5
|
+
// there (`codeActions`) is the right fix; until it lands, a reply is cut
|
|
6
|
+
// here. Only fences that start at the left margin are cut out: one indented
|
|
7
|
+
// under a list item stays in its list, drawn by `<Markdown>` without actions,
|
|
8
|
+
// rather than being pulled out of the list it belongs to.
|
|
9
|
+
|
|
10
|
+
export type Segment =
|
|
11
|
+
| { type: 'prose'; text: string }
|
|
12
|
+
| {
|
|
13
|
+
type: 'fence';
|
|
14
|
+
/** The info string's first word, lowercased: what renderers are keyed by. */
|
|
15
|
+
lang: string;
|
|
16
|
+
text: string;
|
|
17
|
+
/** Still streaming: the fence has not closed and more may come. */
|
|
18
|
+
partial: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const OPEN = /^(`{3,}|~{3,})(.*)$/;
|
|
22
|
+
|
|
23
|
+
export function splitFences(source: string, streaming: boolean): Segment[] {
|
|
24
|
+
const lines = source.split('\n');
|
|
25
|
+
const out: Segment[] = [];
|
|
26
|
+
let prose: string[] = [];
|
|
27
|
+
let i = 0;
|
|
28
|
+
const flush = () => {
|
|
29
|
+
if (prose.length && prose.some((l) => l.trim())) {
|
|
30
|
+
out.push({ type: 'prose', text: prose.join('\n') });
|
|
31
|
+
}
|
|
32
|
+
prose = [];
|
|
33
|
+
};
|
|
34
|
+
while (i < lines.length) {
|
|
35
|
+
const line = lines[i]!;
|
|
36
|
+
const open = OPEN.exec(line);
|
|
37
|
+
// a backtick fence's info string may not hold a backtick
|
|
38
|
+
if (!open || (open[1]![0] === '`' && open[2]!.includes('`'))) {
|
|
39
|
+
prose.push(line);
|
|
40
|
+
i++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const marker = open[1]!;
|
|
44
|
+
const lang = (open[2]!.trim().split(/\s+/)[0] ?? '').toLowerCase();
|
|
45
|
+
const body: string[] = [];
|
|
46
|
+
let closed = false;
|
|
47
|
+
i++;
|
|
48
|
+
while (i < lines.length) {
|
|
49
|
+
const l = lines[i]!;
|
|
50
|
+
const close = /^(`{3,}|~{3,})\s*$/.exec(l);
|
|
51
|
+
if (
|
|
52
|
+
close &&
|
|
53
|
+
close[1]![0] === marker[0] &&
|
|
54
|
+
close[1]!.length >= marker.length
|
|
55
|
+
) {
|
|
56
|
+
closed = true;
|
|
57
|
+
i++;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
body.push(l);
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
flush();
|
|
64
|
+
out.push({
|
|
65
|
+
type: 'fence',
|
|
66
|
+
lang,
|
|
67
|
+
text: body.join('\n'),
|
|
68
|
+
partial: !closed && streaming,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
flush();
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The files under the agent's working directory, for the composer's `@`
|
|
2
|
+
// list. A walk of the tree, kept for a few seconds so a burst of keystrokes
|
|
3
|
+
// reads it once, bounded so a home directory does not hang the list, and
|
|
4
|
+
// blind to what nobody mentions: dependencies, build output, VCS data.
|
|
5
|
+
|
|
6
|
+
import { readdir } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const SKIPPED = new Set([
|
|
10
|
+
'node_modules',
|
|
11
|
+
'.git',
|
|
12
|
+
'.hg',
|
|
13
|
+
'.svn',
|
|
14
|
+
'dist',
|
|
15
|
+
'build',
|
|
16
|
+
'out',
|
|
17
|
+
'target',
|
|
18
|
+
'coverage',
|
|
19
|
+
'.next',
|
|
20
|
+
'.turbo',
|
|
21
|
+
'.cache',
|
|
22
|
+
'.venv',
|
|
23
|
+
'venv',
|
|
24
|
+
'__pycache__',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** How many paths a walk collects before it stops. */
|
|
28
|
+
export const FILE_LIMIT = 5000;
|
|
29
|
+
const KEEP_MS = 10_000;
|
|
30
|
+
|
|
31
|
+
const walks = new Map<string, { at: number; files: Promise<string[]> }>();
|
|
32
|
+
|
|
33
|
+
/** Paths under `root`, relative and `/`-separated, files only. */
|
|
34
|
+
export function listFiles(root: string): Promise<string[]> {
|
|
35
|
+
const kept = walks.get(root);
|
|
36
|
+
if (kept && Date.now() - kept.at < KEEP_MS) return kept.files;
|
|
37
|
+
const files = walk(root).catch(() => []);
|
|
38
|
+
walks.set(root, { at: Date.now(), files });
|
|
39
|
+
return files;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function walk(root: string): Promise<string[]> {
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
// breadth first, so the files nearest the root are the ones kept
|
|
45
|
+
let level = [''];
|
|
46
|
+
while (level.length && out.length < FILE_LIMIT) {
|
|
47
|
+
const next: string[] = [];
|
|
48
|
+
for (const dir of level) {
|
|
49
|
+
let entries;
|
|
50
|
+
try {
|
|
51
|
+
entries = await readdir(path.join(root, dir), { withFileTypes: true });
|
|
52
|
+
} catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
58
|
+
if (e.isDirectory()) {
|
|
59
|
+
if (!SKIPPED.has(e.name)) next.push(rel);
|
|
60
|
+
} else if (e.isFile()) {
|
|
61
|
+
out.push(rel);
|
|
62
|
+
if (out.length >= FILE_LIMIT) break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (out.length >= FILE_LIMIT) break;
|
|
66
|
+
}
|
|
67
|
+
level = next;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The paths a query names, best first: a file whose name starts with the
|
|
74
|
+
* query, then one whose name holds it, then one whose path holds it, then
|
|
75
|
+
* one whose path holds its characters in order. Case does not matter.
|
|
76
|
+
*/
|
|
77
|
+
export function matchFiles(
|
|
78
|
+
files: readonly string[],
|
|
79
|
+
query: string,
|
|
80
|
+
max = 20,
|
|
81
|
+
): string[] {
|
|
82
|
+
const q = query.toLowerCase();
|
|
83
|
+
if (!q) return files.slice(0, max);
|
|
84
|
+
const scored: Array<[number, string]> = [];
|
|
85
|
+
for (const f of files) {
|
|
86
|
+
const p = f.toLowerCase();
|
|
87
|
+
const name = p.slice(p.lastIndexOf('/') + 1);
|
|
88
|
+
const score = name.startsWith(q)
|
|
89
|
+
? 0
|
|
90
|
+
: name.includes(q)
|
|
91
|
+
? 1
|
|
92
|
+
: p.includes(q)
|
|
93
|
+
? 2
|
|
94
|
+
: inOrder(p, q)
|
|
95
|
+
? 3
|
|
96
|
+
: -1;
|
|
97
|
+
if (score >= 0) scored.push([score * 10_000 + p.length, f]);
|
|
98
|
+
}
|
|
99
|
+
scored.sort((a, b) => a[0] - b[0]);
|
|
100
|
+
return scored.slice(0, max).map(([, f]) => f);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function inOrder(text: string, query: string): boolean {
|
|
104
|
+
let at = 0;
|
|
105
|
+
for (const ch of query) {
|
|
106
|
+
at = text.indexOf(ch, at);
|
|
107
|
+
if (at < 0) return false;
|
|
108
|
+
at++;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|