@workerdeck/ui 2.15.0 → 3.0.1
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/README.md +6 -5
- package/build/{SessionPanel-BVKqPs_3.d.mts → SessionPanel-C1bU4yTM.d.mts} +11 -11
- package/build/index.d.mts +46 -9
- package/build/index.mjs +70 -43
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +156 -16
- package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-BEWxt0ZM.mjs} +1566 -852
- package/build/use-path-links-BEWxt0ZM.mjs.map +1 -0
- package/build/workspace.d.mts +3 -3
- package/build/workspace.mjs +3 -3
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/PermissionPrompt.tsx +16 -2
- package/src/components/agent/Response.tsx +8 -1
- package/src/components/agent/SessionBrowser.tsx +6 -0
- package/src/components/agent/SessionItem.tsx +3 -0
- package/src/components/agent/SessionPanel.tsx +28 -33
- package/src/components/agent/SessionSteps.tsx +46 -5
- package/src/components/agent/SessionWorkspace.tsx +3 -3
- package/src/components/agent/ShellStrip.tsx +27 -4
- package/src/components/agent/ShellTerminal.tsx +1 -1
- package/src/components/agent/ToolCallCard.tsx +18 -41
- package/src/components/agent/Transcript.tsx +98 -93
- package/src/components/agent/TranscriptItemView.tsx +102 -59
- package/src/components/agent/TranscriptRows.tsx +4 -2
- package/src/components/agent/image-viewer.tsx +326 -0
- package/src/components/agent/markdown-image.tsx +91 -0
- package/src/components/agent/shell-actions.tsx +2 -0
- package/src/components/agent/tool-result-image.tsx +40 -0
- package/src/components/agent/transcript-variant.tsx +3 -21
- package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
- package/src/components/terminal/PermissionPrompt.tsx +20 -4
- package/src/components/terminal/TerminalTranscript.tsx +38 -21
- package/src/components/terminal/affordances.tsx +100 -74
- package/src/components/terminal/height.ts +48 -32
- package/src/components/terminal/image-box.ts +31 -0
- package/src/components/terminal/items.tsx +92 -14
- package/src/components/terminal/markdown.tsx +4 -1
- package/src/components/terminal/shell-row.ts +17 -3
- package/src/index.ts +19 -4
- package/src/lib/shell-request.ts +115 -0
- package/src/lib/tool-icon.ts +13 -1
- package/src/styles/terminal.css +129 -22
- package/src/styles/theme.css +4 -0
- package/build/use-path-links-BgWXmfAS.mjs.map +0 -1
|
@@ -55,6 +55,46 @@ export function useToolResultImageSrc(ref: ToolResultImageRef): ToolResultImageS
|
|
|
55
55
|
return state
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export type HostImageLoader = (path: string) => Promise<string | undefined>
|
|
59
|
+
|
|
60
|
+
const HostImageContext = createContext<HostImageLoader | undefined>(undefined)
|
|
61
|
+
|
|
62
|
+
export function HostImageProvider({ value, children }: { value: HostImageLoader | undefined; children: ReactNode }) {
|
|
63
|
+
return <HostImageContext.Provider value={value}>{children}</HostImageContext.Provider>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useHostImageSrc(path: string | undefined, override?: HostImageLoader): ToolResultImageState {
|
|
67
|
+
const provided = useContext(HostImageContext)
|
|
68
|
+
const load = override ?? provided
|
|
69
|
+
const [state, setState] = useState<ToolResultImageState>({ failed: false })
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (path === undefined) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (!load) {
|
|
75
|
+
setState({ failed: true })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
let live = true
|
|
79
|
+
setState({ failed: false })
|
|
80
|
+
load(path)
|
|
81
|
+
.then((src) => {
|
|
82
|
+
if (live) {
|
|
83
|
+
setState({ src, failed: src === undefined })
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
.catch(() => {
|
|
87
|
+
if (live) {
|
|
88
|
+
setState({ failed: true })
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
return () => {
|
|
92
|
+
live = false
|
|
93
|
+
}
|
|
94
|
+
}, [load, path])
|
|
95
|
+
return state
|
|
96
|
+
}
|
|
97
|
+
|
|
58
98
|
const CACHE_BUDGET_BYTES = 64 * 1024 * 1024
|
|
59
99
|
|
|
60
100
|
type Entry = { pending: Promise<string | undefined>; url?: string; bytes: number }
|
|
@@ -12,27 +12,9 @@ export function useTranscriptVariant(): TranscriptVariant {
|
|
|
12
12
|
return useContext(VariantContext)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export type TranscriptDensity = 'comfortable' | 'compact'
|
|
16
|
-
|
|
17
|
-
const DensityContext = createContext<TranscriptDensity>('comfortable')
|
|
18
|
-
|
|
19
|
-
export function TranscriptDensityProvider({ value, children }: { value: TranscriptDensity; children: ReactNode }) {
|
|
20
|
-
return <DensityContext.Provider value={value}>{children}</DensityContext.Provider>
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function useTranscriptDensity(): TranscriptDensity {
|
|
24
|
-
return useContext(DensityContext)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
15
|
export type TranscriptFont = 'sans' | 'mono'
|
|
28
16
|
|
|
29
|
-
export const ROW_GAP: Record<TranscriptVariant,
|
|
30
|
-
terminal: {
|
|
31
|
-
|
|
32
|
-
compact: { className: 'term-row-gap', px: 18 },
|
|
33
|
-
},
|
|
34
|
-
cards: {
|
|
35
|
-
comfortable: { className: 'pt-4', px: 16 },
|
|
36
|
-
compact: { className: 'pt-2', px: 8 },
|
|
37
|
-
},
|
|
17
|
+
export const ROW_GAP: Record<TranscriptVariant, { className?: string; px: number }> = {
|
|
18
|
+
terminal: { className: 'term-row-gap', px: 18 },
|
|
19
|
+
cards: { className: 'pt-5', px: 20 },
|
|
38
20
|
}
|
|
@@ -383,10 +383,12 @@ export function usePromptAreaKeydown({
|
|
|
383
383
|
const launcher = triggers.find((t) => t.mode === 'launch' && t.char === e.key)
|
|
384
384
|
const editor = editorRef.current
|
|
385
385
|
if (launcher?.onActivate && editor) {
|
|
386
|
-
const
|
|
387
|
-
if (
|
|
386
|
+
const caret = getCursorOffset(editor)
|
|
387
|
+
if (caret !== null) {
|
|
388
388
|
const segments = readSegmentsFromDOM()
|
|
389
389
|
const plainText = segmentsToPlainText(segments)
|
|
390
|
+
// An emptied editor keeps the browser's filler <br>: the reader drops it, the caret count does not.
|
|
391
|
+
const cursorPos = Math.min(caret, plainText.length)
|
|
390
392
|
if (isValidTriggerPosition(plainText, cursorPos, launcher.position)) {
|
|
391
393
|
e.preventDefault()
|
|
392
394
|
launcher.onActivate({
|
|
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|
|
2
2
|
import type { PermissionRequest } from '@workerdeck/protocol'
|
|
3
3
|
import { toolInputPreview } from '../../lib/format.ts'
|
|
4
4
|
import { planFromRequest } from '../../lib/plan-request.ts'
|
|
5
|
+
import { shellRequestLines, shellRequestPayload, shellRequestTitle } from '../../lib/shell-request.ts'
|
|
5
6
|
import { TerminalDiff, previewPatch } from './diff.tsx'
|
|
6
7
|
import { TerminalMarkdown } from './markdown.tsx'
|
|
7
8
|
import { Choices, Hint, PromptInput, PromptTitle, Rule } from './prompt.tsx'
|
|
@@ -29,9 +30,14 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
const plan = planFromRequest(request)
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
33
|
+
const shell = plan ? undefined : shellRequestPayload(request)
|
|
34
|
+
const patch = plan || shell ? undefined : previewPatch(request.input)
|
|
35
|
+
const summary = plan || shell ? '' : toolInputPreview(request.input)
|
|
36
|
+
const heading = plan
|
|
37
|
+
? 'Plan ready for review'
|
|
38
|
+
: shell
|
|
39
|
+
? shellRequestTitle(shell)
|
|
40
|
+
: (request.displayName ?? request.title ?? 'Permission needed')
|
|
35
41
|
const subject = patch?.path ?? (summary || undefined)
|
|
36
42
|
|
|
37
43
|
const reasonInput = denying ? (
|
|
@@ -82,6 +88,16 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
|
|
|
82
88
|
<Blank />
|
|
83
89
|
<Rule dashed />
|
|
84
90
|
</>
|
|
91
|
+
) : shell ? (
|
|
92
|
+
<>
|
|
93
|
+
{shellRequestLines(shell).map((line, index) => (
|
|
94
|
+
<Row key={index} bold>
|
|
95
|
+
{line}
|
|
96
|
+
</Row>
|
|
97
|
+
))}
|
|
98
|
+
<Blank />
|
|
99
|
+
<Rule dashed />
|
|
100
|
+
</>
|
|
85
101
|
) : patch ? (
|
|
86
102
|
<>
|
|
87
103
|
<TerminalDiff patch={patch} />
|
|
@@ -95,7 +111,7 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
|
|
|
95
111
|
</>
|
|
96
112
|
) : null}
|
|
97
113
|
{request.decisionReason ? <Row tone="faint">{request.decisionReason}</Row> : null}
|
|
98
|
-
<Row>{plan ? 'Ready to implement this plan?' : (request.title ?? `Do you want to proceed?`)}</Row>
|
|
114
|
+
<Row>{plan ? 'Ready to implement this plan?' : shell ? 'Do you want to let it?' : (request.title ?? `Do you want to proceed?`)}</Row>
|
|
99
115
|
<Choices
|
|
100
116
|
label={heading}
|
|
101
117
|
options={options}
|
|
@@ -2,7 +2,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
|
|
|
2
2
|
import type { TranscriptItem, TranscriptState } from '@workerdeck/react'
|
|
3
3
|
import { cn } from '../../lib/utils.ts'
|
|
4
4
|
import type { TerminalAffordances } from './affordances.tsx'
|
|
5
|
-
import { OpenSubagentAction, WithActions } from './affordances.tsx'
|
|
5
|
+
import { ActionPlacementProvider, OpenSubagentAction, WithActions } from './affordances.tsx'
|
|
6
6
|
import {
|
|
7
7
|
AssistantRow,
|
|
8
8
|
CompactionRow,
|
|
@@ -35,10 +35,18 @@ export interface TerminalTranscriptProps {
|
|
|
35
35
|
className?: string
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export function TerminalItemView({
|
|
38
|
+
export function TerminalItemView({
|
|
39
|
+
item,
|
|
40
|
+
fileUrl,
|
|
41
|
+
attachmentUrl,
|
|
42
|
+
}: {
|
|
43
|
+
item: TranscriptItem
|
|
44
|
+
fileUrl?: (path: string) => string
|
|
45
|
+
attachmentUrl?: (attachmentId: string) => string
|
|
46
|
+
}) {
|
|
39
47
|
switch (item.kind) {
|
|
40
48
|
case 'user': {
|
|
41
|
-
return <UserRow item={item} />
|
|
49
|
+
return <UserRow item={item} attachmentUrl={attachmentUrl} />
|
|
42
50
|
}
|
|
43
51
|
case 'assistant_text': {
|
|
44
52
|
return <AssistantRow item={item} />
|
|
@@ -122,12 +130,16 @@ export function TaskRow({
|
|
|
122
130
|
{open ? (
|
|
123
131
|
<div className="term-nested">
|
|
124
132
|
{brief ? <BriefRow text={brief} terminal /> : null}
|
|
125
|
-
{block.children.map((leaf, index) =>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
{block.children.map((leaf, index) => {
|
|
134
|
+
const next = block.children[index + 1]
|
|
135
|
+
const view = 'run' in leaf ? <RunRow items={leaf.run} /> : <TerminalItemView item={leaf.item} fileUrl={fileUrl} />
|
|
136
|
+
return (
|
|
137
|
+
<Fragment key={leaf.key}>
|
|
138
|
+
{index > 0 && blockNeedsBlank(block.children[index - 1]!, leaf) ? <Blank /> : null}
|
|
139
|
+
{next && !blockNeedsBlank(leaf, next) ? <ActionPlacementProvider value="inline">{view}</ActionPlacementProvider> : view}
|
|
140
|
+
</Fragment>
|
|
141
|
+
)
|
|
142
|
+
})}
|
|
131
143
|
</div>
|
|
132
144
|
) : null}
|
|
133
145
|
</div>
|
|
@@ -177,18 +189,23 @@ export function TerminalTranscript({ state, fileUrl, fontSize, lineHeight, affor
|
|
|
177
189
|
className={cn('term-transcript', className)}
|
|
178
190
|
>
|
|
179
191
|
<PeerNamesProvider items={state.items}>
|
|
180
|
-
{blocks.map((block, index) =>
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
{
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
+
{blocks.map((block, index) => {
|
|
193
|
+
const next = blocks[index + 1]
|
|
194
|
+
return (
|
|
195
|
+
<Fragment key={block.key}>
|
|
196
|
+
{index > 0 && blockNeedsBlank(blocks[index - 1]!, block) ? <Blank /> : null}
|
|
197
|
+
<ActionPlacementProvider value={next && !blockNeedsBlank(block, next) ? 'inline' : 'below'}>
|
|
198
|
+
{'run' in block ? (
|
|
199
|
+
<RunRow items={block.run} />
|
|
200
|
+
) : 'item' in block ? (
|
|
201
|
+
<TerminalItemView item={block.item} fileUrl={fileUrl} />
|
|
202
|
+
) : (
|
|
203
|
+
<TaskRow block={block} fileUrl={fileUrl} />
|
|
204
|
+
)}
|
|
205
|
+
</ActionPlacementProvider>
|
|
206
|
+
</Fragment>
|
|
207
|
+
)
|
|
208
|
+
})}
|
|
192
209
|
{working(state) ? (
|
|
193
210
|
<>
|
|
194
211
|
{state.items.length > 0 ? <Blank /> : null}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import { Bot, BotOff, Check, Copy, Maximize2, SquareTerminal, Star, X, type LucideIcon } from 'lucide-react'
|
|
1
2
|
import { createContext, useContext, useState, type ReactNode } from 'react'
|
|
2
3
|
import { copyText } from '../../lib/clipboard.ts'
|
|
3
|
-
import { SHELL_KILL_GLYPH } from './shell-row.ts'
|
|
4
4
|
import { cn } from '../../lib/utils.ts'
|
|
5
5
|
|
|
6
6
|
export type TerminalAffordances = {
|
|
7
7
|
hover?: boolean
|
|
8
8
|
actions?: boolean
|
|
9
|
+
labels?: boolean
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
const DEFAULTS: Required<TerminalAffordances> = { hover: true, actions: true }
|
|
12
|
+
const DEFAULTS: Required<TerminalAffordances> = { hover: true, actions: true, labels: false }
|
|
12
13
|
|
|
13
14
|
const AffordanceContext = createContext<Required<TerminalAffordances>>(DEFAULTS)
|
|
14
15
|
|
|
@@ -18,7 +19,7 @@ export function useAffordances(): Required<TerminalAffordances> {
|
|
|
18
19
|
|
|
19
20
|
export function resolveAffordances(value: TerminalAffordances | boolean | undefined): Required<TerminalAffordances> {
|
|
20
21
|
if (value === false) {
|
|
21
|
-
return { hover: false, actions: false }
|
|
22
|
+
return { hover: false, actions: false, labels: false }
|
|
22
23
|
}
|
|
23
24
|
if (value === true || value === undefined) {
|
|
24
25
|
return DEFAULTS
|
|
@@ -30,22 +31,79 @@ export function AffordanceProvider({ value, children }: { value: Required<Termin
|
|
|
30
31
|
return <AffordanceContext.Provider value={value}>{children}</AffordanceContext.Provider>
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export
|
|
34
|
+
export type ActionPlacement = 'below' | 'inline'
|
|
35
|
+
|
|
36
|
+
const PlacementContext = createContext<ActionPlacement>('below')
|
|
37
|
+
|
|
38
|
+
export function ActionPlacementProvider({ value, children }: { value: ActionPlacement; children: ReactNode }) {
|
|
39
|
+
return <PlacementContext.Provider value={value}>{children}</PlacementContext.Provider>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function WithActions({
|
|
43
|
+
actions,
|
|
44
|
+
children,
|
|
45
|
+
className,
|
|
46
|
+
placement,
|
|
47
|
+
}: {
|
|
48
|
+
actions: ReactNode
|
|
49
|
+
children: ReactNode
|
|
50
|
+
className?: string
|
|
51
|
+
placement?: ActionPlacement
|
|
52
|
+
}) {
|
|
34
53
|
const { actions: enabled } = useAffordances()
|
|
54
|
+
const inherited = useContext(PlacementContext)
|
|
35
55
|
if (!enabled) {
|
|
36
56
|
return <>{children}</>
|
|
37
57
|
}
|
|
38
58
|
return (
|
|
39
|
-
<div className={cn('term-hoverable', className)}>
|
|
40
|
-
{children}
|
|
59
|
+
<div className={cn('term-hoverable', className)} data-placement={placement ?? inherited}>
|
|
60
|
+
<PlacementContext.Provider value="below">{children}</PlacementContext.Provider>
|
|
41
61
|
<div className="term-actions">{actions}</div>
|
|
42
62
|
</div>
|
|
43
63
|
)
|
|
44
64
|
}
|
|
45
65
|
|
|
66
|
+
type Tone = 'magenta' | 'yellow' | 'red' | 'danger'
|
|
67
|
+
|
|
68
|
+
function ActionButton({
|
|
69
|
+
icon: Icon,
|
|
70
|
+
word,
|
|
71
|
+
label,
|
|
72
|
+
tone,
|
|
73
|
+
pressed,
|
|
74
|
+
filled,
|
|
75
|
+
onPress,
|
|
76
|
+
}: {
|
|
77
|
+
icon: LucideIcon
|
|
78
|
+
word: string
|
|
79
|
+
label: string
|
|
80
|
+
tone?: Tone
|
|
81
|
+
pressed?: boolean
|
|
82
|
+
filled?: boolean
|
|
83
|
+
onPress: () => void
|
|
84
|
+
}) {
|
|
85
|
+
const { labels } = useAffordances()
|
|
86
|
+
return (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
className="term-action"
|
|
90
|
+
title={label}
|
|
91
|
+
aria-label={label}
|
|
92
|
+
aria-pressed={pressed}
|
|
93
|
+
data-tone={tone}
|
|
94
|
+
onClick={(event) => {
|
|
95
|
+
event.stopPropagation()
|
|
96
|
+
onPress()
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
<Icon aria-hidden className="term-action-icon" fill={filled ? 'currentColor' : 'none'} />
|
|
100
|
+
{labels ? <span className="term-action-word">{word}</span> : null}
|
|
101
|
+
</button>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
46
105
|
// Bookmarks are a host concern (which items, where they persist); the transcript only needs
|
|
47
|
-
// membership and a toggle. A missing provider renders no action at all
|
|
48
|
-
// never wire bookmarks pay nothing - the same contract AffordanceContext has.
|
|
106
|
+
// membership and a toggle. A missing provider renders no action at all.
|
|
49
107
|
export type BookmarkHandle = {
|
|
50
108
|
has: (itemId: string) => boolean
|
|
51
109
|
toggle: (itemId: string) => void
|
|
@@ -63,87 +121,52 @@ export function BookmarkAction({ id }: { id: string }) {
|
|
|
63
121
|
return null
|
|
64
122
|
}
|
|
65
123
|
const active = handle.has(id)
|
|
66
|
-
const label = active ? 'Remove bookmark' : 'Bookmark'
|
|
67
124
|
return (
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}}
|
|
78
|
-
>
|
|
79
|
-
{active ? '★' : '☆'}
|
|
80
|
-
</button>
|
|
125
|
+
<ActionButton
|
|
126
|
+
icon={Star}
|
|
127
|
+
filled
|
|
128
|
+
word={active ? 'bookmarked' : 'bookmark'}
|
|
129
|
+
label={active ? 'Remove bookmark' : 'Bookmark'}
|
|
130
|
+
tone={active ? 'yellow' : undefined}
|
|
131
|
+
pressed={active}
|
|
132
|
+
onPress={() => handle.toggle(id)}
|
|
133
|
+
/>
|
|
81
134
|
)
|
|
82
135
|
}
|
|
83
136
|
|
|
84
137
|
export function OpenSubagentAction({ onOpen, label = 'Open sub-agent' }: { onOpen: () => void; label?: string }) {
|
|
85
|
-
return
|
|
86
|
-
<button
|
|
87
|
-
type="button"
|
|
88
|
-
className="term-action"
|
|
89
|
-
title={label}
|
|
90
|
-
aria-label={label}
|
|
91
|
-
onClick={(event) => {
|
|
92
|
-
event.stopPropagation()
|
|
93
|
-
onOpen()
|
|
94
|
-
}}
|
|
95
|
-
>
|
|
96
|
-
⤢
|
|
97
|
-
</button>
|
|
98
|
-
)
|
|
138
|
+
return <ActionButton icon={Maximize2} word="open" label={label} onPress={onOpen} />
|
|
99
139
|
}
|
|
100
140
|
|
|
101
141
|
export function OpenShellAction({ onOpen, label = 'Open terminal' }: { onOpen: () => void; label?: string }) {
|
|
102
|
-
return
|
|
103
|
-
<button
|
|
104
|
-
type="button"
|
|
105
|
-
className="term-action"
|
|
106
|
-
title={label}
|
|
107
|
-
aria-label={label}
|
|
108
|
-
data-tone="magenta"
|
|
109
|
-
onClick={(event) => {
|
|
110
|
-
event.stopPropagation()
|
|
111
|
-
onOpen()
|
|
112
|
-
}}
|
|
113
|
-
>
|
|
114
|
-
⤢
|
|
115
|
-
</button>
|
|
116
|
-
)
|
|
142
|
+
return <ActionButton icon={SquareTerminal} word="enter shell" label={label} tone="magenta" onPress={onOpen} />
|
|
117
143
|
}
|
|
118
144
|
|
|
119
145
|
export function KillShellAction({ onKill, label = 'Kill this shell' }: { onKill: () => void; label?: string }) {
|
|
146
|
+
return <ActionButton icon={X} word="kill" label={label} tone="danger" onPress={onKill} />
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function AgentWriteAction({ granted, label, onToggle }: { granted: boolean; label: string; onToggle: () => void }) {
|
|
120
150
|
return (
|
|
121
|
-
<
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
onKill()
|
|
130
|
-
}}
|
|
131
|
-
>
|
|
132
|
-
{SHELL_KILL_GLYPH}
|
|
133
|
-
</button>
|
|
151
|
+
<ActionButton
|
|
152
|
+
icon={granted ? Bot : BotOff}
|
|
153
|
+
word={granted ? 'agent can type' : 'agent read-only'}
|
|
154
|
+
label={label}
|
|
155
|
+
tone={granted ? 'yellow' : undefined}
|
|
156
|
+
pressed={granted}
|
|
157
|
+
onPress={onToggle}
|
|
158
|
+
/>
|
|
134
159
|
)
|
|
135
160
|
}
|
|
136
161
|
|
|
137
162
|
export function CopyAction({ text, label = 'Copy' }: { text: string; label?: string }) {
|
|
138
163
|
const [copied, setCopied] = useState(false)
|
|
139
164
|
return (
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
onClick={(event) => {
|
|
146
|
-
event.stopPropagation()
|
|
165
|
+
<ActionButton
|
|
166
|
+
icon={copied ? Check : Copy}
|
|
167
|
+
word={copied ? 'copied' : 'copy'}
|
|
168
|
+
label={label}
|
|
169
|
+
onPress={() => {
|
|
147
170
|
void copyText(text).then((ok) => {
|
|
148
171
|
if (!ok) {
|
|
149
172
|
return
|
|
@@ -152,8 +175,11 @@ export function CopyAction({ text, label = 'Copy' }: { text: string; label?: str
|
|
|
152
175
|
setTimeout(() => setCopied(false), 1200)
|
|
153
176
|
})
|
|
154
177
|
}}
|
|
155
|
-
|
|
156
|
-
{copied ? '✓' : '⧉'}
|
|
157
|
-
</button>
|
|
178
|
+
/>
|
|
158
179
|
)
|
|
159
180
|
}
|
|
181
|
+
|
|
182
|
+
export function AgentWriteIcon({ granted, className }: { granted: boolean; className?: string }) {
|
|
183
|
+
const Icon = granted ? Bot : BotOff
|
|
184
|
+
return <Icon aria-hidden className={className} />
|
|
185
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { FilePatch, PatchHunk } from '@workerdeck/protocol'
|
|
|
2
2
|
import type { TranscriptItem } from '@workerdeck/react'
|
|
3
3
|
import { compactionText, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
|
|
4
4
|
import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
|
|
5
|
-
import { IMAGE_BOX_LINES } from './image-box.ts'
|
|
5
|
+
import { IMAGE_BOX_LINES, hostImagePathOf } from './image-box.ts'
|
|
6
6
|
import { collapsedResult } from './result-preview.ts'
|
|
7
7
|
import { shellBodyLines, shellFooterText, shellHeaderText } from './shell-row.ts'
|
|
8
8
|
import { todoLine, todoPreview } from './todos.ts'
|
|
@@ -60,6 +60,10 @@ export function measureCh(surface: HTMLElement): number {
|
|
|
60
60
|
return width
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
const MARKDOWN_IMAGE = /!\[[^\]]*\]\([^)]*\)/
|
|
64
|
+
|
|
65
|
+
const MARKDOWN_IMAGE_LINE = /^\s*!\[[^\]]*\]\([^)\s]+\)\s*$/
|
|
66
|
+
|
|
63
67
|
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' })
|
|
64
68
|
|
|
65
69
|
const WIDE_RANGES: [number, number][] = [
|
|
@@ -276,7 +280,8 @@ function stripInline(s: string): string {
|
|
|
276
280
|
}
|
|
277
281
|
|
|
278
282
|
type MdBlock =
|
|
279
|
-
| { t: 'p'; lines: string[] }
|
|
283
|
+
| { t: 'p'; lines: string[]; images: boolean }
|
|
284
|
+
| { t: 'image' }
|
|
280
285
|
| { t: 'h'; text: string }
|
|
281
286
|
| { t: 'fence'; code: string }
|
|
282
287
|
| { t: 'list'; items: { lines: string[]; gutter: number; indent: number }[] }
|
|
@@ -302,11 +307,37 @@ function hardLines(source: string[]): string[] {
|
|
|
302
307
|
return out
|
|
303
308
|
}
|
|
304
309
|
|
|
310
|
+
function isBlank(s: string | undefined): boolean {
|
|
311
|
+
return s !== undefined && s.trim() === ''
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function listMarker(s: string): { depth: number; ordered: boolean; text: string } | undefined {
|
|
315
|
+
const m = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(s)
|
|
316
|
+
if (!m) {
|
|
317
|
+
return undefined
|
|
318
|
+
}
|
|
319
|
+
return { depth: Math.floor(m[1]!.length / 2), ordered: /\d/.test(m[2]!), text: m[3]! }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function numberHunk(hunk: PatchHunk): { number: number; text: string }[] {
|
|
323
|
+
let newLine = hunk.newStart
|
|
324
|
+
let oldLine = hunk.oldStart
|
|
325
|
+
return hunk.lines.map((line) => {
|
|
326
|
+
const kind = line.startsWith('+') ? 'add' : line.startsWith('-') ? 'remove' : 'context'
|
|
327
|
+
const text = line.slice(1)
|
|
328
|
+
const number = kind === 'add' ? newLine++ : kind === 'remove' ? oldLine++ : newLine
|
|
329
|
+
if (kind === 'context') {
|
|
330
|
+
oldLine++
|
|
331
|
+
newLine++
|
|
332
|
+
}
|
|
333
|
+
return { number, text }
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
305
337
|
function parseBlocks(md: string): MdBlock[] {
|
|
306
338
|
const lines = md.split('\n')
|
|
307
339
|
const blocks: MdBlock[] = []
|
|
308
340
|
let i = 0
|
|
309
|
-
const isBlank = (s: string | undefined) => s !== undefined && s.trim() === ''
|
|
310
341
|
while (i < lines.length) {
|
|
311
342
|
const line = lines[i]!
|
|
312
343
|
if (line.trim() === '') {
|
|
@@ -359,15 +390,6 @@ function parseBlocks(md: string): MdBlock[] {
|
|
|
359
390
|
blocks.push({ t: 'quote', paras })
|
|
360
391
|
continue
|
|
361
392
|
}
|
|
362
|
-
const listMarker = (s: string) => {
|
|
363
|
-
const m = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(s)
|
|
364
|
-
if (!m) {
|
|
365
|
-
return undefined
|
|
366
|
-
}
|
|
367
|
-
const depth = Math.floor(m[1]!.length / 2)
|
|
368
|
-
const ordered = /\d/.test(m[2]!)
|
|
369
|
-
return { depth, ordered, text: m[3]! }
|
|
370
|
-
}
|
|
371
393
|
if (listMarker(line)) {
|
|
372
394
|
const items: { lines: string[]; gutter: number; indent: number }[] = []
|
|
373
395
|
const gutters: number[] = []
|
|
@@ -431,7 +453,11 @@ function parseBlocks(md: string): MdBlock[] {
|
|
|
431
453
|
para.push(lines[i]!)
|
|
432
454
|
i += 1
|
|
433
455
|
}
|
|
434
|
-
|
|
456
|
+
if (para.length === 1 && MARKDOWN_IMAGE_LINE.test(para[0]!)) {
|
|
457
|
+
blocks.push({ t: 'image' })
|
|
458
|
+
} else {
|
|
459
|
+
blocks.push({ t: 'p', lines: hardLines(para), images: para.some((row) => MARKDOWN_IMAGE.test(row)) })
|
|
460
|
+
}
|
|
435
461
|
}
|
|
436
462
|
return blocks
|
|
437
463
|
}
|
|
@@ -449,10 +475,14 @@ export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
|
|
|
449
475
|
case 'p': {
|
|
450
476
|
for (const line of block.lines) {
|
|
451
477
|
const r = textLines(line, cols)
|
|
452
|
-
acc = add(acc, { px: r.lines * m.line, exact: r.exact })
|
|
478
|
+
acc = add(acc, { px: r.lines * m.line, exact: r.exact && !block.images })
|
|
453
479
|
}
|
|
454
480
|
break
|
|
455
481
|
}
|
|
482
|
+
case 'image': {
|
|
483
|
+
acc = add(acc, { px: IMAGE_BOX_LINES * m.line, exact: true })
|
|
484
|
+
break
|
|
485
|
+
}
|
|
456
486
|
case 'h': {
|
|
457
487
|
const r = textLines(block.text, cols)
|
|
458
488
|
acc = add(acc, { px: r.lines * m.line, exact: r.exact })
|
|
@@ -512,21 +542,7 @@ export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
|
|
|
512
542
|
}
|
|
513
543
|
|
|
514
544
|
function diffHeight(patch: FilePatch, m: CellMetrics, extraPx = 0): Acc {
|
|
515
|
-
const
|
|
516
|
-
let newLine = hunk.newStart
|
|
517
|
-
let oldLine = hunk.oldStart
|
|
518
|
-
return hunk.lines.map((line) => {
|
|
519
|
-
const kind = line.startsWith('+') ? 'add' : line.startsWith('-') ? 'remove' : 'context'
|
|
520
|
-
const text = line.slice(1)
|
|
521
|
-
const number = kind === 'add' ? newLine++ : kind === 'remove' ? oldLine++ : newLine
|
|
522
|
-
if (kind === 'context') {
|
|
523
|
-
oldLine++
|
|
524
|
-
newLine++
|
|
525
|
-
}
|
|
526
|
-
return { number, text }
|
|
527
|
-
})
|
|
528
|
-
}
|
|
529
|
-
const hunks = patch.hunks.map(walk)
|
|
545
|
+
const hunks = patch.hunks.map(numberHunk)
|
|
530
546
|
const numbered = patch.hunks.some((hunk) => hunk.newStart > 0)
|
|
531
547
|
const width = numbered ? String(Math.max(...hunks.flat().map((row) => row.number), 1)).length : 0
|
|
532
548
|
const columns = numbered ? width + 3 : 2
|
|
@@ -551,9 +567,9 @@ function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc
|
|
|
551
567
|
const backend = item.backend && item.backend !== 'server' ? ` · ${item.backend}` : ''
|
|
552
568
|
let acc = rowH(`${item.name}(${preview})${backend}`, m, { gutterCells: 2, extraPx })
|
|
553
569
|
|
|
554
|
-
const
|
|
555
|
-
if (
|
|
556
|
-
acc = add(acc, { px:
|
|
570
|
+
const boxes = (item.result?.images?.length ?? 0) + (hostImagePathOf(item) === undefined ? 0 : 1)
|
|
571
|
+
if (boxes > 0) {
|
|
572
|
+
acc = add(acc, { px: boxes * IMAGE_BOX_LINES * m.line, exact: true })
|
|
557
573
|
}
|
|
558
574
|
|
|
559
575
|
if (item.patch) {
|
|
@@ -1,9 +1,40 @@
|
|
|
1
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
1
2
|
import { formatBytes } from '../../lib/format.ts'
|
|
2
3
|
|
|
4
|
+
type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
|
|
5
|
+
|
|
3
6
|
export const IMAGE_BOX_LINES = 12
|
|
4
7
|
|
|
8
|
+
const HOST_IMAGE_TOOLS = new Set(['CodexImageGeneration', 'CodexImageView'])
|
|
9
|
+
|
|
10
|
+
const MEDIA_EXTENSIONS: Record<string, string> = {
|
|
11
|
+
'image/png': 'png',
|
|
12
|
+
'image/jpeg': 'jpg',
|
|
13
|
+
'image/gif': 'gif',
|
|
14
|
+
'image/webp': 'webp',
|
|
15
|
+
'image/svg+xml': 'svg',
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
export function imagePlaceholder(image: { bytes: number }): string {
|
|
6
19
|
return `image · ${formatBytes(image.bytes)}`
|
|
7
20
|
}
|
|
8
21
|
|
|
9
22
|
export const IMAGE_UNAVAILABLE = 'image unavailable'
|
|
23
|
+
|
|
24
|
+
export function hostImagePathOf(item: ToolCallItem): string | undefined {
|
|
25
|
+
if (!HOST_IMAGE_TOOLS.has(item.name)) {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
const input = item.input as { savedPath?: unknown; path?: unknown } | null
|
|
29
|
+
const path = input?.savedPath ?? input?.path
|
|
30
|
+
return typeof path === 'string' ? path : undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function baseName(path: string): string {
|
|
34
|
+
return path.split(/[\\/]/).pop() || path
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resultImageName(toolName: string, image: { partIndex: number; mediaType: string }): string {
|
|
38
|
+
const extension = MEDIA_EXTENSIONS[image.mediaType] ?? image.mediaType.split('/').pop() ?? 'img'
|
|
39
|
+
return `${toolName.toLowerCase()}-${image.partIndex + 1}.${extension}`
|
|
40
|
+
}
|