@workerdeck/ui 0.9.0 → 0.12.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 +81 -3
- package/build/SessionPanel-Dy9lQrOV.d.mts +319 -0
- package/build/SessionPanel-NQ8ksCfj.mjs +8474 -0
- package/build/SessionPanel-NQ8ksCfj.mjs.map +1 -0
- package/build/format-DqR56Y8l.mjs +162 -0
- package/build/format-DqR56Y8l.mjs.map +1 -0
- package/build/format-ljc3lKpA.d.mts +59 -0
- package/build/format.d.mts +66 -0
- package/build/format.mjs +119 -0
- package/build/format.mjs.map +1 -0
- package/build/index.d.mts +671 -88
- package/build/index.mjs +387 -5160
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +226 -0
- package/build/workspace.mjs +861 -0
- package/build/workspace.mjs.map +1 -0
- package/package.json +22 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +522 -87
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/Conversation.tsx +11 -3
- package/src/components/agent/EditorTabs.tsx +165 -0
- package/src/components/agent/FileCard.tsx +26 -0
- package/src/components/agent/FileTree.tsx +287 -0
- package/src/components/agent/FileViewer.tsx +148 -0
- package/src/components/agent/HostFilesDialog.tsx +218 -0
- package/src/components/agent/Loader.tsx +82 -14
- package/src/components/agent/McpDialog.tsx +363 -0
- package/src/components/agent/Message.tsx +51 -17
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +133 -22
- package/src/components/agent/PermissionPrompt.tsx +164 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/QuestionPrompt.tsx +122 -0
- package/src/components/agent/Reasoning.tsx +20 -5
- package/src/components/agent/Response.tsx +128 -0
- package/src/components/agent/SessionBrowser.tsx +428 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +783 -91
- package/src/components/agent/SessionWorkspace.tsx +317 -0
- package/src/components/agent/SkillsDialog.tsx +195 -0
- package/src/components/agent/StatusBar.tsx +85 -18
- package/src/components/agent/ToolCallCard.tsx +252 -30
- package/src/components/agent/Transcript.tsx +513 -30
- package/src/components/agent/UsageDialog.tsx +168 -0
- package/src/components/agent/line-prompt.tsx +249 -0
- package/src/components/agent/pulse.tsx +60 -0
- package/src/components/agent/transcript-variant.tsx +123 -0
- package/src/components/prompt-area/prompt-area-engine.ts +53 -0
- package/src/components/prompt-area/types.ts +15 -0
- package/src/components/prompt-area/use-prompt-area.ts +20 -0
- package/src/components/ui/CodeBlock.tsx +40 -2
- package/src/components/ui/CopyButton.tsx +28 -3
- package/src/components/ui/Dialog.tsx +92 -0
- package/src/components/ui/Menu.tsx +55 -0
- package/src/components/ui/Splitter.tsx +133 -0
- package/src/components/ui/Tooltip.tsx +22 -5
- package/src/format.ts +11 -0
- package/src/index.ts +67 -2
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +114 -0
- package/src/lib/status.ts +124 -0
- package/src/lib/tool-icon.ts +96 -0
- package/src/workspace.ts +28 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { ProfileEngine, RateLimitInfo } from '@workerdeck/protocol'
|
|
3
|
+
import { RotateCcw } from 'lucide-react'
|
|
4
|
+
import { Badge } from '../ui/Badge.tsx'
|
|
5
|
+
import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
|
|
6
|
+
import { cn } from '../../lib/utils.ts'
|
|
7
|
+
import {
|
|
8
|
+
formatAgoPrecise,
|
|
9
|
+
formatCost,
|
|
10
|
+
formatCountdown,
|
|
11
|
+
formatRateLimitWindowLong,
|
|
12
|
+
rateLimitWindowSeconds,
|
|
13
|
+
} from '../../lib/format.ts'
|
|
14
|
+
|
|
15
|
+
export interface UsageDialogProps {
|
|
16
|
+
/** Windows in reading order — session, weekly, then per-model weeklies. */
|
|
17
|
+
rateLimits: Array<{ key: string; info: RateLimitInfo }>
|
|
18
|
+
/** claude.ai plan behind the windows ('max', 'pro', …), when there is one. */
|
|
19
|
+
subscriptionType?: string
|
|
20
|
+
engine: ProfileEngine
|
|
21
|
+
totalCostUsd: number
|
|
22
|
+
/** Local receipt time of the last window update. `rate_limit` events are one
|
|
23
|
+
* per turn at best, so a stale reading is normal and worth saying out loud. */
|
|
24
|
+
updatedAt?: number
|
|
25
|
+
open: boolean
|
|
26
|
+
onOpenChange: (open: boolean) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
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
|
+
/**
|
|
45
|
+
* The plan's rate-limit windows, spelled out: how much of each is used, how that
|
|
46
|
+
* compares to the pace that would spend the window exactly, and when it resets.
|
|
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.
|
|
54
|
+
*/
|
|
55
|
+
export function UsageDialog({
|
|
56
|
+
rateLimits,
|
|
57
|
+
subscriptionType,
|
|
58
|
+
engine,
|
|
59
|
+
totalCostUsd,
|
|
60
|
+
updatedAt,
|
|
61
|
+
open,
|
|
62
|
+
onOpenChange,
|
|
63
|
+
}: UsageDialogProps) {
|
|
64
|
+
const now = useMinuteClock(open)
|
|
65
|
+
return (
|
|
66
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
67
|
+
<DialogContent>
|
|
68
|
+
<DialogHeader
|
|
69
|
+
title='Usage'
|
|
70
|
+
description={engine === 'claude' ? 'Claude Code' : engine}
|
|
71
|
+
actions={
|
|
72
|
+
// The CLI reports a tier ('max'), never the multiplier a
|
|
73
|
+
// subscription page shows — so this says "Max" and stops there.
|
|
74
|
+
subscriptionType ? (
|
|
75
|
+
<Badge variant='accent' className='mt-0.5 shrink-0 capitalize'>
|
|
76
|
+
{subscriptionType}
|
|
77
|
+
</Badge>
|
|
78
|
+
) : null
|
|
79
|
+
}
|
|
80
|
+
/>
|
|
81
|
+
<DialogBody>
|
|
82
|
+
{rateLimits.length === 0 ? (
|
|
83
|
+
<p className='py-6 text-center text-body-sm text-fg-4'>
|
|
84
|
+
{engine === 'claude'
|
|
85
|
+
? 'This session reports no plan windows — API-key sessions have none, and a subscription session reports them once a turn has run.'
|
|
86
|
+
: `Plan windows are a claude.ai subscription thing; this session runs on the ${engine} engine.`}
|
|
87
|
+
</p>
|
|
88
|
+
) : (
|
|
89
|
+
<div className='flex flex-col gap-5'>
|
|
90
|
+
{rateLimits.map(({ key, info }) => (
|
|
91
|
+
<UsageWindow key={key} windowKey={key} info={info} now={now} />
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
<div className='mt-5 flex items-baseline justify-between gap-4 border-t border-border pt-3'>
|
|
96
|
+
<span className='text-label text-fg-3'>This session has cost</span>
|
|
97
|
+
<span className='font-mono text-body-sm text-fg-1'>{formatCost(totalCostUsd)}</span>
|
|
98
|
+
</div>
|
|
99
|
+
{updatedAt ? (
|
|
100
|
+
<p className='mt-2 text-label text-fg-4'>Updated {formatAgoPrecise(updatedAt, now)}</p>
|
|
101
|
+
) : null}
|
|
102
|
+
</DialogBody>
|
|
103
|
+
</DialogContent>
|
|
104
|
+
</Dialog>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
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,249 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
import { Response } from './Response.tsx'
|
|
4
|
+
import { LineGlyph } from './transcript-variant.tsx'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The parts a terminal-shaped prompt is built from.
|
|
8
|
+
*
|
|
9
|
+
* The `lines` transcript answers a question the `cards` one does not have to:
|
|
10
|
+
* an approval and a question are *interactive*, so "no boxes" has to be paid for
|
|
11
|
+
* by something else carrying the affordance. That something is the keyboard —
|
|
12
|
+
* a roving `❯` marker, numbered rows, and a hint line — which is what a terminal
|
|
13
|
+
* would do anyway, and what makes the prompt answerable without reaching for the
|
|
14
|
+
* mouse in a dock where the rows are two lines tall.
|
|
15
|
+
*
|
|
16
|
+
* Everything here indents to the same gutter as every other line item, so an
|
|
17
|
+
* approval reads as one more row in the run rather than a dialog dropped on top
|
|
18
|
+
* of it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The left inset that lines up body text with a `LineGlyph`'d row above it. */
|
|
22
|
+
export const LINE_INDENT = 'pl-[calc(0.875rem+0.5rem)]'
|
|
23
|
+
|
|
24
|
+
export type LineChoice = {
|
|
25
|
+
key: string
|
|
26
|
+
label: string
|
|
27
|
+
description?: string
|
|
28
|
+
/**
|
|
29
|
+
* Rendered under the row, outside the button — a preview, an input. The caller
|
|
30
|
+
* decides when it exists (focused, checked); a button may not contain one.
|
|
31
|
+
*/
|
|
32
|
+
detail?: ReactNode
|
|
33
|
+
/**
|
|
34
|
+
* Present → the row carries a selection state and draws it. `marker` says in
|
|
35
|
+
* which idiom: a checkbox for a multi-select, a radio for a one-of. Absent →
|
|
36
|
+
* the row is an action (allow, deny), which has no state to show.
|
|
37
|
+
*/
|
|
38
|
+
checked?: boolean
|
|
39
|
+
marker?: 'check' | 'radio'
|
|
40
|
+
danger?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The two-state glyph pairs, in the character forms a terminal would use. */
|
|
44
|
+
const MARKERS = {
|
|
45
|
+
check: ['[ ]', '[x]'],
|
|
46
|
+
radio: ['( )', '(•)'],
|
|
47
|
+
} as const
|
|
48
|
+
|
|
49
|
+
export interface LineOptionListProps {
|
|
50
|
+
options: LineChoice[]
|
|
51
|
+
/** Roving index: the one row that is tab-reachable and wears the marker. */
|
|
52
|
+
focused: number
|
|
53
|
+
onFocus: (index: number) => void
|
|
54
|
+
onChoose: (index: number) => void
|
|
55
|
+
/**
|
|
56
|
+
* Own the DOM focus, moving it with the roving index. False while something
|
|
57
|
+
* else inside the prompt holds it (a reason input, another question's list) —
|
|
58
|
+
* two lists both chasing `focused` would tear the caret back and forth.
|
|
59
|
+
*/
|
|
60
|
+
active?: boolean
|
|
61
|
+
label: string
|
|
62
|
+
className?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A keyboard-first list of choices as line items: `↑`/`↓` move, `1`–`9` pick
|
|
67
|
+
* directly, `Enter`/`Space` take the focused one (the button does that itself).
|
|
68
|
+
*/
|
|
69
|
+
export function LineOptionList({
|
|
70
|
+
options,
|
|
71
|
+
focused,
|
|
72
|
+
onFocus,
|
|
73
|
+
onChoose,
|
|
74
|
+
active = true,
|
|
75
|
+
label,
|
|
76
|
+
className,
|
|
77
|
+
}: LineOptionListProps) {
|
|
78
|
+
const refs = useRef<Array<HTMLButtonElement | null>>([])
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (active) refs.current[focused]?.focus()
|
|
82
|
+
}, [active, focused])
|
|
83
|
+
|
|
84
|
+
const move = (delta: number) => {
|
|
85
|
+
if (options.length > 0) onFocus((focused + delta + options.length) % options.length)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
role='group'
|
|
91
|
+
aria-label={label}
|
|
92
|
+
className={cn('flex flex-col', className)}
|
|
93
|
+
onKeyDown={(event) => {
|
|
94
|
+
if (event.key === 'ArrowDown') {
|
|
95
|
+
move(1)
|
|
96
|
+
event.preventDefault()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (event.key === 'ArrowUp') {
|
|
100
|
+
move(-1)
|
|
101
|
+
event.preventDefault()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
// Digits are the whole point of numbering the rows — but only up to the
|
|
105
|
+
// rows that exist, so `9` on a three-option prompt stays a no-op rather
|
|
106
|
+
// than a silent miss.
|
|
107
|
+
const digit = Number(event.key)
|
|
108
|
+
if (Number.isInteger(digit) && digit >= 1 && digit <= Math.min(options.length, 9)) {
|
|
109
|
+
onFocus(digit - 1)
|
|
110
|
+
onChoose(digit - 1)
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
}
|
|
113
|
+
}}>
|
|
114
|
+
{options.map((option, index) => {
|
|
115
|
+
const isFocused = index === focused
|
|
116
|
+
return (
|
|
117
|
+
<div key={option.key} className='flex flex-col'>
|
|
118
|
+
<button
|
|
119
|
+
ref={(element) => {
|
|
120
|
+
refs.current[index] = element
|
|
121
|
+
}}
|
|
122
|
+
type='button'
|
|
123
|
+
tabIndex={isFocused ? 0 : -1}
|
|
124
|
+
aria-pressed={option.checked}
|
|
125
|
+
onFocus={() => onFocus(index)}
|
|
126
|
+
onClick={() => onChoose(index)}
|
|
127
|
+
className={cn(
|
|
128
|
+
'flex w-full items-baseline gap-2 text-left outline-none',
|
|
129
|
+
isFocused ? 'bg-surface-hover' : 'hover:bg-surface-hover/60',
|
|
130
|
+
)}>
|
|
131
|
+
<LineGlyph className={isFocused ? 'text-accent' : undefined}>
|
|
132
|
+
{isFocused ? '❯' : ' '}
|
|
133
|
+
</LineGlyph>
|
|
134
|
+
<span className='shrink-0 font-mono text-label leading-5 text-fg-4'>{index + 1}</span>
|
|
135
|
+
{option.checked !== undefined ? (
|
|
136
|
+
<span
|
|
137
|
+
className={cn(
|
|
138
|
+
'shrink-0 font-mono text-label leading-5',
|
|
139
|
+
option.checked ? 'text-accent' : 'text-fg-4',
|
|
140
|
+
)}>
|
|
141
|
+
{MARKERS[option.marker ?? 'check'][option.checked ? 1 : 0]}
|
|
142
|
+
</span>
|
|
143
|
+
) : null}
|
|
144
|
+
<span
|
|
145
|
+
className={cn(
|
|
146
|
+
'min-w-0 flex-1 text-body-sm leading-5',
|
|
147
|
+
option.danger ? 'text-danger' : 'text-fg-1',
|
|
148
|
+
)}>
|
|
149
|
+
{option.label}
|
|
150
|
+
{option.description ? (
|
|
151
|
+
<span className='text-fg-4'> — {option.description}</span>
|
|
152
|
+
) : null}
|
|
153
|
+
</span>
|
|
154
|
+
</button>
|
|
155
|
+
{option.detail ? <div className={LINE_INDENT}>{option.detail}</div> : null}
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
})}
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The dim key legend under a prompt. Separated by `·`, like a status line. */
|
|
164
|
+
export function LineHint({ children }: { children: ReactNode }) {
|
|
165
|
+
return (
|
|
166
|
+
<div className={cn(LINE_INDENT, 'text-label leading-5 text-fg-4')}>{children}</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A single-line text field in the terminal idiom: a caret glyph and a rule,
|
|
171
|
+
* no box. `Enter` commits, `Escape` backs out — the caller says what those mean. */
|
|
172
|
+
export function LineInput({
|
|
173
|
+
value,
|
|
174
|
+
onChange,
|
|
175
|
+
onSubmit,
|
|
176
|
+
onCancel,
|
|
177
|
+
placeholder,
|
|
178
|
+
}: {
|
|
179
|
+
value: string
|
|
180
|
+
onChange: (value: string) => void
|
|
181
|
+
onSubmit: () => void
|
|
182
|
+
onCancel: () => void
|
|
183
|
+
placeholder?: string
|
|
184
|
+
}) {
|
|
185
|
+
return (
|
|
186
|
+
<div className='flex items-baseline gap-2'>
|
|
187
|
+
<LineGlyph className='text-accent'>›</LineGlyph>
|
|
188
|
+
<input
|
|
189
|
+
autoFocus
|
|
190
|
+
value={value}
|
|
191
|
+
placeholder={placeholder}
|
|
192
|
+
onChange={(event) => onChange(event.target.value)}
|
|
193
|
+
onKeyDown={(event) => {
|
|
194
|
+
if (event.key === 'Enter') {
|
|
195
|
+
event.preventDefault()
|
|
196
|
+
onSubmit()
|
|
197
|
+
}
|
|
198
|
+
if (event.key === 'Escape') {
|
|
199
|
+
// The prompt's own Escape means "deny"/"dismiss"; inside the field it
|
|
200
|
+
// only closes the field, so it must not travel further.
|
|
201
|
+
event.stopPropagation()
|
|
202
|
+
onCancel()
|
|
203
|
+
}
|
|
204
|
+
}}
|
|
205
|
+
className='min-w-0 flex-1 border-b border-border bg-transparent text-body-sm leading-5 text-fg-1 outline-none placeholder:text-fg-4 focus:border-accent'
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A payload in the terminal idiom: a dim label line over a **highlighted** band.
|
|
213
|
+
*
|
|
214
|
+
* Highlighting goes through `Response` — the markdown renderer already carries
|
|
215
|
+
* shiki, so wrapping the text in a fence costs no new dependency, no second
|
|
216
|
+
* highlighter and no second theme to keep in sync, and it inherits the terminal
|
|
217
|
+
* flattening plus the hover copy/download the renderer draws for every code
|
|
218
|
+
* block. Text with no language still goes through it, as an unlabelled fence:
|
|
219
|
+
* same band, same grid, no grammar guessed.
|
|
220
|
+
*/
|
|
221
|
+
export function LinePayload({
|
|
222
|
+
code,
|
|
223
|
+
label,
|
|
224
|
+
language,
|
|
225
|
+
className,
|
|
226
|
+
}: {
|
|
227
|
+
code: string
|
|
228
|
+
label: string
|
|
229
|
+
language?: string
|
|
230
|
+
className?: string
|
|
231
|
+
}) {
|
|
232
|
+
return (
|
|
233
|
+
<div className={cn('min-w-0', className)}>
|
|
234
|
+
<span className='block truncate text-label leading-5 text-fg-4'>{label}</span>
|
|
235
|
+
<Response>{fence(code, language)}</Response>
|
|
236
|
+
</div>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Wrap text as a markdown code fence, with a fence long enough to survive the
|
|
242
|
+
* content: a payload containing ``` would otherwise close the block early and
|
|
243
|
+
* spill the rest of it into the transcript as markdown.
|
|
244
|
+
*/
|
|
245
|
+
export function fence(code: string, language?: string): string {
|
|
246
|
+
const longest = Math.max(0, ...[...code.matchAll(/`+/g)].map((match) => match[0].length))
|
|
247
|
+
const ticks = '`'.repeat(Math.max(3, longest + 1))
|
|
248
|
+
return `${ticks}${language ?? ''}\n${code}\n${ticks}`
|
|
249
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The brand mark's pulse, as characters — the working marker every surface in the
|
|
5
|
+
* transcript animates.
|
|
6
|
+
*
|
|
7
|
+
* These are the mark's own four states (`docs/assets/BRAND.md`, "The loading
|
|
8
|
+
* state"): a dot, an outline, a semi and a full diamond, built in the SVG from
|
|
9
|
+
* two shapes rather than four drawings. 150ms each, so one cycle is the 0.6s
|
|
10
|
+
* clock the marker pulses on in `icon-loading.svg` — the same rhythm, in the
|
|
11
|
+
* medium a transcript row actually has.
|
|
12
|
+
*
|
|
13
|
+
* BRAND.md's caveat applies and is satisfied here: `U+25C6/7/8` are East-Asian
|
|
14
|
+
* *ambiguous width*, so they can render double-width in a terminal under an
|
|
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 `LineGlyph` is. Anything
|
|
17
|
+
* writing to a real terminal must use the ASCII set instead.
|
|
18
|
+
*/
|
|
19
|
+
export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
|
|
20
|
+
export const PULSE_MS = 150
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The resting state. Stopping the animation lands on the complete mark rather
|
|
24
|
+
* than on a half-drawn frame — the same property that makes the SVG's
|
|
25
|
+
* `prefers-reduced-motion` free (see BRAND.md: `translateY(0)` *is* the mark).
|
|
26
|
+
*/
|
|
27
|
+
export const PULSE_REST = PULSE_FRAMES[PULSE_FRAMES.length - 1]
|
|
28
|
+
|
|
29
|
+
/** The OS-level "stop moving things" setting. A spinner is decoration — the word
|
|
30
|
+
* beside it carries the meaning — so honouring this costs nothing. */
|
|
31
|
+
export function usePrefersReducedMotion(): boolean {
|
|
32
|
+
const [reduced, setReduced] = useState(false)
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
35
|
+
if (!query) return
|
|
36
|
+
setReduced(query.matches)
|
|
37
|
+
const onChange = () => setReduced(query.matches)
|
|
38
|
+
query.addEventListener('change', onChange)
|
|
39
|
+
return () => query.removeEventListener('change', onChange)
|
|
40
|
+
}, [])
|
|
41
|
+
return reduced
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The current pulse frame, ticking while `animated`. Callers mount this only
|
|
46
|
+
* while something is actually in flight, so nothing here runs on an idle
|
|
47
|
+
* session; with reduced motion, or when not animating, it holds at rest.
|
|
48
|
+
*/
|
|
49
|
+
export function usePulse(animated: boolean): string {
|
|
50
|
+
const reduced = usePrefersReducedMotion()
|
|
51
|
+
const running = animated && !reduced
|
|
52
|
+
const [frame, setFrame] = useState(0)
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!running) return
|
|
55
|
+
const timer = setInterval(() => setFrame((f) => f + 1), PULSE_MS)
|
|
56
|
+
return () => clearInterval(timer)
|
|
57
|
+
}, [running])
|
|
58
|
+
if (!running) return PULSE_REST
|
|
59
|
+
return PULSE_FRAMES[frame % PULSE_FRAMES.length]!
|
|
60
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How the transcript draws a turn.
|
|
6
|
+
*
|
|
7
|
+
* - `cards` — the chat convention: bubbles, bordered tool cards, generous gaps.
|
|
8
|
+
* 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.
|
|
13
|
+
*
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
export type TranscriptVariant = 'cards' | 'lines'
|
|
20
|
+
|
|
21
|
+
const VariantContext = createContext<TranscriptVariant>('cards')
|
|
22
|
+
|
|
23
|
+
export function TranscriptVariantProvider({
|
|
24
|
+
value,
|
|
25
|
+
children,
|
|
26
|
+
}: {
|
|
27
|
+
value: TranscriptVariant
|
|
28
|
+
children: ReactNode
|
|
29
|
+
}) {
|
|
30
|
+
return <VariantContext.Provider value={value}>{children}</VariantContext.Provider>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useTranscriptVariant(): TranscriptVariant {
|
|
34
|
+
return useContext(VariantContext)
|
|
35
|
+
}
|
|
36
|
+
|
|
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
|
+
/**
|
|
43
|
+
* How much room the transcript gives each row.
|
|
44
|
+
*
|
|
45
|
+
* - `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.
|
|
48
|
+
* - `compact` — rows tight against each other, for a dock where every line of
|
|
49
|
+
* vertical space is contested.
|
|
50
|
+
*
|
|
51
|
+
* 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.
|
|
56
|
+
*/
|
|
57
|
+
export type TranscriptDensity = 'comfortable' | 'compact'
|
|
58
|
+
|
|
59
|
+
const DensityContext = createContext<TranscriptDensity>('comfortable')
|
|
60
|
+
|
|
61
|
+
export function TranscriptDensityProvider({
|
|
62
|
+
value,
|
|
63
|
+
children,
|
|
64
|
+
}: {
|
|
65
|
+
value: TranscriptDensity
|
|
66
|
+
children: ReactNode
|
|
67
|
+
}) {
|
|
68
|
+
return <DensityContext.Provider value={value}>{children}</DensityContext.Provider>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useTranscriptDensity(): TranscriptDensity {
|
|
72
|
+
return useContext(DensityContext)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The gap between two rows, per variant and density — the whole of the density
|
|
77
|
+
* feature, since it is the only vertical spacing between rows that exists.
|
|
78
|
+
*
|
|
79
|
+
* `className` goes on the **measured** wrapper (see `Transcript`), so the gap is
|
|
80
|
+
* part of each row's measured height and no pixel constant is load-bearing.
|
|
81
|
+
* `px` is fed to `estimateSize` alone, where being approximate is the contract:
|
|
82
|
+
* it sets the scrollbar's length before rows mount and is replaced by a real
|
|
83
|
+
* measurement the moment one does.
|
|
84
|
+
*
|
|
85
|
+
* `lines` + `compact` is the only combination with no gap at all: there the
|
|
86
|
+
* row's own `py-0.5` is the entire separation, which is what makes it compact.
|
|
87
|
+
*/
|
|
88
|
+
export const ROW_GAP: Record<
|
|
89
|
+
TranscriptVariant,
|
|
90
|
+
Record<TranscriptDensity, { className?: string; px: number }>
|
|
91
|
+
> = {
|
|
92
|
+
cards: {
|
|
93
|
+
comfortable: { className: 'pt-4', px: 16 },
|
|
94
|
+
compact: { className: 'pt-2', px: 8 },
|
|
95
|
+
},
|
|
96
|
+
lines: {
|
|
97
|
+
// 16px on top of the row's own 4px of `py-0.5` is one 20px line — the blank
|
|
98
|
+
// line the CLI leaves, arrived at from the line height rather than picked.
|
|
99
|
+
comfortable: { className: 'pt-4', px: 16 },
|
|
100
|
+
compact: { px: 0 },
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The left gutter of a line item: one glyph, fixed width, so every row's text
|
|
106
|
+
* starts on the same column no matter which kind of event it is. Decorative —
|
|
107
|
+
* the row's own text says what it is.
|
|
108
|
+
*/
|
|
109
|
+
export function LineGlyph({ children, className }: { children: ReactNode; className?: string }) {
|
|
110
|
+
return (
|
|
111
|
+
<span
|
|
112
|
+
aria-hidden
|
|
113
|
+
className={cn(
|
|
114
|
+
'w-3.5 shrink-0 select-none text-center font-mono text-label leading-5 text-fg-4',
|
|
115
|
+
className,
|
|
116
|
+
)}>
|
|
117
|
+
{children}
|
|
118
|
+
</span>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Body text metrics for a line item — tighter than the card variant's. */
|
|
123
|
+
export const LINE_TEXT = 'text-body-sm leading-5'
|
|
@@ -288,6 +288,59 @@ export function resolveChip(
|
|
|
288
288
|
return { segments: merged, cursorOffset }
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Replaces the active trigger's range with **plain text** instead of a chip.
|
|
293
|
+
*
|
|
294
|
+
* The sibling of {@link resolveChip}, for suggestions that are a typing aid
|
|
295
|
+
* rather than a token: what lands in the document is ordinary editable text the
|
|
296
|
+
* user is expected to finish and change, and it must not look or behave like a
|
|
297
|
+
* resolved chip — no immutability, no trigger character, nothing for a consumer
|
|
298
|
+
* to parse back out. The caret is left at the end of the inserted text so typing
|
|
299
|
+
* continues from there.
|
|
300
|
+
*/
|
|
301
|
+
export function resolveText(
|
|
302
|
+
segments: Segment[],
|
|
303
|
+
activeTrigger: ActiveTrigger,
|
|
304
|
+
text: string,
|
|
305
|
+
): { segments: Segment[]; cursorOffset: number } {
|
|
306
|
+
const triggerStart = activeTrigger.startOffset
|
|
307
|
+
const triggerEnd = triggerStart + 1 + activeTrigger.query.length // +1 for trigger char
|
|
308
|
+
|
|
309
|
+
const newSegments: Segment[] = []
|
|
310
|
+
let offset = 0
|
|
311
|
+
let cursorOffset = triggerStart + text.length
|
|
312
|
+
|
|
313
|
+
for (const seg of segments) {
|
|
314
|
+
if (seg.type === 'chip') {
|
|
315
|
+
const chipEnd = offset + `${seg.trigger}${seg.displayText}`.length
|
|
316
|
+
// A trigger range can never overlap a chip — chips are atomic — so a chip
|
|
317
|
+
// is either wholly before or wholly after, and is kept either way.
|
|
318
|
+
if (chipEnd <= triggerStart || offset >= triggerEnd) newSegments.push(seg)
|
|
319
|
+
offset = chipEnd
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
322
|
+
const textStart = offset
|
|
323
|
+
const textEnd = offset + seg.text.length
|
|
324
|
+
if (textEnd <= triggerStart || textStart >= triggerEnd) {
|
|
325
|
+
newSegments.push(seg)
|
|
326
|
+
} else {
|
|
327
|
+
const before = seg.text.slice(0, Math.max(0, triggerStart - textStart))
|
|
328
|
+
const after = seg.text.slice(Math.min(seg.text.length, triggerEnd - textStart))
|
|
329
|
+
if (before) newSegments.push({ type: 'text', text: before })
|
|
330
|
+
newSegments.push({ type: 'text', text })
|
|
331
|
+
if (after) newSegments.push({ type: 'text', text: after })
|
|
332
|
+
}
|
|
333
|
+
offset = textEnd
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const merged = mergeAdjacentTextSegments(newSegments)
|
|
337
|
+
// Clamp: a trigger detected against a stale document could otherwise leave the
|
|
338
|
+
// caret past the end, which renders as no caret at all.
|
|
339
|
+
const total = segmentsToPlainText(merged).length
|
|
340
|
+
if (cursorOffset > total) cursorOffset = total
|
|
341
|
+
return { segments: merged, cursorOffset }
|
|
342
|
+
}
|
|
343
|
+
|
|
291
344
|
// ---------------------------------------------------------------------------
|
|
292
345
|
// Chip removal
|
|
293
346
|
// ---------------------------------------------------------------------------
|
|
@@ -103,6 +103,21 @@ export type TriggerConfig = {
|
|
|
103
103
|
* Return the display text for the chip, or void to use `suggestion.label`.
|
|
104
104
|
*/
|
|
105
105
|
onSelect?: (suggestion: TriggerSuggestion) => string | void
|
|
106
|
+
/**
|
|
107
|
+
* For 'dropdown' mode: opt a suggestion out of becoming a chip.
|
|
108
|
+
*
|
|
109
|
+
* Return a string and the trigger's range is replaced with that **plain,
|
|
110
|
+
* editable text** — the trigger character included — with the caret left at
|
|
111
|
+
* its end. Return undefined and the suggestion resolves to a chip as usual,
|
|
112
|
+
* so one dropdown can mix both kinds.
|
|
113
|
+
*
|
|
114
|
+
* For suggestions that are a typing aid rather than a token: something the
|
|
115
|
+
* user is meant to finish and edit, where a chip would falsely promise the
|
|
116
|
+
* host parses it back out. Takes precedence over `onSelect`, which is not
|
|
117
|
+
* called for a text-resolved suggestion (there is no chip to label), and
|
|
118
|
+
* `onChipAdd` does not fire either.
|
|
119
|
+
*/
|
|
120
|
+
insertAsText?: (suggestion: TriggerSuggestion) => string | undefined
|
|
106
121
|
/**
|
|
107
122
|
* For 'callback' and 'launch' modes: called when the trigger is activated.
|
|
108
123
|
* Receives the full input text and cursor position. For 'launch' it fires on
|