@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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A row you can open, that you can also select text out of.
|
|
6
|
+
*
|
|
7
|
+
* A `<button>` cannot be both: text inside one is selectable in principle, but
|
|
8
|
+
* the drag that selects it ends in a `click`, so releasing the mouse collapses
|
|
9
|
+
* the very block you were highlighting — and the selection is discarded with it.
|
|
10
|
+
* A transcript is *read* far more often than it is opened, so copying a command
|
|
11
|
+
* out of a row has to win over the affordance that expands it.
|
|
12
|
+
*
|
|
13
|
+
* So: a `div` with the button role and the keyboard behaviour restored by hand,
|
|
14
|
+
* and a press that is refused when the pointer travelled (a drag, not a click)
|
|
15
|
+
* or when a selection is standing. Both checks are cheap and neither is a
|
|
16
|
+
* heuristic about intent — a pointer that moved four pixels was dragging, and a
|
|
17
|
+
* non-collapsed selection *is* the user having selected something.
|
|
18
|
+
*/
|
|
19
|
+
const DRAG_SLOP = 4
|
|
20
|
+
|
|
21
|
+
export function Pressable({
|
|
22
|
+
onPress,
|
|
23
|
+
expanded,
|
|
24
|
+
className,
|
|
25
|
+
children,
|
|
26
|
+
}: {
|
|
27
|
+
onPress: () => void
|
|
28
|
+
/** Mirrored to `aria-expanded` when this press opens something. */
|
|
29
|
+
expanded?: boolean
|
|
30
|
+
className?: string
|
|
31
|
+
children: ReactNode
|
|
32
|
+
}) {
|
|
33
|
+
const origin = useRef<{ x: number; y: number } | null>(null)
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
role='button'
|
|
37
|
+
tabIndex={0}
|
|
38
|
+
aria-expanded={expanded}
|
|
39
|
+
className={cn('term-press', className)}
|
|
40
|
+
onPointerDown={(event) => {
|
|
41
|
+
origin.current = { x: event.clientX, y: event.clientY }
|
|
42
|
+
}}
|
|
43
|
+
onClick={(event) => {
|
|
44
|
+
const from = origin.current
|
|
45
|
+
origin.current = null
|
|
46
|
+
if (from && Math.abs(event.clientX - from.x) + Math.abs(event.clientY - from.y) > DRAG_SLOP)
|
|
47
|
+
return
|
|
48
|
+
// A click that merely *ends* a selection elsewhere on the page still
|
|
49
|
+
// reads as a click; one that ends a selection inside this row is the
|
|
50
|
+
// tail of a drag the slop check may have missed (a slow, short drag).
|
|
51
|
+
const selection = window.getSelection?.()
|
|
52
|
+
if (selection && !selection.isCollapsed && selection.containsNode(event.currentTarget, true))
|
|
53
|
+
return
|
|
54
|
+
onPress()
|
|
55
|
+
}}
|
|
56
|
+
onKeyDown={(event) => {
|
|
57
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
58
|
+
event.preventDefault()
|
|
59
|
+
onPress()
|
|
60
|
+
}}>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Keep an expanding block's *first* line reachable.
|
|
68
|
+
*
|
|
69
|
+
* A row that grows from one line to eighty pushes its own top off the screen:
|
|
70
|
+
* the reader presses a summary and lands somewhere in the middle of what they
|
|
71
|
+
* opened, with no clue that the beginning is above them. The fix is not a
|
|
72
|
+
* scroll-into-view on every expand — that would yank a block already fully in
|
|
73
|
+
* view — but the narrow one: if the block now starts above the fold, bring its
|
|
74
|
+
* first line back to the top edge.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately one-directional and only on the open transition. Collapsing needs
|
|
77
|
+
* nothing (the block shrinks toward its own top, which is already on screen),
|
|
78
|
+
* and a block whose top is already visible must not move at all.
|
|
79
|
+
*/
|
|
80
|
+
export function useRevealOnOpen(open: boolean) {
|
|
81
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
82
|
+
const previous = useRef(open)
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const opened = open && !previous.current
|
|
85
|
+
previous.current = open
|
|
86
|
+
if (!opened) return
|
|
87
|
+
const element = ref.current
|
|
88
|
+
if (!element) return
|
|
89
|
+
// After paint: the rows this block just grew by have to be laid out, and
|
|
90
|
+
// the virtualizer's own size-change correction has to have run, before an
|
|
91
|
+
// offset read here means anything.
|
|
92
|
+
const frame = requestAnimationFrame(() => {
|
|
93
|
+
const scroller = scrollParent(element)
|
|
94
|
+
if (!scroller) return
|
|
95
|
+
const top =
|
|
96
|
+
element.getBoundingClientRect().top -
|
|
97
|
+
scroller.getBoundingClientRect().top +
|
|
98
|
+
scroller.scrollTop
|
|
99
|
+
if (top >= scroller.scrollTop) return
|
|
100
|
+
// One line of air above it, so the first row isn't flush against the
|
|
101
|
+
// scroller's edge — the same blank line every block gets.
|
|
102
|
+
const line = Number.parseFloat(getComputedStyle(element).lineHeight) || 0
|
|
103
|
+
scroller.scrollTop = Math.max(0, top - line)
|
|
104
|
+
})
|
|
105
|
+
return () => cancelAnimationFrame(frame)
|
|
106
|
+
}, [open])
|
|
107
|
+
return ref
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The nearest ancestor that actually scrolls. */
|
|
111
|
+
function scrollParent(from: HTMLElement): HTMLElement | null {
|
|
112
|
+
let node = from.parentElement
|
|
113
|
+
while (node) {
|
|
114
|
+
const overflow = getComputedStyle(node).overflowY
|
|
115
|
+
if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight)
|
|
116
|
+
return node
|
|
117
|
+
node = node.parentElement
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
import { Blank, Ink, Row } from './row.tsx'
|
|
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
|
+
export function Rule({ dashed }: { dashed?: boolean }) {
|
|
27
|
+
return <div className={cn('term-rule-row', dashed && 'term-rule-dashed')} aria-hidden />
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The dim `·`-separated key legend under a prompt. */
|
|
31
|
+
export function Hint({ children }: { children: ReactNode }) {
|
|
32
|
+
return (
|
|
33
|
+
<Row tone='faint'>
|
|
34
|
+
{children}
|
|
35
|
+
</Row>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
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
|
+
export function Box({ children, className }: { children: ReactNode; className?: string }) {
|
|
45
|
+
return <div className={cn('term-box', className)}>{children}</div>
|
|
46
|
+
}
|
|
47
|
+
|
|
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
|
+
export function PromptTitle({ title, subject }: { title: string; subject?: string }) {
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<Row tone='blue' bold>
|
|
60
|
+
{title}
|
|
61
|
+
</Row>
|
|
62
|
+
{subject ? <Row tone='dim'>{subject}</Row> : null}
|
|
63
|
+
</>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type Choice = {
|
|
68
|
+
key: string
|
|
69
|
+
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
|
+
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
|
+
checked?: boolean
|
|
80
|
+
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
|
+
selected?: boolean
|
|
87
|
+
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
|
+
detail?: ReactNode
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The two-state glyph pairs, in the forms a terminal would use. */
|
|
94
|
+
const MARKERS = {
|
|
95
|
+
check: ['[ ]', '[✓]'],
|
|
96
|
+
radio: ['( )', '(•)'],
|
|
97
|
+
} as const
|
|
98
|
+
|
|
99
|
+
export interface ChoicesProps {
|
|
100
|
+
options: Choice[]
|
|
101
|
+
/** Roving index: the one row that is tab-reachable and wears the `❯`. */
|
|
102
|
+
focused: number
|
|
103
|
+
onFocus: (index: number) => void
|
|
104
|
+
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
|
+
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
|
+
autoFocus?: boolean
|
|
122
|
+
label: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Is the reader mid-keystroke somewhere that keeps its own caret? */
|
|
126
|
+
function isTyping(element: Element | null): boolean {
|
|
127
|
+
if (!(element instanceof HTMLElement)) return false
|
|
128
|
+
const editable =
|
|
129
|
+
element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.isContentEditable
|
|
130
|
+
if (!editable) return false
|
|
131
|
+
// Focus in a field is not the same as a message in progress, and only the
|
|
132
|
+
// second is worth protecting. This used to return true for any focused
|
|
133
|
+
// editable, which read fine until a host that keeps the composer focused at
|
|
134
|
+
// all times ran it: VS Code puts the caret in the composer when a session is
|
|
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 ?? '')
|
|
146
|
+
return text.trim().length > 0
|
|
147
|
+
}
|
|
148
|
+
|
|
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) {
|
|
166
|
+
const refs = useRef<Array<HTMLButtonElement | null>>([])
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!active) return
|
|
170
|
+
// Two different jobs, told apart by where the keyboard already is rather
|
|
171
|
+
// than by how many times this has run.
|
|
172
|
+
//
|
|
173
|
+
// If focus is already on one of these rows, the roving cursor is moving and
|
|
174
|
+
// the DOM must follow `focused` unconditionally — otherwise the `❯` and the
|
|
175
|
+
// real caret drift apart. If it is not, this is the initial takeover, which
|
|
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
|
|
188
|
+
refs.current[focused]?.focus()
|
|
189
|
+
}, [active, focused, autoFocus])
|
|
190
|
+
|
|
191
|
+
const move = (delta: number) => {
|
|
192
|
+
if (options.length > 0) onFocus((focused + delta + options.length) % options.length)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div
|
|
197
|
+
role='group'
|
|
198
|
+
aria-label={label}
|
|
199
|
+
onKeyDown={(event) => {
|
|
200
|
+
if (event.key === 'ArrowDown') {
|
|
201
|
+
move(1)
|
|
202
|
+
event.preventDefault()
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
if (event.key === 'ArrowUp') {
|
|
206
|
+
move(-1)
|
|
207
|
+
event.preventDefault()
|
|
208
|
+
return
|
|
209
|
+
}
|
|
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
|
+
const digit = Number(event.key)
|
|
214
|
+
if (Number.isInteger(digit) && digit >= 1 && digit <= Math.min(options.length, 9)) {
|
|
215
|
+
onFocus(digit - 1)
|
|
216
|
+
onChoose(digit - 1)
|
|
217
|
+
event.preventDefault()
|
|
218
|
+
}
|
|
219
|
+
}}>
|
|
220
|
+
{options.map((option, index) => {
|
|
221
|
+
const isFocused = index === focused
|
|
222
|
+
return (
|
|
223
|
+
<div key={option.key}>
|
|
224
|
+
<button
|
|
225
|
+
ref={(element) => {
|
|
226
|
+
refs.current[index] = element
|
|
227
|
+
}}
|
|
228
|
+
type='button'
|
|
229
|
+
tabIndex={isFocused ? 0 : -1}
|
|
230
|
+
aria-pressed={option.checked}
|
|
231
|
+
onFocus={() => onFocus(index)}
|
|
232
|
+
onClick={() => onChoose(index)}
|
|
233
|
+
className='term-press'>
|
|
234
|
+
{/* `❯ 1.` is the gutter: marker and number together, so the label
|
|
235
|
+
starts on one column whether or not the row is focused. */}
|
|
236
|
+
<Row
|
|
237
|
+
columns={5}
|
|
238
|
+
glyph={`${isFocused ? '❯' : ' '} ${index + 1}.`}
|
|
239
|
+
glyphTone={isFocused ? 'fg' : 'faint'}
|
|
240
|
+
tone={option.danger ? 'red' : option.selected ? 'green' : 'fg'}
|
|
241
|
+
data-focused={isFocused ? '' : undefined}>
|
|
242
|
+
{option.checked !== undefined ? (
|
|
243
|
+
<Ink tone={option.checked ? 'green' : 'faint'}>
|
|
244
|
+
{MARKERS[option.marker ?? 'check'][option.checked ? 1 : 0]}{' '}
|
|
245
|
+
</Ink>
|
|
246
|
+
) : null}
|
|
247
|
+
<Ink bold={isFocused || option.selected}>{option.label}</Ink>
|
|
248
|
+
</Row>
|
|
249
|
+
</button>
|
|
250
|
+
{option.description ? (
|
|
251
|
+
<Row columns={5} tone='dim'>
|
|
252
|
+
{option.description}
|
|
253
|
+
</Row>
|
|
254
|
+
) : null}
|
|
255
|
+
{option.detail ? <div className='term-detail'>{option.detail}</div> : null}
|
|
256
|
+
</div>
|
|
257
|
+
)
|
|
258
|
+
})}
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
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
|
+
export function PromptInput({
|
|
268
|
+
value,
|
|
269
|
+
onChange,
|
|
270
|
+
onSubmit,
|
|
271
|
+
onCancel,
|
|
272
|
+
placeholder,
|
|
273
|
+
}: {
|
|
274
|
+
value: string
|
|
275
|
+
onChange: (value: string) => void
|
|
276
|
+
onSubmit: () => void
|
|
277
|
+
onCancel: () => void
|
|
278
|
+
placeholder?: string
|
|
279
|
+
}) {
|
|
280
|
+
return (
|
|
281
|
+
<Row columns={5} glyph=' ›' glyphTone='dim'>
|
|
282
|
+
<input
|
|
283
|
+
autoFocus
|
|
284
|
+
value={value}
|
|
285
|
+
placeholder={placeholder}
|
|
286
|
+
onChange={(event) => onChange(event.target.value)}
|
|
287
|
+
onKeyDown={(event) => {
|
|
288
|
+
if (event.key === 'Enter') {
|
|
289
|
+
event.preventDefault()
|
|
290
|
+
onSubmit()
|
|
291
|
+
}
|
|
292
|
+
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
|
+
event.stopPropagation()
|
|
296
|
+
onCancel()
|
|
297
|
+
}
|
|
298
|
+
}}
|
|
299
|
+
className='term-input'
|
|
300
|
+
/>
|
|
301
|
+
</Row>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
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
|
+
export function TabStrip({
|
|
315
|
+
tabs,
|
|
316
|
+
active,
|
|
317
|
+
onSelect,
|
|
318
|
+
}: {
|
|
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
|
+
tabs: { key: string; label: string; glyph: string }[]
|
|
323
|
+
active: number
|
|
324
|
+
onSelect: (index: number) => void
|
|
325
|
+
}) {
|
|
326
|
+
return (
|
|
327
|
+
<Row glyph='←' glyphTone='faint'>
|
|
328
|
+
{tabs.map((tab, index) => (
|
|
329
|
+
<button
|
|
330
|
+
key={tab.key}
|
|
331
|
+
type='button'
|
|
332
|
+
tabIndex={-1}
|
|
333
|
+
onClick={() => onSelect(index)}
|
|
334
|
+
className={cn('term-tab', index === active && 'term-tab-active')}>
|
|
335
|
+
{tab.glyph} {tab.label}
|
|
336
|
+
</button>
|
|
337
|
+
))}
|
|
338
|
+
<Ink tone='faint'> →</Ink>
|
|
339
|
+
</Row>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export { Blank }
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
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
|
+
const PREVIEW_CHARS = 400
|
|
31
|
+
|
|
32
|
+
export type CollapsedResult = {
|
|
33
|
+
/** The lines to draw. The last may be truncated (it ends in `…`). */
|
|
34
|
+
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
|
+
more?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
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
|
+
export function collapsedResult(lines: string[]): CollapsedResult {
|
|
49
|
+
const shown: string[] = []
|
|
50
|
+
let chars = 0
|
|
51
|
+
let cut = false
|
|
52
|
+
|
|
53
|
+
for (const line of lines.slice(0, PREVIEW_LINES)) {
|
|
54
|
+
if (shown.length === 0 && line.length > PREVIEW_CHARS) {
|
|
55
|
+
shown.push(`${line.slice(0, PREVIEW_CHARS)}…`)
|
|
56
|
+
chars = PREVIEW_CHARS
|
|
57
|
+
cut = true
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
if (shown.length > 0 && chars + line.length > PREVIEW_CHARS) break
|
|
61
|
+
shown.push(line)
|
|
62
|
+
chars += line.length + 1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (cut) {
|
|
66
|
+
// `join` because the newlines are part of what is not being shown.
|
|
67
|
+
const hidden = lines.join('\n').length - chars
|
|
68
|
+
return { shown, more: `… +${hidden.toLocaleString()} chars` }
|
|
69
|
+
}
|
|
70
|
+
const hidden = lines.length - shown.length
|
|
71
|
+
return { shown, more: hidden > 0 ? `… +${hidden} line${hidden === 1 ? '' : 's'}` : undefined }
|
|
72
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils.ts'
|
|
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'
|
|
30
|
+
|
|
31
|
+
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
|
+
glyph?: ReactNode
|
|
40
|
+
/** The marker's colour. Defaults to dim — the marker is structure, not content. */
|
|
41
|
+
glyphTone?: Tone
|
|
42
|
+
/** The body's colour. */
|
|
43
|
+
tone?: Tone
|
|
44
|
+
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
|
+
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
|
+
columns?: number
|
|
55
|
+
children?: ReactNode
|
|
56
|
+
}
|
|
57
|
+
|
|
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) {
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className={cn('term-row', className)}
|
|
73
|
+
data-indent={indent ? String(indent) : undefined}
|
|
74
|
+
data-tone={tone}
|
|
75
|
+
data-weight={bold ? 'bold' : undefined}
|
|
76
|
+
// `!== undefined`, not truthiness: `columns={0}` is a real request for a
|
|
77
|
+
// gutterless row (a framed payload) and must not fall back to the default.
|
|
78
|
+
style={
|
|
79
|
+
columns === undefined
|
|
80
|
+
? style
|
|
81
|
+
: ({ ...style, '--term-cell': `${columns}ch` } as CSSProperties)
|
|
82
|
+
}
|
|
83
|
+
{...props}>
|
|
84
|
+
<span className='term-gutter' data-tone={glyphTone} aria-hidden>
|
|
85
|
+
{glyph ?? ' '}
|
|
86
|
+
</span>
|
|
87
|
+
{/* A div, not a span: a body holds block content (a markdown message, a
|
|
88
|
+
band of output) as often as it holds a line of text. */}
|
|
89
|
+
<div className='term-body'>{children}</div>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
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
|
+
export function Blank() {
|
|
104
|
+
return <div className='term-blank' aria-hidden />
|
|
105
|
+
}
|
|
106
|
+
|
|
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
|
+
export function Band({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
114
|
+
return <div className={cn('term-band', className)} {...props} />
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Inline colour/weight inside a row's body. */
|
|
118
|
+
export function Ink({
|
|
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
|
+
)
|
|
132
|
+
}
|