@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.
Files changed (164) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -2
  3. package/dist/core/driver.d.ts +183 -0
  4. package/dist/core/driver.d.ts.map +1 -0
  5. package/dist/core/driver.js +12 -0
  6. package/dist/core/driver.js.map +1 -0
  7. package/dist/core/index.d.ts +8 -0
  8. package/dist/core/index.d.ts.map +1 -0
  9. package/dist/core/index.js +11 -0
  10. package/dist/core/index.js.map +1 -0
  11. package/dist/core/markdown.d.ts +7 -0
  12. package/dist/core/markdown.d.ts.map +1 -0
  13. package/dist/core/markdown.js +53 -0
  14. package/dist/core/markdown.js.map +1 -0
  15. package/dist/core/policy.d.ts +18 -0
  16. package/dist/core/policy.d.ts.map +1 -0
  17. package/dist/core/policy.js +67 -0
  18. package/dist/core/policy.js.map +1 -0
  19. package/dist/core/reducer.d.ts +61 -0
  20. package/dist/core/reducer.d.ts.map +1 -0
  21. package/dist/core/reducer.js +391 -0
  22. package/dist/core/reducer.js.map +1 -0
  23. package/dist/core/session.d.ts +105 -0
  24. package/dist/core/session.d.ts.map +1 -0
  25. package/dist/core/session.js +648 -0
  26. package/dist/core/session.js.map +1 -0
  27. package/dist/core/tools.d.ts +120 -0
  28. package/dist/core/tools.d.ts.map +1 -0
  29. package/dist/core/tools.js +120 -0
  30. package/dist/core/tools.js.map +1 -0
  31. package/dist/core/types.d.ts +295 -0
  32. package/dist/core/types.d.ts.map +1 -0
  33. package/dist/core/types.js +21 -0
  34. package/dist/core/types.js.map +1 -0
  35. package/dist/drivers/claude/index.d.ts +20 -0
  36. package/dist/drivers/claude/index.d.ts.map +1 -0
  37. package/dist/drivers/claude/index.js +727 -0
  38. package/dist/drivers/claude/index.js.map +1 -0
  39. package/dist/drivers/claude/input.d.ts +7 -0
  40. package/dist/drivers/claude/input.d.ts.map +1 -0
  41. package/dist/drivers/claude/input.js +46 -0
  42. package/dist/drivers/claude/input.js.map +1 -0
  43. package/dist/drivers/claude/peers.d.ts +16 -0
  44. package/dist/drivers/claude/peers.d.ts.map +1 -0
  45. package/dist/drivers/claude/peers.js +137 -0
  46. package/dist/drivers/claude/peers.js.map +1 -0
  47. package/dist/drivers/claude/schema.d.ts +5 -0
  48. package/dist/drivers/claude/schema.d.ts.map +1 -0
  49. package/dist/drivers/claude/schema.js +77 -0
  50. package/dist/drivers/claude/schema.js.map +1 -0
  51. package/dist/drivers/claude/sdk.d.ts +86 -0
  52. package/dist/drivers/claude/sdk.d.ts.map +1 -0
  53. package/dist/drivers/claude/sdk.js +47 -0
  54. package/dist/drivers/claude/sdk.js.map +1 -0
  55. package/dist/drivers/claude/tools.d.ts +18 -0
  56. package/dist/drivers/claude/tools.d.ts.map +1 -0
  57. package/dist/drivers/claude/tools.js +119 -0
  58. package/dist/drivers/claude/tools.js.map +1 -0
  59. package/dist/index.d.ts +8 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +12 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/testing/fake.d.ts +63 -0
  64. package/dist/testing/fake.d.ts.map +1 -0
  65. package/dist/testing/fake.js +270 -0
  66. package/dist/testing/fake.js.map +1 -0
  67. package/dist/testing/index.d.ts +2 -0
  68. package/dist/testing/index.d.ts.map +1 -0
  69. package/dist/testing/index.js +4 -0
  70. package/dist/testing/index.js.map +1 -0
  71. package/dist/view/AgentChat.d.ts +53 -0
  72. package/dist/view/AgentChat.d.ts.map +1 -0
  73. package/dist/view/AgentChat.js +87 -0
  74. package/dist/view/AgentChat.js.map +1 -0
  75. package/dist/view/CodeBlock.d.ts +9 -0
  76. package/dist/view/CodeBlock.d.ts.map +1 -0
  77. package/dist/view/CodeBlock.js +60 -0
  78. package/dist/view/CodeBlock.js.map +1 -0
  79. package/dist/view/Composer.d.ts +24 -0
  80. package/dist/view/Composer.d.ts.map +1 -0
  81. package/dist/view/Composer.js +144 -0
  82. package/dist/view/Composer.js.map +1 -0
  83. package/dist/view/Header.d.ts +3 -0
  84. package/dist/view/Header.d.ts.map +1 -0
  85. package/dist/view/Header.js +30 -0
  86. package/dist/view/Header.js.map +1 -0
  87. package/dist/view/MessageList.d.ts +9 -0
  88. package/dist/view/MessageList.d.ts.map +1 -0
  89. package/dist/view/MessageList.js +111 -0
  90. package/dist/view/MessageList.js.map +1 -0
  91. package/dist/view/QuestionCard.d.ts +6 -0
  92. package/dist/view/QuestionCard.d.ts.map +1 -0
  93. package/dist/view/QuestionCard.js +39 -0
  94. package/dist/view/QuestionCard.js.map +1 -0
  95. package/dist/view/TextBlock.d.ts +9 -0
  96. package/dist/view/TextBlock.d.ts.map +1 -0
  97. package/dist/view/TextBlock.js +22 -0
  98. package/dist/view/TextBlock.js.map +1 -0
  99. package/dist/view/ToolCard.d.ts +7 -0
  100. package/dist/view/ToolCard.d.ts.map +1 -0
  101. package/dist/view/ToolCard.js +155 -0
  102. package/dist/view/ToolCard.js.map +1 -0
  103. package/dist/view/Transcript.d.ts +5 -0
  104. package/dist/view/Transcript.d.ts.map +1 -0
  105. package/dist/view/Transcript.js +47 -0
  106. package/dist/view/Transcript.js.map +1 -0
  107. package/dist/view/context.d.ts +21 -0
  108. package/dist/view/context.d.ts.map +1 -0
  109. package/dist/view/context.js +37 -0
  110. package/dist/view/context.js.map +1 -0
  111. package/dist/view/fences.d.ts +13 -0
  112. package/dist/view/fences.d.ts.map +1 -0
  113. package/dist/view/fences.js +59 -0
  114. package/dist/view/fences.js.map +1 -0
  115. package/dist/view/files.d.ts +11 -0
  116. package/dist/view/files.d.ts.map +1 -0
  117. package/dist/view/files.js +109 -0
  118. package/dist/view/files.js.map +1 -0
  119. package/dist/view/look.d.ts +18 -0
  120. package/dist/view/look.d.ts.map +1 -0
  121. package/dist/view/look.js +37 -0
  122. package/dist/view/look.js.map +1 -0
  123. package/dist/view/types.d.ts +50 -0
  124. package/dist/view/types.d.ts.map +1 -0
  125. package/dist/view/types.js +14 -0
  126. package/dist/view/types.js.map +1 -0
  127. package/dist/view/use-agent.d.ts +11 -0
  128. package/dist/view/use-agent.d.ts.map +1 -0
  129. package/dist/view/use-agent.js +33 -0
  130. package/dist/view/use-agent.js.map +1 -0
  131. package/package.json +92 -4
  132. package/src/core/driver.ts +191 -0
  133. package/src/core/index.ts +54 -0
  134. package/src/core/markdown.ts +56 -0
  135. package/src/core/policy.ts +77 -0
  136. package/src/core/reducer.ts +490 -0
  137. package/src/core/session.ts +824 -0
  138. package/src/core/tools.ts +233 -0
  139. package/src/core/types.ts +321 -0
  140. package/src/drivers/claude/index.ts +825 -0
  141. package/src/drivers/claude/input.ts +47 -0
  142. package/src/drivers/claude/peers.ts +153 -0
  143. package/src/drivers/claude/schema.ts +99 -0
  144. package/src/drivers/claude/sdk.ts +138 -0
  145. package/src/drivers/claude/tools.ts +141 -0
  146. package/src/index.ts +51 -0
  147. package/src/testing/fake.ts +374 -0
  148. package/src/testing/index.ts +11 -0
  149. package/src/view/AgentChat.tsx +204 -0
  150. package/src/view/CodeBlock.tsx +108 -0
  151. package/src/view/Composer.tsx +230 -0
  152. package/src/view/Header.tsx +73 -0
  153. package/src/view/MessageList.tsx +290 -0
  154. package/src/view/QuestionCard.tsx +107 -0
  155. package/src/view/TextBlock.tsx +58 -0
  156. package/src/view/ToolCard.tsx +283 -0
  157. package/src/view/Transcript.tsx +81 -0
  158. package/src/view/context.ts +70 -0
  159. package/src/view/dom-globals.d.ts +10 -0
  160. package/src/view/fences.ts +73 -0
  161. package/src/view/files.ts +111 -0
  162. package/src/view/look.ts +53 -0
  163. package/src/view/types.ts +71 -0
  164. 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
+ }