@workerdeck/ui 0.23.0 → 1.0.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/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +15 -23
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +5 -126
- package/build/workspace.mjs +11 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +3 -9
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +315 -996
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +104 -219
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +96 -32
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-BobynUo4.d.mts +0 -669
- package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -2,64 +2,25 @@ import { useEffect, useRef, type ReactNode } from 'react'
|
|
|
2
2
|
import { cn } from '../../lib/utils.ts'
|
|
3
3
|
import { Blank, Ink, Row } from './row.tsx'
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* The parts a terminal prompt is built from.
|
|
7
|
-
*
|
|
8
|
-
* An approval and a question are the two places the transcript stops being a log
|
|
9
|
-
* and becomes a form, and "no boxes" has to be paid for by something. In the CLI
|
|
10
|
-
* it is paid for three ways, and all three are here: a **rule** marks where the
|
|
11
|
-
* run stops and the decision starts, the options are **numbered** so a key press
|
|
12
|
-
* is an answer, and a **hint line** says which keys. That is what makes a prompt
|
|
13
|
-
* answerable without reaching for the mouse — which is the whole reason a
|
|
14
|
-
* terminal UI can be faster than a dialog.
|
|
15
|
-
*
|
|
16
|
-
* Everything stays on the grid: the rules are one line tall with the stroke
|
|
17
|
-
* drawn through the middle by a background (a border would cost layout), and the
|
|
18
|
-
* roving `❯` lives in the same gutter cell every other row uses.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The boundary above a prompt. Solid separates the run from the decision; dashed
|
|
23
|
-
* separates parts *within* it (the CLI puts one between a diff and its question),
|
|
24
|
-
* which is why there are two weights and not one.
|
|
25
|
-
*/
|
|
26
5
|
export function Rule({ dashed }: { dashed?: boolean }) {
|
|
27
6
|
return <div className={cn('term-rule-row', dashed && 'term-rule-dashed')} aria-hidden />
|
|
28
7
|
}
|
|
29
8
|
|
|
30
|
-
/** The dim `·`-separated key legend under a prompt. */
|
|
31
9
|
export function Hint({ children }: { children: ReactNode }) {
|
|
32
|
-
return
|
|
33
|
-
<Row tone='faint'>
|
|
34
|
-
{children}
|
|
35
|
-
</Row>
|
|
36
|
-
)
|
|
10
|
+
return <Row tone="faint">{children}</Row>
|
|
37
11
|
}
|
|
38
12
|
|
|
39
|
-
/**
|
|
40
|
-
* A framed payload — a preview, a snippet. The frame is drawn with four
|
|
41
|
-
* background gradients rather than a border, so it costs no layout: a 1px border
|
|
42
|
-
* would push its contents a pixel off the column every other row sits on.
|
|
43
|
-
*/
|
|
44
13
|
export function Box({ children, className }: { children: ReactNode; className?: string }) {
|
|
45
14
|
return <div className={cn('term-box', className)}>{children}</div>
|
|
46
15
|
}
|
|
47
16
|
|
|
48
|
-
/**
|
|
49
|
-
* The prompt's heading: what is being asked, and about what.
|
|
50
|
-
*
|
|
51
|
-
* Two lines because the engine gives two — `displayName` ("Edit file") is the
|
|
52
|
-
* action, and the subject (the path) is the thing it acts on. The CLI shows them
|
|
53
|
-
* exactly this way, and it is the one place in the theme where colour is used
|
|
54
|
-
* for emphasis rather than for state.
|
|
55
|
-
*/
|
|
56
17
|
export function PromptTitle({ title, subject }: { title: string; subject?: string }) {
|
|
57
18
|
return (
|
|
58
19
|
<>
|
|
59
|
-
<Row tone=
|
|
20
|
+
<Row tone="blue" bold>
|
|
60
21
|
{title}
|
|
61
22
|
</Row>
|
|
62
|
-
{subject ? <Row tone=
|
|
23
|
+
{subject ? <Row tone="dim">{subject}</Row> : null}
|
|
63
24
|
</>
|
|
64
25
|
)
|
|
65
26
|
}
|
|
@@ -67,30 +28,14 @@ export function PromptTitle({ title, subject }: { title: string; subject?: strin
|
|
|
67
28
|
export type Choice = {
|
|
68
29
|
key: string
|
|
69
30
|
label: string
|
|
70
|
-
/** Rendered dim on its own row under the label, as the CLI does for a
|
|
71
|
-
* multi-select's options — never appended to the label, which would make the
|
|
72
|
-
* row wrap and cost the list its scannability. */
|
|
73
31
|
description?: string
|
|
74
|
-
/**
|
|
75
|
-
* Present → the row carries selection state and draws it. `marker` says in
|
|
76
|
-
* which idiom: `[x]` for a multi-select, `(•)` for a one-of. Absent → the row
|
|
77
|
-
* is an action (Allow, Cancel), which has no state to show.
|
|
78
|
-
*/
|
|
79
32
|
checked?: boolean
|
|
80
33
|
marker?: 'check' | 'radio'
|
|
81
|
-
/**
|
|
82
|
-
* Chosen, in a list that draws no markers (a one-of). The colour is the whole
|
|
83
|
-
* signal there: without it, tabbing back to an answered question would show no
|
|
84
|
-
* trace of the answer given.
|
|
85
|
-
*/
|
|
86
34
|
selected?: boolean
|
|
87
35
|
danger?: boolean
|
|
88
|
-
/** Rendered under the row, outside the button — a preview, a text field. The
|
|
89
|
-
* caller decides when it exists (focused, checked); a button may not hold one. */
|
|
90
36
|
detail?: ReactNode
|
|
91
37
|
}
|
|
92
38
|
|
|
93
|
-
/** The two-state glyph pairs, in the forms a terminal would use. */
|
|
94
39
|
const MARKERS = {
|
|
95
40
|
check: ['[ ]', '[✓]'],
|
|
96
41
|
radio: ['( )', '(•)'],
|
|
@@ -98,103 +43,49 @@ const MARKERS = {
|
|
|
98
43
|
|
|
99
44
|
export interface ChoicesProps {
|
|
100
45
|
options: Choice[]
|
|
101
|
-
/** Roving index: the one row that is tab-reachable and wears the `❯`. */
|
|
102
46
|
focused: number
|
|
103
47
|
onFocus: (index: number) => void
|
|
104
48
|
onChoose: (index: number) => void
|
|
105
|
-
/**
|
|
106
|
-
* Own the DOM focus, moving it with the roving index. False while something
|
|
107
|
-
* else inside the prompt holds it (a text field, another question's list) —
|
|
108
|
-
* two lists both chasing `focused` would tear the caret back and forth.
|
|
109
|
-
*/
|
|
110
49
|
active?: boolean
|
|
111
|
-
/**
|
|
112
|
-
* Take the keyboard when the list first appears. True by default — a prompt
|
|
113
|
-
* whose whole affordance is "press 1" is useless if the keys go somewhere
|
|
114
|
-
* else, and the CLI hands the keyboard over the moment it asks.
|
|
115
|
-
*
|
|
116
|
-
* It is a *first mount* decision only, and it declines when the reader is
|
|
117
|
-
* already typing (see {@link isTyping}): an approval landing mid-sentence must
|
|
118
|
-
* not pull the caret out of the composer and scatter the rest of the sentence
|
|
119
|
-
* across an option list.
|
|
120
|
-
*/
|
|
121
50
|
autoFocus?: boolean
|
|
122
51
|
label: string
|
|
123
52
|
}
|
|
124
53
|
|
|
125
|
-
/** Is the reader mid-keystroke somewhere that keeps its own caret? */
|
|
126
54
|
function isTyping(element: Element | null): boolean {
|
|
127
|
-
if (!(element instanceof HTMLElement))
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// shown and again on any click in dead space, so the field was *always* the
|
|
136
|
-
// active element and the prompt therefore *never* took the keyboard. The
|
|
137
|
-
// approval that has to be answered was the one thing you could not answer
|
|
138
|
-
// without reaching for the mouse.
|
|
139
|
-
//
|
|
140
|
-
// An empty field has nothing to lose, so the takeover proceeds; a half-typed
|
|
141
|
-
// message still wins, which is the case the guard was written for.
|
|
142
|
-
const text =
|
|
143
|
-
element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
|
|
144
|
-
? element.value
|
|
145
|
-
: (element.textContent ?? '')
|
|
55
|
+
if (!(element instanceof HTMLElement)) {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
const editable = element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.isContentEditable
|
|
59
|
+
if (!editable) {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
const text = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : (element.textContent ?? '')
|
|
146
63
|
return text.trim().length > 0
|
|
147
64
|
}
|
|
148
65
|
|
|
149
|
-
|
|
150
|
-
* A keyboard-first list of choices, as rows: `↑`/`↓` move, `1`–`9` pick
|
|
151
|
-
* directly, `Enter`/`Space` take the focused one (the button does that itself).
|
|
152
|
-
*
|
|
153
|
-
* The number is part of the gutter, not the label, so every option's text starts
|
|
154
|
-
* on the same column and the list reads as a column of answers rather than a
|
|
155
|
-
* ragged paragraph.
|
|
156
|
-
*/
|
|
157
|
-
export function Choices({
|
|
158
|
-
options,
|
|
159
|
-
focused,
|
|
160
|
-
onFocus,
|
|
161
|
-
onChoose,
|
|
162
|
-
active = true,
|
|
163
|
-
autoFocus = true,
|
|
164
|
-
label,
|
|
165
|
-
}: ChoicesProps) {
|
|
66
|
+
export function Choices({ options, focused, onFocus, onChoose, active = true, autoFocus = true, label }: ChoicesProps) {
|
|
166
67
|
const refs = useRef<Array<HTMLButtonElement | null>>([])
|
|
167
68
|
|
|
168
69
|
useEffect(() => {
|
|
169
|
-
if (!active)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// is refusable so it cannot snatch a half-written message.
|
|
177
|
-
//
|
|
178
|
-
// This used to be a `mounted` ref: refuse on the first pass, follow on
|
|
179
|
-
// every pass after. That is not safe under StrictMode, which mounts,
|
|
180
|
-
// unmounts and remounts in development — the ref survives the simulated
|
|
181
|
-
// remount, so the second pass saw `mounted === true`, skipped the guard
|
|
182
|
-
// entirely and stole focus from whatever you were typing. It read as
|
|
183
|
-
// correct in production and wrong in dev, which is the worst way round.
|
|
184
|
-
const focusIsInList = refs.current.some(
|
|
185
|
-
(row) => row !== null && row === document.activeElement,
|
|
186
|
-
)
|
|
187
|
-
if (!focusIsInList && (!autoFocus || isTyping(document.activeElement))) return
|
|
70
|
+
if (!active) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const focusIsInList = refs.current.some((row) => row !== null && row === document.activeElement)
|
|
74
|
+
if (!focusIsInList && (!autoFocus || isTyping(document.activeElement))) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
188
77
|
refs.current[focused]?.focus()
|
|
189
78
|
}, [active, focused, autoFocus])
|
|
190
79
|
|
|
191
80
|
const move = (delta: number) => {
|
|
192
|
-
if (options.length > 0)
|
|
81
|
+
if (options.length > 0) {
|
|
82
|
+
onFocus((focused + delta + options.length) % options.length)
|
|
83
|
+
}
|
|
193
84
|
}
|
|
194
85
|
|
|
195
86
|
return (
|
|
196
87
|
<div
|
|
197
|
-
role=
|
|
88
|
+
role="group"
|
|
198
89
|
aria-label={label}
|
|
199
90
|
onKeyDown={(event) => {
|
|
200
91
|
if (event.key === 'ArrowDown') {
|
|
@@ -207,16 +98,14 @@ export function Choices({
|
|
|
207
98
|
event.preventDefault()
|
|
208
99
|
return
|
|
209
100
|
}
|
|
210
|
-
// Digits are the whole point of numbering the rows — but only as far as
|
|
211
|
-
// the rows that exist, so `9` on a three-option prompt stays a no-op
|
|
212
|
-
// rather than a silent miss.
|
|
213
101
|
const digit = Number(event.key)
|
|
214
102
|
if (Number.isInteger(digit) && digit >= 1 && digit <= Math.min(options.length, 9)) {
|
|
215
103
|
onFocus(digit - 1)
|
|
216
104
|
onChoose(digit - 1)
|
|
217
105
|
event.preventDefault()
|
|
218
106
|
}
|
|
219
|
-
}}
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
220
109
|
{options.map((option, index) => {
|
|
221
110
|
const isFocused = index === focused
|
|
222
111
|
return (
|
|
@@ -225,34 +114,32 @@ export function Choices({
|
|
|
225
114
|
ref={(element) => {
|
|
226
115
|
refs.current[index] = element
|
|
227
116
|
}}
|
|
228
|
-
type=
|
|
117
|
+
type="button"
|
|
229
118
|
tabIndex={isFocused ? 0 : -1}
|
|
230
119
|
aria-pressed={option.checked}
|
|
231
120
|
onFocus={() => onFocus(index)}
|
|
232
121
|
onClick={() => onChoose(index)}
|
|
233
|
-
className=
|
|
234
|
-
|
|
235
|
-
starts on one column whether or not the row is focused. */}
|
|
122
|
+
className="term-press"
|
|
123
|
+
>
|
|
236
124
|
<Row
|
|
237
125
|
columns={5}
|
|
238
126
|
glyph={`${isFocused ? '❯' : ' '} ${index + 1}.`}
|
|
239
127
|
glyphTone={isFocused ? 'fg' : 'faint'}
|
|
240
128
|
tone={option.danger ? 'red' : option.selected ? 'green' : 'fg'}
|
|
241
|
-
data-focused={isFocused ? '' : undefined}
|
|
129
|
+
data-focused={isFocused ? '' : undefined}
|
|
130
|
+
>
|
|
242
131
|
{option.checked !== undefined ? (
|
|
243
|
-
<Ink tone={option.checked ? 'green' : 'faint'}>
|
|
244
|
-
{MARKERS[option.marker ?? 'check'][option.checked ? 1 : 0]}{' '}
|
|
245
|
-
</Ink>
|
|
132
|
+
<Ink tone={option.checked ? 'green' : 'faint'}>{MARKERS[option.marker ?? 'check'][option.checked ? 1 : 0]} </Ink>
|
|
246
133
|
) : null}
|
|
247
134
|
<Ink bold={isFocused || option.selected}>{option.label}</Ink>
|
|
248
135
|
</Row>
|
|
249
136
|
</button>
|
|
250
137
|
{option.description ? (
|
|
251
|
-
<Row columns={5} tone=
|
|
138
|
+
<Row columns={5} tone="dim">
|
|
252
139
|
{option.description}
|
|
253
140
|
</Row>
|
|
254
141
|
) : null}
|
|
255
|
-
{option.detail ? <div className=
|
|
142
|
+
{option.detail ? <div className="term-detail">{option.detail}</div> : null}
|
|
256
143
|
</div>
|
|
257
144
|
)
|
|
258
145
|
})}
|
|
@@ -260,10 +147,6 @@ export function Choices({
|
|
|
260
147
|
)
|
|
261
148
|
}
|
|
262
149
|
|
|
263
|
-
/**
|
|
264
|
-
* A single-line text field in the terminal idiom: a caret and a rule, no box.
|
|
265
|
-
* `Enter` commits, `Escape` backs out — the caller says what those mean.
|
|
266
|
-
*/
|
|
267
150
|
export function PromptInput({
|
|
268
151
|
value,
|
|
269
152
|
onChange,
|
|
@@ -278,7 +161,7 @@ export function PromptInput({
|
|
|
278
161
|
placeholder?: string
|
|
279
162
|
}) {
|
|
280
163
|
return (
|
|
281
|
-
<Row columns={5} glyph=
|
|
164
|
+
<Row columns={5} glyph=" ›" glyphTone="dim">
|
|
282
165
|
<input
|
|
283
166
|
autoFocus
|
|
284
167
|
value={value}
|
|
@@ -290,52 +173,39 @@ export function PromptInput({
|
|
|
290
173
|
onSubmit()
|
|
291
174
|
}
|
|
292
175
|
if (event.key === 'Escape') {
|
|
293
|
-
// The prompt's own Escape means deny/dismiss; inside the field it
|
|
294
|
-
// only closes the field, so it must not travel further.
|
|
295
176
|
event.stopPropagation()
|
|
296
177
|
onCancel()
|
|
297
178
|
}
|
|
298
179
|
}}
|
|
299
|
-
className=
|
|
180
|
+
className="term-input"
|
|
300
181
|
/>
|
|
301
182
|
</Row>
|
|
302
183
|
)
|
|
303
184
|
}
|
|
304
185
|
|
|
305
|
-
/**
|
|
306
|
-
* The question strip: one chip per question plus the submit step, with the
|
|
307
|
-
* active one filled.
|
|
308
|
-
*
|
|
309
|
-
* It exists because the CLI asks **one question at a time**, and a form that
|
|
310
|
-
* hides two of its three questions has to say so — otherwise answering the first
|
|
311
|
-
* looks like finishing. The arrows are not controls, they are the legend for
|
|
312
|
-
* `Tab`/`Shift+Tab`, which is what actually moves between them.
|
|
313
|
-
*/
|
|
314
186
|
export function TabStrip({
|
|
315
187
|
tabs,
|
|
316
188
|
active,
|
|
317
189
|
onSelect,
|
|
318
190
|
}: {
|
|
319
|
-
/** `glyph` rather than a derived done/not-done mark: the submit step is always
|
|
320
|
-
* a `✓` (it is the act of finishing, not a thing to answer), and deriving it
|
|
321
|
-
* would make it a hollow box until every question was done. */
|
|
322
191
|
tabs: { key: string; label: string; glyph: string }[]
|
|
323
192
|
active: number
|
|
324
193
|
onSelect: (index: number) => void
|
|
325
194
|
}) {
|
|
326
195
|
return (
|
|
327
|
-
<Row glyph=
|
|
196
|
+
<Row glyph="←" glyphTone="faint">
|
|
328
197
|
{tabs.map((tab, index) => (
|
|
329
198
|
<button
|
|
330
199
|
key={tab.key}
|
|
331
|
-
type=
|
|
200
|
+
type="button"
|
|
332
201
|
tabIndex={-1}
|
|
333
202
|
onClick={() => onSelect(index)}
|
|
334
|
-
className={cn('term-tab', index === active && 'term-tab-active')}
|
|
203
|
+
className={cn('term-tab', index === active && 'term-tab-active')}
|
|
204
|
+
>
|
|
335
205
|
{tab.glyph} {tab.label}
|
|
336
206
|
</button>
|
|
337
207
|
))}
|
|
338
|
-
<Ink tone=
|
|
208
|
+
<Ink tone="faint"> →</Ink>
|
|
339
209
|
</Row>
|
|
340
210
|
)
|
|
341
211
|
}
|
|
@@ -1,59 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* How much of a tool result a collapsed row shows, and what it says it hid.
|
|
3
|
-
*
|
|
4
|
-
* Its own module, and pure, because **two** consumers must agree on it to the
|
|
5
|
-
* character: `items.tsx` draws these rows, and `height.ts` predicts their pixel
|
|
6
|
-
* height for the virtualizer's `estimateSize` without a DOM. The budget used to
|
|
7
|
-
* be a private constant in `items.tsx` restated as a copy in `height.ts` with a
|
|
8
|
-
* comment admitting the drift risk — this is that comment's fix.
|
|
9
|
-
*
|
|
10
|
-
* **Two budgets, not one.** Lines alone was the old rule and it has an exact
|
|
11
|
-
* blind spot: a minified JSON reply — which is every MCP tool's reply — is ONE
|
|
12
|
-
* line, so a four-line slice kept all thirty thousand characters of it and the
|
|
13
|
-
* row wrapped to a screenful. `hidden` was computed as `lines.length -
|
|
14
|
-
* shown.length`, so it came out zero and the row did not even offer the "+N"
|
|
15
|
-
* affordance: the whole blob was simply the transcript now. Characters alone
|
|
16
|
-
* would be wrong the other way, cutting an ordinary short-line result mid-way
|
|
17
|
-
* for no reason. So both apply, and a *first* line longer than the budget is
|
|
18
|
-
* truncated rather than shown whole — a row has to show something or it opens
|
|
19
|
-
* onto nothing.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/** At most this many lines, however short they are. */
|
|
23
1
|
const PREVIEW_LINES = 4
|
|
24
|
-
/**
|
|
25
|
-
* …and at most this many characters, however few lines they are. Four lines'
|
|
26
|
-
* worth at any realistic terminal width — the row is indented six cells, so a
|
|
27
|
-
* 100ch panel fits ~94 per line — which keeps the budget honest whether the
|
|
28
|
-
* result arrives as four lines or as one long one.
|
|
29
|
-
*/
|
|
30
2
|
const PREVIEW_CHARS = 400
|
|
31
3
|
|
|
32
4
|
export type CollapsedResult = {
|
|
33
|
-
/** The lines to draw. The last may be truncated (it ends in `…`). */
|
|
34
5
|
shown: string[]
|
|
35
|
-
/**
|
|
36
|
-
* The trailing "there is more" row, already spelled. The *string* rather than
|
|
37
|
-
* a count, because `height.ts` wraps this exact text to size the row, and two
|
|
38
|
-
* spellings would be two different heights.
|
|
39
|
-
*/
|
|
40
6
|
more?: string
|
|
41
7
|
}
|
|
42
8
|
|
|
43
|
-
/**
|
|
44
|
-
* Reported in characters when the truncation happened *inside* a line and in
|
|
45
|
-
* lines otherwise — a one-line JSON blob has no hidden lines to count, and
|
|
46
|
-
* "+0 lines" under a visibly cut-off row is worse than saying nothing.
|
|
47
|
-
*/
|
|
48
|
-
/**
|
|
49
|
-
* `totalChars` is the **untruncated** length when the replay delivered only a
|
|
50
|
-
* head (protocol's `ToolResultBlock.total_chars`). Passing it is not cosmetic:
|
|
51
|
-
* computed from the head this row would say "… +7,600 chars" where the truth is
|
|
52
|
-
* 641,003, and the wrong string is a *different pixel height* — which is exactly
|
|
53
|
-
* the drift this module exists to prevent, since `height.ts` sizes the row by
|
|
54
|
-
* wrapping this same text. Omitted for a whole result, where the lines are the
|
|
55
|
-
* whole truth.
|
|
56
|
-
*/
|
|
57
9
|
export function collapsedResult(lines: string[], totalChars?: number): CollapsedResult {
|
|
58
10
|
const shown: string[] = []
|
|
59
11
|
let chars = 0
|
|
@@ -66,21 +18,21 @@ export function collapsedResult(lines: string[], totalChars?: number): Collapsed
|
|
|
66
18
|
cut = true
|
|
67
19
|
break
|
|
68
20
|
}
|
|
69
|
-
if (shown.length > 0 && chars + line.length > PREVIEW_CHARS)
|
|
21
|
+
if (shown.length > 0 && chars + line.length > PREVIEW_CHARS) {
|
|
22
|
+
break
|
|
23
|
+
}
|
|
70
24
|
shown.push(line)
|
|
71
25
|
chars += line.length + 1
|
|
72
26
|
}
|
|
73
27
|
|
|
74
|
-
// `join` because the newlines are part of what is not being shown — and
|
|
75
|
-
// `totalChars` wins when it exists, because the lines in hand are then a head
|
|
76
|
-
// rather than the result.
|
|
77
28
|
const held = lines.join('\n').length
|
|
78
29
|
const total = totalChars ?? held
|
|
79
|
-
if (cut)
|
|
80
|
-
// A truncated result always has more, even when its head happened to fit the
|
|
81
|
-
// line budget: the row must never claim to be showing everything.
|
|
82
|
-
if (totalChars !== undefined && total > held)
|
|
30
|
+
if (cut) {
|
|
83
31
|
return { shown, more: `… +${(total - chars).toLocaleString()} chars` }
|
|
32
|
+
}
|
|
33
|
+
if (totalChars !== undefined && total > held) {
|
|
34
|
+
return { shown, more: `… +${(total - chars).toLocaleString()} chars` }
|
|
35
|
+
}
|
|
84
36
|
const hidden = lines.length - shown.length
|
|
85
37
|
return { shown, more: hidden > 0 ? `… +${hidden} line${hidden === 1 ? '' : 's'}` : undefined }
|
|
86
38
|
}
|
|
@@ -1,132 +1,45 @@
|
|
|
1
1
|
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
|
|
2
2
|
import { cn } from '../../lib/utils.ts'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
* The primitives every terminal row is built from.
|
|
6
|
-
*
|
|
7
|
-
* There are three, and that is the whole vocabulary: a {@link Row} (a gutter
|
|
8
|
-
* cell and a body cell), a {@link Blank} (one empty line), and a {@link Band} (a
|
|
9
|
-
* run of rows carrying a full-bleed background). Anything the theme draws — a
|
|
10
|
-
* message, a tool call, a diff hunk, an approval prompt — is some arrangement of
|
|
11
|
-
* those, which is what keeps the grid a property of the renderer rather than a
|
|
12
|
-
* thing each component re-derives.
|
|
13
|
-
*
|
|
14
|
-
* Geometry is in `styles/terminal.css`. These components choose a class, a
|
|
15
|
-
* marker and a tone; they never carry a measurement.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/** The palette, as a name. See the `[data-tone]` rules in `terminal.css`. */
|
|
19
|
-
export type Tone =
|
|
20
|
-
| 'fg'
|
|
21
|
-
| 'bright'
|
|
22
|
-
| 'dim'
|
|
23
|
-
| 'faint'
|
|
24
|
-
| 'mark'
|
|
25
|
-
| 'blue'
|
|
26
|
-
| 'green'
|
|
27
|
-
| 'red'
|
|
28
|
-
| 'yellow'
|
|
29
|
-
| 'magenta'
|
|
4
|
+
export type Tone = 'fg' | 'bright' | 'dim' | 'faint' | 'mark' | 'blue' | 'green' | 'red' | 'yellow' | 'magenta'
|
|
30
5
|
|
|
31
6
|
export interface RowProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
32
|
-
/**
|
|
33
|
-
* What sits in the gutter — `●`, `⎿`, `>`, a list bullet, or nothing. Kept to
|
|
34
|
-
* the width of the gutter (`--term-cell`, two columns by default): a wider
|
|
35
|
-
* marker would push its own body off the column every other row starts on.
|
|
36
|
-
* Omitted, the gutter is still drawn as empty space, so an unmarked row's
|
|
37
|
-
* text lines up with a marked one's.
|
|
38
|
-
*/
|
|
39
7
|
glyph?: ReactNode
|
|
40
|
-
/** The marker's colour. Defaults to dim — the marker is structure, not content. */
|
|
41
8
|
glyphTone?: Tone
|
|
42
|
-
/** The body's colour. */
|
|
43
9
|
tone?: Tone
|
|
44
10
|
bold?: boolean
|
|
45
|
-
/** Indent levels, one character cell each. A child row's marker then sits
|
|
46
|
-
* exactly under its parent row's first letter. */
|
|
47
11
|
indent?: 0 | 1 | 2 | 3
|
|
48
|
-
/**
|
|
49
|
-
* Gutter width in columns, when the marker needs other than two — an ordered
|
|
50
|
-
* list's `10.` is four, a prompt's `❯ 1.` is five, and a framed payload wants
|
|
51
|
-
* `0`. Changes only this row's split, so the body still starts on a whole
|
|
52
|
-
* column.
|
|
53
|
-
*/
|
|
54
12
|
columns?: number
|
|
55
13
|
children?: ReactNode
|
|
56
14
|
}
|
|
57
15
|
|
|
58
|
-
export function Row({
|
|
59
|
-
glyph,
|
|
60
|
-
glyphTone,
|
|
61
|
-
tone,
|
|
62
|
-
bold,
|
|
63
|
-
indent,
|
|
64
|
-
columns,
|
|
65
|
-
className,
|
|
66
|
-
children,
|
|
67
|
-
style,
|
|
68
|
-
...props
|
|
69
|
-
}: RowProps) {
|
|
16
|
+
export function Row({ glyph, glyphTone, tone, bold, indent, columns, className, children, style, ...props }: RowProps) {
|
|
70
17
|
return (
|
|
71
18
|
<div
|
|
72
19
|
className={cn('term-row', className)}
|
|
73
20
|
data-indent={indent ? String(indent) : undefined}
|
|
74
21
|
data-tone={tone}
|
|
75
22
|
data-weight={bold ? 'bold' : undefined}
|
|
76
|
-
// `!== undefined`, not truthiness: `columns={0}` is a real request for a
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
: ({ ...style, '--term-cell': `${columns}ch` } as CSSProperties)
|
|
82
|
-
}
|
|
83
|
-
{...props}>
|
|
84
|
-
<span className='term-gutter' data-tone={glyphTone} aria-hidden>
|
|
23
|
+
// `!== undefined`, not truthiness: `columns={0}` is a real request for a gutterless row.
|
|
24
|
+
style={columns === undefined ? style : ({ ...style, '--term-cell': `${columns}ch` } as CSSProperties)}
|
|
25
|
+
{...props}
|
|
26
|
+
>
|
|
27
|
+
<span className="term-gutter" data-tone={glyphTone} aria-hidden>
|
|
85
28
|
{glyph ?? ' '}
|
|
86
29
|
</span>
|
|
87
|
-
|
|
88
|
-
band of output) as often as it holds a line of text. */}
|
|
89
|
-
<div className='term-body'>{children}</div>
|
|
30
|
+
<div className="term-body">{children}</div>
|
|
90
31
|
</div>
|
|
91
32
|
)
|
|
92
33
|
}
|
|
93
34
|
|
|
94
|
-
/**
|
|
95
|
-
* One empty line — the theme's only vertical spacing.
|
|
96
|
-
*
|
|
97
|
-
* A terminal separates blocks with a blank line, not with padding, and saying it
|
|
98
|
-
* that way has a practical payoff: spacing is part of the row list, so it can be
|
|
99
|
-
* decided by whoever knows whether two blocks belong together (a tool call and
|
|
100
|
-
* its output do not get one; two assistant turns do), instead of by a margin
|
|
101
|
-
* rule that cannot tell them apart.
|
|
102
|
-
*/
|
|
103
35
|
export function Blank() {
|
|
104
|
-
return <div className=
|
|
36
|
+
return <div className="term-blank" aria-hidden />
|
|
105
37
|
}
|
|
106
38
|
|
|
107
|
-
/**
|
|
108
|
-
* A run of rows under a background: a code block, a command's output, a diff
|
|
109
|
-
* hunk. Full-bleed — the wash reaches the scroller's edges, because in a
|
|
110
|
-
* terminal the line is the full width of the screen. See `--term-bleed` on
|
|
111
|
-
* {@link TerminalSurface}.
|
|
112
|
-
*/
|
|
113
39
|
export function Band({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
114
40
|
return <div className={cn('term-band', className)} {...props} />
|
|
115
41
|
}
|
|
116
42
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
tone,
|
|
120
|
-
bold,
|
|
121
|
-
className,
|
|
122
|
-
...props
|
|
123
|
-
}: HTMLAttributes<HTMLSpanElement> & { tone?: Tone; bold?: boolean }) {
|
|
124
|
-
return (
|
|
125
|
-
<span
|
|
126
|
-
data-tone={tone}
|
|
127
|
-
data-weight={bold ? 'bold' : undefined}
|
|
128
|
-
className={className}
|
|
129
|
-
{...props}
|
|
130
|
-
/>
|
|
131
|
-
)
|
|
43
|
+
export function Ink({ tone, bold, className, ...props }: HTMLAttributes<HTMLSpanElement> & { tone?: Tone; bold?: boolean }) {
|
|
44
|
+
return <span data-tone={tone} data-weight={bold ? 'bold' : undefined} className={className} {...props} />
|
|
132
45
|
}
|