@workerdeck/ui 0.15.0 → 0.17.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 +60 -0
- package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
- package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
- package/build/SessionPanel-DMPhsNlW.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 +493 -45
- package/build/index.mjs +343 -17
- 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 +16 -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/ProjectIcon.tsx +119 -0
- 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/SessionBrowser.tsx +84 -3
- package/src/components/agent/SessionPanel.tsx +249 -28
- package/src/components/agent/SessionWorkspace.tsx +29 -0
- package/src/components/agent/StatusBar.tsx +20 -4
- package/src/components/agent/ToolCallCard.tsx +85 -112
- package/src/components/agent/Transcript.tsx +780 -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/tool-result-fetch.tsx +36 -0
- package/src/components/agent/tool-result-image.tsx +209 -0
- package/src/components/agent/transcript-rows.ts +173 -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 +225 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/blocks.ts +232 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +770 -0
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +486 -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 +86 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +784 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +224 -0
- package/src/index.ts +36 -1
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1081 -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,191 @@
|
|
|
1
|
+
import { memo, type ReactNode } from 'react'
|
|
2
|
+
import { Streamdown, type Components } from 'streamdown'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { CopyAction, WithActions } from './affordances.tsx'
|
|
5
|
+
import { Band } from './row.tsx'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Markdown on the character grid.
|
|
9
|
+
*
|
|
10
|
+
* The `lines` variant did this by letting the renderer draw its prose defaults
|
|
11
|
+
* and then overriding roughly sixty declarations back off with `!important` —
|
|
12
|
+
* every margin, every list gap, the fenced-code card's four nested boxes, the
|
|
13
|
+
* table's frame and its floating button pill. That is a losing position by
|
|
14
|
+
* construction: each renderer upgrade is a new set of boxes to find and unpaint,
|
|
15
|
+
* and the CSS says what the output must *not* look like rather than what it is.
|
|
16
|
+
*
|
|
17
|
+
* So this maps the elements instead. Streamdown keeps what only it can do —
|
|
18
|
+
* streaming-safe parsing of half-written markdown — and every block it emits is
|
|
19
|
+
* built from the same {@link Row}/{@link Band} primitives the rest of the theme
|
|
20
|
+
* uses. There is no `!important` here and there is no CSS fighting anything.
|
|
21
|
+
*
|
|
22
|
+
* The rendering rules are a terminal's, not a document's:
|
|
23
|
+
*
|
|
24
|
+
* - **One type size.** Headings are weight and colour; a bigger glyph would
|
|
25
|
+
* break the only line height the grid has.
|
|
26
|
+
* - **Markers are cells.** A bullet is `- ` (two columns), an ordered marker
|
|
27
|
+
* `1. ` (three), and the text starts on the next column exactly as it does in
|
|
28
|
+
* the markdown source — so a wrapped line hangs under the text, not the
|
|
29
|
+
* bullet, and nesting costs one marker width per level.
|
|
30
|
+
* - **Code is a band, not a card.** No frame, no language strip, no floating
|
|
31
|
+
* buttons: a fenced block is a wash of dim text running to the screen edge,
|
|
32
|
+
* which is what a terminal shows.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Pull the text out of a fenced block's React children (`<code>…</code>`). */
|
|
36
|
+
function codeText(node: ReactNode): string {
|
|
37
|
+
if (node === null || node === undefined || typeof node === 'boolean') return ''
|
|
38
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
|
39
|
+
if (Array.isArray(node)) return node.map(codeText).join('')
|
|
40
|
+
const element = node as { props?: { children?: ReactNode } }
|
|
41
|
+
return element.props ? codeText(element.props.children) : ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The language a fence declared, from the `language-*` class react-markdown
|
|
45
|
+
* puts on the inner `<code>`. Kept as a data attribute rather than used to
|
|
46
|
+
* highlight: the CLI does not colour a fenced block inside a message, and a
|
|
47
|
+
* second highlighter here would be a second theme to keep in sync. */
|
|
48
|
+
function fenceLanguage(node: ReactNode): string | undefined {
|
|
49
|
+
const child = Array.isArray(node) ? node.find(Boolean) : node
|
|
50
|
+
const className = (child as { props?: { className?: string } } | undefined)?.props?.className
|
|
51
|
+
const match = /language-([\w-]+)/.exec(className ?? '')
|
|
52
|
+
return match?.[1]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A fenced block: a band of dim text, and the one place a copy affordance earns
|
|
57
|
+
* its keep most — a command in a message is there to be run.
|
|
58
|
+
*
|
|
59
|
+
* The renderer's own copy/download buttons are turned off (`controls={false}`)
|
|
60
|
+
* in favour of this: they are web buttons floating over the content in another
|
|
61
|
+
* application's idiom, and they are not switchable by the surface the way
|
|
62
|
+
* everything else here is.
|
|
63
|
+
*/
|
|
64
|
+
function CodeBand({ code, language }: { code: string; language?: string }) {
|
|
65
|
+
return (
|
|
66
|
+
<WithActions className='term-block' actions={<CopyAction text={code} label='Copy code' />}>
|
|
67
|
+
<Band className='term-code' data-language={language}>
|
|
68
|
+
<pre className='term-pre'>{code}</pre>
|
|
69
|
+
</Band>
|
|
70
|
+
</WithActions>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Headings differ only in tone — a terminal has one type size, so `h1` and `h4`
|
|
75
|
+
* cannot differ in anything else. Return-typed so the parameter picks up the
|
|
76
|
+
* renderer's own component signature rather than a narrower hand-written one. */
|
|
77
|
+
const heading = (tone: 'bright' | 'fg'): Components['h1'] =>
|
|
78
|
+
function Heading({ children }) {
|
|
79
|
+
return (
|
|
80
|
+
<div className='term-block' data-tone={tone} data-weight='bold'>
|
|
81
|
+
{children}
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const TERMINAL_COMPONENTS: Components = {
|
|
87
|
+
p: ({ children }) => <div className='term-block'>{children}</div>,
|
|
88
|
+
|
|
89
|
+
h1: heading('bright'),
|
|
90
|
+
h2: heading('bright'),
|
|
91
|
+
h3: heading('bright'),
|
|
92
|
+
h4: heading('fg'),
|
|
93
|
+
h5: heading('fg'),
|
|
94
|
+
h6: heading('fg'),
|
|
95
|
+
|
|
96
|
+
// `term-block` on every block-level output, without exception: it is what the
|
|
97
|
+
// one-blank-line-between-blocks rule keys on, and a block that forgets it butts
|
|
98
|
+
// straight up against its neighbour (a list running into the paragraph after
|
|
99
|
+
// it, which is exactly how this was found).
|
|
100
|
+
ul: ({ children }) => <ul className='term-block term-list'>{children}</ul>,
|
|
101
|
+
ol: ({ children }) => <ol className='term-block term-list term-list-ordered'>{children}</ol>,
|
|
102
|
+
// The marker is the gutter's `::before` (a CSS counter for the ordered case),
|
|
103
|
+
// so a list item is literally a Row: same two columns, same hanging indent,
|
|
104
|
+
// and a nested list inside the body indents by exactly one marker width.
|
|
105
|
+
li: ({ children }) => (
|
|
106
|
+
<li className='term-row term-li'>
|
|
107
|
+
<span className='term-gutter' aria-hidden />
|
|
108
|
+
<div className='term-body'>{children}</div>
|
|
109
|
+
</li>
|
|
110
|
+
),
|
|
111
|
+
|
|
112
|
+
blockquote: ({ children }) => (
|
|
113
|
+
<blockquote className='term-block term-quote' data-tone='dim'>
|
|
114
|
+
{children}
|
|
115
|
+
</blockquote>
|
|
116
|
+
),
|
|
117
|
+
|
|
118
|
+
hr: () => <div className='term-block term-rule' aria-hidden />,
|
|
119
|
+
|
|
120
|
+
// Fenced code. `pre` owns the whole block — the inner `<code>` is only where
|
|
121
|
+
// the text and the language live — so the band is built here and `code` never
|
|
122
|
+
// sees a fence.
|
|
123
|
+
pre: ({ children }) => <CodeBand code={codeText(children)} language={fenceLanguage(children)} />,
|
|
124
|
+
code: ({ children }) => (
|
|
125
|
+
<code className='term-inline-code' data-tone='blue'>
|
|
126
|
+
{children}
|
|
127
|
+
</code>
|
|
128
|
+
),
|
|
129
|
+
|
|
130
|
+
strong: ({ children }) => (
|
|
131
|
+
<strong data-tone='bright' data-weight='bold'>
|
|
132
|
+
{children}
|
|
133
|
+
</strong>
|
|
134
|
+
),
|
|
135
|
+
em: ({ children }) => <em className='term-em'>{children}</em>,
|
|
136
|
+
a: ({ children, href }) => (
|
|
137
|
+
<a className='term-link' data-tone='blue' href={href} target='_blank' rel='noreferrer'>
|
|
138
|
+
{children}
|
|
139
|
+
</a>
|
|
140
|
+
),
|
|
141
|
+
|
|
142
|
+
// Tables keep the grid by being a grid: monospace cells, one line per row, and
|
|
143
|
+
// dim box-drawing rules instead of borders that would land between cells.
|
|
144
|
+
table: ({ children }) => (
|
|
145
|
+
<div className='term-block term-table-wrap'>
|
|
146
|
+
<table className='term-table'>{children}</table>
|
|
147
|
+
</div>
|
|
148
|
+
),
|
|
149
|
+
// Every table element, and not just the ones that looked wrong: any element
|
|
150
|
+
// left unmapped keeps the renderer's own padded, bordered default, and a
|
|
151
|
+
// single one of those puts its rows off the line grid (`td`'s `py-2` was
|
|
152
|
+
// making table rows 23px in an 18px theme).
|
|
153
|
+
thead: ({ children }) => <thead className='term-thead'>{children}</thead>,
|
|
154
|
+
tbody: ({ children }) => <tbody>{children}</tbody>,
|
|
155
|
+
tr: ({ children }) => <tr>{children}</tr>,
|
|
156
|
+
th: ({ children }) => (
|
|
157
|
+
<th data-tone='bright' data-weight='bold'>
|
|
158
|
+
{children}
|
|
159
|
+
</th>
|
|
160
|
+
),
|
|
161
|
+
td: ({ children }) => <td>{children}</td>,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface TerminalMarkdownProps {
|
|
165
|
+
children: string
|
|
166
|
+
/** Streaming text: tolerate half-written markdown (unclosed fences, half links). */
|
|
167
|
+
streaming?: boolean
|
|
168
|
+
className?: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const TerminalMarkdown = memo(
|
|
172
|
+
function TerminalMarkdown({ children, streaming, className }: TerminalMarkdownProps) {
|
|
173
|
+
return (
|
|
174
|
+
<Streamdown
|
|
175
|
+
mode={streaming ? 'streaming' : 'static'}
|
|
176
|
+
parseIncompleteMarkdown={streaming}
|
|
177
|
+
// The renderer's copy/download affordances are web buttons floating over
|
|
178
|
+
// the content. A terminal has none, and the transcript's own selection
|
|
179
|
+
// is how you copy from one.
|
|
180
|
+
controls={false}
|
|
181
|
+
components={TERMINAL_COMPONENTS}
|
|
182
|
+
className={cn('term-md', className)}>
|
|
183
|
+
{children}
|
|
184
|
+
</Streamdown>
|
|
185
|
+
)
|
|
186
|
+
},
|
|
187
|
+
(prev, next) =>
|
|
188
|
+
prev.children === next.children &&
|
|
189
|
+
prev.streaming === next.streaming &&
|
|
190
|
+
prev.className === next.className,
|
|
191
|
+
)
|
|
@@ -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 }
|