@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.
- package/dist/core/driver.d.ts +13 -0
- package/dist/core/driver.d.ts.map +1 -1
- package/dist/core/driver.js.map +1 -1
- package/dist/core/markdown.js +29 -0
- package/dist/core/markdown.js.map +1 -1
- package/dist/core/reducer.d.ts.map +1 -1
- package/dist/core/reducer.js +16 -0
- package/dist/core/reducer.js.map +1 -1
- package/dist/core/session.d.ts +3 -0
- package/dist/core/session.d.ts.map +1 -1
- package/dist/core/session.js +17 -1
- package/dist/core/session.js.map +1 -1
- package/dist/core/types.d.ts +43 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/drivers/claude/index.d.ts.map +1 -1
- package/dist/drivers/claude/index.js +56 -8
- package/dist/drivers/claude/index.js.map +1 -1
- package/dist/drivers/claude/results.d.ts +40 -0
- package/dist/drivers/claude/results.d.ts.map +1 -0
- package/dist/drivers/claude/results.js +235 -0
- package/dist/drivers/claude/results.js.map +1 -0
- package/dist/drivers/claude/tools.d.ts +3 -1
- package/dist/drivers/claude/tools.d.ts.map +1 -1
- package/dist/drivers/claude/tools.js +7 -1
- package/dist/drivers/claude/tools.js.map +1 -1
- package/dist/view/AgentChat.d.ts.map +1 -1
- package/dist/view/AgentChat.js +14 -3
- package/dist/view/AgentChat.js.map +1 -1
- package/dist/view/ArtifactCard.d.ts +6 -0
- package/dist/view/ArtifactCard.d.ts.map +1 -0
- package/dist/view/ArtifactCard.js +6 -0
- package/dist/view/ArtifactCard.js.map +1 -0
- package/dist/view/AttachmentCard.d.ts +8 -0
- package/dist/view/AttachmentCard.d.ts.map +1 -0
- package/dist/view/AttachmentCard.js +55 -0
- package/dist/view/AttachmentCard.js.map +1 -0
- package/dist/view/Composer.d.ts.map +1 -1
- package/dist/view/Composer.js +9 -1
- package/dist/view/Composer.js.map +1 -1
- package/dist/view/DataPartView.d.ts +6 -0
- package/dist/view/DataPartView.d.ts.map +1 -0
- package/dist/view/DataPartView.js +23 -0
- package/dist/view/DataPartView.js.map +1 -0
- package/dist/view/ImageView.d.ts +18 -0
- package/dist/view/ImageView.d.ts.map +1 -0
- package/dist/view/ImageView.js +35 -0
- package/dist/view/ImageView.js.map +1 -0
- package/dist/view/LinkCard.d.ts +14 -0
- package/dist/view/LinkCard.d.ts.map +1 -0
- package/dist/view/LinkCard.js +40 -0
- package/dist/view/LinkCard.js.map +1 -0
- package/dist/view/MessageList.d.ts.map +1 -1
- package/dist/view/MessageList.js +10 -10
- package/dist/view/MessageList.js.map +1 -1
- package/dist/view/PlanChecklist.d.ts +6 -0
- package/dist/view/PlanChecklist.d.ts.map +1 -0
- package/dist/view/PlanChecklist.js +16 -0
- package/dist/view/PlanChecklist.js.map +1 -0
- package/dist/view/PullRequestCard.d.ts +6 -0
- package/dist/view/PullRequestCard.d.ts.map +1 -0
- package/dist/view/PullRequestCard.js +12 -0
- package/dist/view/PullRequestCard.js.map +1 -0
- package/dist/view/SessionCard.d.ts +6 -0
- package/dist/view/SessionCard.d.ts.map +1 -0
- package/dist/view/SessionCard.js +6 -0
- package/dist/view/SessionCard.js.map +1 -0
- package/dist/view/SuggestedReplies.d.ts +5 -0
- package/dist/view/SuggestedReplies.d.ts.map +1 -0
- package/dist/view/SuggestedReplies.js +10 -0
- package/dist/view/SuggestedReplies.js.map +1 -0
- package/dist/view/ToolCard.d.ts.map +1 -1
- package/dist/view/ToolCard.js +4 -1
- package/dist/view/ToolCard.js.map +1 -1
- package/dist/view/Transcript.d.ts.map +1 -1
- package/dist/view/Transcript.js +8 -1
- package/dist/view/Transcript.js.map +1 -1
- package/dist/view/context.d.ts +9 -0
- package/dist/view/context.d.ts.map +1 -1
- package/dist/view/context.js +9 -0
- package/dist/view/context.js.map +1 -1
- package/dist/view/look.d.ts +14 -0
- package/dist/view/look.d.ts.map +1 -1
- package/dist/view/look.js +7 -0
- package/dist/view/look.js.map +1 -1
- package/package.json +1 -1
- package/src/core/driver.ts +4 -0
- package/src/core/markdown.ts +34 -0
- package/src/core/reducer.ts +25 -0
- package/src/core/session.ts +17 -1
- package/src/core/types.ts +51 -1
- package/src/drivers/claude/index.ts +62 -7
- package/src/drivers/claude/results.ts +252 -0
- package/src/drivers/claude/tools.ts +7 -1
- package/src/view/AgentChat.tsx +20 -2
- package/src/view/ArtifactCard.tsx +18 -0
- package/src/view/AttachmentCard.tsx +103 -0
- package/src/view/Composer.tsx +10 -1
- package/src/view/DataPartView.tsx +40 -0
- package/src/view/ImageView.tsx +64 -0
- package/src/view/LinkCard.tsx +99 -0
- package/src/view/MessageList.tsx +21 -39
- package/src/view/PlanChecklist.tsx +49 -0
- package/src/view/PullRequestCard.tsx +28 -0
- package/src/view/SessionCard.tsx +22 -0
- package/src/view/SuggestedReplies.tsx +34 -0
- package/src/view/ToolCard.tsx +10 -1
- package/src/view/Transcript.tsx +9 -0
- package/src/view/context.ts +19 -0
- 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
|
|
package/src/view/AgentChat.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
[
|
|
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
|
+
}
|
package/src/view/Composer.tsx
CHANGED
|
@@ -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
|
+
}
|