@windowkit/agent-chat 0.3.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 (110) 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/Composer.d.ts.map +1 -1
  39. package/dist/view/Composer.js +9 -1
  40. package/dist/view/Composer.js.map +1 -1
  41. package/dist/view/DataPartView.d.ts +6 -0
  42. package/dist/view/DataPartView.d.ts.map +1 -0
  43. package/dist/view/DataPartView.js +23 -0
  44. package/dist/view/DataPartView.js.map +1 -0
  45. package/dist/view/ImageView.d.ts +18 -0
  46. package/dist/view/ImageView.d.ts.map +1 -0
  47. package/dist/view/ImageView.js +35 -0
  48. package/dist/view/ImageView.js.map +1 -0
  49. package/dist/view/LinkCard.d.ts +14 -0
  50. package/dist/view/LinkCard.d.ts.map +1 -0
  51. package/dist/view/LinkCard.js +40 -0
  52. package/dist/view/LinkCard.js.map +1 -0
  53. package/dist/view/MessageList.d.ts.map +1 -1
  54. package/dist/view/MessageList.js +10 -10
  55. package/dist/view/MessageList.js.map +1 -1
  56. package/dist/view/PlanChecklist.d.ts +6 -0
  57. package/dist/view/PlanChecklist.d.ts.map +1 -0
  58. package/dist/view/PlanChecklist.js +16 -0
  59. package/dist/view/PlanChecklist.js.map +1 -0
  60. package/dist/view/PullRequestCard.d.ts +6 -0
  61. package/dist/view/PullRequestCard.d.ts.map +1 -0
  62. package/dist/view/PullRequestCard.js +12 -0
  63. package/dist/view/PullRequestCard.js.map +1 -0
  64. package/dist/view/SessionCard.d.ts +6 -0
  65. package/dist/view/SessionCard.d.ts.map +1 -0
  66. package/dist/view/SessionCard.js +6 -0
  67. package/dist/view/SessionCard.js.map +1 -0
  68. package/dist/view/SuggestedReplies.d.ts +5 -0
  69. package/dist/view/SuggestedReplies.d.ts.map +1 -0
  70. package/dist/view/SuggestedReplies.js +10 -0
  71. package/dist/view/SuggestedReplies.js.map +1 -0
  72. package/dist/view/ToolCard.d.ts.map +1 -1
  73. package/dist/view/ToolCard.js +4 -1
  74. package/dist/view/ToolCard.js.map +1 -1
  75. package/dist/view/Transcript.d.ts.map +1 -1
  76. package/dist/view/Transcript.js +8 -1
  77. package/dist/view/Transcript.js.map +1 -1
  78. package/dist/view/context.d.ts +9 -0
  79. package/dist/view/context.d.ts.map +1 -1
  80. package/dist/view/context.js +9 -0
  81. package/dist/view/context.js.map +1 -1
  82. package/dist/view/look.d.ts +14 -0
  83. package/dist/view/look.d.ts.map +1 -1
  84. package/dist/view/look.js +7 -0
  85. package/dist/view/look.js.map +1 -1
  86. package/package.json +1 -1
  87. package/src/core/driver.ts +4 -0
  88. package/src/core/markdown.ts +34 -0
  89. package/src/core/reducer.ts +25 -0
  90. package/src/core/session.ts +17 -1
  91. package/src/core/types.ts +51 -1
  92. package/src/drivers/claude/index.ts +62 -7
  93. package/src/drivers/claude/results.ts +252 -0
  94. package/src/drivers/claude/tools.ts +7 -1
  95. package/src/view/AgentChat.tsx +20 -2
  96. package/src/view/ArtifactCard.tsx +18 -0
  97. package/src/view/AttachmentCard.tsx +103 -0
  98. package/src/view/Composer.tsx +10 -1
  99. package/src/view/DataPartView.tsx +40 -0
  100. package/src/view/ImageView.tsx +64 -0
  101. package/src/view/LinkCard.tsx +99 -0
  102. package/src/view/MessageList.tsx +21 -39
  103. package/src/view/PlanChecklist.tsx +49 -0
  104. package/src/view/PullRequestCard.tsx +28 -0
  105. package/src/view/SessionCard.tsx +22 -0
  106. package/src/view/SuggestedReplies.tsx +34 -0
  107. package/src/view/ToolCard.tsx +10 -1
  108. package/src/view/Transcript.tsx +9 -0
  109. package/src/view/context.ts +19 -0
  110. package/src/view/look.ts +21 -0
@@ -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
+ }
@@ -59,11 +59,20 @@ export function Composer({
59
59
  autoFocus,
60
60
  options = {},
61
61
  }: ComposerProps): ReactElement {
62
- const { session } = useChat();
62
+ const { session, composer } = useChat();
63
63
  const snap = useSessionSnapshot(session);
64
64
  const look = useLook();
65
65
  const editor = useRef<RichTextEditorHandle | null>(null);
66
66
 
67
+ // a suggested reply, put in the box to edit or send
68
+ useEffect(() => {
69
+ composer.attach((text) => {
70
+ editor.current?.setValue(text);
71
+ editor.current?.focus();
72
+ });
73
+ return () => composer.attach(null);
74
+ }, [composer]);
75
+
67
76
  const busy =
68
77
  snap.status === 'running' ||
69
78
  snap.status === 'waiting' ||
@@ -0,0 +1,40 @@
1
+ // A data part, drawn by its name. The names are the ones this package's
2
+ // drivers write (`DataValues`); a part named anything else, or with a value
3
+ // that is not the shape its name says, draws nothing.
4
+
5
+ import type { ReactElement } from 'react';
6
+
7
+ import type { DataPart, DataValues } from '../core/types.js';
8
+ import { ArtifactCard } from './ArtifactCard.js';
9
+ import { AttachmentCard } from './AttachmentCard.js';
10
+ import { PullRequestCard } from './PullRequestCard.js';
11
+ import { SessionCard } from './SessionCard.js';
12
+
13
+ export function DataPartView({
14
+ part,
15
+ }: {
16
+ part: DataPart;
17
+ }): ReactElement | null {
18
+ const v = part.value as Record<string, unknown> | null | undefined;
19
+ if (!v || typeof v !== 'object') return null;
20
+ switch (part.name) {
21
+ case 'pull-request':
22
+ return typeof v.number === 'number' && typeof v.action === 'string' ? (
23
+ <PullRequestCard value={v as unknown as DataValues['pull-request']} />
24
+ ) : null;
25
+ case 'session':
26
+ return typeof v.url === 'string' ? (
27
+ <SessionCard value={v as unknown as DataValues['session']} />
28
+ ) : null;
29
+ case 'artifact':
30
+ return typeof v.url === 'string' ? (
31
+ <ArtifactCard value={v as unknown as DataValues['artifact']} />
32
+ ) : null;
33
+ case 'attachments':
34
+ return Array.isArray(v.files) ? (
35
+ <AttachmentCard value={v as unknown as DataValues['attachments']} />
36
+ ) : null;
37
+ default:
38
+ return null;
39
+ }
40
+ }
@@ -0,0 +1,64 @@
1
+ // An image the agent read, a tool returned, or the user pasted: base64
2
+ // bytes, or a file on this machine. No taller than the look says, never
3
+ // wider than the space on offer, and in its own proportions.
4
+ //
5
+ // A format this runtime cannot decode (GIF under Node) draws nothing, as a
6
+ // missing file does; `alt` is still what a screen reader says.
7
+
8
+ import path from 'node:path';
9
+ import { useMemo, type ReactElement } from 'react';
10
+
11
+ import { useLook } from './look.js';
12
+
13
+ export interface ImageViewProps {
14
+ /** base64 bytes, or a path, relative to `cwd` unless absolute. */
15
+ source: { data: string } | { path: string; cwd?: string };
16
+ /** Pixels, where known: drawn at that size, scaled down to fit. */
17
+ width?: number;
18
+ height?: number;
19
+ /** `look.imageHeight` unless said. */
20
+ maxHeight?: number;
21
+ alt: string;
22
+ }
23
+
24
+ export function ImageView({
25
+ source,
26
+ width,
27
+ height,
28
+ maxHeight,
29
+ alt,
30
+ }: ImageViewProps): ReactElement {
31
+ const look = useLook();
32
+ const limit = maxHeight ?? look.imageHeight;
33
+ const data = 'data' in source ? source.data : undefined;
34
+ const file =
35
+ 'path' in source
36
+ ? source.cwd
37
+ ? path.resolve(source.cwd, source.path)
38
+ : source.path
39
+ : undefined;
40
+ const src = useMemo(
41
+ () => (data !== undefined ? Buffer.from(data, 'base64') : file!),
42
+ [data, file],
43
+ );
44
+ const size =
45
+ width && height
46
+ ? (() => {
47
+ const scale = Math.min(1, limit / height);
48
+ return { width: width * scale, height: height * scale };
49
+ })()
50
+ : { maxHeight: limit };
51
+ return (
52
+ <image
53
+ src={src}
54
+ alt={alt}
55
+ style={{
56
+ ...size,
57
+ maxWidth: '100%',
58
+ objectFit: 'contain',
59
+ alignSelf: 'flex-start',
60
+ borderRadius: look.radius,
61
+ }}
62
+ />
63
+ );
64
+ }
@@ -0,0 +1,99 @@
1
+ // The frame every link card shares: what the link is, its title, where it
2
+ // goes, and an Open button that hands the address to the app's `onLink`.
3
+ // Without `onLink` there is no button — nothing here navigates by itself —
4
+ // and the address is still there to read.
5
+
6
+ import type { ReactElement } from 'react';
7
+ import { Button } from 'react-x11';
8
+
9
+ import { useChat } from './context.js';
10
+ import { useLook } from './look.js';
11
+
12
+ export interface LinkCardProps {
13
+ /** What kind of link: `Pull request`, `Session`, `Artifact`. */
14
+ kind: string;
15
+ title: string;
16
+ /** A line under the title: what happened, where it is. */
17
+ detail?: string;
18
+ /** Where Open goes. Absent: no button. */
19
+ href?: string;
20
+ }
21
+
22
+ export function LinkCard({
23
+ kind,
24
+ title,
25
+ detail,
26
+ href,
27
+ }: LinkCardProps): ReactElement {
28
+ const { onLink } = useChat();
29
+ const look = useLook();
30
+ return (
31
+ <box
32
+ style={{
33
+ flexDirection: 'row',
34
+ alignItems: 'center',
35
+ gap: look.inset,
36
+ padding: look.inset,
37
+ borderWidth: 1,
38
+ borderColor: look.border,
39
+ borderRadius: look.radius,
40
+ }}
41
+ aria-label={`${kind}: ${title}`}
42
+ >
43
+ {/* the accent edge: a stripe, since a border of one side is square */}
44
+ <box
45
+ style={{
46
+ width: 3,
47
+ alignSelf: 'stretch',
48
+ borderRadius: 2,
49
+ backgroundColor: look.accent,
50
+ }}
51
+ />
52
+ <box
53
+ style={{
54
+ flexDirection: 'column',
55
+ gap: 2,
56
+ flexGrow: 1,
57
+ flexShrink: 1,
58
+ }}
59
+ >
60
+ <text
61
+ style={{
62
+ color: look.muted,
63
+ fontSize: look.smallSize,
64
+ fontWeight: 'bold',
65
+ }}
66
+ >
67
+ {kind.toUpperCase()}
68
+ </text>
69
+ <text style={{ color: look.text, fontWeight: 'bold', maxLines: 2 }}>
70
+ {title}
71
+ </text>
72
+ {detail ? (
73
+ <text
74
+ style={{
75
+ color: look.muted,
76
+ fontSize: look.captionSize,
77
+ maxLines: 1,
78
+ }}
79
+ >
80
+ {detail}
81
+ </text>
82
+ ) : null}
83
+ </box>
84
+ {href && onLink ? (
85
+ <Button
86
+ size="small"
87
+ variant="outline"
88
+ label="Open"
89
+ onPress={() => onLink(href)}
90
+ />
91
+ ) : null}
92
+ </box>
93
+ );
94
+ }
95
+
96
+ /** `github.com/o/r/pull/7` for `https://github.com/o/r/pull/7`. */
97
+ export function shortUrl(href: string): string {
98
+ return href.replace(/^[a-z]+:\/\//i, '').replace(/\/$/, '');
99
+ }