@windowkit/agent-chat 0.1.0 → 0.4.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 (118) hide show
  1. package/dist/core/driver.d.ts +13 -0
  2. package/dist/core/driver.d.ts.map +1 -1
  3. package/dist/core/driver.js.map +1 -1
  4. package/dist/core/markdown.js +29 -0
  5. package/dist/core/markdown.js.map +1 -1
  6. package/dist/core/reducer.d.ts.map +1 -1
  7. package/dist/core/reducer.js +16 -0
  8. package/dist/core/reducer.js.map +1 -1
  9. package/dist/core/session.d.ts +3 -0
  10. package/dist/core/session.d.ts.map +1 -1
  11. package/dist/core/session.js +17 -1
  12. package/dist/core/session.js.map +1 -1
  13. package/dist/core/types.d.ts +43 -1
  14. package/dist/core/types.d.ts.map +1 -1
  15. package/dist/core/types.js.map +1 -1
  16. package/dist/drivers/claude/index.d.ts.map +1 -1
  17. package/dist/drivers/claude/index.js +56 -8
  18. package/dist/drivers/claude/index.js.map +1 -1
  19. package/dist/drivers/claude/results.d.ts +40 -0
  20. package/dist/drivers/claude/results.d.ts.map +1 -0
  21. package/dist/drivers/claude/results.js +235 -0
  22. package/dist/drivers/claude/results.js.map +1 -0
  23. package/dist/drivers/claude/tools.d.ts +3 -1
  24. package/dist/drivers/claude/tools.d.ts.map +1 -1
  25. package/dist/drivers/claude/tools.js +7 -1
  26. package/dist/drivers/claude/tools.js.map +1 -1
  27. package/dist/view/AgentChat.d.ts.map +1 -1
  28. package/dist/view/AgentChat.js +14 -3
  29. package/dist/view/AgentChat.js.map +1 -1
  30. package/dist/view/ArtifactCard.d.ts +6 -0
  31. package/dist/view/ArtifactCard.d.ts.map +1 -0
  32. package/dist/view/ArtifactCard.js +6 -0
  33. package/dist/view/ArtifactCard.js.map +1 -0
  34. package/dist/view/AttachmentCard.d.ts +8 -0
  35. package/dist/view/AttachmentCard.d.ts.map +1 -0
  36. package/dist/view/AttachmentCard.js +55 -0
  37. package/dist/view/AttachmentCard.js.map +1 -0
  38. package/dist/view/CodeBlock.d.ts.map +1 -1
  39. package/dist/view/CodeBlock.js +18 -11
  40. package/dist/view/CodeBlock.js.map +1 -1
  41. package/dist/view/Composer.d.ts.map +1 -1
  42. package/dist/view/Composer.js +9 -1
  43. package/dist/view/Composer.js.map +1 -1
  44. package/dist/view/DataPartView.d.ts +6 -0
  45. package/dist/view/DataPartView.d.ts.map +1 -0
  46. package/dist/view/DataPartView.js +23 -0
  47. package/dist/view/DataPartView.js.map +1 -0
  48. package/dist/view/ImageView.d.ts +18 -0
  49. package/dist/view/ImageView.d.ts.map +1 -0
  50. package/dist/view/ImageView.js +35 -0
  51. package/dist/view/ImageView.js.map +1 -0
  52. package/dist/view/LinkCard.d.ts +14 -0
  53. package/dist/view/LinkCard.d.ts.map +1 -0
  54. package/dist/view/LinkCard.js +40 -0
  55. package/dist/view/LinkCard.js.map +1 -0
  56. package/dist/view/MessageList.d.ts.map +1 -1
  57. package/dist/view/MessageList.js +10 -10
  58. package/dist/view/MessageList.js.map +1 -1
  59. package/dist/view/PlanChecklist.d.ts +6 -0
  60. package/dist/view/PlanChecklist.d.ts.map +1 -0
  61. package/dist/view/PlanChecklist.js +16 -0
  62. package/dist/view/PlanChecklist.js.map +1 -0
  63. package/dist/view/PullRequestCard.d.ts +6 -0
  64. package/dist/view/PullRequestCard.d.ts.map +1 -0
  65. package/dist/view/PullRequestCard.js +12 -0
  66. package/dist/view/PullRequestCard.js.map +1 -0
  67. package/dist/view/SessionCard.d.ts +6 -0
  68. package/dist/view/SessionCard.d.ts.map +1 -0
  69. package/dist/view/SessionCard.js +6 -0
  70. package/dist/view/SessionCard.js.map +1 -0
  71. package/dist/view/SuggestedReplies.d.ts +5 -0
  72. package/dist/view/SuggestedReplies.d.ts.map +1 -0
  73. package/dist/view/SuggestedReplies.js +10 -0
  74. package/dist/view/SuggestedReplies.js.map +1 -0
  75. package/dist/view/TextBlock.d.ts.map +1 -1
  76. package/dist/view/TextBlock.js +4 -1
  77. package/dist/view/TextBlock.js.map +1 -1
  78. package/dist/view/ToolCard.d.ts.map +1 -1
  79. package/dist/view/ToolCard.js +4 -1
  80. package/dist/view/ToolCard.js.map +1 -1
  81. package/dist/view/Transcript.d.ts.map +1 -1
  82. package/dist/view/Transcript.js +18 -2
  83. package/dist/view/Transcript.js.map +1 -1
  84. package/dist/view/context.d.ts +9 -0
  85. package/dist/view/context.d.ts.map +1 -1
  86. package/dist/view/context.js +9 -0
  87. package/dist/view/context.js.map +1 -1
  88. package/dist/view/look.d.ts +21 -0
  89. package/dist/view/look.d.ts.map +1 -1
  90. package/dist/view/look.js +17 -0
  91. package/dist/view/look.js.map +1 -1
  92. package/package.json +3 -3
  93. package/src/core/driver.ts +4 -0
  94. package/src/core/markdown.ts +34 -0
  95. package/src/core/reducer.ts +25 -0
  96. package/src/core/session.ts +17 -1
  97. package/src/core/types.ts +51 -1
  98. package/src/drivers/claude/index.ts +62 -7
  99. package/src/drivers/claude/results.ts +252 -0
  100. package/src/drivers/claude/tools.ts +7 -1
  101. package/src/view/AgentChat.tsx +20 -2
  102. package/src/view/ArtifactCard.tsx +18 -0
  103. package/src/view/AttachmentCard.tsx +103 -0
  104. package/src/view/CodeBlock.tsx +18 -11
  105. package/src/view/Composer.tsx +10 -1
  106. package/src/view/DataPartView.tsx +40 -0
  107. package/src/view/ImageView.tsx +64 -0
  108. package/src/view/LinkCard.tsx +99 -0
  109. package/src/view/MessageList.tsx +21 -39
  110. package/src/view/PlanChecklist.tsx +49 -0
  111. package/src/view/PullRequestCard.tsx +28 -0
  112. package/src/view/SessionCard.tsx +22 -0
  113. package/src/view/SuggestedReplies.tsx +34 -0
  114. package/src/view/TextBlock.tsx +4 -1
  115. package/src/view/ToolCard.tsx +10 -1
  116. package/src/view/Transcript.tsx +20 -1
  117. package/src/view/context.ts +19 -0
  118. package/src/view/look.ts +38 -0
package/src/core/types.ts CHANGED
@@ -58,7 +58,14 @@ export type ToolOutput =
58
58
  | { type: 'terminal'; text: string; exitCode?: number }
59
59
  /** A file's change. `oldText` null for a new file. */
60
60
  | { type: 'diff'; path: string; oldText: string | null; newText: string }
61
- | { type: 'image'; mime: string; data: string /* base64 */ }
61
+ /** `width` and `height` in pixels, where the harness says. */
62
+ | {
63
+ type: 'image';
64
+ mime: string;
65
+ data: string /* base64 */;
66
+ width?: number;
67
+ height?: number;
68
+ }
62
69
  /** What an app tool's `run` returned, as it returned it. */
63
70
  | { type: 'value'; value: unknown };
64
71
 
@@ -161,6 +168,49 @@ export interface DataPart {
161
168
  value: unknown;
162
169
  }
163
170
 
171
+ /** `DataPart.name`s a driver of this package writes, and their values. */
172
+ export interface DataValues {
173
+ /** A pull request a command opened, edited or merged. */
174
+ 'pull-request': PullRequestData;
175
+ /** An agent session started elsewhere: a cloud session, a workflow. */
176
+ session: SessionLinkData;
177
+ /** A page the agent published. */
178
+ artifact: ArtifactData;
179
+ /** Files the agent sent the user. */
180
+ attachments: AttachmentsData;
181
+ }
182
+
183
+ export interface PullRequestData {
184
+ number: number;
185
+ url?: string;
186
+ /** `'created'`, `'edited'`, `'merged'`, or what else the harness says. */
187
+ action: string;
188
+ }
189
+
190
+ export interface SessionLinkData {
191
+ url: string;
192
+ /** What the session was started to do. */
193
+ title?: string;
194
+ }
195
+
196
+ export interface ArtifactData {
197
+ url: string;
198
+ title?: string;
199
+ /** The local file it was published from. */
200
+ path?: string;
201
+ }
202
+
203
+ export interface AttachmentsData {
204
+ caption?: string;
205
+ files: readonly {
206
+ path: string;
207
+ /** Bytes. */
208
+ size?: number;
209
+ mime?: string;
210
+ isImage: boolean;
211
+ }[];
212
+ }
213
+
164
214
  export type Part =
165
215
  | TextPart
166
216
  | ReasoningPart
@@ -36,6 +36,7 @@ import type {
36
36
  ToolCall,
37
37
  } from '../../core/types.js';
38
38
  import { InputQueue } from './input.js';
39
+ import { TASK_TOOLS, TaskFold, dataOf, imagesOf } from './results.js';
39
40
  import { incomingOf, originOf, peerEnvelope } from './peers.js';
40
41
  import { shapeFromJsonSchema, zodShapeOf } from './schema.js';
41
42
  import {
@@ -171,7 +172,11 @@ async function startClaude(
171
172
  }
172
173
 
173
174
  // --- tool calls, as the session sees them --------------------------------
174
- const seen = new Map<string, { name: string; input: unknown }>();
175
+ const seen = new Map<
176
+ string,
177
+ { name: string; input: unknown; parent?: string }
178
+ >();
179
+ const tasks = new TaskFold();
175
180
  const denied = new Set<string>();
176
181
  const hidden = new Set<string>();
177
182
 
@@ -271,6 +276,9 @@ async function startClaude(
271
276
  // already the default for the models that think; `driverOptions.thinking`
272
277
  // overrides it, for an older model or for none.
273
278
  thinking: { type: 'adaptive', display: 'summarized' },
279
+ // a reply the user might send next, after each turn: drawn as a chip
280
+ // that fills the composer
281
+ promptSuggestions: true,
274
282
  hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
275
283
  canUseTool,
276
284
  env: {
@@ -345,6 +353,11 @@ async function startClaude(
345
353
  }
346
354
  case 'result':
347
355
  return onResult(m);
356
+ case 'prompt_suggestion':
357
+ if (typeof m.suggestion === 'string') {
358
+ host.emit({ type: 'suggestion', text: m.suggestion });
359
+ }
360
+ return;
348
361
  }
349
362
  };
350
363
 
@@ -407,7 +420,11 @@ async function startClaude(
407
420
  const id = String(block.id);
408
421
  const name = String(block.name);
409
422
  blocks.set(index, { kind: 'tool', id });
410
- seen.set(id, { name, input: undefined });
423
+ seen.set(id, {
424
+ name,
425
+ input: undefined,
426
+ ...(parent ? { parent } : {}),
427
+ });
411
428
  if (HIDDEN_TOOLS.has(name)) {
412
429
  hidden.add(id);
413
430
  return;
@@ -486,7 +503,11 @@ async function startClaude(
486
503
  } else if (block.type === 'tool_use') {
487
504
  const id = String(block.id);
488
505
  const name = String(block.name);
489
- seen.set(id, { name, input: block.input });
506
+ seen.set(id, {
507
+ name,
508
+ input: block.input,
509
+ ...(parent ? { parent } : {}),
510
+ });
490
511
  if (name === 'TodoWrite') {
491
512
  hidden.add(id);
492
513
  host.emit({
@@ -519,12 +540,29 @@ async function startClaude(
519
540
  const onToolResults = (m: SdkMessage) => {
520
541
  const msg = m.message as { content?: unknown } | undefined;
521
542
  if (!Array.isArray(msg?.content)) return;
522
- for (const block of msg.content as Record<string, unknown>[]) {
523
- if (block.type !== 'tool_result') continue;
543
+ const results = (msg.content as Record<string, unknown>[]).filter(
544
+ (b) => b.type === 'tool_result',
545
+ );
546
+ // the tool's own output object, which is one per message: a message
547
+ // with several results says nothing of which it belongs to
548
+ const structured = results.length === 1 ? m.tool_use_result : undefined;
549
+ for (const block of results) {
524
550
  const id = String(block.tool_use_id);
525
- if (hidden.has(id)) continue;
526
551
  const call = seen.get(id);
527
552
  const isError = block.is_error === true;
553
+ if (
554
+ call &&
555
+ TASK_TOOLS.has(call.name) &&
556
+ !isError &&
557
+ tasks.apply(call.name, call.input, structured)
558
+ ) {
559
+ host.emit({
560
+ type: 'plan',
561
+ entries: tasks.entries(),
562
+ ...(call.parent ? { parent: call.parent } : {}),
563
+ });
564
+ }
565
+ if (hidden.has(id)) continue;
528
566
  const text = resultText(block.content);
529
567
  const name = call?.name ?? '';
530
568
  const app = isAppTool(aliases[name] ?? name);
@@ -538,8 +576,25 @@ async function startClaude(
538
576
  state: isError ? 'error' : 'done',
539
577
  ...(isError ? { error: text } : {}),
540
578
  // an app tool's outputs are the session's, from the value `run` returned
541
- ...(app ? {} : { output: outputOf(name, call?.input, text, isError) }),
579
+ ...(app
580
+ ? {}
581
+ : {
582
+ output: [
583
+ ...outputOf(name, call?.input, text, isError),
584
+ ...imagesOf(block.content, structured),
585
+ ],
586
+ }),
542
587
  });
588
+ if (isError || app) continue;
589
+ for (const d of dataOf(name, call?.input, structured)) {
590
+ host.emit({
591
+ type: 'data',
592
+ id: `${id}:${d.key}`,
593
+ name: d.name,
594
+ value: d.value,
595
+ ...(call?.parent ? { parent: call.parent } : {}),
596
+ });
597
+ }
543
598
  }
544
599
  };
545
600
 
@@ -0,0 +1,252 @@
1
+ // What Claude Code's structured tool results say beyond their text.
2
+ //
3
+ // A tool's result reaches the client twice: as the `tool_result` block the
4
+ // model reads, and as the user message's `tool_use_result`, the tool's own
5
+ // output object (typed `unknown` by the SDK; the shapes here are its
6
+ // `sdk-tools.d.ts`). The text is what a card shows. The object is where the
7
+ // rest is: the task list's changes, the pull request a command opened, the
8
+ // page the agent published, the files it sent, the pixels of an image.
9
+
10
+ import type {
11
+ ArtifactData,
12
+ AttachmentsData,
13
+ PlanEntry,
14
+ PullRequestData,
15
+ SessionLinkData,
16
+ ToolOutput,
17
+ } from '../../core/types.js';
18
+
19
+ type Obj = Record<string, unknown>;
20
+
21
+ const obj = (v: unknown): Obj | undefined =>
22
+ v && typeof v === 'object' && !Array.isArray(v) ? (v as Obj) : undefined;
23
+ const str = (v: unknown): string | undefined =>
24
+ typeof v === 'string' && v !== '' ? v : undefined;
25
+ const num = (v: unknown): number | undefined =>
26
+ typeof v === 'number' && Number.isFinite(v) ? v : undefined;
27
+
28
+ /** The tools whose calls are the plan, not cards. */
29
+ export const TASK_TOOLS: ReadonlySet<string> = new Set([
30
+ 'TaskCreate',
31
+ 'TaskUpdate',
32
+ 'TaskList',
33
+ 'TaskGet',
34
+ ]);
35
+
36
+ type Status = PlanEntry['status'];
37
+ const statusOf = (v: unknown): Status | undefined =>
38
+ v === 'pending' || v === 'in_progress' || v === 'completed' ? v : undefined;
39
+
40
+ /**
41
+ * Claude Code's task list, as the fold of every `TaskCreate` and
42
+ * `TaskUpdate` this session saw, corrected by what `TaskList` and `TaskGet`
43
+ * report. It lives as long as the session: a task made in one turn is
44
+ * updated in the next.
45
+ */
46
+ export class TaskFold {
47
+ readonly #tasks = new Map<string, { subject: string; status: Status }>();
48
+
49
+ /** Fold one finished call in. True if the list changed. */
50
+ apply(name: string, input: unknown, result: unknown): boolean {
51
+ const i = obj(input) ?? {};
52
+ const r = obj(result) ?? {};
53
+ switch (name) {
54
+ case 'TaskCreate': {
55
+ const task = obj(r.task);
56
+ const id = str(task?.id);
57
+ const subject = str(task?.subject) ?? str(i.subject);
58
+ if (!id || !subject) return false;
59
+ this.#tasks.set(id, { subject, status: 'pending' });
60
+ return true;
61
+ }
62
+ case 'TaskUpdate': {
63
+ if (r.success === false) return false;
64
+ const id = str(r.taskId) ?? str(i.taskId);
65
+ if (!id) return false;
66
+ if (i.status === 'deleted') return this.#tasks.delete(id);
67
+ const known = this.#tasks.get(id);
68
+ const subject = str(i.subject) ?? known?.subject;
69
+ // a task from before this session, named by nothing here
70
+ if (!subject) return false;
71
+ const status =
72
+ statusOf(obj(r.statusChange)?.to) ??
73
+ statusOf(i.status) ??
74
+ known?.status ??
75
+ 'pending';
76
+ if (known && known.subject === subject && known.status === status) {
77
+ return false;
78
+ }
79
+ this.#tasks.set(id, { subject, status });
80
+ return true;
81
+ }
82
+ case 'TaskList': {
83
+ if (!Array.isArray(r.tasks)) return false;
84
+ const before = JSON.stringify(this.entries());
85
+ this.#tasks.clear();
86
+ for (const t of r.tasks as unknown[]) {
87
+ const task = obj(t);
88
+ const id = str(task?.id);
89
+ const subject = str(task?.subject);
90
+ if (id && subject) {
91
+ this.#tasks.set(id, {
92
+ subject,
93
+ status: statusOf(task?.status) ?? 'pending',
94
+ });
95
+ }
96
+ }
97
+ return JSON.stringify(this.entries()) !== before;
98
+ }
99
+ case 'TaskGet': {
100
+ const task = obj(r.task);
101
+ const id = str(task?.id);
102
+ const subject = str(task?.subject);
103
+ if (!id || !subject) return false;
104
+ const status = statusOf(task?.status) ?? 'pending';
105
+ const known = this.#tasks.get(id);
106
+ if (known && known.subject === subject && known.status === status) {
107
+ return false;
108
+ }
109
+ this.#tasks.set(id, { subject, status });
110
+ return true;
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+
116
+ entries(): PlanEntry[] {
117
+ return [...this.#tasks.values()].map((t) => ({
118
+ text: t.subject,
119
+ status: t.status,
120
+ }));
121
+ }
122
+ }
123
+
124
+ /** A data part a finished call's result is worth, besides its card. */
125
+ export type ResultData =
126
+ | { name: 'pull-request'; key: string; value: PullRequestData }
127
+ | { name: 'session'; key: string; value: SessionLinkData }
128
+ | { name: 'artifact'; key: string; value: ArtifactData }
129
+ | { name: 'attachments'; key: string; value: AttachmentsData };
130
+
131
+ export function dataOf(
132
+ name: string,
133
+ input: unknown,
134
+ result: unknown,
135
+ ): ResultData[] {
136
+ const r = obj(result);
137
+ if (!r) return [];
138
+ const i = obj(input) ?? {};
139
+ switch (name) {
140
+ case 'Bash': {
141
+ const pr = obj(obj(r.gitOperation)?.pr);
142
+ const number = num(pr?.number);
143
+ if (!pr || number === undefined) return [];
144
+ const url = str(pr.url);
145
+ return [
146
+ {
147
+ name: 'pull-request',
148
+ key: 'pr',
149
+ value: {
150
+ number,
151
+ ...(url ? { url } : {}),
152
+ action: str(pr.action) ?? 'created',
153
+ },
154
+ },
155
+ ];
156
+ }
157
+ case 'Agent':
158
+ case 'Task':
159
+ case 'Workflow': {
160
+ const url = str(r.sessionUrl);
161
+ if (!url) return [];
162
+ const title = str(r.description) ?? str(i.description);
163
+ return [
164
+ {
165
+ name: 'session',
166
+ key: 'session',
167
+ value: { url, ...(title ? { title } : {}) },
168
+ },
169
+ ];
170
+ }
171
+ case 'Artifact': {
172
+ // a publish: list, read and the rest return other shapes
173
+ const url = str(r.url);
174
+ const path = str(r.path);
175
+ if (!url || !path) return [];
176
+ const title = str(r.title);
177
+ return [
178
+ {
179
+ name: 'artifact',
180
+ key: 'artifact',
181
+ value: { url, path, ...(title ? { title } : {}) },
182
+ },
183
+ ];
184
+ }
185
+ case 'SendUserFile': {
186
+ if (!Array.isArray(r.attachments)) return [];
187
+ const files = (r.attachments as unknown[]).flatMap((a) => {
188
+ const f = obj(a);
189
+ const path = str(f?.path);
190
+ if (!f || !path) return [];
191
+ const size = num(f.size);
192
+ const mime = str(f.media_type);
193
+ return [
194
+ {
195
+ path,
196
+ ...(size !== undefined ? { size } : {}),
197
+ ...(mime ? { mime } : {}),
198
+ isImage: f.isImage === true || !!mime?.startsWith('image/'),
199
+ },
200
+ ];
201
+ });
202
+ if (!files.length) return [];
203
+ const caption = str(r.caption) ?? str(i.caption);
204
+ return [
205
+ {
206
+ name: 'attachments',
207
+ key: 'files',
208
+ value: { files, ...(caption ? { caption } : {}) },
209
+ },
210
+ ];
211
+ }
212
+ }
213
+ return [];
214
+ }
215
+
216
+ /**
217
+ * The images a result carries: `image` blocks in the `tool_result` (an MCP
218
+ * tool's screenshot), or else Read's own of an image file.
219
+ */
220
+ export function imagesOf(content: unknown, result: unknown): ToolOutput[] {
221
+ const out: ToolOutput[] = [];
222
+ if (Array.isArray(content)) {
223
+ for (const c of content as unknown[]) {
224
+ const block = obj(c);
225
+ if (block?.type !== 'image') continue;
226
+ const source = obj(block.source);
227
+ const data = str(source?.data);
228
+ if (source?.type !== 'base64' || !data) continue;
229
+ out.push({
230
+ type: 'image',
231
+ mime: str(source.media_type) ?? 'image/png',
232
+ data,
233
+ });
234
+ }
235
+ }
236
+ if (out.length) return out;
237
+ const r = obj(result);
238
+ const file = obj(r?.file);
239
+ const data = str(file?.base64);
240
+ if (r?.type !== 'image' || !file || !data) return [];
241
+ const dims = obj(file.dimensions);
242
+ const width = num(dims?.displayWidth) ?? num(dims?.originalWidth);
243
+ const height = num(dims?.displayHeight) ?? num(dims?.originalHeight);
244
+ return [
245
+ {
246
+ type: 'image',
247
+ mime: str(file.type) ?? 'image/png',
248
+ data,
249
+ ...(width !== undefined && height !== undefined ? { width, height } : {}),
250
+ },
251
+ ];
252
+ }
@@ -39,9 +39,15 @@ const KINDS: Readonly<Record<string, ToolKind>> = {
39
39
  ExitPlanMode: 'think',
40
40
  };
41
41
 
42
- /** The tools this driver draws as something other than a tool card. */
42
+ /** The tools this driver draws as something other than a tool card: the
43
+ * plan (`TodoWrite`, and the task tools Claude Code plans with now) and the
44
+ * question card. */
43
45
  export const HIDDEN_TOOLS: ReadonlySet<string> = new Set([
44
46
  'TodoWrite',
47
+ 'TaskCreate',
48
+ 'TaskUpdate',
49
+ 'TaskList',
50
+ 'TaskGet',
45
51
  'AskUserQuestion',
46
52
  ]);
47
53
 
@@ -7,7 +7,13 @@
7
7
  // `onToolCall`). The top rung lifts the session out (`useAgent`, then
8
8
  // `session`) with the same options, moved rather than rewritten.
9
9
 
10
- import { useMemo, useRef, type ReactElement, type ReactNode } from 'react';
10
+ import {
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ type ReactElement,
15
+ type ReactNode,
16
+ } from 'react';
11
17
  import type { StyleProp } from 'react-x11';
12
18
 
13
19
  import {
@@ -21,6 +27,7 @@ import { Composer, type ComposerOptions } from './Composer.js';
21
27
  import {
22
28
  ChatContext,
23
29
  componentMap,
30
+ composerChannel,
24
31
  fenceMap,
25
32
  type ChatContextValue,
26
33
  } from './context.js';
@@ -126,6 +133,7 @@ function ChatView({
126
133
  const look = useLook();
127
134
  const { fences, components, renderTool, codeActions, onLink, fontSize } =
128
135
  view;
136
+ const [composer] = useState(composerChannel);
129
137
  const context = useMemo<ChatContextValue>(
130
138
  () => ({
131
139
  session,
@@ -135,8 +143,18 @@ function ChatView({
135
143
  codeActions: codeActions ?? [copyCode],
136
144
  onLink,
137
145
  fontSize,
146
+ composer,
138
147
  }),
139
- [session, fences, components, renderTool, codeActions, onLink, fontSize],
148
+ [
149
+ session,
150
+ fences,
151
+ components,
152
+ renderTool,
153
+ codeActions,
154
+ onLink,
155
+ fontSize,
156
+ composer,
157
+ ],
140
158
  );
141
159
 
142
160
  if (snap.status === 'unavailable' && view.fallback !== undefined) {
@@ -0,0 +1,18 @@
1
+ // A page the agent published: the Artifact tool's `url`, `title` and the
2
+ // file it came from.
3
+
4
+ import type { ReactElement } from 'react';
5
+
6
+ import type { ArtifactData } from '../core/types.js';
7
+ import { LinkCard, shortUrl } from './LinkCard.js';
8
+
9
+ export function ArtifactCard({ value }: { value: ArtifactData }): ReactElement {
10
+ return (
11
+ <LinkCard
12
+ kind="Published"
13
+ title={value.title ?? value.path ?? shortUrl(value.url)}
14
+ detail={[value.path, shortUrl(value.url)].filter(Boolean).join(' · ')}
15
+ href={value.url}
16
+ />
17
+ );
18
+ }
@@ -0,0 +1,103 @@
1
+ // Files the agent sent the user (`SendUserFile`): an image is drawn, read
2
+ // from where the agent left it; any other file is a row with its size and,
3
+ // where the app takes links, an Open button with its `file:` URL.
4
+
5
+ import path from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import type { ReactElement } from 'react';
8
+ import { Button } from 'react-x11';
9
+
10
+ import type { AttachmentsData } from '../core/types.js';
11
+ import { useChat } from './context.js';
12
+ import { ImageView } from './ImageView.js';
13
+ import { useLook } from './look.js';
14
+ import { useSessionSnapshot } from './use-agent.js';
15
+
16
+ export function AttachmentCard({
17
+ value,
18
+ }: {
19
+ value: AttachmentsData;
20
+ }): ReactElement {
21
+ const { session, onLink } = useChat();
22
+ const cwd = useSessionSnapshot(session).info.cwd;
23
+ const look = useLook();
24
+ return (
25
+ <box
26
+ style={{
27
+ flexDirection: 'column',
28
+ gap: look.gap,
29
+ padding: look.inset,
30
+ borderWidth: 1,
31
+ borderColor: look.border,
32
+ borderRadius: look.radius,
33
+ }}
34
+ aria-label="Attachments"
35
+ >
36
+ {value.caption ? (
37
+ <text style={{ color: look.text }}>{value.caption}</text>
38
+ ) : null}
39
+ {value.files.map((f, i) => {
40
+ const name = path.basename(f.path);
41
+ if (f.isImage) {
42
+ return (
43
+ <box key={i} style={{ flexDirection: 'column', gap: 2 }}>
44
+ <ImageView
45
+ source={cwd ? { path: f.path, cwd } : { path: f.path }}
46
+ alt={name}
47
+ />
48
+ <text style={{ color: look.muted, fontSize: look.smallSize }}>
49
+ {[name, sizeOf(f.size)].filter(Boolean).join(' · ')}
50
+ </text>
51
+ </box>
52
+ );
53
+ }
54
+ const full = cwd ? path.resolve(cwd, f.path) : f.path;
55
+ return (
56
+ <box
57
+ key={i}
58
+ style={{
59
+ flexDirection: 'row',
60
+ alignItems: 'center',
61
+ gap: look.gap,
62
+ }}
63
+ >
64
+ <text style={{ color: look.text, flexShrink: 1, maxLines: 1 }}>
65
+ {name}
66
+ </text>
67
+ <text
68
+ style={{
69
+ color: look.muted,
70
+ fontSize: look.smallSize,
71
+ flexGrow: 1,
72
+ }}
73
+ >
74
+ {sizeOf(f.size)}
75
+ </text>
76
+ {onLink && path.isAbsolute(full) ? (
77
+ <Button
78
+ size="small"
79
+ variant="ghost"
80
+ label="Open"
81
+ onPress={() => onLink(pathToFileURL(full).href)}
82
+ />
83
+ ) : null}
84
+ </box>
85
+ );
86
+ })}
87
+ </box>
88
+ );
89
+ }
90
+
91
+ /** `2 KB`, `1.4 MB`. */
92
+ export function sizeOf(bytes: number | undefined): string {
93
+ if (bytes === undefined) return '';
94
+ if (bytes < 1024) return `${bytes} B`;
95
+ const units = ['KB', 'MB', 'GB'];
96
+ let n = bytes / 1024;
97
+ let u = 0;
98
+ while (n >= 1024 && u < units.length - 1) {
99
+ n /= 1024;
100
+ u++;
101
+ }
102
+ return `${n < 10 ? n.toFixed(1).replace(/\.0$/, '') : Math.round(n)} ${units[u]}`;
103
+ }