@workerdeck/ui 0.15.0 → 0.16.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 +53 -0
- package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-B9CHoq8x.d.mts} +158 -21
- package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DII9MmQ8.mjs} +4254 -1661
- package/build/SessionPanel-DII9MmQ8.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +442 -45
- package/build/index.mjs +105 -2
- package/build/index.mjs.map +1 -1
- package/build/status-Ydzi7n6j.mjs +143 -0
- package/build/status-Ydzi7n6j.mjs.map +1 -0
- package/build/workspace.d.mts +9 -1
- package/build/workspace.mjs +108 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +14 -7
- package/src/components/agent/Composer.tsx +189 -89
- package/src/components/agent/Conversation.tsx +12 -12
- package/src/components/agent/FileCard.tsx +0 -26
- package/src/components/agent/FileTree.tsx +9 -8
- package/src/components/agent/Loader.tsx +22 -72
- package/src/components/agent/Message.tsx +11 -46
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionPanel.tsx +224 -28
- package/src/components/agent/SessionWorkspace.tsx +29 -0
- package/src/components/agent/StatusBar.tsx +20 -4
- package/src/components/agent/ToolCallCard.tsx +17 -111
- package/src/components/agent/Transcript.tsx +710 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/transcript-rows.ts +82 -0
- package/src/components/agent/transcript-variant.tsx +29 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +147 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +727 -0
- package/src/components/terminal/items.tsx +449 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +72 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +663 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +91 -0
- package/src/index.ts +34 -0
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1011 -0
- package/src/styles/theme.css +41 -0
- package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- package/src/components/agent/line-prompt.tsx +0 -249
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { HTMLAttributes } from 'react'
|
|
2
2
|
import { cn } from '../../lib/utils.ts'
|
|
3
|
-
import { LINE_TEXT, LineGlyph, useLines } from './transcript-variant.tsx'
|
|
4
3
|
|
|
5
4
|
export interface MessageProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
5
|
from: 'user' | 'assistant'
|
|
@@ -16,67 +15,33 @@ export interface MessageProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
16
15
|
* column separates a prompt from the reply it produced. The bubble alone is
|
|
17
16
|
* enough to say who spoke.
|
|
18
17
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* already distinguishable by its marker, and the bubble's padding is vertical
|
|
22
|
-
* space the terminal treatment refuses to spend.
|
|
18
|
+
* The terminal theme draws none of this: it is its own renderer
|
|
19
|
+
* (`components/terminal/`) and mounts instead of these rows.
|
|
23
20
|
*/
|
|
24
21
|
export function Message({ from, className, children, ...props }: MessageProps) {
|
|
25
|
-
const lines = useLines()
|
|
26
22
|
return (
|
|
27
23
|
<div
|
|
28
24
|
data-slot='message'
|
|
29
25
|
data-from={from}
|
|
30
|
-
className={cn(
|
|
31
|
-
'flex w-full',
|
|
32
|
-
lines
|
|
33
|
-
? cn(
|
|
34
|
-
'flex-row gap-2',
|
|
35
|
-
// What YOU said, on a band of its own — the CLI's own treatment.
|
|
36
|
-
// Full-bleed (the negative margin cancels the row's padding) so
|
|
37
|
-
// the band lines up with the hover highlight rather than sitting
|
|
38
|
-
// inside it, and square-ish so it reads as a strip, not a bubble.
|
|
39
|
-
from === 'user' && '-mx-1 rounded-sm bg-surface px-1',
|
|
40
|
-
)
|
|
41
|
-
: 'flex-col items-start gap-1',
|
|
42
|
-
className,
|
|
43
|
-
)}
|
|
26
|
+
className={cn('flex w-full flex-col items-start gap-1', className)}
|
|
44
27
|
{...props}>
|
|
45
|
-
{
|
|
46
|
-
<>
|
|
47
|
-
{/* The prompt marker is accent, the same blue VS Code's own chat and
|
|
48
|
-
the iOS app give it: in a column of grey rows it is the one thing
|
|
49
|
-
that says "you said this", and it is what the eye scrolls back to. */}
|
|
50
|
-
<LineGlyph className={from === 'user' ? 'text-accent' : 'text-fg-3'}>
|
|
51
|
-
{from === 'user' ? '❯' : '●'}
|
|
52
|
-
</LineGlyph>
|
|
53
|
-
<div className='flex min-w-0 flex-1 flex-col gap-1'>{children}</div>
|
|
54
|
-
</>
|
|
55
|
-
) : (
|
|
56
|
-
children
|
|
57
|
-
)}
|
|
28
|
+
{children}
|
|
58
29
|
</div>
|
|
59
30
|
)
|
|
60
31
|
}
|
|
61
32
|
|
|
62
33
|
export function MessageContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
63
|
-
const lines = useLines()
|
|
64
34
|
return (
|
|
65
35
|
<div
|
|
66
36
|
data-slot='message-content'
|
|
67
37
|
className={cn(
|
|
68
|
-
'min-w-0',
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
'in-data-[from=user]:max-w-[85%] in-data-[from=user]:rounded-lg in-data-[from=user]:rounded-bl-sm',
|
|
76
|
-
'in-data-[from=user]:bg-accent-bg in-data-[from=user]:px-3 in-data-[from=user]:py-2',
|
|
77
|
-
'in-data-[from=user]:whitespace-pre-wrap',
|
|
78
|
-
'in-data-[from=assistant]:w-full',
|
|
79
|
-
),
|
|
38
|
+
'min-w-0 text-body-sm leading-6 text-fg-1',
|
|
39
|
+
// Bubble treatment only within a user message row.
|
|
40
|
+
// The tail points bottom-*left* now that the bubble is left-aligned.
|
|
41
|
+
'in-data-[from=user]:max-w-[85%] in-data-[from=user]:rounded-lg in-data-[from=user]:rounded-bl-sm',
|
|
42
|
+
'in-data-[from=user]:bg-accent-bg in-data-[from=user]:px-3 in-data-[from=user]:py-2',
|
|
43
|
+
'in-data-[from=user]:whitespace-pre-wrap',
|
|
44
|
+
'in-data-[from=assistant]:w-full',
|
|
80
45
|
className,
|
|
81
46
|
)}
|
|
82
47
|
{...props}
|
|
@@ -6,14 +6,6 @@ import { Input } from '../ui/Input.tsx'
|
|
|
6
6
|
import { cn } from '../../lib/utils.ts'
|
|
7
7
|
import { toolInputPreview } from '../../lib/format.ts'
|
|
8
8
|
import { toolIcon } from '../../lib/tool-icon.ts'
|
|
9
|
-
import {
|
|
10
|
-
LINE_INDENT,
|
|
11
|
-
LineHint,
|
|
12
|
-
LineInput,
|
|
13
|
-
LineOptionList,
|
|
14
|
-
LinePayload,
|
|
15
|
-
} from './line-prompt.tsx'
|
|
16
|
-
import { LineGlyph, useLines } from './transcript-variant.tsx'
|
|
17
9
|
|
|
18
10
|
export interface PermissionPromptProps {
|
|
19
11
|
request: PermissionRequest
|
|
@@ -37,11 +29,9 @@ export interface PermissionPromptProps {
|
|
|
37
29
|
* already written the sentence that says so.
|
|
38
30
|
*/
|
|
39
31
|
export function PermissionPrompt({ request, onApprove, onDeny, className }: PermissionPromptProps) {
|
|
40
|
-
const lines = useLines()
|
|
41
32
|
const [showInput, setShowInput] = useState(false)
|
|
42
33
|
const [denying, setDenying] = useState(false)
|
|
43
34
|
const [reason, setReason] = useState('')
|
|
44
|
-
const [focused, setFocused] = useState(0)
|
|
45
35
|
|
|
46
36
|
const deny = (interrupt: boolean) => {
|
|
47
37
|
const message = reason.trim()
|
|
@@ -52,88 +42,6 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
|
|
|
52
42
|
|
|
53
43
|
const summary = toolInputPreview(request.input)
|
|
54
44
|
const ToolIcon = toolIcon(request.toolName)
|
|
55
|
-
const heading = request.title ?? request.displayName ?? 'Permission needed'
|
|
56
|
-
|
|
57
|
-
if (lines) {
|
|
58
|
-
// The same three outcomes as the card, as rows. "Deny" opens the reason
|
|
59
|
-
// field rather than acting, because a denial the agent can learn from is the
|
|
60
|
-
// one worth defaulting to — an empty field still just denies.
|
|
61
|
-
const options = [
|
|
62
|
-
{ key: 'allow', label: 'Allow' },
|
|
63
|
-
{
|
|
64
|
-
key: 'deny',
|
|
65
|
-
label: 'Deny',
|
|
66
|
-
description: 'the turn continues',
|
|
67
|
-
detail: denying ? (
|
|
68
|
-
<LineInput
|
|
69
|
-
value={reason}
|
|
70
|
-
onChange={setReason}
|
|
71
|
-
onSubmit={() => deny(false)}
|
|
72
|
-
onCancel={() => setDenying(false)}
|
|
73
|
-
placeholder='Reason (optional) — the agent reads this and can try something else'
|
|
74
|
-
/>
|
|
75
|
-
) : undefined,
|
|
76
|
-
},
|
|
77
|
-
{ key: 'stop', label: 'Deny & stop', description: 'interrupt the turn', danger: true },
|
|
78
|
-
]
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
<div
|
|
82
|
-
data-slot='permission-prompt'
|
|
83
|
-
className={cn('w-full', className)}
|
|
84
|
-
onKeyDown={(event) => {
|
|
85
|
-
if (event.key === 'Escape') {
|
|
86
|
-
event.preventDefault()
|
|
87
|
-
deny(false)
|
|
88
|
-
}
|
|
89
|
-
}}>
|
|
90
|
-
<div className='flex items-baseline gap-2'>
|
|
91
|
-
<LineGlyph className='text-warning'>!</LineGlyph>
|
|
92
|
-
<span className='min-w-0 flex-1 text-body-sm leading-5 font-medium text-fg-1'>
|
|
93
|
-
{heading}
|
|
94
|
-
</span>
|
|
95
|
-
</div>
|
|
96
|
-
{request.description ? (
|
|
97
|
-
<div className={cn(LINE_INDENT, 'text-label leading-5 text-fg-3')}>
|
|
98
|
-
{request.description}
|
|
99
|
-
</div>
|
|
100
|
-
) : null}
|
|
101
|
-
{request.decisionReason ? (
|
|
102
|
-
<div className={cn(LINE_INDENT, 'text-label leading-5 text-fg-4')}>
|
|
103
|
-
{request.decisionReason}
|
|
104
|
-
</div>
|
|
105
|
-
) : null}
|
|
106
|
-
<button
|
|
107
|
-
type='button'
|
|
108
|
-
onClick={() => setShowInput((v) => !v)}
|
|
109
|
-
className='flex w-full items-baseline gap-2 text-left outline-none hover:bg-surface-hover/60'>
|
|
110
|
-
<LineGlyph className='text-fg-4'>⎿</LineGlyph>
|
|
111
|
-
<span className='min-w-0 flex-1 truncate text-label leading-5 text-fg-4'>
|
|
112
|
-
<span className='font-medium text-fg-2'>{request.toolName}</span>
|
|
113
|
-
{summary ? `(${summary})` : null}
|
|
114
|
-
</span>
|
|
115
|
-
</button>
|
|
116
|
-
{showInput ? (
|
|
117
|
-
<div className={LINE_INDENT}>
|
|
118
|
-
<LinePayload code={JSON.stringify(request.input, null, 2)} language='json' label='Input' />
|
|
119
|
-
</div>
|
|
120
|
-
) : null}
|
|
121
|
-
<LineOptionList
|
|
122
|
-
label={heading}
|
|
123
|
-
options={options}
|
|
124
|
-
focused={focused}
|
|
125
|
-
onFocus={setFocused}
|
|
126
|
-
active={!denying}
|
|
127
|
-
onChoose={(index) => {
|
|
128
|
-
if (index === 0) onApprove(request.id)
|
|
129
|
-
else if (index === 1) setDenying(true)
|
|
130
|
-
else deny(true)
|
|
131
|
-
}}
|
|
132
|
-
/>
|
|
133
|
-
<LineHint>↑↓ move · 1–3 choose · esc deny</LineHint>
|
|
134
|
-
</div>
|
|
135
|
-
)
|
|
136
|
-
}
|
|
137
45
|
|
|
138
46
|
return (
|
|
139
47
|
<div
|
|
@@ -10,8 +10,6 @@ import { Badge } from '../ui/Badge.tsx'
|
|
|
10
10
|
import { Button } from '../ui/Button.tsx'
|
|
11
11
|
import { Input } from '../ui/Input.tsx'
|
|
12
12
|
import { cn } from '../../lib/utils.ts'
|
|
13
|
-
import { LINE_INDENT, LineHint, LineInput, LineOptionList, LinePayload } from './line-prompt.tsx'
|
|
14
|
-
import { LineGlyph, useLines } from './transcript-variant.tsx'
|
|
15
13
|
|
|
16
14
|
export type QuestionBehaviorMeta = {
|
|
17
15
|
value: QuestionBehavior
|
|
@@ -49,9 +47,6 @@ export function parseUserQuestions(input: Record<string, unknown>): UserQuestion
|
|
|
49
47
|
|
|
50
48
|
type Selection = { labels: string[]; other: string; otherActive: boolean }
|
|
51
49
|
|
|
52
|
-
/** Whether the key hint should say "toggle" rather than "choose". */
|
|
53
|
-
const multiSelectAnywhere = (questions: UserQuestion[]) => questions.some((q) => q.multiSelect === true)
|
|
54
|
-
|
|
55
50
|
const EMPTY_SELECTION: Selection = { labels: [], other: '', otherActive: false }
|
|
56
51
|
|
|
57
52
|
/** A question's answer string: chosen label(s) (multi-select comma-joined), with any
|
|
@@ -77,16 +72,10 @@ export interface QuestionPromptProps {
|
|
|
77
72
|
* and the focused option's preview. Falls back to nothing renderable → the caller
|
|
78
73
|
* should show a generic PermissionPrompt if `parseUserQuestions` finds no questions. */
|
|
79
74
|
export function QuestionPrompt({ request, onAnswer, onDismiss, className }: QuestionPromptProps) {
|
|
80
|
-
const lines = useLines()
|
|
81
75
|
const questions = parseUserQuestions(request.input)
|
|
82
76
|
const [selections, setSelections] = useState<Selection[]>(() =>
|
|
83
77
|
questions.map(() => EMPTY_SELECTION),
|
|
84
78
|
)
|
|
85
|
-
// `lines` only: the roving cursor per question, and which question's list owns
|
|
86
|
-
// the DOM focus — two lists both chasing their own index would tear the caret
|
|
87
|
-
// between them on every render.
|
|
88
|
-
const [cursors, setCursors] = useState<number[]>(() => questions.map(() => 0))
|
|
89
|
-
const [activeQuestion, setActiveQuestion] = useState(0)
|
|
90
79
|
|
|
91
80
|
const update = (index: number, patch: Partial<Selection>) => {
|
|
92
81
|
setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
|
|
@@ -115,117 +104,6 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
|
|
|
115
104
|
onAnswer(request.id, { ...request.input, answers })
|
|
116
105
|
}
|
|
117
106
|
|
|
118
|
-
if (lines) {
|
|
119
|
-
const dismiss = () => onDismiss(request.id, 'Question dismissed by user')
|
|
120
|
-
return (
|
|
121
|
-
<div
|
|
122
|
-
data-slot='question-prompt'
|
|
123
|
-
className={cn('flex w-full flex-col gap-2', className)}
|
|
124
|
-
onKeyDown={(event) => {
|
|
125
|
-
if (event.key === 'Escape') {
|
|
126
|
-
event.preventDefault()
|
|
127
|
-
dismiss()
|
|
128
|
-
return
|
|
129
|
-
}
|
|
130
|
-
// Enter belongs to the focused row (it is a button), so committing the
|
|
131
|
-
// whole form needs the modifier — the composer's own convention.
|
|
132
|
-
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && complete) {
|
|
133
|
-
event.preventDefault()
|
|
134
|
-
submit()
|
|
135
|
-
}
|
|
136
|
-
}}>
|
|
137
|
-
{questions.map((q, index) => {
|
|
138
|
-
const selection = selections[index] ?? EMPTY_SELECTION
|
|
139
|
-
const cursor = cursors[index] ?? 0
|
|
140
|
-
const multiSelect = q.multiSelect === true
|
|
141
|
-
const options = [
|
|
142
|
-
...q.options.map((option, optionIndex) => {
|
|
143
|
-
const selected = selection.labels.includes(option.label)
|
|
144
|
-
return {
|
|
145
|
-
key: option.label,
|
|
146
|
-
label: option.label,
|
|
147
|
-
description: option.description,
|
|
148
|
-
// A preview shows on focus, not only on selection: in a list you
|
|
149
|
-
// walk with the arrow keys, "what does this one look like" is the
|
|
150
|
-
// question the cursor is asking.
|
|
151
|
-
detail:
|
|
152
|
-
option.preview && (selected || cursor === optionIndex) ? (
|
|
153
|
-
<LinePayload code={option.preview} label='Preview' />
|
|
154
|
-
) : undefined,
|
|
155
|
-
// Both kinds draw their state: without a marker a chosen
|
|
156
|
-
// one-of is invisible the moment the cursor moves off it.
|
|
157
|
-
checked: selected,
|
|
158
|
-
marker: multiSelect ? ('check' as const) : ('radio' as const),
|
|
159
|
-
}
|
|
160
|
-
}),
|
|
161
|
-
{
|
|
162
|
-
key: '__other',
|
|
163
|
-
label: 'Other…',
|
|
164
|
-
checked: selection.otherActive,
|
|
165
|
-
marker: multiSelect ? ('check' as const) : ('radio' as const),
|
|
166
|
-
detail: selection.otherActive ? (
|
|
167
|
-
<LineInput
|
|
168
|
-
value={selection.other}
|
|
169
|
-
onChange={(value) => update(index, { other: value })}
|
|
170
|
-
onSubmit={() => {
|
|
171
|
-
// The typed text is already in state, so `complete` is
|
|
172
|
-
// current: Enter finishes the form when this was the last
|
|
173
|
-
// question outstanding, and is a no-op when it wasn't.
|
|
174
|
-
if (complete) submit()
|
|
175
|
-
}}
|
|
176
|
-
onCancel={() => update(index, { otherActive: false, other: '' })}
|
|
177
|
-
placeholder='Type your own answer'
|
|
178
|
-
/>
|
|
179
|
-
) : undefined,
|
|
180
|
-
},
|
|
181
|
-
]
|
|
182
|
-
return (
|
|
183
|
-
<div key={index} className='flex flex-col'>
|
|
184
|
-
<div className='flex items-baseline gap-2'>
|
|
185
|
-
<LineGlyph className='text-fg-3'>?</LineGlyph>
|
|
186
|
-
<span className='min-w-0 flex-1 text-body-sm leading-5 text-fg-1'>
|
|
187
|
-
{q.header ? <span className='text-fg-4'>{q.header} · </span> : null}
|
|
188
|
-
<span className='font-medium'>{q.question}</span>
|
|
189
|
-
</span>
|
|
190
|
-
</div>
|
|
191
|
-
<LineOptionList
|
|
192
|
-
label={q.question}
|
|
193
|
-
options={options}
|
|
194
|
-
focused={cursor}
|
|
195
|
-
active={activeQuestion === index && !selection.otherActive}
|
|
196
|
-
onFocus={(next) => {
|
|
197
|
-
setActiveQuestion(index)
|
|
198
|
-
setCursors((prev) => prev.map((c, i) => (i === index ? next : c)))
|
|
199
|
-
}}
|
|
200
|
-
onChoose={(next) => {
|
|
201
|
-
if (next === q.options.length) {
|
|
202
|
-
update(index, { otherActive: !selection.otherActive })
|
|
203
|
-
return
|
|
204
|
-
}
|
|
205
|
-
const option = q.options[next]
|
|
206
|
-
if (option) toggle(index, option.label, multiSelect)
|
|
207
|
-
}}
|
|
208
|
-
/>
|
|
209
|
-
</div>
|
|
210
|
-
)
|
|
211
|
-
})}
|
|
212
|
-
<div className={cn(LINE_INDENT, 'flex flex-col')}>
|
|
213
|
-
<button
|
|
214
|
-
type='button'
|
|
215
|
-
disabled={!complete}
|
|
216
|
-
onClick={submit}
|
|
217
|
-
className='w-fit text-body-sm leading-5 text-accent underline-offset-2 outline-none hover:underline disabled:text-fg-4 disabled:no-underline'>
|
|
218
|
-
⏎ Answer
|
|
219
|
-
</button>
|
|
220
|
-
</div>
|
|
221
|
-
<LineHint>
|
|
222
|
-
↑↓ move · {multiSelectAnywhere(questions) ? 'space/1–9 toggle' : '1–9 choose'} · ⌘⏎ answer
|
|
223
|
-
· esc dismiss
|
|
224
|
-
</LineHint>
|
|
225
|
-
</div>
|
|
226
|
-
)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
107
|
return (
|
|
230
108
|
<div
|
|
231
109
|
data-slot='question-prompt'
|
|
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
2
2
|
import { Brain, ChevronDown } from 'lucide-react'
|
|
3
3
|
import { cn } from '../../lib/utils.ts'
|
|
4
4
|
import { Response } from './Response.tsx'
|
|
5
|
-
import { LineGlyph, useLines } from './transcript-variant.tsx'
|
|
6
5
|
|
|
7
6
|
export interface ReasoningProps {
|
|
8
7
|
children: string
|
|
@@ -15,7 +14,6 @@ export interface ReasoningProps {
|
|
|
15
14
|
/** Collapsible extended-thinking block: open while the model is thinking, tucks itself
|
|
16
15
|
* away once the thought is finished (unless the user toggled it manually). */
|
|
17
16
|
export function Reasoning({ children, isStreaming = false, defaultOpen, className }: ReasoningProps) {
|
|
18
|
-
const lines = useLines()
|
|
19
17
|
const [open, setOpen] = useState(defaultOpen ?? isStreaming)
|
|
20
18
|
const userToggled = useRef(false)
|
|
21
19
|
|
|
@@ -43,28 +41,16 @@ export function Reasoning({ children, isStreaming = false, defaultOpen, classNam
|
|
|
43
41
|
userToggled.current = true
|
|
44
42
|
setOpen((v) => !v)
|
|
45
43
|
}}
|
|
46
|
-
className=
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
{lines ? (
|
|
51
|
-
<LineGlyph className='text-fg-4'>✻</LineGlyph>
|
|
52
|
-
) : (
|
|
53
|
-
<Brain className='size-3.5' />
|
|
54
|
-
)}
|
|
55
|
-
<span className={cn(lines && 'leading-5')}>
|
|
56
|
-
{isStreaming ? 'Thinking…' : 'Thought process'}
|
|
57
|
-
</span>
|
|
58
|
-
{hasText && !lines ? (
|
|
44
|
+
className='flex items-center gap-1.5 text-label text-fg-3 transition-colors outline-none disabled:cursor-default hover:text-fg-1 disabled:hover:text-fg-3'>
|
|
45
|
+
<Brain className='size-3.5' />
|
|
46
|
+
<span>{isStreaming ? 'Thinking…' : 'Thought process'}</span>
|
|
47
|
+
{hasText ? (
|
|
59
48
|
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
|
|
60
49
|
) : null}
|
|
61
50
|
</button>
|
|
62
51
|
{open && hasText ? (
|
|
63
52
|
<div
|
|
64
|
-
className=
|
|
65
|
-
'border-l border-border text-body-sm text-fg-3 [&_*]:text-fg-3',
|
|
66
|
-
lines ? 'ml-[0.4375rem] mt-0.5 pl-2 leading-5' : 'mt-2 border-l-2 pl-3',
|
|
67
|
-
)}>
|
|
53
|
+
className='mt-2 border-l-2 border-border pl-3 text-body-sm text-fg-3 [&_*]:text-fg-3'>
|
|
68
54
|
<Response streaming={isStreaming}>{children}</Response>
|
|
69
55
|
</div>
|
|
70
56
|
) : null}
|
|
@@ -1,131 +1,6 @@
|
|
|
1
1
|
import { memo } from 'react'
|
|
2
2
|
import { Streamdown } from 'streamdown'
|
|
3
3
|
import { cn } from '../../lib/utils.ts'
|
|
4
|
-
import { useLines } from './transcript-variant.tsx'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Markdown on a terminal's grid.
|
|
8
|
-
*
|
|
9
|
-
* Prose defaults give every block its own rhythm — a heading is bigger, a list
|
|
10
|
-
* item is looser, an inline `code` chip is padded taller than the line it sits
|
|
11
|
-
* in — and the result is text that visibly breathes at different rates as you
|
|
12
|
-
* scroll. A terminal has exactly one line height and one type size, and that
|
|
13
|
-
* uniformity is most of what makes it readable at a glance.
|
|
14
|
-
*
|
|
15
|
-
* So: every line 1.25rem, every block flush, and one blank line (1.25rem, i.e.
|
|
16
|
-
* exactly one row) between blocks. Headings keep their weight but lose their
|
|
17
|
-
* size. Inline code keeps a background — it still has to be distinguishable —
|
|
18
|
-
* but no vertical padding, because that is what pushes a line off the grid.
|
|
19
|
-
*/
|
|
20
|
-
const TERMINAL_PROSE = [
|
|
21
|
-
'text-body-sm leading-5',
|
|
22
|
-
'[&_*]:leading-5',
|
|
23
|
-
// Block rhythm: nothing has its own margins, and siblings are one row apart.
|
|
24
|
-
// `!` throughout because these fight the renderer's own utilities, and some of
|
|
25
|
-
// those (`space-y-*`, which is a `> * + *` rule) outrank a plain descendant
|
|
26
|
-
// selector — a list would otherwise keep its half-row gaps while every other
|
|
27
|
-
// block snapped to the grid.
|
|
28
|
-
'[&_p]:my-0! [&_pre]:my-0! [&_blockquote]:my-0! [&_table]:my-0!',
|
|
29
|
-
'[&_h1]:my-0! [&_h2]:my-0! [&_h3]:my-0! [&_h4]:my-0! [&_h5]:my-0! [&_h6]:my-0!',
|
|
30
|
-
'[&>*+*]:mt-5',
|
|
31
|
-
// Lists are rows, not paragraphs: no margin anywhere in them, and no
|
|
32
|
-
// inter-item spacing from a `space-y` on the list itself.
|
|
33
|
-
'[&_ul]:my-0! [&_ol]:my-0! [&_li]:my-0! [&_li>p]:my-0!',
|
|
34
|
-
'[&_li+li]:mt-0! [&_ul_ul]:mt-0! [&_ul_ol]:mt-0! [&_ol_ul]:mt-0! [&_ol_ol]:mt-0!',
|
|
35
|
-
'[&_li]:py-0! [&_ul]:py-0! [&_ol]:py-0!',
|
|
36
|
-
// Markers on their own column, at the width the marker actually is — the
|
|
37
|
-
// indent a terminal (and the markdown source) uses, not a typographic one.
|
|
38
|
-
// `ch` is the unit because this variant runs in a monospace face: `• ` is two
|
|
39
|
-
// cells, `1. ` is three, so the text starts exactly where it would in the raw
|
|
40
|
-
// document and a wrapped line hangs under it instead of under the bullet.
|
|
41
|
-
// Nesting then costs one marker width per level, which is the 2-space step.
|
|
42
|
-
// `list-outside` is the load-bearing half: the renderer marks lists `inside`,
|
|
43
|
-
// where the marker joins the text flow — so the indent moves the bullet too
|
|
44
|
-
// (the list sits a marker-width right of the paragraph above it) and a wrapped
|
|
45
|
-
// line runs back under the bullet instead of hanging under its own text.
|
|
46
|
-
'[&_ul]:list-outside! [&_ol]:list-outside!',
|
|
47
|
-
'[&_ul]:pl-[2ch]! [&_ol]:pl-[3ch]! [&_li]:pl-0!',
|
|
48
|
-
// Headings are weight, not size — a terminal has one type size.
|
|
49
|
-
'[&_h1]:text-body-sm [&_h2]:text-body-sm [&_h3]:text-body-sm',
|
|
50
|
-
'[&_h4]:text-body-sm [&_h5]:text-body-sm [&_h6]:text-body-sm',
|
|
51
|
-
'[&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold',
|
|
52
|
-
'[&_h4]:font-semibold [&_h5]:font-semibold [&_h6]:font-semibold',
|
|
53
|
-
'[&_blockquote]:border-l [&_blockquote]:border-border [&_blockquote]:pl-2',
|
|
54
|
-
// Inline code only (`:not(pre) > code`) — fenced blocks keep their own box.
|
|
55
|
-
// Horizontal padding is free; vertical padding is what pushes a line off grid.
|
|
56
|
-
'[&_:not(pre)>code]:rounded-none [&_:not(pre)>code]:px-1! [&_:not(pre)>code]:py-0!',
|
|
57
|
-
'[&_:not(pre)>code]:text-body-sm [&_:not(pre)>code]:leading-5! [&_:not(pre)>code]:text-fg-1',
|
|
58
|
-
'[&_th]:py-0! [&_td]:py-0!',
|
|
59
|
-
// The renderer's fenced-code card: rounded frame, its own header strip, a
|
|
60
|
-
// second rounded frame inside it, and a floating pill for the buttons. Four
|
|
61
|
-
// boxes around what is, in a terminal, a band of dim text. Flattened to
|
|
62
|
-
// exactly that — the band keeps a background so a block still reads as code,
|
|
63
|
-
// and the actions keep their top-right corner but lose the pill.
|
|
64
|
-
'[&_[data-streamdown=code-block]]:my-0! [&_[data-streamdown=code-block]]:gap-0!',
|
|
65
|
-
'[&_[data-streamdown=code-block]]:rounded-none! [&_[data-streamdown=code-block]]:border-0!',
|
|
66
|
-
'[&_[data-streamdown=code-block]]:bg-transparent! [&_[data-streamdown=code-block]]:p-0!',
|
|
67
|
-
// The language strip costs a whole row to say what the code already shows, and
|
|
68
|
-
// the actions cost another. Both go: the block is the band, and the buttons
|
|
69
|
-
// float over its top-right corner, appearing on hover or keyboard focus.
|
|
70
|
-
'[&_[data-streamdown=code-block]]:relative!',
|
|
71
|
-
'[&_[data-streamdown=code-block-header]]:hidden!',
|
|
72
|
-
'[&_div:has(>[data-streamdown=code-block-actions])]:absolute! [&_div:has(>[data-streamdown=code-block-actions])]:top-0!',
|
|
73
|
-
'[&_div:has(>[data-streamdown=code-block-actions])]:right-0! [&_div:has(>[data-streamdown=code-block-actions])]:mt-0!',
|
|
74
|
-
'[&_div:has(>[data-streamdown=code-block-actions])]:h-5! [&_div:has(>[data-streamdown=code-block-actions])]:opacity-0!',
|
|
75
|
-
'[&_div:has(>[data-streamdown=code-block-actions])]:transition-opacity',
|
|
76
|
-
'[&_[data-streamdown=code-block]:hover_div:has(>[data-streamdown=code-block-actions])]:opacity-100!',
|
|
77
|
-
'[&_[data-streamdown=code-block]:focus-within_div:has(>[data-streamdown=code-block-actions])]:opacity-100!',
|
|
78
|
-
'[&_[data-streamdown=code-block-actions]]:rounded-none! [&_[data-streamdown=code-block-actions]]:border-0!',
|
|
79
|
-
'[&_[data-streamdown=code-block-actions]]:bg-code-bg! [&_[data-streamdown=code-block-actions]]:px-1!',
|
|
80
|
-
'[&_[data-streamdown=code-block-actions]]:py-0! [&_[data-streamdown=code-block-actions]]:gap-1!',
|
|
81
|
-
'[&_[data-streamdown=code-block-actions]]:backdrop-blur-none!',
|
|
82
|
-
'[&_[data-streamdown=code-block-body]]:rounded-none! [&_[data-streamdown=code-block-body]]:border-0!',
|
|
83
|
-
'[&_[data-streamdown=code-block-body]]:bg-code-bg! [&_[data-streamdown=code-block-body]]:px-2!',
|
|
84
|
-
'[&_[data-streamdown=code-block-body]]:py-0! [&_[data-streamdown=code-block-body]]:text-body-sm!',
|
|
85
|
-
// The action buttons' hit areas, on the grid like everything else.
|
|
86
|
-
'[&_[data-streamdown=code-block-copy-button]]:p-0! [&_[data-streamdown=code-block-download-button]]:p-0!',
|
|
87
|
-
// Tables come in a frame of their own, plus a controls row above them and a
|
|
88
|
-
// second frame around the scroller — three boxes and a blank line to hold
|
|
89
|
-
// three buttons. Same treatment: the table is the content, the buttons float
|
|
90
|
-
// over its top-right on hover. The controls row is the child that has no
|
|
91
|
-
// `<table>` in it, which is also how it knows to disappear when the renderer
|
|
92
|
-
// draws no controls at all.
|
|
93
|
-
'[&_[data-streamdown=table-wrapper]]:rounded-none! [&_[data-streamdown=table-wrapper]]:border-0!',
|
|
94
|
-
'[&_[data-streamdown=table-wrapper]]:my-0! [&_[data-streamdown=table-wrapper]]:bg-transparent!',
|
|
95
|
-
'[&_[data-streamdown=table-wrapper]]:relative! [&_[data-streamdown=table-wrapper]]:gap-0!',
|
|
96
|
-
'[&_[data-streamdown=table-wrapper]]:p-0!',
|
|
97
|
-
'[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:absolute! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:top-0!',
|
|
98
|
-
'[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:right-0! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:z-10!',
|
|
99
|
-
'[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:opacity-0! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:bg-code-bg!',
|
|
100
|
-
'[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:px-1! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:transition-opacity',
|
|
101
|
-
'[&_[data-streamdown=table-wrapper]:hover>div:not(:has(table))]:opacity-100!',
|
|
102
|
-
'[&_[data-streamdown=table-wrapper]:focus-within>div:not(:has(table))]:opacity-100!',
|
|
103
|
-
'[&_[data-streamdown=table-wrapper]>div:has(table)]:rounded-none! [&_[data-streamdown=table-wrapper]>div:has(table)]:border-0!',
|
|
104
|
-
'[&_[data-streamdown=table-wrapper]>div:has(table)]:bg-transparent!',
|
|
105
|
-
].join(' ')
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The controls' icons as characters.
|
|
109
|
-
*
|
|
110
|
-
* The renderer draws its copy/download affordances as line-art SVGs, which next
|
|
111
|
-
* to monospace text read as buttons from another application. These are the
|
|
112
|
-
* glyphs a terminal would use — and they inherit the surrounding font, so they
|
|
113
|
-
* sit on the same grid as the code they belong to.
|
|
114
|
-
*/
|
|
115
|
-
const glyphIcon = (glyph: string) =>
|
|
116
|
-
function GlyphIcon({ className }: { className?: string }) {
|
|
117
|
-
return (
|
|
118
|
-
<span aria-hidden className={cn('font-mono text-body-sm leading-5', className)}>
|
|
119
|
-
{glyph}
|
|
120
|
-
</span>
|
|
121
|
-
)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const TERMINAL_ICONS = {
|
|
125
|
-
DownloadIcon: glyphIcon('⤓'),
|
|
126
|
-
CopyIcon: glyphIcon('⧉'),
|
|
127
|
-
CheckIcon: glyphIcon('✓'),
|
|
128
|
-
}
|
|
129
4
|
|
|
130
5
|
export interface ResponseProps {
|
|
131
6
|
children: string
|
|
@@ -138,18 +13,12 @@ export interface ResponseProps {
|
|
|
138
13
|
* highlighted with shiki (dual theme follows [data-theme] through the dark: variant). */
|
|
139
14
|
export const Response = memo(
|
|
140
15
|
function Response({ children, streaming, className }: ResponseProps) {
|
|
141
|
-
const lines = useLines()
|
|
142
16
|
return (
|
|
143
17
|
<Streamdown
|
|
144
18
|
mode={streaming ? 'streaming' : 'static'}
|
|
145
19
|
parseIncompleteMarkdown={streaming}
|
|
146
20
|
shikiTheme={['github-light', 'github-dark']}
|
|
147
|
-
|
|
148
|
-
className={cn(
|
|
149
|
-
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
|
150
|
-
lines && TERMINAL_PROSE,
|
|
151
|
-
className,
|
|
152
|
-
)}>
|
|
21
|
+
className={cn('size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0', className)}>
|
|
153
22
|
{children}
|
|
154
23
|
</Streamdown>
|
|
155
24
|
)
|