@workerdeck/ui 2.15.0 → 3.1.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/README.md +6 -5
- package/build/{SessionPanel-BVKqPs_3.d.mts → SessionPanel-BNNc8grK.d.mts} +18 -16
- package/build/index.d.mts +194 -49
- package/build/index.mjs +562 -260
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +170 -21
- package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-C6Fh5zdx.mjs} +3312 -2758
- package/build/use-path-links-C6Fh5zdx.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/ApprovalPrompts.tsx +87 -0
- package/src/components/agent/Composer.tsx +25 -47
- package/src/components/agent/ContextDialog.tsx +75 -67
- package/src/components/agent/FrameStrip.tsx +74 -0
- package/src/components/agent/HostFilesDialog.tsx +3 -2
- package/src/components/agent/McpDialog.tsx +112 -78
- package/src/components/agent/PermissionPrompt.tsx +22 -17
- package/src/components/agent/QuestionPrompt.tsx +6 -41
- package/src/components/agent/Response.tsx +8 -1
- package/src/components/agent/Scrubber.tsx +6 -75
- package/src/components/agent/SessionBrowser.tsx +131 -214
- package/src/components/agent/SessionFilters.tsx +266 -0
- package/src/components/agent/SessionInfoDialog.tsx +47 -63
- package/src/components/agent/SessionItem.tsx +29 -6
- package/src/components/agent/SessionPanel.tsx +249 -498
- package/src/components/agent/SessionSteps.tsx +98 -14
- package/src/components/agent/SessionWorkspace.tsx +3 -3
- package/src/components/agent/ShellStrip.tsx +43 -36
- package/src/components/agent/ShellTerminal.tsx +1 -1
- package/src/components/agent/SubagentStrip.tsx +13 -30
- package/src/components/agent/ToolCallCard.tsx +18 -41
- package/src/components/agent/Transcript.tsx +111 -93
- package/src/components/agent/TranscriptItemView.tsx +80 -67
- package/src/components/agent/TranscriptRows.tsx +4 -2
- package/src/components/agent/composer-commands.ts +80 -1
- package/src/components/agent/image-viewer.tsx +326 -0
- package/src/components/agent/markdown-image.tsx +139 -0
- package/src/components/agent/scrubber-marks.ts +33 -11
- package/src/components/agent/scrubber-peek.tsx +122 -0
- package/src/components/agent/session-panel-providers.tsx +41 -0
- package/src/components/agent/shell-actions.tsx +38 -1
- package/src/components/agent/tool-result-image.tsx +40 -0
- package/src/components/agent/transcript-variant.tsx +3 -21
- package/src/components/agent/use-catch-up.ts +20 -0
- package/src/components/agent/use-frame.ts +88 -0
- package/src/components/agent/use-host-image.ts +81 -0
- package/src/components/agent/use-shell-frame.ts +12 -70
- package/src/components/agent/use-subagent-frame.ts +19 -75
- package/src/components/prompt-area/prompt-area-engine.ts +21 -20
- package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
- package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
- package/src/components/terminal/PermissionPrompt.tsx +31 -28
- package/src/components/terminal/QuestionPrompt.tsx +10 -46
- package/src/components/terminal/TerminalTranscript.tsx +23 -89
- 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 +70 -30
- package/src/components/terminal/markdown.tsx +4 -1
- package/src/components/terminal/scrubber.tsx +25 -272
- package/src/components/terminal/shell-row.ts +17 -3
- package/src/components/ui/Popover.tsx +29 -0
- package/src/components/ui/Select.tsx +25 -0
- package/src/index.ts +65 -12
- package/src/lib/file-link.ts +3 -16
- package/src/lib/permission-prompt.ts +57 -0
- package/src/lib/question-answers.ts +65 -0
- 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
- package/src/components/prompt-area/use-markdown-mode.ts +0 -103
|
@@ -304,28 +304,25 @@ export function normalizeListPrefixText(text: string, markdownEnabled: boolean):
|
|
|
304
304
|
* preserved, while an unterminated fence does not suppress later bullets.
|
|
305
305
|
*/
|
|
306
306
|
export function normalizeListPrefixes(segments: Segment[], markdownEnabled: boolean): Segment[] {
|
|
307
|
-
const
|
|
308
|
-
segments.forEach((seg) => {
|
|
309
|
-
if (seg.type === 'text') {
|
|
310
|
-
globalLines.push(...seg.text.split('\n'))
|
|
311
|
-
}
|
|
312
|
-
})
|
|
313
|
-
const protectedLines = fenceProtectedLineIndices(globalLines)
|
|
307
|
+
const protectedLines = fenceProtectedLineIndices(segmentsToPlainText(segments).split('\n'))
|
|
314
308
|
|
|
315
|
-
let
|
|
309
|
+
let line = 0
|
|
310
|
+
// A chip is inline, so text right after one continues its line rather than starting a new one.
|
|
311
|
+
let atLineStart = true
|
|
316
312
|
let changed = false
|
|
317
313
|
const result = segments.map((seg) => {
|
|
318
314
|
if (seg.type !== 'text') {
|
|
315
|
+
atLineStart = false
|
|
319
316
|
return seg
|
|
320
317
|
}
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
.map((
|
|
324
|
-
const out = protectedLines.has(globalIndex) ? line : swapListPrefixLine(line, markdownEnabled)
|
|
325
|
-
globalIndex++
|
|
326
|
-
return out
|
|
327
|
-
})
|
|
318
|
+
const pieces = seg.text.split('\n')
|
|
319
|
+
const newText = pieces
|
|
320
|
+
.map((piece, k) => ((k > 0 || atLineStart) && !protectedLines.has(line + k) ? swapListPrefixLine(piece, markdownEnabled) : piece))
|
|
328
321
|
.join('\n')
|
|
322
|
+
line += pieces.length - 1
|
|
323
|
+
if (pieces.length > 1 || seg.text !== '') {
|
|
324
|
+
atLineStart = pieces[pieces.length - 1] === ''
|
|
325
|
+
}
|
|
329
326
|
if (newText === seg.text) {
|
|
330
327
|
return seg
|
|
331
328
|
}
|
|
@@ -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({
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import type { PermissionRequest } from '@workerdeck/protocol'
|
|
3
3
|
import { toolInputPreview } from '../../lib/format.ts'
|
|
4
|
-
import {
|
|
4
|
+
import { permissionPromptModel } from '../../lib/permission-prompt.ts'
|
|
5
|
+
import { shellRequestLines } 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'
|
|
@@ -28,10 +29,10 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
|
|
|
28
29
|
setDenying(false)
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
32
|
+
const model = permissionPromptModel(request)
|
|
33
|
+
const { plan, shell, heading } = model
|
|
34
|
+
const patch = plan || shell ? undefined : previewPatch(request.input)
|
|
35
|
+
const summary = plan || shell ? '' : toolInputPreview(request.input)
|
|
35
36
|
const subject = patch?.path ?? (summary || undefined)
|
|
36
37
|
|
|
37
38
|
const reasonInput = denying ? (
|
|
@@ -40,25 +41,16 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
|
|
|
40
41
|
onChange={setReason}
|
|
41
42
|
onSubmit={() => deny(false)}
|
|
42
43
|
onCancel={() => setDenying(false)}
|
|
43
|
-
placeholder={
|
|
44
|
-
plan
|
|
45
|
-
? 'What should change? (optional) - the agent keeps planning and reads this'
|
|
46
|
-
: 'Reason (optional) - the agent reads this and can try something else'
|
|
47
|
-
}
|
|
44
|
+
placeholder={model.denyPlaceholder}
|
|
48
45
|
/>
|
|
49
46
|
) : undefined
|
|
50
47
|
|
|
51
|
-
const options =
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
: [
|
|
58
|
-
{ key: 'allow', label: 'Yes' },
|
|
59
|
-
{ key: 'deny', label: 'No, and tell the agent what to do differently', detail: reasonInput },
|
|
60
|
-
{ key: 'stop', label: 'No, and stop the turn', danger: true },
|
|
61
|
-
]
|
|
48
|
+
const options = model.choices.map((choice) => ({
|
|
49
|
+
key: choice.key,
|
|
50
|
+
label: choice.option,
|
|
51
|
+
danger: choice.danger,
|
|
52
|
+
detail: choice.key === 'deny' ? reasonInput : undefined,
|
|
53
|
+
}))
|
|
62
54
|
|
|
63
55
|
return (
|
|
64
56
|
<div
|
|
@@ -82,20 +74,30 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
|
|
|
82
74
|
<Blank />
|
|
83
75
|
<Rule dashed />
|
|
84
76
|
</>
|
|
77
|
+
) : shell ? (
|
|
78
|
+
<>
|
|
79
|
+
{shellRequestLines(shell).map((line, index) => (
|
|
80
|
+
<Row key={index} bold>
|
|
81
|
+
{line}
|
|
82
|
+
</Row>
|
|
83
|
+
))}
|
|
84
|
+
<Blank />
|
|
85
|
+
<Rule dashed />
|
|
86
|
+
</>
|
|
85
87
|
) : patch ? (
|
|
86
88
|
<>
|
|
87
89
|
<TerminalDiff patch={patch} />
|
|
88
90
|
<Blank />
|
|
89
91
|
<Rule dashed />
|
|
90
92
|
</>
|
|
91
|
-
) :
|
|
93
|
+
) : model.description ? (
|
|
92
94
|
<>
|
|
93
|
-
<Row tone="dim">{
|
|
95
|
+
<Row tone="dim">{model.description}</Row>
|
|
94
96
|
<Blank />
|
|
95
97
|
</>
|
|
96
98
|
) : null}
|
|
97
|
-
{
|
|
98
|
-
<Row>{
|
|
99
|
+
{model.decisionReason ? <Row tone="faint">{model.decisionReason}</Row> : null}
|
|
100
|
+
<Row>{model.question}</Row>
|
|
99
101
|
<Choices
|
|
100
102
|
label={heading}
|
|
101
103
|
options={options}
|
|
@@ -103,11 +105,12 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
|
|
|
103
105
|
onFocus={setFocused}
|
|
104
106
|
active={!denying}
|
|
105
107
|
onChoose={(index) => {
|
|
106
|
-
|
|
108
|
+
const key = model.choices[index]?.key
|
|
109
|
+
if (key === 'allow') {
|
|
107
110
|
onApprove(request.id)
|
|
108
|
-
} else if (
|
|
111
|
+
} else if (key === 'deny') {
|
|
109
112
|
setDenying(true)
|
|
110
|
-
} else {
|
|
113
|
+
} else if (key === 'stop') {
|
|
111
114
|
deny(true)
|
|
112
115
|
}
|
|
113
116
|
}}
|
|
@@ -1,21 +1,10 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import type { PermissionRequest, UserQuestion } from '@workerdeck/protocol'
|
|
3
3
|
import { parseUserQuestions } from '../agent/QuestionPrompt.tsx'
|
|
4
|
+
import { answerFor, useQuestionAnswers, type Selection } from '../../lib/question-answers.ts'
|
|
4
5
|
import { Box, Choices, Hint, PromptInput, Rule, TabStrip, type Choice } from './prompt.tsx'
|
|
5
6
|
import { Blank, Ink, Row } from './row.tsx'
|
|
6
7
|
|
|
7
|
-
type Selection = { labels: string[]; other: string; otherActive: boolean }
|
|
8
|
-
|
|
9
|
-
const EMPTY: Selection = { labels: [], other: '', otherActive: false }
|
|
10
|
-
|
|
11
|
-
function answerFor(selection: Selection): string {
|
|
12
|
-
const parts = [...selection.labels]
|
|
13
|
-
if (selection.otherActive && selection.other.trim()) {
|
|
14
|
-
parts.push(selection.other.trim())
|
|
15
|
-
}
|
|
16
|
-
return parts.join(', ')
|
|
17
|
-
}
|
|
18
|
-
|
|
19
8
|
export interface TerminalQuestionPromptProps {
|
|
20
9
|
request: PermissionRequest
|
|
21
10
|
onAnswer: (requestId: string, updatedInput: Record<string, unknown>) => void
|
|
@@ -25,43 +14,15 @@ export interface TerminalQuestionPromptProps {
|
|
|
25
14
|
|
|
26
15
|
export function TerminalQuestionPrompt({ request, onAnswer, onDismiss, className }: TerminalQuestionPromptProps) {
|
|
27
16
|
const questions = parseUserQuestions(request.input)
|
|
28
|
-
const
|
|
17
|
+
const { selectionFor, answered, complete, update, toggle, toggleOther, answers } = useQuestionAnswers(questions)
|
|
29
18
|
const [cursors, setCursors] = useState<number[]>(() => questions.map(() => 0))
|
|
30
19
|
const [tab, setTab] = useState(0)
|
|
31
20
|
const [reviewCursor, setReviewCursor] = useState(0)
|
|
32
21
|
|
|
33
22
|
const review = tab >= questions.length
|
|
34
|
-
const selection =
|
|
35
|
-
const answered = (index: number) => answerFor(selections[index] ?? EMPTY) !== ''
|
|
36
|
-
const complete = questions.every((_, index) => answered(index))
|
|
37
|
-
|
|
38
|
-
const update = (index: number, patch: Partial<Selection>) =>
|
|
39
|
-
setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
|
|
40
|
-
|
|
41
|
-
const toggle = (index: number, label: string, multiSelect: boolean) => {
|
|
42
|
-
setSelections((prev) =>
|
|
43
|
-
prev.map((current, i) => {
|
|
44
|
-
if (i !== index) {
|
|
45
|
-
return current
|
|
46
|
-
}
|
|
47
|
-
if (!multiSelect) {
|
|
48
|
-
return { ...current, labels: [label], otherActive: false }
|
|
49
|
-
}
|
|
50
|
-
return {
|
|
51
|
-
...current,
|
|
52
|
-
labels: current.labels.includes(label) ? current.labels.filter((l) => l !== label) : [...current.labels, label],
|
|
53
|
-
}
|
|
54
|
-
}),
|
|
55
|
-
)
|
|
56
|
-
}
|
|
23
|
+
const selection = selectionFor(tab)
|
|
57
24
|
|
|
58
|
-
const submit = () => {
|
|
59
|
-
const answers: Record<string, string> = {}
|
|
60
|
-
questions.forEach((question, index) => {
|
|
61
|
-
answers[question.question] = answerFor(selections[index] ?? EMPTY)
|
|
62
|
-
})
|
|
63
|
-
onAnswer(request.id, { ...request.input, answers })
|
|
64
|
-
}
|
|
25
|
+
const submit = () => onAnswer(request.id, { ...request.input, answers: answers() })
|
|
65
26
|
|
|
66
27
|
const dismiss = () => onDismiss(request.id, 'Question dismissed by user')
|
|
67
28
|
|
|
@@ -100,7 +61,7 @@ export function TerminalQuestionPrompt({ request, onAnswer, onDismiss, className
|
|
|
100
61
|
{review ? (
|
|
101
62
|
<ReviewStep
|
|
102
63
|
questions={questions}
|
|
103
|
-
answers={questions.map((_, index) => answerFor(
|
|
64
|
+
answers={questions.map((_, index) => answerFor(selectionFor(index)))}
|
|
104
65
|
complete={complete}
|
|
105
66
|
cursor={reviewCursor}
|
|
106
67
|
onCursor={setReviewCursor}
|
|
@@ -115,8 +76,9 @@ export function TerminalQuestionPrompt({ request, onAnswer, onDismiss, className
|
|
|
115
76
|
selection={selection}
|
|
116
77
|
cursor={cursors[tab] ?? 0}
|
|
117
78
|
onCursor={(next) => setCursors((prev) => prev.map((c, i) => (i === tab ? next : c)))}
|
|
118
|
-
onToggle={(label) => toggle(tab, label
|
|
79
|
+
onToggle={(label) => toggle(tab, label)}
|
|
119
80
|
onOther={(patch) => update(tab, patch)}
|
|
81
|
+
onToggleOther={() => toggleOther(tab)}
|
|
120
82
|
onAdvance={() => move(1)}
|
|
121
83
|
onDismiss={dismiss}
|
|
122
84
|
/>
|
|
@@ -138,6 +100,7 @@ function QuestionStep({
|
|
|
138
100
|
onCursor,
|
|
139
101
|
onToggle,
|
|
140
102
|
onOther,
|
|
103
|
+
onToggleOther,
|
|
141
104
|
onAdvance,
|
|
142
105
|
onDismiss,
|
|
143
106
|
}: {
|
|
@@ -147,6 +110,7 @@ function QuestionStep({
|
|
|
147
110
|
onCursor: (index: number) => void
|
|
148
111
|
onToggle: (label: string) => void
|
|
149
112
|
onOther: (patch: Partial<Selection>) => void
|
|
113
|
+
onToggleOther: () => void
|
|
150
114
|
onAdvance: () => void
|
|
151
115
|
onDismiss: () => void
|
|
152
116
|
}) {
|
|
@@ -214,7 +178,7 @@ function QuestionStep({
|
|
|
214
178
|
return
|
|
215
179
|
}
|
|
216
180
|
if (index === question.options.length) {
|
|
217
|
-
|
|
181
|
+
onToggleOther()
|
|
218
182
|
return
|
|
219
183
|
}
|
|
220
184
|
if (multiSelect && index === question.options.length + 1) {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { Fragment,
|
|
2
|
-
import type { TranscriptItem
|
|
1
|
+
import { Fragment, useMemo, useState } from 'react'
|
|
2
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
3
3
|
import { cn } from '../../lib/utils.ts'
|
|
4
|
-
import
|
|
5
|
-
import { OpenSubagentAction, WithActions } from './affordances.tsx'
|
|
4
|
+
import { ActionPlacementProvider, OpenSubagentAction, WithActions } from './affordances.tsx'
|
|
6
5
|
import {
|
|
7
6
|
AssistantRow,
|
|
8
7
|
CompactionRow,
|
|
@@ -15,30 +14,26 @@ import {
|
|
|
15
14
|
ToolRow,
|
|
16
15
|
TurnResultRow,
|
|
17
16
|
UserRow,
|
|
18
|
-
WorkingRow,
|
|
19
17
|
} from './items.tsx'
|
|
20
|
-
import { blockNeedsBlank, taskChildItems,
|
|
18
|
+
import { blockNeedsBlank, taskChildItems, type TaskBlock } from './blocks.ts'
|
|
21
19
|
import { usePulse } from '../agent/pulse.tsx'
|
|
22
|
-
import { PeerNamesProvider } from './peer-names.tsx'
|
|
23
20
|
import { Pressable, useRevealOnOpen } from './press.tsx'
|
|
24
21
|
import { isPeerSend, taskBrief, taskBusy, taskFailed, taskSummary } from './tool-run.ts'
|
|
25
22
|
import { BRIEF_LINES } from './height.ts'
|
|
26
23
|
import { Blank, Row } from './row.tsx'
|
|
27
|
-
import { TerminalSurface } from './surface.tsx'
|
|
28
24
|
|
|
29
|
-
export
|
|
30
|
-
|
|
25
|
+
export function TerminalItemView({
|
|
26
|
+
item,
|
|
27
|
+
fileUrl,
|
|
28
|
+
attachmentUrl,
|
|
29
|
+
}: {
|
|
30
|
+
item: TranscriptItem
|
|
31
31
|
fileUrl?: (path: string) => string
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
affordances?: TerminalAffordances | boolean
|
|
35
|
-
className?: string
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function TerminalItemView({ item, fileUrl }: { item: TranscriptItem; fileUrl?: (path: string) => string }) {
|
|
32
|
+
attachmentUrl?: (attachmentId: string) => string
|
|
33
|
+
}) {
|
|
39
34
|
switch (item.kind) {
|
|
40
35
|
case 'user': {
|
|
41
|
-
return <UserRow item={item} />
|
|
36
|
+
return <UserRow item={item} attachmentUrl={attachmentUrl} />
|
|
42
37
|
}
|
|
43
38
|
case 'assistant_text': {
|
|
44
39
|
return <AssistantRow item={item} />
|
|
@@ -122,12 +117,16 @@ export function TaskRow({
|
|
|
122
117
|
{open ? (
|
|
123
118
|
<div className="term-nested">
|
|
124
119
|
{brief ? <BriefRow text={brief} terminal /> : null}
|
|
125
|
-
{block.children.map((leaf, index) =>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
120
|
+
{block.children.map((leaf, index) => {
|
|
121
|
+
const next = block.children[index + 1]
|
|
122
|
+
const view = 'run' in leaf ? <RunRow items={leaf.run} /> : <TerminalItemView item={leaf.item} fileUrl={fileUrl} />
|
|
123
|
+
return (
|
|
124
|
+
<Fragment key={leaf.key}>
|
|
125
|
+
{index > 0 && blockNeedsBlank(block.children[index - 1]!, leaf) ? <Blank /> : null}
|
|
126
|
+
{next && !blockNeedsBlank(leaf, next) ? <ActionPlacementProvider value="inline">{view}</ActionPlacementProvider> : view}
|
|
127
|
+
</Fragment>
|
|
128
|
+
)
|
|
129
|
+
})}
|
|
131
130
|
</div>
|
|
132
131
|
) : null}
|
|
133
132
|
</div>
|
|
@@ -138,68 +137,3 @@ export function TaskRow({
|
|
|
138
137
|
<WithActions actions={<OpenSubagentAction onOpen={() => onOpenSubagent(block.task.id)} />}>{row}</WithActions>
|
|
139
138
|
)
|
|
140
139
|
}
|
|
141
|
-
|
|
142
|
-
function useRunStart(status: TranscriptState['status']): number | undefined {
|
|
143
|
-
const running = status === 'running' || status === 'starting'
|
|
144
|
-
const [startedAt, setStartedAt] = useState<number | undefined>(undefined)
|
|
145
|
-
useEffect(() => {
|
|
146
|
-
setStartedAt((previous) => (running ? (previous ?? Date.now()) : undefined))
|
|
147
|
-
}, [running])
|
|
148
|
-
return running ? startedAt : undefined
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function working(state: TranscriptState): boolean {
|
|
152
|
-
if (state.status !== 'running' && state.status !== 'starting') {
|
|
153
|
-
return false
|
|
154
|
-
}
|
|
155
|
-
const last = state.items.at(-1)
|
|
156
|
-
if (!last) {
|
|
157
|
-
return true
|
|
158
|
-
}
|
|
159
|
-
if (last.kind === 'assistant_text' && last.streaming) {
|
|
160
|
-
return false
|
|
161
|
-
}
|
|
162
|
-
if (last.kind === 'thinking' && last.id === 'streaming-thinking') {
|
|
163
|
-
return false
|
|
164
|
-
}
|
|
165
|
-
return true
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export function TerminalTranscript({ state, fileUrl, fontSize, lineHeight, affordances, className }: TerminalTranscriptProps) {
|
|
169
|
-
const runStartedAt = useRunStart(state.status)
|
|
170
|
-
const blocks = useMemo(() => terminalBlocks(state.items), [state.items])
|
|
171
|
-
return (
|
|
172
|
-
<TerminalSurface
|
|
173
|
-
fontSize={fontSize}
|
|
174
|
-
lineHeight={lineHeight}
|
|
175
|
-
affordances={affordances}
|
|
176
|
-
bleed="1ch"
|
|
177
|
-
className={cn('term-transcript', className)}
|
|
178
|
-
>
|
|
179
|
-
<PeerNamesProvider items={state.items}>
|
|
180
|
-
{blocks.map((block, index) => (
|
|
181
|
-
<Fragment key={block.key}>
|
|
182
|
-
{index > 0 && blockNeedsBlank(blocks[index - 1]!, block) ? <Blank /> : null}
|
|
183
|
-
{'run' in block ? (
|
|
184
|
-
<RunRow items={block.run} />
|
|
185
|
-
) : 'item' in block ? (
|
|
186
|
-
<TerminalItemView item={block.item} fileUrl={fileUrl} />
|
|
187
|
-
) : (
|
|
188
|
-
<TaskRow block={block} fileUrl={fileUrl} />
|
|
189
|
-
)}
|
|
190
|
-
</Fragment>
|
|
191
|
-
))}
|
|
192
|
-
{working(state) ? (
|
|
193
|
-
<>
|
|
194
|
-
{state.items.length > 0 ? <Blank /> : null}
|
|
195
|
-
<WorkingRow
|
|
196
|
-
label={state.status === 'starting' ? 'Starting…' : 'Working…'}
|
|
197
|
-
startedAt={runStartedAt}
|
|
198
|
-
tokens={state.contextUsage?.totalTokens}
|
|
199
|
-
/>
|
|
200
|
-
</>
|
|
201
|
-
) : null}
|
|
202
|
-
</PeerNamesProvider>
|
|
203
|
-
</TerminalSurface>
|
|
204
|
-
)
|
|
205
|
-
}
|
|
@@ -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
|
+
}
|