@workerdeck/ui 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
- package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
- package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +493 -45
- package/build/index.mjs +343 -17
- package/build/index.mjs.map +1 -1
- package/build/status-Ydzi7n6j.mjs +143 -0
- package/build/status-Ydzi7n6j.mjs.map +1 -0
- package/build/workspace.d.mts +9 -1
- package/build/workspace.mjs +108 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +16 -7
- package/src/components/agent/Composer.tsx +189 -89
- package/src/components/agent/Conversation.tsx +12 -12
- package/src/components/agent/FileCard.tsx +0 -26
- package/src/components/agent/FileTree.tsx +9 -8
- package/src/components/agent/Loader.tsx +22 -72
- package/src/components/agent/Message.tsx +11 -46
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/ProjectIcon.tsx +119 -0
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionBrowser.tsx +84 -3
- package/src/components/agent/SessionPanel.tsx +249 -28
- package/src/components/agent/SessionWorkspace.tsx +29 -0
- package/src/components/agent/StatusBar.tsx +20 -4
- package/src/components/agent/ToolCallCard.tsx +85 -112
- package/src/components/agent/Transcript.tsx +780 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/tool-result-fetch.tsx +36 -0
- package/src/components/agent/tool-result-image.tsx +209 -0
- package/src/components/agent/transcript-rows.ts +173 -0
- package/src/components/agent/transcript-variant.tsx +29 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +225 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/blocks.ts +232 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +770 -0
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +486 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +86 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +784 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +224 -0
- package/src/index.ts +36 -1
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1081 -0
- package/src/styles/theme.css +41 -0
- package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- package/src/components/agent/line-prompt.tsx +0 -249
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The virtual row model: what one row of the transcript's virtual list *is*,
|
|
3
|
+
* and the two rules everything positional must go through — which item a row
|
|
4
|
+
* is spaced as, and how an item index maps onto a row index. Pure; shared by
|
|
5
|
+
* the transcript shell and its hooks.
|
|
6
|
+
*/
|
|
7
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
8
|
+
import { needsBlank, type TerminalBlock } from '../terminal/items.tsx'
|
|
9
|
+
|
|
10
|
+
/** One row of the virtual list: a {@link TerminalBlock} (a transcript item,
|
|
11
|
+
* — under the terminal theme — a folded run of tool calls, or a task block
|
|
12
|
+
* standing for a `Task` call and everything its subagent produced), or the
|
|
13
|
+
* recap boundary line spliced in at `catchUp.from`. One flat array so the
|
|
14
|
+
* virtualizer sees stable indices, and each row carries the key the item was
|
|
15
|
+
* already React-keyed by — measurements are cached per key, so a row keeps its
|
|
16
|
+
* measured height when the recap splice shifts every index after it. */
|
|
17
|
+
export type TranscriptRow = TerminalBlock | { key: 'recap'; line: string }
|
|
18
|
+
|
|
19
|
+
/** The item a row is spaced *as*. A run stands for the calls it folded, and a
|
|
20
|
+
* task block for the `Task` call it absorbed into — all tool calls, so a run,
|
|
21
|
+
* a task and a lone tool call below them still read as one block. */
|
|
22
|
+
export function rowItem(row: TranscriptRow | undefined): TranscriptItem | undefined {
|
|
23
|
+
if (!row) return undefined
|
|
24
|
+
if ('item' in row) return row.item
|
|
25
|
+
if ('run' in row) return row.run[0]
|
|
26
|
+
if ('task' in row) return row.task
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Does a blank line go above this row, in the terminal theme?
|
|
32
|
+
*
|
|
33
|
+
* The recap row always earns one — it is a boundary, and a boundary flush
|
|
34
|
+
* against the row above reads as part of it. Otherwise the pair decides
|
|
35
|
+
* (`needsBlank`): consecutive tool calls are one block in the CLI and get none.
|
|
36
|
+
*/
|
|
37
|
+
export function gapBefore(rows: TranscriptRow[], index: number): boolean {
|
|
38
|
+
const before = rowItem(rows[index - 1])
|
|
39
|
+
const after = rowItem(rows[index])
|
|
40
|
+
if (!before || !after) return true
|
|
41
|
+
return needsBlank(before, after)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Which items each task block absorbed, as itemIndex → rowIndex — the one
|
|
46
|
+
* lookup {@link rowIndexForItem} cannot answer from ordering (see its comment).
|
|
47
|
+
* Memoized per rows array identity: the shell builds `rows` in a `useMemo`, so
|
|
48
|
+
* within one row list this is built once, and a WeakMap means a discarded list
|
|
49
|
+
* takes its map with it. Memoization only — the answer is a pure function of
|
|
50
|
+
* the array.
|
|
51
|
+
*/
|
|
52
|
+
const absorbedCache = new WeakMap<readonly TranscriptRow[], Map<number, number>>()
|
|
53
|
+
|
|
54
|
+
function absorbedRows(rows: readonly TranscriptRow[]): Map<number, number> {
|
|
55
|
+
const hit = absorbedCache.get(rows)
|
|
56
|
+
if (hit) return hit
|
|
57
|
+
const map = new Map<number, number>()
|
|
58
|
+
rows.forEach((row, rowIndex) => {
|
|
59
|
+
if ('task' in row) for (const itemIndex of row.childIndices) map.set(itemIndex, rowIndex)
|
|
60
|
+
})
|
|
61
|
+
absorbedCache.set(rows, map)
|
|
62
|
+
return map
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Transcript-item index → virtual-row index — **the off-by-a-fold trap.**
|
|
67
|
+
*
|
|
68
|
+
* The virtualizer's rows are {@link TerminalBlock}s, not items: a folded tool
|
|
69
|
+
* run occupies ONE row for `run.length` consecutive items, a task block
|
|
70
|
+
* occupies ONE row for its `Task` call *plus every item its subagent produced*,
|
|
71
|
+
* and the recap boundary is a row with *no* item index at all, shifting every
|
|
72
|
+
* row after it by one. `virtualizer.scrollToIndex(itemIndex)` is therefore
|
|
73
|
+
* wrong by construction on any folded or spliced transcript — every jump that
|
|
74
|
+
* starts from an item (the scrubber's marks, a future bookmark) must come
|
|
75
|
+
* through here first.
|
|
76
|
+
*
|
|
77
|
+
* The contract, in two halves:
|
|
78
|
+
*
|
|
79
|
+
* - An index a task block **absorbed** maps to that block's row, wherever the
|
|
80
|
+
* child fell in the stream. Subagents run in parallel, so absorbed indices
|
|
81
|
+
* interleave arbitrarily with later rows' starts — no ordering argument can
|
|
82
|
+
* find their row, which is why they are answered first, from a per-row-list
|
|
83
|
+
* map ({@link absorbedRows}) built once per rows array. A row's coverage is
|
|
84
|
+
* its `childIndices`, never `[index, index + N)` arithmetic.
|
|
85
|
+
* - Every other index maps to the **last non-recap row whose start (`index`)
|
|
86
|
+
* is ≤ the target** — the original rule, still a binary search. Rows stay
|
|
87
|
+
* ordered by `index`, and the ordering argument is now: between one row's
|
|
88
|
+
* start and the next row's, every index is either absorbed (answered above)
|
|
89
|
+
* or a member of the earlier row — note that is *weaker* than the old
|
|
90
|
+
* contiguity claim, because a run can fold across an absorbed gap (two
|
|
91
|
+
* top-level calls separated only by a subagent's step are adjacent on
|
|
92
|
+
* screen), so `[index, index + run.length)` arithmetic no longer describes
|
|
93
|
+
* a run's coverage; membership does. The recap row is skipped by giving it
|
|
94
|
+
* its successor's start for navigation (both qualify at the boundary, and
|
|
95
|
+
* "last wins" lands on the real row) while never letting it be the answer.
|
|
96
|
+
*
|
|
97
|
+
* Exhaustively checked against a linear reference — every fixture × every item
|
|
98
|
+
* index × several splice positions — by `__wdCheckMapping` in `dev/App.tsx`,
|
|
99
|
+
* and against constructed interleavings in `test/transcript-rows.test.ts`.
|
|
100
|
+
*/
|
|
101
|
+
export function rowIndexForItem(rows: readonly TranscriptRow[], itemIndex: number): number {
|
|
102
|
+
const absorbed = absorbedRows(rows).get(itemIndex)
|
|
103
|
+
if (absorbed !== undefined) return absorbed
|
|
104
|
+
let lo = 0
|
|
105
|
+
let hi = rows.length - 1
|
|
106
|
+
let best = 0
|
|
107
|
+
while (lo <= hi) {
|
|
108
|
+
const mid = (lo + hi) >> 1
|
|
109
|
+
const row = rows[mid]!
|
|
110
|
+
let start: number
|
|
111
|
+
if ('index' in row) start = row.index
|
|
112
|
+
else {
|
|
113
|
+
const next = rows[mid + 1]
|
|
114
|
+
start = next && 'index' in next ? next.index : Number.MAX_SAFE_INTEGER
|
|
115
|
+
}
|
|
116
|
+
if (start <= itemIndex) {
|
|
117
|
+
if ('index' in row) best = mid
|
|
118
|
+
lo = mid + 1
|
|
119
|
+
} else {
|
|
120
|
+
hi = mid - 1
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return best
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Where an item sits inside a row it shares with other items: its 0-based
|
|
127
|
+
* ordinal in stream order, out of `count` siblings. `0 ≤ ordinal < count`. */
|
|
128
|
+
export type RowPosition = { ordinal: number; count: number }
|
|
129
|
+
|
|
130
|
+
const positionCache = new WeakMap<readonly TranscriptRow[], Map<number, RowPosition>>()
|
|
131
|
+
|
|
132
|
+
function rowPositions(rows: readonly TranscriptRow[]): Map<number, RowPosition> {
|
|
133
|
+
const hit = positionCache.get(rows)
|
|
134
|
+
if (hit) return hit
|
|
135
|
+
const map = new Map<number, RowPosition>()
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
if ('task' in row) {
|
|
138
|
+
const count = row.childIndices.length
|
|
139
|
+
row.childIndices.forEach((itemIndex, ordinal) => map.set(itemIndex, { ordinal, count }))
|
|
140
|
+
} else if ('run' in row && row.run.length > 1) {
|
|
141
|
+
const count = row.run.length
|
|
142
|
+
row.indices.forEach((itemIndex, ordinal) => map.set(itemIndex, { ordinal, count }))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
positionCache.set(rows, map)
|
|
146
|
+
return map
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Where an item sits inside a row that holds MORE than itself — a task block's
|
|
151
|
+
* absorbed child, or a member of a folded run of two or more. `undefined` for
|
|
152
|
+
* everything else, including a row's own head item (the `Task` call, a run's
|
|
153
|
+
* first member is *not* exempt) and a **singleton run**: there the row's extent
|
|
154
|
+
* IS the item's, and a mark spanning it is honest.
|
|
155
|
+
*
|
|
156
|
+
* That carve-out is load-bearing rather than tidy: `pushLeaf` makes *every*
|
|
157
|
+
* top-level tool call a `RunBlock`, usually of length 1, so without it every
|
|
158
|
+
* ordinary failed call's scrubber mark would shrink from its row's extent to a
|
|
159
|
+
* tick and the rail would stop reading as a map — a regression traded for a fix.
|
|
160
|
+
*
|
|
161
|
+
* The scrubber is the consumer: a mark for a shared-row item anchors at
|
|
162
|
+
* `ordinal / count` of the row's *measured* height instead of inheriting an
|
|
163
|
+
* extent that is mostly other items' work (one failed child of a hundred-call
|
|
164
|
+
* task painted the whole expanded block red). Memoized per rows array identity
|
|
165
|
+
* exactly like {@link absorbedRows}, and pure — the answer is a function of the
|
|
166
|
+
* array alone, and a discarded array takes its map with it.
|
|
167
|
+
*/
|
|
168
|
+
export function positionInRow(
|
|
169
|
+
rows: readonly TranscriptRow[],
|
|
170
|
+
itemIndex: number,
|
|
171
|
+
): RowPosition | undefined {
|
|
172
|
+
return rowPositions(rows).get(itemIndex)
|
|
173
|
+
}
|
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
-
import { cn } from '../../lib/utils.ts'
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* How the transcript draws a turn.
|
|
6
5
|
*
|
|
7
6
|
* - `cards` — the chat convention: bubbles, bordered tool cards, generous gaps.
|
|
8
7
|
* Right for a wide dashboard where the transcript is the page.
|
|
9
|
-
* - `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* nothing is boxed, the content and its marker carry the comprehension.
|
|
8
|
+
* - `terminal` — the CLI's own form: every row on a character cell, no boxes
|
|
9
|
+
* anywhere, diffs as full-width bands. Right where the transcript sits beside
|
|
10
|
+
* a terminal (a VS Code dock) and where vertical space is scarce.
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* `terminal` is **not** a second set of branches in the components under
|
|
13
|
+
* `components/agent/`: it is its own renderer (`components/terminal/`) that the
|
|
14
|
+
* shell mounts *instead* of them. So a row component here never asks which
|
|
15
|
+
* variant it is in — if it is drawing at all, it is drawing cards.
|
|
16
|
+
*
|
|
17
|
+
* A context rather than a prop chain because the pieces that DO need it sit
|
|
18
|
+
* outside the transcript (the composer, the pending prompts) and because
|
|
19
|
+
* `Message`/`ToolCallCard` are exported on their own, so an embedder composing
|
|
20
|
+
* them by hand gets the right treatment for free.
|
|
18
21
|
*/
|
|
19
|
-
export type TranscriptVariant = 'cards' | '
|
|
22
|
+
export type TranscriptVariant = 'cards' | 'terminal'
|
|
20
23
|
|
|
21
24
|
const VariantContext = createContext<TranscriptVariant>('cards')
|
|
22
25
|
|
|
@@ -34,25 +37,21 @@ export function useTranscriptVariant(): TranscriptVariant {
|
|
|
34
37
|
return useContext(VariantContext)
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
/** True in `lines`, for the many `cond ? a : b` reads in the row components. */
|
|
38
|
-
export function useLines(): boolean {
|
|
39
|
-
return useTranscriptVariant() === 'lines'
|
|
40
|
-
}
|
|
41
|
-
|
|
42
40
|
/**
|
|
43
41
|
* How much room the transcript gives each row.
|
|
44
42
|
*
|
|
45
43
|
* - `comfortable` — a blank line between messages, which is what the Claude Code
|
|
46
|
-
* CLI does
|
|
47
|
-
* a transcript is prose before it is a table.
|
|
44
|
+
* CLI does. The default: a transcript is prose before it is a table.
|
|
48
45
|
* - `compact` — rows tight against each other, for a dock where every line of
|
|
49
46
|
* vertical space is contested.
|
|
50
47
|
*
|
|
51
48
|
* Separate from the variant, and deliberately: they answer different questions.
|
|
52
|
-
* The variant decides *how a row is drawn*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
49
|
+
* The variant decides *how a row is drawn* and follows from the surface; density
|
|
50
|
+
* decides *how much air is around it* and is a preference the reader holds.
|
|
51
|
+
*
|
|
52
|
+
* Reaches `cards` only. The terminal theme's spacing is a blank *line*, decided
|
|
53
|
+
* per pair of blocks by `needsBlank` — a terminal has one line height, which is
|
|
54
|
+
* the whole premise — so there is nothing there for this to turn.
|
|
56
55
|
*/
|
|
57
56
|
export type TranscriptDensity = 'comfortable' | 'compact'
|
|
58
57
|
|
|
@@ -95,43 +94,22 @@ export type TranscriptFont = 'sans' | 'mono'
|
|
|
95
94
|
* `px` is fed to `estimateSize` alone, where being approximate is the contract:
|
|
96
95
|
* it sets the scrollbar's length before rows mount and is replaced by a real
|
|
97
96
|
* measurement the moment one does.
|
|
98
|
-
*
|
|
99
|
-
* `lines` + `compact` is the only combination with no gap at all: there the
|
|
100
|
-
* row's own `py-0.5` is the entire separation, which is what makes it compact.
|
|
101
97
|
*/
|
|
102
98
|
export const ROW_GAP: Record<
|
|
103
99
|
TranscriptVariant,
|
|
104
100
|
Record<TranscriptDensity, { className?: string; px: number }>
|
|
105
101
|
> = {
|
|
102
|
+
// The terminal theme has no gap scale: the space between blocks is a **blank
|
|
103
|
+
// line**, decided per pair by `needsBlank` (a tool call and its output get
|
|
104
|
+
// none), so it is a class the shell applies conditionally rather than a
|
|
105
|
+
// constant it applies to every row. Density does not reach it — a terminal
|
|
106
|
+
// has one line height, which is the whole premise.
|
|
107
|
+
terminal: {
|
|
108
|
+
comfortable: { className: 'term-row-gap', px: 18 },
|
|
109
|
+
compact: { className: 'term-row-gap', px: 18 },
|
|
110
|
+
},
|
|
106
111
|
cards: {
|
|
107
112
|
comfortable: { className: 'pt-4', px: 16 },
|
|
108
113
|
compact: { className: 'pt-2', px: 8 },
|
|
109
114
|
},
|
|
110
|
-
lines: {
|
|
111
|
-
// 16px on top of the row's own 4px of `py-0.5` is one 20px line — the blank
|
|
112
|
-
// line the CLI leaves, arrived at from the line height rather than picked.
|
|
113
|
-
comfortable: { className: 'pt-4', px: 16 },
|
|
114
|
-
compact: { px: 0 },
|
|
115
|
-
},
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* The left gutter of a line item: one glyph, fixed width, so every row's text
|
|
120
|
-
* starts on the same column no matter which kind of event it is. Decorative —
|
|
121
|
-
* the row's own text says what it is.
|
|
122
|
-
*/
|
|
123
|
-
export function LineGlyph({ children, className }: { children: ReactNode; className?: string }) {
|
|
124
|
-
return (
|
|
125
|
-
<span
|
|
126
|
-
aria-hidden
|
|
127
|
-
className={cn(
|
|
128
|
-
'w-3.5 shrink-0 select-none text-center font-mono text-label leading-5 text-fg-4',
|
|
129
|
-
className,
|
|
130
|
-
)}>
|
|
131
|
-
{children}
|
|
132
|
-
</span>
|
|
133
|
-
)
|
|
134
115
|
}
|
|
135
|
-
|
|
136
|
-
/** Body text metrics for a line item — tighter than the card variant's. */
|
|
137
|
-
export const LINE_TEXT = 'text-body-sm leading-5'
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The height epoch: one cache generation of computed row heights (terminal
|
|
3
|
+
* theme only — cards have no calculator and keep the flat estimate). Owned by
|
|
4
|
+
* the transcript shell because it owns the virtualizer the heights feed; the
|
|
5
|
+
* WeakMap inside self-invalidates through the reducer's replace-on-mutation,
|
|
6
|
+
* and the epoch itself is replaced wholesale when the wrap width or the cell
|
|
7
|
+
* changes. Measured off the rows container: it *is* the width rows wrap in
|
|
8
|
+
* (the scroller can resize without it moving — `ConversationContent` caps at
|
|
9
|
+
* 48rem — and the window never hears about a splitter drag), and it inherits
|
|
10
|
+
* the surface's font, which is what makes the `ch` probe honest. All DOM
|
|
11
|
+
* reads happen in the effect, debounced; render never touches layout.
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useState, type RefObject } from 'react'
|
|
14
|
+
import { createHeightEpoch, measureCh, type HeightEpoch } from '../terminal/height.ts'
|
|
15
|
+
|
|
16
|
+
export function useHeightEpoch(options: {
|
|
17
|
+
terminal: boolean
|
|
18
|
+
/** The terminal cell, when the host set one — only read as a signal that the
|
|
19
|
+
* epoch must re-measure; the epoch's numbers come from the DOM. */
|
|
20
|
+
fontSize?: number
|
|
21
|
+
lineHeight?: number
|
|
22
|
+
rowsRef: RefObject<HTMLDivElement | null>
|
|
23
|
+
}): HeightEpoch | null {
|
|
24
|
+
const { terminal, fontSize, lineHeight, rowsRef } = options
|
|
25
|
+
const [epoch, setEpoch] = useState<HeightEpoch | null>(null)
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!terminal) return
|
|
28
|
+
const element = rowsRef.current
|
|
29
|
+
if (!element) return
|
|
30
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
31
|
+
const measure = () => {
|
|
32
|
+
const line = Number.parseFloat(
|
|
33
|
+
getComputedStyle(element).getPropertyValue('--term-line'),
|
|
34
|
+
)
|
|
35
|
+
const width = element.clientWidth
|
|
36
|
+
const ch = measureCh(element)
|
|
37
|
+
if (!line || !width || !ch) return
|
|
38
|
+
setEpoch((previous) =>
|
|
39
|
+
previous && previous.width === width && previous.ch === ch && previous.line === line
|
|
40
|
+
? previous
|
|
41
|
+
: createHeightEpoch(width, ch, line),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
const observer = new ResizeObserver(() => {
|
|
45
|
+
clearTimeout(timer)
|
|
46
|
+
timer = setTimeout(measure, 150)
|
|
47
|
+
})
|
|
48
|
+
observer.observe(element)
|
|
49
|
+
measure()
|
|
50
|
+
return () => {
|
|
51
|
+
observer.disconnect()
|
|
52
|
+
clearTimeout(timer)
|
|
53
|
+
}
|
|
54
|
+
// fontSize/lineHeight: a cell change re-renders every row, which usually
|
|
55
|
+
// moves the container's size and fires the observer — but a transcript
|
|
56
|
+
// whose height happens to survive the change would keep a stale `ch`, so
|
|
57
|
+
// the props re-arm the measurement directly.
|
|
58
|
+
}, [terminal, fontSize, lineHeight, rowsRef])
|
|
59
|
+
return epoch
|
|
60
|
+
}
|
|
@@ -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
|
+
}
|