@workerdeck/ui 0.13.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +72 -0
  2. package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-B9CHoq8x.d.mts} +213 -27
  3. package/build/{SessionPanel-CKQa4i0Y.mjs → SessionPanel-DII9MmQ8.mjs} +5192 -2542
  4. package/build/SessionPanel-DII9MmQ8.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 +494 -45
  9. package/build/index.mjs +182 -24
  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 +13 -1
  14. package/build/workspace.mjs +111 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +14 -7
  17. package/src/components/agent/Composer.tsx +251 -84
  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 -43
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/QuestionPrompt.tsx +0 -122
  25. package/src/components/agent/Reasoning.tsx +5 -19
  26. package/src/components/agent/Response.tsx +1 -132
  27. package/src/components/agent/SessionBrowser.tsx +35 -25
  28. package/src/components/agent/SessionPanel.tsx +312 -63
  29. package/src/components/agent/SessionWorkspace.tsx +36 -0
  30. package/src/components/agent/StatusBar.tsx +70 -15
  31. package/src/components/agent/ToolCallCard.tsx +17 -111
  32. package/src/components/agent/Transcript.tsx +710 -203
  33. package/src/components/agent/UsageDialog.tsx +20 -106
  34. package/src/components/agent/UsageMeters.tsx +133 -0
  35. package/src/components/agent/pulse.tsx +3 -2
  36. package/src/components/agent/transcript-rows.ts +82 -0
  37. package/src/components/agent/transcript-variant.tsx +43 -51
  38. package/src/components/agent/use-height-epoch.ts +60 -0
  39. package/src/components/agent/use-path-links.ts +147 -0
  40. package/src/components/agent/use-transcript-jumps.ts +190 -0
  41. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  42. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  43. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  44. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  45. package/src/components/terminal/StatusLine.tsx +159 -0
  46. package/src/components/terminal/TerminalTranscript.tsx +147 -0
  47. package/src/components/terminal/affordances.tsx +118 -0
  48. package/src/components/terminal/diff.tsx +130 -0
  49. package/src/components/terminal/height.ts +727 -0
  50. package/src/components/terminal/items.tsx +449 -0
  51. package/src/components/terminal/markdown.tsx +191 -0
  52. package/src/components/terminal/press.tsx +120 -0
  53. package/src/components/terminal/prompt.tsx +343 -0
  54. package/src/components/terminal/result-preview.ts +72 -0
  55. package/src/components/terminal/row.tsx +132 -0
  56. package/src/components/terminal/scrubber.tsx +663 -0
  57. package/src/components/terminal/surface.tsx +80 -0
  58. package/src/components/terminal/tool-run.ts +91 -0
  59. package/src/components/ui/Badge.tsx +6 -1
  60. package/src/components/ui/Empty.tsx +56 -0
  61. package/src/components/ui/Splitter.tsx +14 -0
  62. package/src/index.ts +36 -0
  63. package/src/lib/status.ts +59 -3
  64. package/src/lib/tool-icon.ts +14 -0
  65. package/src/styles/terminal.css +1011 -0
  66. package/src/styles/theme.css +73 -0
  67. package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
  68. package/build/format.mjs.map +0 -1
  69. package/src/components/agent/line-prompt.tsx +0 -249
@@ -9,9 +9,19 @@ import { Tip } from '../ui/Tooltip.tsx'
9
9
  import { cn } from '../../lib/utils.ts'
10
10
  import { formatCost, formatCountdown, formatTokens } from '../../lib/format.ts'
11
11
  import { STATUS_META } from './status.ts'
12
+ import { useTranscriptVariant } from './transcript-variant.tsx'
12
13
 
13
14
  export interface StatusBarProps {
14
15
  state: TranscriptState
16
+ /**
17
+ * Plan windows to draw, when they should not be the session's own. The panel
18
+ * hands over the gateway's per-profile state merged over this transcript's
19
+ * reading (`mergeUsage`): a session's own `rate_limit` readings arrive only at
20
+ * a turn's edges, so one idle since yesterday would otherwise render
21
+ * yesterday's number as current. Absent = `state.rateLimits`, which is what a
22
+ * bar composed by hand outside the panel gets.
23
+ */
24
+ rateLimits?: Record<string, RateLimitInfo>
15
25
  /** @deprecated Pass {@link StatusBarProps.connection}; kept so an embedder
16
26
  * still handing over a boolean keeps working. */
17
27
  connected?: boolean
@@ -28,6 +38,13 @@ export interface StatusBarProps {
28
38
  onOpenStatus?: () => void
29
39
  onOpenContext?: () => void
30
40
  onOpenUsage?: () => void
41
+ /**
42
+ * The session's own controls (model, permission mode), for
43
+ * `controlsSurface: 'status'`. They sit at the end of the readings cluster —
44
+ * status, context, usage, then what you can *change* — rather than out by the
45
+ * actions menu, because they belong with the session facts they act on.
46
+ */
47
+ controls?: ReactNode
31
48
  /** Trailing slot — the session-actions menu, at the bar's trailing edge. */
32
49
  actions?: ReactNode
33
50
  /** Which edge the bar sits on, so its separating rule goes on the other side.
@@ -113,16 +130,22 @@ function RateLimitMeter({ label, info, now }: { label: string; info: RateLimitIn
113
130
  {info.status === 'rejected' ? <div className='text-danger'>Limit reached</div> : null}
114
131
  </div>
115
132
  }>
133
+ {/* Inline, not `inline-flex`: a flex container contributes its FIRST
134
+ item's baseline, and the first item here is the ring — so a flex
135
+ version hands the row a 13px circle's baseline instead of the number's,
136
+ and every reading beside it sits on a different line. Inline text has
137
+ only one baseline to give. `align-middle` centres the ring on the
138
+ x-height, which is where it looked right anyway. */}
116
139
  <span
117
140
  className={cn(
118
- 'inline-flex cursor-default items-center gap-1 font-mono text-label',
141
+ 'cursor-default font-mono text-label whitespace-nowrap',
119
142
  info.status === 'rejected' ? 'text-danger' : utilizationColor(pct ?? 0),
120
143
  )}>
121
- <ProgressRing value={pct ?? 0} />
144
+ <ProgressRing value={pct ?? 0} className='mr-1 inline-block align-middle' />
122
145
  {label}
123
146
  {pct !== undefined ? ` ${pct.toFixed(0)}%` : ''}
124
147
  {resetsAtMs !== undefined ? (
125
- <span className='text-fg-4' {formatCountdown(resetsAtMs, now)}</span>
148
+ <span className='text-fg-4'> · {formatCountdown(resetsAtMs, now)}</span>
126
149
  ) : null}
127
150
  </span>
128
151
  </Tip>
@@ -138,7 +161,15 @@ function Slot({ onClick, hint, children }: { onClick?: () => void; hint: string;
138
161
  type='button'
139
162
  onClick={onClick}
140
163
  aria-label={hint}
141
- className='rounded-md px-1 py-0.5 transition-colors outline-none hover:bg-surface-hover focus-visible:bg-surface-hover'>
164
+ // `leading-4` matches the label metrics inside: a button's own line box is
165
+ // the page's 24px one, and the extra strut was padding the bar by 2.5px
166
+ // that nothing was using.
167
+ // No horizontal padding: the bar's own 6px is the inset, and a slot that
168
+ // added its own would start the first reading 10px in. The hover surface
169
+ // hugs the text instead, which is what a status line's items do anyway.
170
+ // `leading-4` matches the label metrics inside — a button's own line box
171
+ // is the page's 24px one, and the strut padded the bar with nothing.
172
+ className='rounded-md py-0.5 leading-4 transition-colors outline-none hover:bg-surface-hover focus-visible:bg-surface-hover'>
142
173
  {children}
143
174
  </button>
144
175
  )
@@ -146,28 +177,45 @@ function Slot({ onClick, hint, children }: { onClick?: () => void; hint: string;
146
177
 
147
178
  export function StatusBar({
148
179
  state,
180
+ rateLimits,
149
181
  connected,
150
182
  connection,
151
183
  onOpenStatus,
152
184
  onOpenContext,
153
185
  onOpenUsage,
186
+ controls,
154
187
  actions,
155
188
  placement = 'top',
156
189
  className,
157
190
  }: StatusBarProps) {
158
191
  const meta = STATUS_META[state.status]
159
192
  const now = useNow()
160
- const session = state.rateLimits?.five_hour
161
- const weekly = state.rateLimits?.seven_day
193
+ const windows = rateLimits ?? state.rateLimits
194
+ const session = windows?.five_hour
195
+ const weekly = windows?.seven_day
162
196
  const link: ConnectionState = connection ?? (connected === false ? 'reconnecting' : 'live')
197
+ const terminal = useTranscriptVariant() === 'terminal'
163
198
  return (
164
199
  <div
165
200
  data-slot='status-bar'
166
201
  className={cn(
167
- 'flex items-center gap-2 border-border bg-surface px-3 py-1.5',
202
+ // Baselines, not boxes. `items-center` centres each child's *box*, and
203
+ // this bar's children are boxes of different heights for reasons that
204
+ // have nothing to do with their text — a badge's pill padding, a ring
205
+ // beside a number, a select's border and chevron. Centring those lands
206
+ // their text on four slightly different lines. A flex item's baseline is
207
+ // its first line's baseline, so aligning on it puts every reading on one
208
+ // line by construction, whatever is drawn around it.
209
+ // One explicit height, shared with the docked composer above it (see
210
+ // `Composer.tsx`) — the two strips along the foot of the panel read as
211
+ // one piece of chrome, and a pixel of drift between them shows.
212
+ 'flex h-[38px] items-baseline gap-2 border-border bg-surface p-1.5',
168
213
  // The rule goes between the bar and the content, so which edge it sits
169
- // on follows the placement.
170
- placement === 'bottom' ? 'border-t' : 'border-b',
214
+ // on follows the placement — except under the terminal theme at the
215
+ // foot, where the composer directly above already closes itself with a
216
+ // rule of its own. Two adjacent 1px rules is a 2px rule with a seam in
217
+ // it, and it is off the line grid besides.
218
+ placement === 'bottom' ? (terminal ? undefined : 'border-t') : 'border-b',
171
219
  className,
172
220
  )}>
173
221
  {/* One slot, two meanings: connection trouble wins it, because a session
@@ -176,16 +224,22 @@ export function StatusBar({
176
224
  with which credentials — which is the question a status prompts. */}
177
225
  <Slot onClick={onOpenStatus} hint='Session info'>
178
226
  {link === 'live' ? (
179
- <Badge variant={meta.variant} dot={!meta.busy}>
180
- {meta.busy ? <Spinner className='size-3 text-current' /> : null}
227
+ // `items-baseline` so the badge answers the row with its label's
228
+ // baseline rather than its pill's box; the dot and the spinner keep
229
+ // their own centring.
230
+ <Badge variant={meta.variant} dot={!meta.busy} className='items-baseline'>
231
+ {meta.busy ? <Spinner className='size-3 self-center text-current' /> : null}
181
232
  {meta.label}
182
233
  </Badge>
183
234
  ) : (
184
- <Badge variant={link === 'offline' ? 'danger' : 'warning'} dot={false}>
235
+ <Badge
236
+ variant={link === 'offline' ? 'danger' : 'warning'}
237
+ dot={false}
238
+ className='items-baseline'>
185
239
  {link === 'offline' ? (
186
- <WifiOff className='size-3 text-current' />
240
+ <WifiOff className='size-3 self-center text-current' />
187
241
  ) : (
188
- <RefreshCw className='size-3 animate-spin text-current' />
242
+ <RefreshCw className='size-3 animate-spin self-center text-current' />
189
243
  )}
190
244
  {link === 'offline' ? 'Offline' : 'Reconnecting…'}
191
245
  </Badge>
@@ -200,12 +254,13 @@ export function StatusBar({
200
254
  ) : null}
201
255
  {session || weekly ? (
202
256
  <Slot onClick={onOpenUsage} hint='Plan usage'>
203
- <span className='inline-flex items-center gap-2'>
257
+ <span className='inline-flex items-baseline gap-2'>
204
258
  {session ? <RateLimitMeter label='Session' info={session} now={now} /> : null}
205
259
  {weekly ? <RateLimitMeter label='Weekly' info={weekly} now={now} /> : null}
206
260
  </span>
207
261
  </Slot>
208
262
  ) : null}
263
+ {controls}
209
264
  <span className='flex-1' />
210
265
  <span className='font-mono text-label text-fg-3'>{formatCost(state.totalCostUsd)}</span>
211
266
  {actions}
@@ -6,10 +6,7 @@ import { CodeBlock } from '../ui/CodeBlock.tsx'
6
6
  import { Spinner } from '../ui/Spinner.tsx'
7
7
  import { cn } from '../../lib/utils.ts'
8
8
  import { toolInputPreview } from '../../lib/format.ts'
9
- import { isMutatingTool, toolIcon } from '../../lib/tool-icon.ts'
10
- import { LINE_INDENT, LinePayload } from './line-prompt.tsx'
11
- import { usePulse } from './pulse.tsx'
12
- import { LineGlyph, useLines } from './transcript-variant.tsx'
9
+ import { toolIcon } from '../../lib/tool-icon.ts'
13
10
 
14
11
  export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
15
12
 
@@ -51,15 +48,6 @@ const STATE_BADGE = {
51
48
  failed: { label: 'Error', variant: 'danger', busy: false },
52
49
  } as const
53
50
 
54
- /** The gutter dot's colour in `lines`: the state, said without a badge. */
55
- const STATE_GLYPH = {
56
- running: 'text-info',
57
- pending: 'text-info',
58
- deferred: 'text-accent',
59
- settled: 'text-fg-4',
60
- failed: 'text-danger',
61
- } as const
62
-
63
51
  /**
64
52
  * The language to highlight a payload as.
65
53
  *
@@ -91,47 +79,35 @@ function resultLanguage(item: ToolCallItem): string | undefined {
91
79
  type Status = keyof typeof STATE_BADGE
92
80
 
93
81
  export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps) {
94
- const lines = useLines()
95
82
  const [open, setOpen] = useState(false)
96
83
  const [fullResult, setFullResult] = useState(false)
97
84
  const imagePath = imagePathOf(item)
98
85
  const status: Status = item.status ?? (item.result === undefined ? 'running' : 'settled')
99
86
  const badge = STATE_BADGE[status]
100
87
  const isError = status === 'failed' || item.result?.isError === true
101
- // Ticks only while this row is actually running, and only in the variant with a
102
- // gutter to pulse in — an idle transcript of a hundred settled tools starts no
103
- // timers at all.
104
- const pulse = usePulse(lines && badge.busy)
105
88
  const Icon = toolIcon(item.name)
106
89
 
107
90
  const resultText = item.result?.text ?? ''
108
91
  const truncated = !fullResult && resultText.length > RESULT_PREVIEW_CHARS
109
92
  const shownResult = truncated ? resultText.slice(0, RESULT_PREVIEW_CHARS) : resultText
110
93
 
111
- // In `lines` the payloads are dim label + highlighted band, not framed cards:
112
- // a tool call is already one row, and boxing what it expands into puts back
113
- // the chrome the variant exists to remove.
114
- const blockVariant = lines ? 'plain' : 'panel'
115
- const Payload = lines ? HighlightedPayload : PlainPayload
116
94
  const details = open ? (
117
- <div className={cn('flex flex-col', lines ? 'gap-1 py-1' : 'gap-2 border-t border-border p-2.5')}>
118
- <Payload
95
+ <div className='flex flex-col gap-2 border-t border-border p-2.5'>
96
+ <PlainPayload
119
97
  code={JSON.stringify(item.input, null, 2)}
120
98
  language='json'
121
99
  label='Parameters'
122
- variant={blockVariant}
123
100
  />
124
101
  {item.logs?.length ? (
125
- <Payload code={item.logs.join('\n')} label='Logs' variant={blockVariant} />
102
+ <PlainPayload code={item.logs.join('\n')} label='Logs'/>
126
103
  ) : null}
127
104
  {item.result !== undefined ? (
128
105
  <div>
129
- <Payload
106
+ <PlainPayload
130
107
  code={shownResult || '(empty result)'}
131
108
  language={resultLanguage(item)}
132
109
  label={isError ? 'Error' : 'Result'}
133
- variant={blockVariant}
134
- className={cn(isError && (lines ? '[&_pre]:text-danger' : 'border-danger/40 [&_pre]:text-danger'))}
110
+ className={cn(isError && 'border-danger/40 [&_pre]:text-danger')}
135
111
  />
136
112
  {truncated ? (
137
113
  <button
@@ -148,63 +124,7 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
148
124
 
149
125
  // The picture is the point of the call — shown without expanding, the way the
150
126
  // tool's own output would be if the engine had sent bytes.
151
- const image = imagePath && hostImage ? <HostImage path={imagePath} load={hostImage} lines={lines} /> : null
152
-
153
- if (lines) {
154
- return (
155
- <div data-slot='tool-call' data-state={status} className={cn('w-full', className)}>
156
- <button
157
- type='button'
158
- onClick={() => setOpen((v) => !v)}
159
- className='flex w-full items-baseline gap-2 text-left outline-none'>
160
- <LineGlyph
161
- className={
162
- // A settled write is green: skimming a run, "what did it change"
163
- // is the question you come back to, and it is the one you might
164
- // need to undo. Every other state keeps its own colour — a failed
165
- // write is a failure first.
166
- status === 'settled' && !isError && isMutatingTool(item.name)
167
- ? 'text-success'
168
- : STATE_GLYPH[status]
169
- }>
170
- {/* Running: the mark's own pulse, so a working tool row and the
171
- transcript's working line beat together. Settled: a plain dot,
172
- which reads as "done" precisely by not moving. */}
173
- {badge.busy ? pulse : '●'}
174
- </LineGlyph>
175
- <span className='min-w-0 flex-1 truncate text-body-sm leading-5 text-fg-3'>
176
- <span className='font-medium text-fg-1'>{item.name}</span>
177
- <span className='text-fg-4'>({toolInputPreview(item.input)})</span>
178
- </span>
179
- {item.backend && item.backend !== 'server' ? (
180
- <span className='shrink-0 text-label text-fg-4'>{item.backend}</span>
181
- ) : null}
182
- {/* No Spinner here: the gutter glyph animates now, and two spinners on
183
- one row is one too many. `cards` keeps its own — it has no gutter. */}
184
- {status === 'deferred' ? <Clock className='size-3 shrink-0 self-center text-fg-4' /> : null}
185
- {isError && !badge.busy ? (
186
- <span className='shrink-0 text-label text-danger'>error</span>
187
- ) : null}
188
- </button>
189
- {/* Collapsed, the first line of the output is the whole story most of
190
- the time — a summary line costs one row and saves an expand. */}
191
- {!open && resultText ? (
192
- <div className='flex items-baseline gap-2'>
193
- <LineGlyph className='text-fg-4'>⎿</LineGlyph>
194
- <span
195
- className={cn(
196
- 'min-w-0 flex-1 truncate text-label leading-5',
197
- isError ? 'text-danger' : 'text-fg-4',
198
- )}>
199
- {resultSummary(resultText)}
200
- </span>
201
- </div>
202
- ) : null}
203
- {image}
204
- {details ? <div className={LINE_INDENT}>{details}</div> : null}
205
- </div>
206
- )
207
- }
127
+ const image = imagePath && hostImage ? <HostImage path={imagePath} load={hostImage} /> : null
208
128
 
209
129
  return (
210
130
  <div
@@ -243,31 +163,19 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
243
163
  )
244
164
  }
245
165
 
246
- /** The first line worth showing of a tool result, for the collapsed summary. */
247
- function resultSummary(text: string): string {
248
- const first = text.split('\n').find((line) => line.trim() !== '') ?? ''
249
- const rest = text.trimEnd().split('\n').length - 1
250
- const trimmed = first.trim()
251
- return rest > 0 ? `${trimmed} (+${rest} lines)` : trimmed
252
- }
253
-
254
- type PayloadProps = {
166
+ /** The framed card the `cards` transcript expands into. Unhighlighted by
167
+ * design: it is structured data in a panel, not a file. */
168
+ function PlainPayload({
169
+ code,
170
+ label,
171
+ className,
172
+ }: {
255
173
  code: string
256
174
  label: string
257
175
  language?: string
258
- variant: 'panel' | 'plain'
259
176
  className?: string
260
- }
261
-
262
- /** The framed card, for the `cards` transcript. Unhighlighted by design: it is
263
- * structured data in a panel, not a file. */
264
- function PlainPayload({ code, label, variant, className }: PayloadProps) {
265
- return <CodeBlock code={code} label={label} variant={variant} className={className} />
266
- }
267
-
268
- /** The terminal payload — shared with the line-shaped prompts. */
269
- function HighlightedPayload({ code, label, language, className }: PayloadProps) {
270
- return <LinePayload code={code} label={label} language={language} className={className} />
177
+ }) {
178
+ return <CodeBlock code={code} label={label} variant='panel' className={className} />
271
179
  }
272
180
 
273
181
  /**
@@ -282,11 +190,9 @@ function HighlightedPayload({ code, label, language, className }: PayloadProps)
282
190
  function HostImage({
283
191
  path,
284
192
  load,
285
- lines,
286
193
  }: {
287
194
  path: string
288
195
  load: (path: string) => Promise<string | undefined>
289
- lines?: boolean
290
196
  }) {
291
197
  const [src, setSrc] = useState<string | undefined>()
292
198
  useEffect(() => {
@@ -305,7 +211,7 @@ function HostImage({
305
211
  }, [path, load])
306
212
  if (!src) return null
307
213
  return (
308
- <div className={cn(lines ? cn('py-1', LINE_INDENT) : 'border-t border-border p-2.5')}>
214
+ <div className='border-t border-border p-2.5'>
309
215
  <img
310
216
  src={src}
311
217
  alt={path.split('/').pop() ?? 'Generated image'}