@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.
Files changed (71) hide show
  1. package/README.md +60 -0
  2. package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
  3. package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
  4. package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
  5. package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
  6. package/build/format.d.mts +39 -4
  7. package/build/format.mjs +2 -118
  8. package/build/index.d.mts +493 -45
  9. package/build/index.mjs +343 -17
  10. package/build/index.mjs.map +1 -1
  11. package/build/status-Ydzi7n6j.mjs +143 -0
  12. package/build/status-Ydzi7n6j.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -1
  14. package/build/workspace.mjs +108 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +16 -7
  17. package/src/components/agent/Composer.tsx +189 -89
  18. package/src/components/agent/Conversation.tsx +12 -12
  19. package/src/components/agent/FileCard.tsx +0 -26
  20. package/src/components/agent/FileTree.tsx +9 -8
  21. package/src/components/agent/Loader.tsx +22 -72
  22. package/src/components/agent/Message.tsx +11 -46
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/ProjectIcon.tsx +119 -0
  25. package/src/components/agent/QuestionPrompt.tsx +0 -122
  26. package/src/components/agent/Reasoning.tsx +5 -19
  27. package/src/components/agent/Response.tsx +1 -132
  28. package/src/components/agent/SessionBrowser.tsx +84 -3
  29. package/src/components/agent/SessionPanel.tsx +249 -28
  30. package/src/components/agent/SessionWorkspace.tsx +29 -0
  31. package/src/components/agent/StatusBar.tsx +20 -4
  32. package/src/components/agent/ToolCallCard.tsx +85 -112
  33. package/src/components/agent/Transcript.tsx +780 -203
  34. package/src/components/agent/UsageDialog.tsx +20 -106
  35. package/src/components/agent/UsageMeters.tsx +133 -0
  36. package/src/components/agent/pulse.tsx +3 -2
  37. package/src/components/agent/tool-result-fetch.tsx +36 -0
  38. package/src/components/agent/tool-result-image.tsx +209 -0
  39. package/src/components/agent/transcript-rows.ts +173 -0
  40. package/src/components/agent/transcript-variant.tsx +29 -51
  41. package/src/components/agent/use-height-epoch.ts +60 -0
  42. package/src/components/agent/use-path-links.ts +147 -0
  43. package/src/components/agent/use-transcript-jumps.ts +190 -0
  44. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  45. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  46. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  47. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  48. package/src/components/terminal/StatusLine.tsx +159 -0
  49. package/src/components/terminal/TerminalTranscript.tsx +225 -0
  50. package/src/components/terminal/affordances.tsx +118 -0
  51. package/src/components/terminal/blocks.ts +232 -0
  52. package/src/components/terminal/diff.tsx +130 -0
  53. package/src/components/terminal/height.ts +770 -0
  54. package/src/components/terminal/image-box.ts +53 -0
  55. package/src/components/terminal/items.tsx +486 -0
  56. package/src/components/terminal/markdown.tsx +191 -0
  57. package/src/components/terminal/press.tsx +120 -0
  58. package/src/components/terminal/prompt.tsx +343 -0
  59. package/src/components/terminal/result-preview.ts +86 -0
  60. package/src/components/terminal/row.tsx +132 -0
  61. package/src/components/terminal/scrubber.tsx +784 -0
  62. package/src/components/terminal/surface.tsx +80 -0
  63. package/src/components/terminal/tool-run.ts +224 -0
  64. package/src/index.ts +36 -1
  65. package/src/lib/status.ts +59 -3
  66. package/src/lib/tool-icon.ts +14 -0
  67. package/src/styles/terminal.css +1081 -0
  68. package/src/styles/theme.css +41 -0
  69. package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
  70. package/build/format.mjs.map +0 -1
  71. 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 }