@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.
- 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/CodeBlock.d.ts.map +1 -1
- package/dist/view/CodeBlock.js +18 -11
- package/dist/view/CodeBlock.js.map +1 -1
- 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/TextBlock.d.ts.map +1 -1
- package/dist/view/TextBlock.js +4 -1
- package/dist/view/TextBlock.js.map +1 -1
- 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 +18 -2
- 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 +21 -0
- package/dist/view/look.d.ts.map +1 -1
- package/dist/view/look.js +17 -0
- package/dist/view/look.js.map +1 -1
- package/package.json +3 -3
- 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/CodeBlock.tsx +18 -11
- 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/TextBlock.tsx +4 -1
- package/src/view/ToolCard.tsx +10 -1
- package/src/view/Transcript.tsx +20 -1
- package/src/view/context.ts +19 -0
- package/src/view/look.ts +38 -0
package/src/view/CodeBlock.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// A fenced code block with a header of actions: Copy by default, and
|
|
2
2
|
// whatever the app's `codeActions` add. Nothing here runs code; an action
|
|
3
|
-
// that does is the app's.
|
|
3
|
+
// that does is the app's. It is drawn as `<Markdown>` draws a fence, with
|
|
4
|
+
// the header inside the block's fill.
|
|
4
5
|
|
|
5
6
|
import { useEffect, useRef, useState, type ReactElement } from 'react';
|
|
6
7
|
import { Button, useClipboard } from 'react-x11';
|
|
@@ -9,7 +10,7 @@ import type { FenceInfo } from '@react-x11/components/markdown';
|
|
|
9
10
|
|
|
10
11
|
import type { AgentMessage } from '../core/types.js';
|
|
11
12
|
import { useChat } from './context.js';
|
|
12
|
-
import { useLook } from './look.js';
|
|
13
|
+
import { useCodeLook, useLook } from './look.js';
|
|
13
14
|
import type { CodeAction, CodeActionContext } from './types.js';
|
|
14
15
|
|
|
15
16
|
export interface CodeBlockProps {
|
|
@@ -18,8 +19,9 @@ export interface CodeBlockProps {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export function CodeBlock({ fence, message }: CodeBlockProps): ReactElement {
|
|
21
|
-
const { codeActions, session } = useChat();
|
|
22
|
+
const { codeActions, session, fontSize } = useChat();
|
|
22
23
|
const look = useLook();
|
|
24
|
+
const code = useCodeLook(fontSize);
|
|
23
25
|
const clipboard = useClipboard();
|
|
24
26
|
const [done, setDone] = useState<string | null>(null);
|
|
25
27
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -62,9 +64,8 @@ export function CodeBlock({ fence, message }: CodeBlockProps): ReactElement {
|
|
|
62
64
|
<box
|
|
63
65
|
style={{
|
|
64
66
|
flexDirection: 'column',
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
borderRadius: 6,
|
|
67
|
+
backgroundColor: code.fill,
|
|
68
|
+
borderRadius: code.radius,
|
|
68
69
|
overflow: 'hidden',
|
|
69
70
|
}}
|
|
70
71
|
>
|
|
@@ -73,11 +74,10 @@ export function CodeBlock({ fence, message }: CodeBlockProps): ReactElement {
|
|
|
73
74
|
flexDirection: 'row',
|
|
74
75
|
alignItems: 'center',
|
|
75
76
|
gap: 4,
|
|
76
|
-
paddingLeft:
|
|
77
|
-
paddingRight:
|
|
77
|
+
paddingLeft: code.padding,
|
|
78
|
+
paddingRight: Math.round(code.padding / 3),
|
|
79
|
+
paddingTop: Math.round(code.padding / 3),
|
|
78
80
|
minHeight: 26,
|
|
79
|
-
borderBottomWidth: 1,
|
|
80
|
-
borderBottomColor: look.border,
|
|
81
81
|
}}
|
|
82
82
|
>
|
|
83
83
|
<text style={{ color: look.muted, fontSize: 11, flexGrow: 1 }}>
|
|
@@ -100,8 +100,15 @@ export function CodeBlock({ fence, message }: CodeBlockProps): ReactElement {
|
|
|
100
100
|
<Code
|
|
101
101
|
source={fence.text}
|
|
102
102
|
{...(fence.lang ? { lang: fence.lang } : {})}
|
|
103
|
+
fontSize={code.size}
|
|
103
104
|
wrap
|
|
104
|
-
|
|
105
|
+
// the frame holds the fill: the code's own, a tint, would lay a
|
|
106
|
+
// second over it
|
|
107
|
+
style={{
|
|
108
|
+
backgroundColor: 'transparent',
|
|
109
|
+
borderRadius: 0,
|
|
110
|
+
paddingTop: Math.round(code.padding / 3),
|
|
111
|
+
}}
|
|
105
112
|
/>
|
|
106
113
|
</box>
|
|
107
114
|
);
|
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
|
+
}
|
package/src/view/MessageList.tsx
CHANGED
|
@@ -6,8 +6,11 @@ import { memo, useState, type ReactElement } from 'react';
|
|
|
6
6
|
import { Button, Icon, useClipboard } from 'react-x11';
|
|
7
7
|
|
|
8
8
|
import { messageMarkdown, sourceLabel } from '../core/markdown.js';
|
|
9
|
-
import type { AgentMessage,
|
|
9
|
+
import type { AgentMessage, FilePart, Part } from '../core/types.js';
|
|
10
|
+
import { DataPartView } from './DataPartView.js';
|
|
11
|
+
import { ImageView } from './ImageView.js';
|
|
10
12
|
import { useLook, type ChatLook } from './look.js';
|
|
13
|
+
import { PlanChecklist } from './PlanChecklist.js';
|
|
11
14
|
import { QuestionCard } from './QuestionCard.js';
|
|
12
15
|
import { TextBlock } from './TextBlock.js';
|
|
13
16
|
import { ToolCard } from './ToolCard.js';
|
|
@@ -89,6 +92,14 @@ function UserMessage({
|
|
|
89
92
|
streaming={false}
|
|
90
93
|
message={message}
|
|
91
94
|
/>
|
|
95
|
+
) : p.type === 'file' && isImage(p) ? (
|
|
96
|
+
// a pasted screenshot is the picture, not its token
|
|
97
|
+
<ImageView
|
|
98
|
+
key={p.id}
|
|
99
|
+
source={{ data: p.data! }}
|
|
100
|
+
maxHeight={look.thumbHeight}
|
|
101
|
+
alt={p.label}
|
|
102
|
+
/>
|
|
92
103
|
) : p.type === 'file' && !textOf(message).includes(`[${p.label}]`) ? (
|
|
93
104
|
// an attachment the composer made is already in the text as its
|
|
94
105
|
// token; one sent some other way gets a line of its own
|
|
@@ -157,7 +168,7 @@ function PartView({
|
|
|
157
168
|
case 'question':
|
|
158
169
|
return <QuestionCard part={part} />;
|
|
159
170
|
case 'plan':
|
|
160
|
-
return <
|
|
171
|
+
return <PlanChecklist part={part} />;
|
|
161
172
|
case 'notice':
|
|
162
173
|
return (
|
|
163
174
|
<text
|
|
@@ -175,13 +186,15 @@ function PartView({
|
|
|
175
186
|
</text>
|
|
176
187
|
);
|
|
177
188
|
case 'file':
|
|
178
|
-
return (
|
|
189
|
+
return isImage(part) ? (
|
|
190
|
+
<ImageView source={{ data: part.data! }} alt={part.label} />
|
|
191
|
+
) : (
|
|
179
192
|
<text
|
|
180
193
|
style={{ color: look.muted, fontSize: 11 }}
|
|
181
194
|
>{`[${part.label}]`}</text>
|
|
182
195
|
);
|
|
183
196
|
case 'data':
|
|
184
|
-
return
|
|
197
|
+
return <DataPartView part={part} />;
|
|
185
198
|
}
|
|
186
199
|
}
|
|
187
200
|
|
|
@@ -232,41 +245,6 @@ function Reasoning({
|
|
|
232
245
|
);
|
|
233
246
|
}
|
|
234
247
|
|
|
235
|
-
function Plan({
|
|
236
|
-
part,
|
|
237
|
-
look,
|
|
238
|
-
}: {
|
|
239
|
-
part: PlanPart;
|
|
240
|
-
look: ChatLook;
|
|
241
|
-
}): ReactElement {
|
|
242
|
-
return (
|
|
243
|
-
<box style={{ flexDirection: 'column', gap: 2 }}>
|
|
244
|
-
{part.entries.map((e, i) => (
|
|
245
|
-
<box
|
|
246
|
-
key={i}
|
|
247
|
-
style={{ flexDirection: 'row', gap: 6, alignItems: 'center' }}
|
|
248
|
-
>
|
|
249
|
-
{e.status === 'completed' ? (
|
|
250
|
-
<Icon name="check" size={12} color={look.success} />
|
|
251
|
-
) : e.status === 'in_progress' ? (
|
|
252
|
-
<Icon name="dot" size={12} color={look.accent} />
|
|
253
|
-
) : (
|
|
254
|
-
<Icon name="dash" size={12} color={look.muted} />
|
|
255
|
-
)}
|
|
256
|
-
<text
|
|
257
|
-
style={{
|
|
258
|
-
color: e.status === 'completed' ? look.muted : look.text,
|
|
259
|
-
fontWeight: e.status === 'in_progress' ? 'bold' : 'normal',
|
|
260
|
-
}}
|
|
261
|
-
>
|
|
262
|
-
{e.text}
|
|
263
|
-
</text>
|
|
264
|
-
</box>
|
|
265
|
-
))}
|
|
266
|
-
</box>
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
248
|
function CopyButton({ message }: { message: AgentMessage }): ReactElement {
|
|
271
249
|
const clipboard = useClipboard();
|
|
272
250
|
const [copied, setCopied] = useState(false);
|
|
@@ -285,6 +263,10 @@ function CopyButton({ message }: { message: AgentMessage }): ReactElement {
|
|
|
285
263
|
);
|
|
286
264
|
}
|
|
287
265
|
|
|
266
|
+
function isImage(p: FilePart): boolean {
|
|
267
|
+
return p.mime.startsWith('image/') && !!p.data;
|
|
268
|
+
}
|
|
269
|
+
|
|
288
270
|
function textOf(message: AgentMessage): string {
|
|
289
271
|
return message.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
|
|
290
272
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The agent's plan as a checklist: Claude Code's task list (or a
|
|
2
|
+
// `TodoWrite`), kept current as it works — what is done in the muted
|
|
3
|
+
// colour, what is under way in bold, and how far along it is.
|
|
4
|
+
|
|
5
|
+
import type { ReactElement } from 'react';
|
|
6
|
+
import { Icon } from 'react-x11';
|
|
7
|
+
|
|
8
|
+
import type { PlanPart } from '../core/types.js';
|
|
9
|
+
import { useLook } from './look.js';
|
|
10
|
+
|
|
11
|
+
export function PlanChecklist({ part }: { part: PlanPart }): ReactElement {
|
|
12
|
+
const look = useLook();
|
|
13
|
+
const done = part.entries.filter((e) => e.status === 'completed').length;
|
|
14
|
+
return (
|
|
15
|
+
<box style={{ flexDirection: 'column', gap: 2 }} aria-label="Plan">
|
|
16
|
+
<text
|
|
17
|
+
style={{
|
|
18
|
+
color: look.muted,
|
|
19
|
+
fontSize: look.smallSize,
|
|
20
|
+
fontWeight: 'bold',
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
{`PLAN · ${done} OF ${part.entries.length} DONE`}
|
|
24
|
+
</text>
|
|
25
|
+
{part.entries.map((e, i) => (
|
|
26
|
+
<box
|
|
27
|
+
key={i}
|
|
28
|
+
style={{ flexDirection: 'row', gap: look.gap, alignItems: 'center' }}
|
|
29
|
+
>
|
|
30
|
+
{e.status === 'completed' ? (
|
|
31
|
+
<Icon name="check" size={look.captionSize} color={look.success} />
|
|
32
|
+
) : e.status === 'in_progress' ? (
|
|
33
|
+
<Icon name="dot" size={look.captionSize} color={look.accent} />
|
|
34
|
+
) : (
|
|
35
|
+
<Icon name="dash" size={look.captionSize} color={look.muted} />
|
|
36
|
+
)}
|
|
37
|
+
<text
|
|
38
|
+
style={{
|
|
39
|
+
color: e.status === 'completed' ? look.muted : look.text,
|
|
40
|
+
fontWeight: e.status === 'in_progress' ? 'bold' : 'normal',
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{e.text}
|
|
44
|
+
</text>
|
|
45
|
+
</box>
|
|
46
|
+
))}
|
|
47
|
+
</box>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// A pull request a command opened, edited or merged: Bash's
|
|
2
|
+
// `gitOperation.pr`.
|
|
3
|
+
|
|
4
|
+
import type { ReactElement } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { PullRequestData } from '../core/types.js';
|
|
7
|
+
import { LinkCard, shortUrl } from './LinkCard.js';
|
|
8
|
+
|
|
9
|
+
export function PullRequestCard({
|
|
10
|
+
value,
|
|
11
|
+
}: {
|
|
12
|
+
value: PullRequestData;
|
|
13
|
+
}): ReactElement {
|
|
14
|
+
const action = value.action.replace(/-/g, ' ');
|
|
15
|
+
return (
|
|
16
|
+
<LinkCard
|
|
17
|
+
kind="Pull request"
|
|
18
|
+
title={`#${value.number}`}
|
|
19
|
+
detail={[
|
|
20
|
+
action.charAt(0).toUpperCase() + action.slice(1),
|
|
21
|
+
value.url ? shortUrl(value.url) : '',
|
|
22
|
+
]
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.join(' · ')}
|
|
25
|
+
{...(value.url ? { href: value.url } : {})}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// An agent session started somewhere else — a cloud session the Agent tool
|
|
2
|
+
// launched, a workflow — which the user can open and follow.
|
|
3
|
+
|
|
4
|
+
import type { ReactElement } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { SessionLinkData } from '../core/types.js';
|
|
7
|
+
import { LinkCard, shortUrl } from './LinkCard.js';
|
|
8
|
+
|
|
9
|
+
export function SessionCard({
|
|
10
|
+
value,
|
|
11
|
+
}: {
|
|
12
|
+
value: SessionLinkData;
|
|
13
|
+
}): ReactElement {
|
|
14
|
+
return (
|
|
15
|
+
<LinkCard
|
|
16
|
+
kind="Session started"
|
|
17
|
+
title={value.title ?? 'A new session'}
|
|
18
|
+
detail={shortUrl(value.url)}
|
|
19
|
+
href={value.url}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// What the harness suggests the user might say next, as chips under the
|
|
2
|
+
// last reply. Pressing one puts it in the composer to edit or send; it never
|
|
3
|
+
// sends by itself.
|
|
4
|
+
|
|
5
|
+
import type { ReactElement } from 'react';
|
|
6
|
+
import { Button } from 'react-x11';
|
|
7
|
+
|
|
8
|
+
import { useChat } from './context.js';
|
|
9
|
+
import { useLook } from './look.js';
|
|
10
|
+
|
|
11
|
+
export function SuggestedReplies({
|
|
12
|
+
suggestions,
|
|
13
|
+
}: {
|
|
14
|
+
suggestions: readonly string[];
|
|
15
|
+
}): ReactElement {
|
|
16
|
+
const { composer } = useChat();
|
|
17
|
+
const look = useLook();
|
|
18
|
+
return (
|
|
19
|
+
<box
|
|
20
|
+
style={{ flexDirection: 'row', flexWrap: 'wrap', gap: look.gap }}
|
|
21
|
+
aria-label="Suggested replies"
|
|
22
|
+
>
|
|
23
|
+
{suggestions.map((s) => (
|
|
24
|
+
<Button
|
|
25
|
+
key={s}
|
|
26
|
+
size="small"
|
|
27
|
+
variant="outline"
|
|
28
|
+
label={s}
|
|
29
|
+
onPress={() => composer.fill(s)}
|
|
30
|
+
/>
|
|
31
|
+
))}
|
|
32
|
+
</box>
|
|
33
|
+
);
|
|
34
|
+
}
|
package/src/view/TextBlock.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { Markdown } from '@react-x11/components/markdown';
|
|
|
7
7
|
import type { AgentMessage } from '../core/types.js';
|
|
8
8
|
import { CodeBlock } from './CodeBlock.js';
|
|
9
9
|
import { useChat } from './context.js';
|
|
10
|
+
import { useCodeLook } from './look.js';
|
|
10
11
|
import { splitFences } from './fences.js';
|
|
11
12
|
|
|
12
13
|
export interface TextBlockProps {
|
|
@@ -21,6 +22,8 @@ export const TextBlock = memo(function TextBlock({
|
|
|
21
22
|
message,
|
|
22
23
|
}: TextBlockProps): ReactElement {
|
|
23
24
|
const { fences, components, onLink, fontSize } = useChat();
|
|
25
|
+
// segments alternate prose and fences: the gap is the space a fence keeps
|
|
26
|
+
const { spacing } = useCodeLook(fontSize);
|
|
24
27
|
const segments = useMemo(
|
|
25
28
|
() => splitFences(text, streaming),
|
|
26
29
|
[text, streaming],
|
|
@@ -30,7 +33,7 @@ export const TextBlock = memo(function TextBlock({
|
|
|
30
33
|
[onLink],
|
|
31
34
|
);
|
|
32
35
|
return (
|
|
33
|
-
<box style={{ flexDirection: 'column', gap:
|
|
36
|
+
<box style={{ flexDirection: 'column', gap: spacing }}>
|
|
34
37
|
{segments.map((s, i) => {
|
|
35
38
|
if (s.type === 'prose') {
|
|
36
39
|
return (
|
package/src/view/ToolCard.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import { TerminalOutput } from '@react-x11/components/terminal-output';
|
|
|
12
12
|
|
|
13
13
|
import type { ToolOutput, ToolPart } from '../core/types.js';
|
|
14
14
|
import { useChat } from './context.js';
|
|
15
|
+
import { ImageView } from './ImageView.js';
|
|
15
16
|
import { clipLines, useLook, type ChatLook } from './look.js';
|
|
16
17
|
import { MessageList } from './MessageList.js';
|
|
17
18
|
|
|
@@ -235,7 +236,15 @@ function Output({
|
|
|
235
236
|
case 'diff':
|
|
236
237
|
return <Diff output={output} />;
|
|
237
238
|
case 'image':
|
|
238
|
-
return
|
|
239
|
+
return (
|
|
240
|
+
<ImageView
|
|
241
|
+
source={{ data: output.data }}
|
|
242
|
+
{...(output.width && output.height
|
|
243
|
+
? { width: output.width, height: output.height }
|
|
244
|
+
: {})}
|
|
245
|
+
alt={`An image, ${output.mime}`}
|
|
246
|
+
/>
|
|
247
|
+
);
|
|
239
248
|
case 'value':
|
|
240
249
|
return (
|
|
241
250
|
<Code source={clipLines(json(output.value), 80)} lang="json" wrap />
|
package/src/view/Transcript.tsx
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
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.
|
|
2
|
+
// is at the bottom, and stops following once they scroll up to read. It
|
|
3
|
+
// follows content that grows after it was laid out too: a fence the app
|
|
4
|
+
// draws itself (a diagram compiled after mount) would otherwise push the end
|
|
5
|
+
// of the reply out of view.
|
|
3
6
|
|
|
4
7
|
import {
|
|
5
8
|
useLayoutEffect,
|
|
@@ -12,6 +15,7 @@ import type { ScrollableNode, ScrollEvent } from 'react-x11';
|
|
|
12
15
|
import { useChat } from './context.js';
|
|
13
16
|
import { useLook } from './look.js';
|
|
14
17
|
import { MessageList } from './MessageList.js';
|
|
18
|
+
import { SuggestedReplies } from './SuggestedReplies.js';
|
|
15
19
|
import { useSessionSnapshot } from './use-agent.js';
|
|
16
20
|
|
|
17
21
|
/** Within this many pixels of the end counts as at the end. */
|
|
@@ -30,16 +34,30 @@ export function Transcript({ empty }: { empty?: ReactNode }): ReactElement {
|
|
|
30
34
|
if (stick.current) pane.current?.scrollTo(Infinity);
|
|
31
35
|
}, [snap.messages, snap.status]);
|
|
32
36
|
|
|
37
|
+
// fires from layout whenever the content or viewport size changes, not
|
|
38
|
+
// only when the snapshot does
|
|
39
|
+
const onViewport = () => {
|
|
40
|
+
if (stick.current) pane.current?.scrollTo(Infinity);
|
|
41
|
+
};
|
|
42
|
+
|
|
33
43
|
const onScroll = (ev: ScrollEvent) => {
|
|
34
44
|
stick.current =
|
|
35
45
|
ev.scrollY + ev.viewportHeight >= ev.contentHeight - STICK_PX;
|
|
36
46
|
};
|
|
37
47
|
|
|
38
48
|
const activity = activityOf(snap.status, snap.messages);
|
|
49
|
+
// offered once the turn is over, under the reply they follow
|
|
50
|
+
const suggestions =
|
|
51
|
+
snap.status === 'idle' &&
|
|
52
|
+
snap.suggestions.length &&
|
|
53
|
+
snap.messages[snap.messages.length - 1]?.role === 'assistant'
|
|
54
|
+
? snap.suggestions
|
|
55
|
+
: null;
|
|
39
56
|
return (
|
|
40
57
|
<box
|
|
41
58
|
ref={pane}
|
|
42
59
|
onScroll={onScroll}
|
|
60
|
+
onViewport={onViewport}
|
|
43
61
|
style={{ flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: 'scroll' }}
|
|
44
62
|
data-testname="agent-chat-transcript"
|
|
45
63
|
>
|
|
@@ -49,6 +67,7 @@ export function Transcript({ empty }: { empty?: ReactNode }): ReactElement {
|
|
|
49
67
|
) : (
|
|
50
68
|
(empty ?? null)
|
|
51
69
|
)}
|
|
70
|
+
{suggestions ? <SuggestedReplies suggestions={suggestions} /> : null}
|
|
52
71
|
{activity ? (
|
|
53
72
|
<text style={{ color: look.muted, fontSize: 12 }}>{activity}</text>
|
|
54
73
|
) : null}
|
package/src/view/context.ts
CHANGED
|
@@ -33,6 +33,25 @@ export interface ChatContextValue {
|
|
|
33
33
|
onLink: ((href: string) => void) | undefined;
|
|
34
34
|
/** Base text size, the theme's unless the app said. */
|
|
35
35
|
fontSize: number | undefined;
|
|
36
|
+
/** The message box, for a part that puts text in it without sending. */
|
|
37
|
+
composer: ComposerChannel;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How a part reaches the composer: it attaches while mounted. */
|
|
41
|
+
export interface ComposerChannel {
|
|
42
|
+
/** Replace what is in the box, and focus it. Sends nothing. */
|
|
43
|
+
fill(text: string): void;
|
|
44
|
+
attach(fill: ((text: string) => void) | null): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function composerChannel(): ComposerChannel {
|
|
48
|
+
let current: ((text: string) => void) | null = null;
|
|
49
|
+
return {
|
|
50
|
+
fill: (text) => current?.(text),
|
|
51
|
+
attach: (fill) => {
|
|
52
|
+
current = fill;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
export const ChatContext = createContext<ChatContextValue | null>(null);
|