@workerdeck/ui 0.13.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.
Files changed (69) hide show
  1. package/README.md +72 -0
  2. package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-B9CHoq8x.d.mts} +213 -27
  3. package/build/{SessionPanel-CKQa4i0Y.mjs → SessionPanel-DII9MmQ8.mjs} +5192 -2542
  4. package/build/SessionPanel-DII9MmQ8.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 +494 -45
  9. package/build/index.mjs +182 -24
  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 +13 -1
  14. package/build/workspace.mjs +111 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +14 -7
  17. package/src/components/agent/Composer.tsx +251 -84
  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 -43
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/QuestionPrompt.tsx +0 -122
  25. package/src/components/agent/Reasoning.tsx +5 -19
  26. package/src/components/agent/Response.tsx +1 -132
  27. package/src/components/agent/SessionBrowser.tsx +35 -25
  28. package/src/components/agent/SessionPanel.tsx +312 -63
  29. package/src/components/agent/SessionWorkspace.tsx +36 -0
  30. package/src/components/agent/StatusBar.tsx +70 -15
  31. package/src/components/agent/ToolCallCard.tsx +17 -111
  32. package/src/components/agent/Transcript.tsx +710 -203
  33. package/src/components/agent/UsageDialog.tsx +20 -106
  34. package/src/components/agent/UsageMeters.tsx +133 -0
  35. package/src/components/agent/pulse.tsx +3 -2
  36. package/src/components/agent/transcript-rows.ts +82 -0
  37. package/src/components/agent/transcript-variant.tsx +43 -51
  38. package/src/components/agent/use-height-epoch.ts +60 -0
  39. package/src/components/agent/use-path-links.ts +147 -0
  40. package/src/components/agent/use-transcript-jumps.ts +190 -0
  41. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  42. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  43. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  44. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  45. package/src/components/terminal/StatusLine.tsx +159 -0
  46. package/src/components/terminal/TerminalTranscript.tsx +147 -0
  47. package/src/components/terminal/affordances.tsx +118 -0
  48. package/src/components/terminal/diff.tsx +130 -0
  49. package/src/components/terminal/height.ts +727 -0
  50. package/src/components/terminal/items.tsx +449 -0
  51. package/src/components/terminal/markdown.tsx +191 -0
  52. package/src/components/terminal/press.tsx +120 -0
  53. package/src/components/terminal/prompt.tsx +343 -0
  54. package/src/components/terminal/result-preview.ts +72 -0
  55. package/src/components/terminal/row.tsx +132 -0
  56. package/src/components/terminal/scrubber.tsx +663 -0
  57. package/src/components/terminal/surface.tsx +80 -0
  58. package/src/components/terminal/tool-run.ts +91 -0
  59. package/src/components/ui/Badge.tsx +6 -1
  60. package/src/components/ui/Empty.tsx +56 -0
  61. package/src/components/ui/Splitter.tsx +14 -0
  62. package/src/index.ts +36 -0
  63. package/src/lib/status.ts +59 -3
  64. package/src/lib/tool-icon.ts +14 -0
  65. package/src/styles/terminal.css +1011 -0
  66. package/src/styles/theme.css +73 -0
  67. package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
  68. package/build/format.mjs.map +0 -1
  69. package/src/components/agent/line-prompt.tsx +0 -249
@@ -0,0 +1,147 @@
1
+ import { useEffect, type RefObject } from 'react'
2
+
3
+ /**
4
+ * Cmd/Ctrl+click a file path in the transcript to open it.
5
+ *
6
+ * The agent names files constantly — in prose, in tables, in tool arguments —
7
+ * and every one of them is a place the reader wants to go. They already *look*
8
+ * like links (inline code renders in the accent), so the affordance is half
9
+ * promised before anything is wired: this makes it true.
10
+ *
11
+ * **Detection is deliberately conservative**, because the cost is asymmetric. A
12
+ * missed path is a path you select and open yourself; a false positive is a
13
+ * word that lights up under the modifier and then does nothing, which teaches
14
+ * the reader not to trust any of them. So:
15
+ *
16
+ * - A trailing `/` means a directory. Directories are not openable in a text
17
+ * editor, and offering it is worse than staying quiet.
18
+ * - Outside a `<code>` element the match must be *most* of what the element
19
+ * says, or a paragraph mentioning `src/index.ts` in passing would underline
20
+ * whole sentences.
21
+ * - A bare filename (`worker.mjs`) is only a path **inside code**, and only
22
+ * with an extension. `18 B` and `Type` are table cells, not files.
23
+ *
24
+ * The modifier is the editor's own: hold it and what would open lights up
25
+ * (`wd-path-link`), which is the only honest way to show which of the blue
26
+ * things on screen are actually reachable.
27
+ */
28
+ export type PathHit = { path: string; line?: number }
29
+
30
+ /** Marks the element under the pointer while the modifier is held. */
31
+ const LINKISH = 'wd-path-link'
32
+
33
+ /** Absolute, or relative with at least one separator. `:12` is a line number. */
34
+ const PATH_RE = /^(\/[^\s:'"`()[\]{}]+|[\w.@~-]+(?:\/[\w.@~-]+)+)(?::(\d+))?$/
35
+ /** A bare filename — only trusted inside a code element. */
36
+ const FILE_RE = /^([\w.@-]+\.[A-Za-z0-9]{1,10})(?::(\d+))?$/
37
+
38
+ /** How much of an element's text the match must cover, outside code. */
39
+ const COVERAGE = 0.6
40
+
41
+ export function matchPath(text: string, inCode: boolean): PathHit | undefined {
42
+ const trimmed = text.trim()
43
+ if (!trimmed || trimmed.endsWith('/')) return undefined
44
+ const hit = PATH_RE.exec(trimmed) ?? (inCode ? FILE_RE.exec(trimmed) : null)
45
+ if (!hit) return undefined
46
+ return { path: hit[1]!, line: hit[2] ? Number(hit[2]) : undefined }
47
+ }
48
+
49
+ /** The path a click on this element means, if any. */
50
+ function hitFor(element: HTMLElement | undefined): PathHit | undefined {
51
+ if (!element) return undefined
52
+ const inCode = element.closest('code') !== null
53
+ const text = element.textContent ?? ''
54
+ const hit = matchPath(text, inCode)
55
+ if (!hit) return undefined
56
+ if (!inCode && hit.path.length < text.trim().length * COVERAGE) return undefined
57
+ return hit
58
+ }
59
+
60
+ export function usePathLinks({
61
+ container,
62
+ onOpen,
63
+ enabled = true,
64
+ ignore,
65
+ }: {
66
+ /** The subtree to listen in — normally the panel. */
67
+ container: RefObject<HTMLElement | null>
68
+ onOpen: (hit: PathHit) => void
69
+ /** Off when there is no host filesystem to open from: a link that cannot
70
+ * resolve is worse than plain text. */
71
+ enabled?: boolean
72
+ /**
73
+ * A selector for subtrees to leave alone. Anything with its own meaning for
74
+ * the modifier needs this — a code editor's Cmd+click is go-to-definition,
75
+ * and every identifier in it would otherwise match as a filename.
76
+ */
77
+ ignore?: string
78
+ }) {
79
+ useEffect(() => {
80
+ const root = container.current
81
+ if (!root || !enabled) return
82
+ const excluded = (element: HTMLElement | undefined) =>
83
+ ignore !== undefined && element?.closest(ignore) !== null && element !== undefined
84
+
85
+ let marked: HTMLElement | undefined
86
+ const unmark = () => {
87
+ marked?.classList.remove(LINKISH)
88
+ marked = undefined
89
+ }
90
+
91
+ const onClick = (event: MouseEvent) => {
92
+ if (!event.metaKey && !event.ctrlKey) return
93
+ const target = event.target instanceof HTMLElement ? event.target : undefined
94
+ if (excluded(target)) return
95
+ const hit = hitFor(target)
96
+ if (!hit) return
97
+ // Capture phase and stopped here: the row underneath is usually pressable
98
+ // (a tool call expands), and opening a file is not expanding it.
99
+ event.preventDefault()
100
+ event.stopPropagation()
101
+ unmark()
102
+ onOpen(hit)
103
+ }
104
+
105
+ const onMove = (event: MouseEvent) => {
106
+ if (!event.metaKey && !event.ctrlKey) return unmark()
107
+ const element = event.target instanceof HTMLElement ? event.target : undefined
108
+ if (element === marked) return
109
+ unmark()
110
+ if (!element || excluded(element) || !hitFor(element)) return
111
+ marked = element
112
+ element.classList.add(LINKISH)
113
+ }
114
+
115
+ // Releasing the modifier has to clear it: an underline that outlives the key
116
+ // promises a click that will not work.
117
+ const onKey = (event: KeyboardEvent) => {
118
+ if (!event.metaKey && !event.ctrlKey) unmark()
119
+ }
120
+
121
+ root.addEventListener('click', onClick, true)
122
+ root.addEventListener('mousemove', onMove)
123
+ document.addEventListener('keydown', onKey)
124
+ document.addEventListener('keyup', onKey)
125
+ window.addEventListener('blur', unmark)
126
+ return () => {
127
+ unmark()
128
+ root.removeEventListener('click', onClick, true)
129
+ root.removeEventListener('mousemove', onMove)
130
+ document.removeEventListener('keydown', onKey)
131
+ document.removeEventListener('keyup', onKey)
132
+ window.removeEventListener('blur', unmark)
133
+ }
134
+ }, [container, onOpen, enabled, ignore])
135
+ }
136
+
137
+ /**
138
+ * A transcript path against the session's cwd.
139
+ *
140
+ * Absolute wins outright. Everything else is relative to where the agent is
141
+ * working, which is the only root that makes `worker.mjs` mean anything.
142
+ */
143
+ export function resolveAgainstCwd(path: string, cwd: string | undefined): string {
144
+ if (path.startsWith('/')) return path
145
+ if (!cwd) return path
146
+ return `${cwd.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
147
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The jump: aim at a virtual row, land exactly. Every jump on the transcript
3
+ * surface — the catch-up strip's, and each of the scrubber's marks — comes
4
+ * through here, along with the re-pin the catch-up strip's dismiss uses.
5
+ * **Instant**, like everything else that moves the scroll position: VS Code's
6
+ * editor jumps when you click its scrollbar and so does this.
7
+ *
8
+ * The aim loop survives from before the height calculator, with its job
9
+ * changed twice. It used to be the mechanism: offsets over unmeasured spans
10
+ * were sums of flat estimates (~3300px off over 600 rows), and the loop
11
+ * walked them in up to six passes, each pass better than the last because
12
+ * the rows it crossed had measured. With `estimateSize` computed the first
13
+ * aim lands within a line or two; with the travel now instant there is also
14
+ * no in-flight animation suppressing the virtualizer's own adjustments — the
15
+ * condition that made a *single* smooth `scrollToIndex` unable to
16
+ * self-correct at all. What is left is convergence insurance for the two
17
+ * things the calculator cannot know: the recap row's estimated constant, and
18
+ * flagged content (CJK, compressed tables). It is cheap now — each pass is a
19
+ * jump, not a journey, so a pass that was already on target costs nothing and
20
+ * the check exits after one.
21
+ */
22
+ import { useEffect, useRef, type RefObject } from 'react'
23
+ import type { Virtualizer } from '@tanstack/react-virtual'
24
+ import type { useStickToBottomContext } from 'use-stick-to-bottom'
25
+ import type { HeightEpoch } from '../terminal/height.ts'
26
+ import { gapBefore, type TranscriptRow } from './transcript-rows.ts'
27
+
28
+ /** How many times a jump re-checks its landing, and how long it waits before
29
+ * doing so. Both are much smaller than the smooth era's 6 × 300ms: a pass is
30
+ * now a jump rather than a journey, so the only thing being waited on is a
31
+ * layout pass in which the crossed rows measure. */
32
+ const AIM_PASSES = 4
33
+ const AIM_SETTLE_MS = 50
34
+
35
+ export function useTranscriptJumps(options: {
36
+ rows: TranscriptRow[]
37
+ terminal: boolean
38
+ stickyPrompt: boolean
39
+ epoch: HeightEpoch | null
40
+ promptRows: readonly number[]
41
+ scrollElement: HTMLElement | null
42
+ rowsRef: RefObject<HTMLDivElement | null>
43
+ virtualizer: Virtualizer<HTMLElement, HTMLDivElement>
44
+ stick: ReturnType<typeof useStickToBottomContext>
45
+ jumpToRecapRef?: RefObject<(() => void) | null>
46
+ repinRef?: RefObject<(() => void) | null>
47
+ }): (rowIndex: number, align?: 'start' | 'center') => void {
48
+ const {
49
+ rows,
50
+ terminal,
51
+ stickyPrompt,
52
+ epoch,
53
+ promptRows,
54
+ scrollElement,
55
+ rowsRef,
56
+ virtualizer,
57
+ stick,
58
+ jumpToRecapRef,
59
+ repinRef,
60
+ } = options
61
+
62
+ // The pending re-aim lives in a ref, and this is the whole reason: the
63
+ // closure is rebuilt every render to keep `rows` fresh, so anything held in
64
+ // its scope is torn down every render too — and a jump in flight re-renders
65
+ // constantly, because that is what rows mounting *is*. A timer in the
66
+ // closure would be cancelled by the very work it is waiting for.
67
+ const aimTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
68
+ useEffect(() => () => clearTimeout(aimTimer.current), [])
69
+
70
+ // `align` is the caller's, and every caller on this surface now asks for
71
+ // `'start'`: a jump target is a place to *start reading* — what you came for
72
+ // runs downward from it — so its line lands at exactly the top edge with the
73
+ // screen below it to read into, and centring wastes half the viewport on what
74
+ // you have already read. The recap boundary was the one exception, argued as
75
+ // "a seam, and seeing a little of what came before is how you place it"; in
76
+ // practice the seam is a starting line too. `'center'` survives as the
77
+ // default because it is what `scrollIntoView` means and a future caller may
78
+ // want it, not because anything here uses it.
79
+ //
80
+ // The two aligns aim differently, and the split is load-bearing. `'start'`
81
+ // computes the target offset itself — never `scrollIntoView` — because a
82
+ // prompt row is `position: sticky` and scrollIntoView aims at the element's
83
+ // *current* rect, which for a stuck row is wherever it is pinned: the jump
84
+ // would be a no-op on the very rows the scrubber's left lane points at. The
85
+ // target is the row's virtual start plus the rows container's own offset in
86
+ // the scroll content, plus the row's gap padding (the blank line is padding
87
+ // *on* the row, and the reader asked for the line, not its air) — which also
88
+ // lands exactly on the sticky engage threshold, so the row arrives pinned.
89
+ // Re-aims recompute it while rows crossed by the travel measure.
90
+ //
91
+ // `stopScroll()` first, because the pin spring is the other `scrollTop`
92
+ // writer and this is the library's own switch for "the user is leaving the
93
+ // bottom".
94
+ const jumpToRow = (rowIndex: number, align: 'start' | 'center' = 'center') => {
95
+ if (rowIndex < 0 || rowIndex >= rows.length || !scrollElement) return
96
+ stick.stopScroll()
97
+ clearTimeout(aimTimer.current)
98
+ if (align === 'start') {
99
+ // The gap padding a row's *line* sits below — the target is the line,
100
+ // not its air.
101
+ const linePad = (index: number) =>
102
+ terminal && epoch && index > 0 && gapBefore(rows, index) ? epoch.line : 0
103
+ const target = () => {
104
+ const start = virtualizer.measurementsCache[rowIndex]?.start ?? 0
105
+ const spacer = rowsRef.current
106
+ ? rowsRef.current.getBoundingClientRect().top -
107
+ scrollElement.getBoundingClientRect().top +
108
+ scrollElement.scrollTop
109
+ : 0
110
+ let top = start + spacer + linePad(rowIndex)
111
+ // A non-prompt target lands *below* the pinned prompt, not under it:
112
+ // at this offset the turn's own prompt head is stuck at the top, and a
113
+ // jump that puts the line at zero puts it exactly behind that band —
114
+ // the scrubber's answer marks would all land their first line hidden.
115
+ // The head is one line tall. A jump *to* a prompt keeps zero: it
116
+ // becomes the pinned row itself.
117
+ if (terminal && stickyPrompt && epoch && !promptRows.includes(rowIndex)) {
118
+ const pinned = promptRows.some((index) => index < rowIndex)
119
+ if (pinned) top -= epoch.line
120
+ }
121
+ return Math.round(top)
122
+ }
123
+ const aim = (attempt: number) => {
124
+ const top = target()
125
+ scrollElement.scrollTo({ top, behavior: 'instant' })
126
+ if (attempt >= AIM_PASSES) return
127
+ aimTimer.current = setTimeout(() => {
128
+ // Off target: a row that measured under the jump moved it. (It can no
129
+ // longer be "still travelling" — that was the smooth era.)
130
+ if (Math.abs(scrollElement.scrollTop - target()) > 1) aim(attempt + 1)
131
+ }, AIM_SETTLE_MS)
132
+ }
133
+ aim(0)
134
+ return
135
+ }
136
+ const aim = (attempt: number) => {
137
+ const row = scrollElement.querySelector(`[data-index="${rowIndex}"]`)
138
+ if (row) {
139
+ row.scrollIntoView({ behavior: 'instant', block: align })
140
+ return
141
+ }
142
+ if (attempt >= AIM_PASSES) return
143
+ virtualizer.scrollToIndex(rowIndex, { align, behavior: 'auto' })
144
+ // A frame or two, not a journey: the aim is only better once the rows
145
+ // crossed have mounted *and* been measured, and that is a layout pass
146
+ // away — but with the jump instant there is nothing else to wait for.
147
+ aimTimer.current = setTimeout(() => aim(attempt + 1), AIM_SETTLE_MS)
148
+ }
149
+ aim(0)
150
+ }
151
+
152
+ useEffect(() => {
153
+ if (!jumpToRecapRef) return
154
+ jumpToRecapRef.current = () => {
155
+ // `'start'`, like the scrubber's marks. The seam is a place to *start
156
+ // reading* — everything you missed runs downward from it — so it belongs
157
+ // at the top edge with the screen below it to read into. Centring it
158
+ // spent half the viewport on rows you had already read.
159
+ jumpToRow(
160
+ rows.findIndex((row) => row.key === 'recap'),
161
+ 'start',
162
+ )
163
+ }
164
+ return () => {
165
+ jumpToRecapRef.current = null
166
+ }
167
+ })
168
+
169
+ // Re-pin to the bottom. Published the same way as the jump above, and for the
170
+ // same reason: only the scroll context can do it, and it lives in here.
171
+ //
172
+ // `'instant'`, never the follow spring. The spring exists to keep up with a
173
+ // stream a few pixels at a time; from a reader who had scrolled a long way up
174
+ // it would animate the whole way down, which is the smooth-scroll-on-switch
175
+ // complaint this component already carries two guards against. The target is
176
+ // trustworthy because the bottom is `totalSize`, which the height calculator
177
+ // makes exact — unlike a mid-list row, which is why `jumpToRow` needs its
178
+ // aim/re-aim loop and this needs none.
179
+ useEffect(() => {
180
+ if (!repinRef) return
181
+ repinRef.current = () => {
182
+ void stick.scrollToBottom('instant')
183
+ }
184
+ return () => {
185
+ repinRef.current = null
186
+ }
187
+ })
188
+
189
+ return jumpToRow
190
+ }
@@ -101,6 +101,69 @@ export function createRangeAtOffset(editor: HTMLElement, targetOffset: number):
101
101
  return range
102
102
  }
103
103
 
104
+ /**
105
+ * Where the caret is on screen, in viewport coordinates.
106
+ *
107
+ * A collapsed range usually measures fine, but not when it sits *between* child
108
+ * nodes of an element — which is exactly where a freshly inserted newline puts
109
+ * it, because this editor's content is a flat run of text nodes and `<br>`s
110
+ * directly under the editor element. There `getBoundingClientRect()` is all
111
+ * zeros, and the obvious fallback (measure `startContainer`'s element) resolves
112
+ * to the editor itself, whose rect is the viewport box — so it reports the
113
+ * caret as trivially visible and scrolls nothing.
114
+ *
115
+ * So measure the neighbour instead: the child the caret sits before, or failing
116
+ * that the one it sits after. Selecting a node gives a real rect even for a
117
+ * `<br>`, which is what the line the caret just moved to consists of.
118
+ */
119
+ function caretRect(range: Range): DOMRect | null {
120
+ const direct = range.getBoundingClientRect()
121
+ if (direct.height > 0) return direct
122
+
123
+ const node = range.startContainer
124
+ if (node.nodeType !== Node.ELEMENT_NODE) return null
125
+ const children = (node as Element).childNodes
126
+ const neighbours = [children[range.startOffset], children[range.startOffset - 1]]
127
+ for (const neighbour of neighbours) {
128
+ if (!neighbour) continue
129
+ const probe = document.createRange()
130
+ probe.selectNode(neighbour)
131
+ const rect = probe.getBoundingClientRect()
132
+ if (rect.height > 0) return rect
133
+ }
134
+ return null
135
+ }
136
+
137
+ /**
138
+ * Keep the caret inside the editor's visible box after a *programmatic* move.
139
+ *
140
+ * The editor is its own scroll container once it hits `maxHeight`
141
+ * (`prompt-area.tsx` sets `overflowY: auto` there). Typing scrolls the caret
142
+ * into view for free, because that is the browser's own behaviour for a native
143
+ * edit — but every path that calls `preventDefault()` and rebuilds the DOM
144
+ * itself (Shift+Enter, list continuation, undo/redo, the bold/italic wrap,
145
+ * paste) places the caret with a Range instead, and setting a selection does
146
+ * not scroll anything. The symptom is a prompt that has grown past its cap and
147
+ * stops following what you are writing: type and it scrolls, press Shift+Enter
148
+ * and the new line appears below the fold.
149
+ *
150
+ * A no-op when the caret is already visible, so it is safe on every call — it
151
+ * corrects an off-screen caret rather than scrolling to one.
152
+ */
153
+ export function scrollCaretIntoView(editor: HTMLElement): void {
154
+ const sel = window.getSelection()
155
+ if (!sel || sel.rangeCount === 0) return
156
+ const range = sel.getRangeAt(0)
157
+ if (!editor.contains(range.startContainer)) return
158
+
159
+ const rect = caretRect(range)
160
+ if (!rect) return
161
+
162
+ const box = editor.getBoundingClientRect()
163
+ if (rect.bottom > box.bottom) editor.scrollTop += rect.bottom - box.bottom
164
+ else if (rect.top < box.top) editor.scrollTop -= box.top - rect.top
165
+ }
166
+
104
167
  export function setCursorAtOffset(editor: HTMLElement, targetOffset: number): void {
105
168
  const sel = window.getSelection()
106
169
  if (!sel) return
@@ -112,6 +175,7 @@ export function setCursorAtOffset(editor: HTMLElement, targetOffset: number): vo
112
175
  range.collapse(true)
113
176
  sel.removeAllRanges()
114
177
  sel.addRange(range)
178
+ scrollCaretIntoView(editor)
115
179
  return
116
180
  }
117
181
 
@@ -121,6 +185,7 @@ export function setCursorAtOffset(editor: HTMLElement, targetOffset: number): vo
121
185
  range.collapse(false)
122
186
  sel.removeAllRanges()
123
187
  sel.addRange(range)
188
+ scrollCaretIntoView(editor)
124
189
  }
125
190
 
126
191
  export function getTextLengthInRange(range: Range): number {
@@ -1307,21 +1307,27 @@ export function usePromptArea({
1307
1307
  return
1308
1308
  }
1309
1309
 
1310
- // 3. Enter without Shift (skipping IME): continue a list, else submit when
1311
- // `submitOnEnter` is set, else insert a newline.
1310
+ // 3. Enter without Shift (skipping IME): submit when `submitOnEnter` is
1311
+ // set, else insert a newline — continuing a list if we are in one.
1312
+ //
1313
+ // Under `submitOnEnter`, Enter submits *unconditionally*. It used to try
1314
+ // a list continuation first, which meant that once you had typed a
1315
+ // bullet, the send key silently stopped sending: every Enter added
1316
+ // another empty bullet, and the only way out was to clear the list. Enter
1317
+ // is the send key in a chat composer, and a key that does something else
1318
+ // depending on what the line above happens to start with is not one.
1319
+ // Continuation lives on Shift+Enter (branch 2.8), which is the newline
1320
+ // key here and therefore where "newline, and keep the list going"
1321
+ // belongs. Without `submitOnEnter`, Enter *is* the newline key, so it
1322
+ // keeps the continuation.
1312
1323
  if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
1313
- const editor = editorRef.current
1314
- if (editor && tryListContinuation(editor)) {
1315
- e.preventDefault()
1316
- return
1317
- }
1324
+ e.preventDefault()
1318
1325
  if (submitOnEnter) {
1319
- e.preventDefault()
1320
1326
  onSubmit?.(readSegmentsFromDOM())
1321
1327
  return
1322
1328
  }
1323
- e.preventDefault()
1324
- if (editor) insertPlainNewline(editor)
1329
+ const editor = editorRef.current
1330
+ if (editor && !tryListContinuation(editor)) insertPlainNewline(editor)
1325
1331
  return
1326
1332
  }
1327
1333
 
@@ -0,0 +1,119 @@
1
+ import { useState } from 'react'
2
+ import type { PermissionRequest } from '@workerdeck/protocol'
3
+ import { toolInputPreview } from '../../lib/format.ts'
4
+ import { TerminalDiff, previewPatch } from './diff.tsx'
5
+ import { Choices, Hint, PromptInput, PromptTitle, Rule } from './prompt.tsx'
6
+ import { Blank, Row } from './row.tsx'
7
+
8
+ /**
9
+ * The approval, as the CLI draws it: a rule, what is being asked and about what,
10
+ * the change itself, a dashed rule, the question, numbered answers, and the keys.
11
+ *
12
+ * Two things it does that the card version does not, both taken from the CLI:
13
+ *
14
+ * - **It shows the change, not the payload.** For a file edit, `old_string` and
15
+ * `new_string` are a diff — so it renders one, rather than the JSON input a
16
+ * reader would have to diff in their head. Without line numbers, necessarily:
17
+ * the edit has not happened and this client has never read the file (see
18
+ * {@link previewPatch}). Everything else falls back to the input.
19
+ * - **The heading is the engine's own sentence.** `displayName` ("Edit file")
20
+ * over `title`, never "wants to use {tool}" composed here — for codex an
21
+ * approval is an *escalation after a sandbox refusal*, and the runner has
22
+ * already written the sentence that says so.
23
+ */
24
+ export interface TerminalPermissionPromptProps {
25
+ request: PermissionRequest
26
+ onApprove: (requestId: string) => void
27
+ /** `message` is fed back to the agent, which can then try something else;
28
+ * `interrupt` also stops the turn. */
29
+ onDeny: (requestId: string, message?: string, interrupt?: boolean) => void
30
+ }
31
+
32
+ export function TerminalPermissionPrompt({
33
+ request,
34
+ onApprove,
35
+ onDeny,
36
+ }: TerminalPermissionPromptProps) {
37
+ const [focused, setFocused] = useState(0)
38
+ const [denying, setDenying] = useState(false)
39
+ const [reason, setReason] = useState('')
40
+
41
+ const deny = (interrupt: boolean) => {
42
+ const message = reason.trim()
43
+ onDeny(request.id, message || undefined, interrupt || undefined)
44
+ setReason('')
45
+ setDenying(false)
46
+ }
47
+
48
+ const patch = previewPatch(request.input)
49
+ const summary = toolInputPreview(request.input)
50
+ const heading = request.displayName ?? request.title ?? 'Permission needed'
51
+ // The path is the subject when there is one; otherwise the input preview says
52
+ // what this is about, which for a Bash approval is the command itself.
53
+ const subject = patch?.path ?? (summary || undefined)
54
+
55
+ // "Deny" opens the reason field rather than acting, because a denial the agent
56
+ // can learn from is the one worth defaulting to — an empty field still denies.
57
+ const options = [
58
+ { key: 'allow', label: 'Yes' },
59
+ {
60
+ key: 'deny',
61
+ label: 'No, and tell the agent what to do differently',
62
+ detail: denying ? (
63
+ <PromptInput
64
+ value={reason}
65
+ onChange={setReason}
66
+ onSubmit={() => deny(false)}
67
+ onCancel={() => setDenying(false)}
68
+ placeholder='Reason (optional) — the agent reads this and can try something else'
69
+ />
70
+ ) : undefined,
71
+ },
72
+ { key: 'stop', label: 'No, and stop the turn', danger: true },
73
+ ]
74
+
75
+ return (
76
+ <div
77
+ data-slot='permission-prompt'
78
+ onKeyDown={(event) => {
79
+ if (event.key === 'Escape') {
80
+ event.preventDefault()
81
+ deny(false)
82
+ }
83
+ }}>
84
+ <Rule />
85
+ <PromptTitle title={heading} subject={subject} />
86
+ <Blank />
87
+ {patch ? (
88
+ <>
89
+ <TerminalDiff patch={patch} />
90
+ <Blank />
91
+ <Rule dashed />
92
+ </>
93
+ ) : request.description ? (
94
+ <>
95
+ <Row tone='dim'>{request.description}</Row>
96
+ <Blank />
97
+ </>
98
+ ) : null}
99
+ {request.decisionReason ? <Row tone='faint'>{request.decisionReason}</Row> : null}
100
+ <Row>
101
+ {request.title ?? `Do you want to proceed?`}
102
+ </Row>
103
+ <Choices
104
+ label={heading}
105
+ options={options}
106
+ focused={focused}
107
+ onFocus={setFocused}
108
+ active={!denying}
109
+ onChoose={(index) => {
110
+ if (index === 0) onApprove(request.id)
111
+ else if (index === 1) setDenying(true)
112
+ else deny(true)
113
+ }}
114
+ />
115
+ <Blank />
116
+ <Hint>Enter to select · ↑/↓ to navigate · 1–3 to choose · Esc to cancel</Hint>
117
+ </div>
118
+ )
119
+ }