@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.
- package/README.md +72 -0
- package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-B9CHoq8x.d.mts} +213 -27
- package/build/{SessionPanel-CKQa4i0Y.mjs → SessionPanel-DII9MmQ8.mjs} +5192 -2542
- package/build/SessionPanel-DII9MmQ8.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +494 -45
- package/build/index.mjs +182 -24
- 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 +13 -1
- package/build/workspace.mjs +111 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +14 -7
- package/src/components/agent/Composer.tsx +251 -84
- 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 -43
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionBrowser.tsx +35 -25
- package/src/components/agent/SessionPanel.tsx +312 -63
- package/src/components/agent/SessionWorkspace.tsx +36 -0
- package/src/components/agent/StatusBar.tsx +70 -15
- package/src/components/agent/ToolCallCard.tsx +17 -111
- package/src/components/agent/Transcript.tsx +710 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/transcript-rows.ts +82 -0
- package/src/components/agent/transcript-variant.tsx +43 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +147 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +727 -0
- package/src/components/terminal/items.tsx +449 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +72 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +663 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +91 -0
- package/src/components/ui/Badge.tsx +6 -1
- package/src/components/ui/Empty.tsx +56 -0
- package/src/components/ui/Splitter.tsx +14 -0
- package/src/index.ts +36 -0
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1011 -0
- package/src/styles/theme.css +73 -0
- package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- 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):
|
|
1311
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|