@workerdeck/ui 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +60 -0
  2. package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
  3. package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
  4. package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
  5. package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
  6. package/build/format.d.mts +39 -4
  7. package/build/format.mjs +2 -118
  8. package/build/index.d.mts +493 -45
  9. package/build/index.mjs +343 -17
  10. package/build/index.mjs.map +1 -1
  11. package/build/status-Ydzi7n6j.mjs +143 -0
  12. package/build/status-Ydzi7n6j.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -1
  14. package/build/workspace.mjs +108 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +16 -7
  17. package/src/components/agent/Composer.tsx +189 -89
  18. package/src/components/agent/Conversation.tsx +12 -12
  19. package/src/components/agent/FileCard.tsx +0 -26
  20. package/src/components/agent/FileTree.tsx +9 -8
  21. package/src/components/agent/Loader.tsx +22 -72
  22. package/src/components/agent/Message.tsx +11 -46
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/ProjectIcon.tsx +119 -0
  25. package/src/components/agent/QuestionPrompt.tsx +0 -122
  26. package/src/components/agent/Reasoning.tsx +5 -19
  27. package/src/components/agent/Response.tsx +1 -132
  28. package/src/components/agent/SessionBrowser.tsx +84 -3
  29. package/src/components/agent/SessionPanel.tsx +249 -28
  30. package/src/components/agent/SessionWorkspace.tsx +29 -0
  31. package/src/components/agent/StatusBar.tsx +20 -4
  32. package/src/components/agent/ToolCallCard.tsx +85 -112
  33. package/src/components/agent/Transcript.tsx +780 -203
  34. package/src/components/agent/UsageDialog.tsx +20 -106
  35. package/src/components/agent/UsageMeters.tsx +133 -0
  36. package/src/components/agent/pulse.tsx +3 -2
  37. package/src/components/agent/tool-result-fetch.tsx +36 -0
  38. package/src/components/agent/tool-result-image.tsx +209 -0
  39. package/src/components/agent/transcript-rows.ts +173 -0
  40. package/src/components/agent/transcript-variant.tsx +29 -51
  41. package/src/components/agent/use-height-epoch.ts +60 -0
  42. package/src/components/agent/use-path-links.ts +147 -0
  43. package/src/components/agent/use-transcript-jumps.ts +190 -0
  44. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  45. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  46. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  47. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  48. package/src/components/terminal/StatusLine.tsx +159 -0
  49. package/src/components/terminal/TerminalTranscript.tsx +225 -0
  50. package/src/components/terminal/affordances.tsx +118 -0
  51. package/src/components/terminal/blocks.ts +232 -0
  52. package/src/components/terminal/diff.tsx +130 -0
  53. package/src/components/terminal/height.ts +770 -0
  54. package/src/components/terminal/image-box.ts +53 -0
  55. package/src/components/terminal/items.tsx +486 -0
  56. package/src/components/terminal/markdown.tsx +191 -0
  57. package/src/components/terminal/press.tsx +120 -0
  58. package/src/components/terminal/prompt.tsx +343 -0
  59. package/src/components/terminal/result-preview.ts +86 -0
  60. package/src/components/terminal/row.tsx +132 -0
  61. package/src/components/terminal/scrubber.tsx +784 -0
  62. package/src/components/terminal/surface.tsx +80 -0
  63. package/src/components/terminal/tool-run.ts +224 -0
  64. package/src/index.ts +36 -1
  65. package/src/lib/status.ts +59 -3
  66. package/src/lib/tool-icon.ts +14 -0
  67. package/src/styles/terminal.css +1081 -0
  68. package/src/styles/theme.css +41 -0
  69. package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
  70. package/build/format.mjs.map +0 -1
  71. package/src/components/agent/line-prompt.tsx +0 -249
@@ -0,0 +1,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
- * - `lines` — one full-width line item per event, transparent, hover-highlit,
10
- * with a glyph in a fixed left gutter. Right where vertical space is the
11
- * scarce resource (a VS Code dock) and the terminal is the reference UX:
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
- * A context rather than a prop chain because every row component needs it and
15
- * only the transcript root knows it and because `Message`/`ToolCallCard` are
16
- * exported on their own, so an embedder composing them by hand gets the right
17
- * treatment for free.
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' | 'lines'
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 and what the `lines` variant is trying to read like. The default:
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* (boxed or not) and follows from the
53
- * surface; density decides *how much air is around it* and is a preference the
54
- * reader holds. Coupling them would mean a dock could not be roomy and a
55
- * dashboard could not be dense.
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
+ }