@workerdeck/ui 0.15.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 +53 -0
- package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-B9CHoq8x.d.mts} +158 -21
- package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DII9MmQ8.mjs} +4254 -1661
- 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 +442 -45
- package/build/index.mjs +105 -2
- 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 +14 -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/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionPanel.tsx +224 -28
- package/src/components/agent/SessionWorkspace.tsx +29 -0
- package/src/components/agent/StatusBar.tsx +20 -4
- 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 +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 +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/index.ts +34 -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 +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
|
@@ -1,56 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ProfileEngine, RateLimitInfo } from '@workerdeck/protocol'
|
|
3
|
-
import { RotateCcw } from 'lucide-react'
|
|
1
|
+
import type { ProfileEngine, UsageWindowRow } from '@workerdeck/protocol'
|
|
4
2
|
import { Badge } from '../ui/Badge.tsx'
|
|
5
3
|
import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
formatAgoPrecise,
|
|
9
|
-
formatCost,
|
|
10
|
-
formatCountdown,
|
|
11
|
-
formatRateLimitWindowLong,
|
|
12
|
-
rateLimitWindowSeconds,
|
|
13
|
-
} from '../../lib/format.ts'
|
|
4
|
+
import { formatAgoPrecise, formatCost } from '../../lib/format.ts'
|
|
5
|
+
import { UsageMeters, useMinuteClock } from './UsageMeters.tsx'
|
|
14
6
|
|
|
15
7
|
export interface UsageDialogProps {
|
|
16
|
-
/**
|
|
17
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Windows in reading order — session, weekly, then per-model weeklies.
|
|
10
|
+
*
|
|
11
|
+
* `updatedAt`/`inferredReset` ride each window because the readings no longer
|
|
12
|
+
* share one clock: the panel merges the gateway's per-profile state over this
|
|
13
|
+
* session's, so a `five_hour` learned from a sibling session two minutes ago
|
|
14
|
+
* can sit beside a `seven_day` this session last heard about yesterday.
|
|
15
|
+
* `inferredReset` marks the ones the *gateway* zeroed because their own reset
|
|
16
|
+
* time passed with nothing newer — a floor, not a report.
|
|
17
|
+
*/
|
|
18
|
+
rateLimits: UsageWindowRow[]
|
|
18
19
|
/** claude.ai plan behind the windows ('max', 'pro', …), when there is one. */
|
|
19
20
|
subscriptionType?: string
|
|
20
21
|
engine: ProfileEngine
|
|
21
22
|
totalCostUsd: number
|
|
22
|
-
/**
|
|
23
|
-
* per turn at best
|
|
23
|
+
/** When the *freshest* of the windows was read, for the footer line. Readings
|
|
24
|
+
* arrive one per turn at best (and for an idle session, not at all), so a
|
|
25
|
+
* stale one is normal and worth saying out loud. */
|
|
24
26
|
updatedAt?: number
|
|
25
27
|
open: boolean
|
|
26
28
|
onOpenChange: (open: boolean) => void
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
/** Ticking clock — the countdowns and the pace markers both move with it, and a
|
|
30
|
-
* minute is the finest resolution either of them prints. */
|
|
31
|
-
function useMinuteClock(open: boolean): number {
|
|
32
|
-
const [now, setNow] = useState(() => Date.now())
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
if (!open) return
|
|
35
|
-
setNow(Date.now())
|
|
36
|
-
const timer = setInterval(() => setNow(Date.now()), 60_000)
|
|
37
|
-
return () => clearInterval(timer)
|
|
38
|
-
}, [open])
|
|
39
|
-
return now
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
|
|
43
|
-
|
|
44
31
|
/**
|
|
45
|
-
* The
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* The pace marker is the point. A bar alone says "17% used", which is only
|
|
49
|
-
* alarming or reassuring once you know how far into the week you are — so every
|
|
50
|
-
* window draws a tick at the elapsed share of its duration. Left of the tick is
|
|
51
|
-
* under budget, right of it is ahead of it. The duration comes from the window
|
|
52
|
-
* key (5h, 7d) because the CLI reports a reset time and a percentage and never a
|
|
53
|
-
* duration; a window whose key doesn't say gets no marker rather than a guessed one.
|
|
32
|
+
* The session's view of the plan: every window as a meter (see
|
|
33
|
+
* {@link UsageMeters}), the plan tier, and what this session itself has cost.
|
|
54
34
|
*/
|
|
55
35
|
export function UsageDialog({
|
|
56
36
|
rateLimits,
|
|
@@ -86,11 +66,7 @@ export function UsageDialog({
|
|
|
86
66
|
: `Plan windows are a claude.ai subscription thing; this session runs on the ${engine} engine.`}
|
|
87
67
|
</p>
|
|
88
68
|
) : (
|
|
89
|
-
<
|
|
90
|
-
{rateLimits.map(({ key, info }) => (
|
|
91
|
-
<UsageWindow key={key} windowKey={key} info={info} now={now} />
|
|
92
|
-
))}
|
|
93
|
-
</div>
|
|
69
|
+
<UsageMeters windows={rateLimits} now={now} />
|
|
94
70
|
)}
|
|
95
71
|
<div className='mt-5 flex items-baseline justify-between gap-4 border-t border-border pt-3'>
|
|
96
72
|
<span className='text-label text-fg-3'>This session has cost</span>
|
|
@@ -104,65 +80,3 @@ export function UsageDialog({
|
|
|
104
80
|
</Dialog>
|
|
105
81
|
)
|
|
106
82
|
}
|
|
107
|
-
|
|
108
|
-
function UsageWindow({
|
|
109
|
-
windowKey,
|
|
110
|
-
info,
|
|
111
|
-
now,
|
|
112
|
-
}: {
|
|
113
|
-
windowKey: string
|
|
114
|
-
info: RateLimitInfo
|
|
115
|
-
now: number
|
|
116
|
-
}) {
|
|
117
|
-
const utilization = info.utilization ?? 0
|
|
118
|
-
const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
|
|
119
|
-
// Share of the window already elapsed — where usage *would* be if it were
|
|
120
|
-
// spent evenly. Needs both a duration (from the key) and a reset time.
|
|
121
|
-
const duration = rateLimitWindowSeconds(windowKey)
|
|
122
|
-
const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
|
|
123
|
-
const pace =
|
|
124
|
-
duration !== undefined && remaining !== undefined && remaining > 0 && remaining < duration
|
|
125
|
-
? (duration - remaining) / duration
|
|
126
|
-
: undefined
|
|
127
|
-
return (
|
|
128
|
-
<div>
|
|
129
|
-
<div className='flex items-baseline justify-between gap-3'>
|
|
130
|
-
<span className='truncate text-body-sm text-fg-1'>
|
|
131
|
-
{formatRateLimitWindowLong(windowKey)}
|
|
132
|
-
</span>
|
|
133
|
-
<span
|
|
134
|
-
className={cn(
|
|
135
|
-
'shrink-0 font-mono text-body-sm font-medium',
|
|
136
|
-
info.status === 'rejected' ? 'text-danger' : 'text-fg-1',
|
|
137
|
-
)}>
|
|
138
|
-
{utilization.toFixed(0)}% used
|
|
139
|
-
</span>
|
|
140
|
-
</div>
|
|
141
|
-
<div className='relative mt-2 h-2 rounded-full bg-border'>
|
|
142
|
-
<div
|
|
143
|
-
className={cn('h-full rounded-full', usageTint(utilization))}
|
|
144
|
-
// A floor, so a barely-touched window still shows a mark instead of
|
|
145
|
-
// reading as missing data.
|
|
146
|
-
style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
|
|
147
|
-
/>
|
|
148
|
-
{pace !== undefined ? (
|
|
149
|
-
<span
|
|
150
|
-
aria-hidden
|
|
151
|
-
title='Spent evenly, usage would be here by now'
|
|
152
|
-
className='absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1'
|
|
153
|
-
style={{ left: `${Math.min(100, Math.max(0, pace * 100))}%` }}
|
|
154
|
-
/>
|
|
155
|
-
) : null}
|
|
156
|
-
</div>
|
|
157
|
-
<div className='mt-1.5 flex items-center gap-3 text-label text-fg-4'>
|
|
158
|
-
{resetsAtMs !== undefined ? (
|
|
159
|
-
<span className='inline-flex items-center gap-1'>
|
|
160
|
-
<RotateCcw className='size-3' /> Resets in {formatCountdown(resetsAtMs, now)}
|
|
161
|
-
</span>
|
|
162
|
-
) : null}
|
|
163
|
-
{info.isUsingOverage ? <span className='text-warning'>overage</span> : null}
|
|
164
|
-
{info.status === 'rejected' ? <span className='text-danger'>limit reached</span> : null}
|
|
165
|
-
</div>
|
|
166
|
-
</div>
|
|
167
|
-
)
|
|
168
|
-
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { UsageWindowRow } from '@workerdeck/protocol'
|
|
3
|
+
import { RotateCcw } from 'lucide-react'
|
|
4
|
+
import { cn } from '../../lib/utils.ts'
|
|
5
|
+
import {
|
|
6
|
+
formatAgoPrecise,
|
|
7
|
+
formatCountdown,
|
|
8
|
+
formatRateLimitWindowLong,
|
|
9
|
+
rateLimitWindowSeconds,
|
|
10
|
+
} from '../../lib/format.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ticking clock — the countdowns and the pace markers both move with it, and a
|
|
14
|
+
* minute is the finest resolution either of them prints.
|
|
15
|
+
*
|
|
16
|
+
* `active` is what a dialog passes its `open`: a closed panel's clock is a timer
|
|
17
|
+
* re-rendering something nobody can see.
|
|
18
|
+
*/
|
|
19
|
+
export function useMinuteClock(active = true): number {
|
|
20
|
+
const [now, setNow] = useState(() => Date.now())
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!active) return
|
|
23
|
+
setNow(Date.now())
|
|
24
|
+
const timer = setInterval(() => setNow(Date.now()), 60_000)
|
|
25
|
+
return () => clearInterval(timer)
|
|
26
|
+
}, [active])
|
|
27
|
+
return now
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The plan's rate-limit windows, spelled out: how much of each is used, how that
|
|
34
|
+
* compares to the pace that would spend the window exactly, and when it resets.
|
|
35
|
+
*
|
|
36
|
+
* The pace marker is the point. A bar alone says "17% used", which is only
|
|
37
|
+
* alarming or reassuring once you know how far into the week you are — so every
|
|
38
|
+
* window draws a tick at the elapsed share of its duration. Left of the tick is
|
|
39
|
+
* under budget, right of it is ahead of it. The duration comes from the window
|
|
40
|
+
* key (5h, 7d) because the engine reports a reset time and a percentage and
|
|
41
|
+
* never a duration; a window whose key doesn't say gets no marker rather than a
|
|
42
|
+
* guessed one.
|
|
43
|
+
*
|
|
44
|
+
* Its own component rather than the Usage dialog's private helper because two
|
|
45
|
+
* surfaces draw these now — the session panel's Usage panel, from the session's
|
|
46
|
+
* readings merged with the gateway's, and the dashboard's profile page, straight
|
|
47
|
+
* off `ProfileInfo.usage` with no session in sight. Same account, same bars.
|
|
48
|
+
*/
|
|
49
|
+
export function UsageMeters({
|
|
50
|
+
windows,
|
|
51
|
+
now,
|
|
52
|
+
className,
|
|
53
|
+
}: {
|
|
54
|
+
/** In reading order — protocol's `orderUsageWindows`. */
|
|
55
|
+
windows: UsageWindowRow[]
|
|
56
|
+
/** A shared clock, when the caller already ticks one. Omitted, this ticks its
|
|
57
|
+
* own for as long as it is mounted. */
|
|
58
|
+
now?: number
|
|
59
|
+
className?: string
|
|
60
|
+
}) {
|
|
61
|
+
const ownClock = useMinuteClock(now === undefined)
|
|
62
|
+
const clock = now ?? ownClock
|
|
63
|
+
return (
|
|
64
|
+
<div className={cn('flex flex-col gap-5', className)}>
|
|
65
|
+
{windows.map((window) => (
|
|
66
|
+
<UsageMeter key={window.key} window={window} now={clock} />
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
|
|
73
|
+
const { key: windowKey, info, updatedAt, inferredReset } = window
|
|
74
|
+
const utilization = info.utilization ?? 0
|
|
75
|
+
const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
|
|
76
|
+
// Share of the window already elapsed — where usage *would* be if it were
|
|
77
|
+
// spent evenly. Needs both a duration (from the key) and a reset time.
|
|
78
|
+
const duration = rateLimitWindowSeconds(windowKey)
|
|
79
|
+
const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
|
|
80
|
+
const pace =
|
|
81
|
+
duration !== undefined && remaining !== undefined && remaining > 0 && remaining < duration
|
|
82
|
+
? (duration - remaining) / duration
|
|
83
|
+
: undefined
|
|
84
|
+
return (
|
|
85
|
+
<div>
|
|
86
|
+
<div className='flex items-baseline justify-between gap-3'>
|
|
87
|
+
<span className='truncate text-body-sm text-fg-1'>
|
|
88
|
+
{formatRateLimitWindowLong(windowKey)}
|
|
89
|
+
</span>
|
|
90
|
+
<span
|
|
91
|
+
className={cn(
|
|
92
|
+
'shrink-0 font-mono text-body-sm font-medium',
|
|
93
|
+
info.status === 'rejected' ? 'text-danger' : 'text-fg-1',
|
|
94
|
+
)}>
|
|
95
|
+
{utilization.toFixed(0)}% used
|
|
96
|
+
</span>
|
|
97
|
+
</div>
|
|
98
|
+
<div className='relative mt-2 h-2 rounded-full bg-border'>
|
|
99
|
+
<div
|
|
100
|
+
className={cn('h-full rounded-full', usageTint(utilization))}
|
|
101
|
+
// A floor, so a barely-touched window still shows a mark instead of
|
|
102
|
+
// reading as missing data.
|
|
103
|
+
style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
|
|
104
|
+
/>
|
|
105
|
+
{pace !== undefined ? (
|
|
106
|
+
<span
|
|
107
|
+
aria-hidden
|
|
108
|
+
title='Spent evenly, usage would be here by now'
|
|
109
|
+
className='absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1'
|
|
110
|
+
style={{ left: `${Math.min(100, Math.max(0, pace * 100))}%` }}
|
|
111
|
+
/>
|
|
112
|
+
) : null}
|
|
113
|
+
</div>
|
|
114
|
+
<div className='mt-1.5 flex items-center gap-3 text-label text-fg-4'>
|
|
115
|
+
{resetsAtMs !== undefined ? (
|
|
116
|
+
<span className='inline-flex items-center gap-1'>
|
|
117
|
+
<RotateCcw className='size-3' /> Resets in {formatCountdown(resetsAtMs, now)}
|
|
118
|
+
</span>
|
|
119
|
+
) : null}
|
|
120
|
+
{info.isUsingOverage ? <span className='text-warning'>overage</span> : null}
|
|
121
|
+
{info.status === 'rejected' ? <span className='text-danger'>limit reached</span> : null}
|
|
122
|
+
{/* Said plainly rather than drawn as a fact: the gateway zeroed this
|
|
123
|
+
because the reading's own reset time passed with nothing newer, so 0
|
|
124
|
+
is a floor — the account may have been spent elsewhere since. */}
|
|
125
|
+
{inferredReset ? <span>window reset · nothing reported since</span> : null}
|
|
126
|
+
{/* Per window, because they no longer share one clock: a reading learned
|
|
127
|
+
from a sibling session minutes ago can sit beside one this session
|
|
128
|
+
last heard about yesterday. */}
|
|
129
|
+
{updatedAt && !inferredReset ? <span>{formatAgoPrecise(updatedAt, now)}</span> : null}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -13,8 +13,9 @@ import { useEffect, useState } from 'react'
|
|
|
13
13
|
* BRAND.md's caveat applies and is satisfied here: `U+25C6/7/8` are East-Asian
|
|
14
14
|
* *ambiguous width*, so they can render double-width in a terminal under an
|
|
15
15
|
* East-Asian locale and shift every line with them. They are safe wherever the
|
|
16
|
-
* glyph is centred in a fixed-width box, which is what
|
|
17
|
-
*
|
|
16
|
+
* glyph is centred in a fixed-width box, which is what the terminal theme's
|
|
17
|
+
* gutter cell (`.term-gutter`, one `--term-cell` wide) is. Anything writing to a
|
|
18
|
+
* real terminal must use the ASCII set instead.
|
|
18
19
|
*/
|
|
19
20
|
export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
|
|
20
21
|
export const PULSE_MS = 150
|
|
@@ -0,0 +1,82 @@
|
|
|
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, or
|
|
11
|
+
* — under the terminal theme — a folded run of tool calls), or the recap
|
|
12
|
+
* boundary line spliced in at `catchUp.from`. One flat array so the virtualizer
|
|
13
|
+
* sees stable indices, and each row carries the key the item was already
|
|
14
|
+
* React-keyed by — measurements are cached per key, so a row keeps its measured
|
|
15
|
+
* height when the recap splice shifts every index after it. */
|
|
16
|
+
export type TranscriptRow = TerminalBlock | { key: 'recap'; line: string }
|
|
17
|
+
|
|
18
|
+
/** The item a row is spaced *as*. A run stands for the calls it folded, so a
|
|
19
|
+
* run and a lone tool call below it still read as one block. */
|
|
20
|
+
export function rowItem(row: TranscriptRow | undefined): TranscriptItem | undefined {
|
|
21
|
+
if (!row) return undefined
|
|
22
|
+
if ('item' in row) return row.item
|
|
23
|
+
if ('run' in row) return row.run[0]
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Does a blank line go above this row, in the terminal theme?
|
|
29
|
+
*
|
|
30
|
+
* The recap row always earns one — it is a boundary, and a boundary flush
|
|
31
|
+
* against the row above reads as part of it. Otherwise the pair decides
|
|
32
|
+
* (`needsBlank`): consecutive tool calls are one block in the CLI and get none.
|
|
33
|
+
*/
|
|
34
|
+
export function gapBefore(rows: TranscriptRow[], index: number): boolean {
|
|
35
|
+
const before = rowItem(rows[index - 1])
|
|
36
|
+
const after = rowItem(rows[index])
|
|
37
|
+
if (!before || !after) return true
|
|
38
|
+
return needsBlank(before, after)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Transcript-item index → virtual-row index — **the off-by-a-fold trap.**
|
|
43
|
+
*
|
|
44
|
+
* The virtualizer's rows are {@link TerminalBlock}s, not items: a folded tool
|
|
45
|
+
* run occupies ONE row for `run.length` consecutive items, and the recap
|
|
46
|
+
* boundary is a row with *no* item index at all, shifting every row after it
|
|
47
|
+
* by one. `virtualizer.scrollToIndex(itemIndex)` is therefore wrong by
|
|
48
|
+
* construction on any folded or spliced transcript — every jump that starts
|
|
49
|
+
* from an item (the scrubber's marks, a future bookmark) must come through
|
|
50
|
+
* here first.
|
|
51
|
+
*
|
|
52
|
+
* The rule: the **last non-recap row whose first item index is ≤ the target**.
|
|
53
|
+
* Rows are ordered by `index` (a run's row covers
|
|
54
|
+
* `[index, index + run.length)`), so this is a binary search; the recap row
|
|
55
|
+
* is skipped by giving it its successor's start for navigation (both qualify
|
|
56
|
+
* at the boundary, and "last wins" lands on the real row) while never letting
|
|
57
|
+
* it be the answer. Exhaustively checked against a linear reference — every
|
|
58
|
+
* fixture × every item index × several splice positions — by
|
|
59
|
+
* `__wdCheckMapping` in `dev/App.tsx`.
|
|
60
|
+
*/
|
|
61
|
+
export function rowIndexForItem(rows: readonly TranscriptRow[], itemIndex: number): number {
|
|
62
|
+
let lo = 0
|
|
63
|
+
let hi = rows.length - 1
|
|
64
|
+
let best = 0
|
|
65
|
+
while (lo <= hi) {
|
|
66
|
+
const mid = (lo + hi) >> 1
|
|
67
|
+
const row = rows[mid]!
|
|
68
|
+
let start: number
|
|
69
|
+
if ('index' in row) start = row.index
|
|
70
|
+
else {
|
|
71
|
+
const next = rows[mid + 1]
|
|
72
|
+
start = next && 'index' in next ? next.index : Number.MAX_SAFE_INTEGER
|
|
73
|
+
}
|
|
74
|
+
if (start <= itemIndex) {
|
|
75
|
+
if ('index' in row) best = mid
|
|
76
|
+
lo = mid + 1
|
|
77
|
+
} else {
|
|
78
|
+
hi = mid - 1
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return best
|
|
82
|
+
}
|
|
@@ -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
|
+
}
|