@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.
Files changed (65) hide show
  1. package/README.md +53 -0
  2. package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-B9CHoq8x.d.mts} +158 -21
  3. package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DII9MmQ8.mjs} +4254 -1661
  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 +442 -45
  9. package/build/index.mjs +105 -2
  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 +14 -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/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/SessionPanel.tsx +224 -28
  28. package/src/components/agent/SessionWorkspace.tsx +29 -0
  29. package/src/components/agent/StatusBar.tsx +20 -4
  30. package/src/components/agent/ToolCallCard.tsx +17 -111
  31. package/src/components/agent/Transcript.tsx +710 -203
  32. package/src/components/agent/UsageDialog.tsx +20 -106
  33. package/src/components/agent/UsageMeters.tsx +133 -0
  34. package/src/components/agent/pulse.tsx +3 -2
  35. package/src/components/agent/transcript-rows.ts +82 -0
  36. package/src/components/agent/transcript-variant.tsx +29 -51
  37. package/src/components/agent/use-height-epoch.ts +60 -0
  38. package/src/components/agent/use-path-links.ts +147 -0
  39. package/src/components/agent/use-transcript-jumps.ts +190 -0
  40. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  41. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  42. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  43. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  44. package/src/components/terminal/StatusLine.tsx +159 -0
  45. package/src/components/terminal/TerminalTranscript.tsx +147 -0
  46. package/src/components/terminal/affordances.tsx +118 -0
  47. package/src/components/terminal/diff.tsx +130 -0
  48. package/src/components/terminal/height.ts +727 -0
  49. package/src/components/terminal/items.tsx +449 -0
  50. package/src/components/terminal/markdown.tsx +191 -0
  51. package/src/components/terminal/press.tsx +120 -0
  52. package/src/components/terminal/prompt.tsx +343 -0
  53. package/src/components/terminal/result-preview.ts +72 -0
  54. package/src/components/terminal/row.tsx +132 -0
  55. package/src/components/terminal/scrubber.tsx +663 -0
  56. package/src/components/terminal/surface.tsx +80 -0
  57. package/src/components/terminal/tool-run.ts +91 -0
  58. package/src/index.ts +34 -0
  59. package/src/lib/status.ts +59 -3
  60. package/src/lib/tool-icon.ts +14 -0
  61. package/src/styles/terminal.css +1011 -0
  62. package/src/styles/theme.css +41 -0
  63. package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
  64. package/build/format.mjs.map +0 -1
  65. package/src/components/agent/line-prompt.tsx +0 -249
@@ -8,12 +8,20 @@ import {
8
8
  type ReactNode,
9
9
  } from 'react'
10
10
  import type { WorkerDeckClient } from '@workerdeck/client'
11
- import { PROTOCOL_VERSION, type ModelOption, type PermissionMode } from '@workerdeck/protocol'
12
11
  import {
13
- rateLimitWindows,
12
+ PROTOCOL_VERSION,
13
+ mergeUsage,
14
+ orderUsageWindows,
15
+ usageInfos,
16
+ type ModelOption,
17
+ type PermissionMode,
18
+ type RateLimitInfo,
19
+ } from '@workerdeck/protocol'
20
+ import {
14
21
  useAttachments,
15
22
  useClaudeSession,
16
23
  useHostFileSearch,
24
+ useProfileUsage,
17
25
  useToolCallHost,
18
26
  type ConnectionState,
19
27
  type ProducedFileRef,
@@ -47,6 +55,11 @@ import {
47
55
  } from './PermissionModeSelect.tsx'
48
56
  import { PermissionPrompt } from './PermissionPrompt.tsx'
49
57
  import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
58
+ import { TerminalPermissionPrompt } from '../terminal/PermissionPrompt.tsx'
59
+ import { TerminalQuestionPrompt } from '../terminal/QuestionPrompt.tsx'
60
+ import { TerminalSurface } from '../terminal/surface.tsx'
61
+ import type { TerminalAffordances } from '../terminal/affordances.tsx'
62
+
50
63
  import { SessionInfoDialog } from './SessionInfoDialog.tsx'
51
64
  import { StatusBar } from './StatusBar.tsx'
52
65
  import { Transcript } from './Transcript.tsx'
@@ -59,6 +72,59 @@ import {
59
72
  } from './transcript-variant.tsx'
60
73
  import { UsageDialog } from './UsageDialog.tsx'
61
74
 
75
+ /**
76
+ * The box the pending prompts sit in.
77
+ *
78
+ * Under the terminal theme they are rows of the same run as the transcript, so
79
+ * they need that theme's cell — and its `--term-bleed`, so an approval's diff
80
+ * bands reach the same edges the transcript's do. Every other variant keeps the
81
+ * centred content column the panel uses everywhere else.
82
+ */
83
+ function PromptSurface({
84
+ terminal,
85
+ metrics,
86
+ affordances,
87
+ children,
88
+ }: {
89
+ terminal: boolean
90
+ metrics?: TerminalMetrics
91
+ affordances?: TerminalAffordances | boolean
92
+ children: ReactNode
93
+ }) {
94
+ if (!terminal) {
95
+ return (
96
+ <div className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] flex-col gap-2'>
97
+ {children}
98
+ </div>
99
+ )
100
+ }
101
+ return (
102
+ <TerminalSurface
103
+ fontSize={metrics?.fontSize}
104
+ lineHeight={metrics?.lineHeight}
105
+ affordances={affordances}
106
+ bleed='1ch'
107
+ className='term-transcript'>
108
+ {children}
109
+ </TerminalSurface>
110
+ )
111
+ }
112
+
113
+ /**
114
+ * The character cell the terminal theme draws on, in **whole pixels**.
115
+ *
116
+ * One object rather than two props because the panel mounts three separate
117
+ * `TerminalSurface`s — the transcript, the pending prompts, the composer — and
118
+ * they must agree: a prompt drawn at a different line height from the rows above
119
+ * it is three surfaces on three grids, which is the failure this theme is built
120
+ * to make impossible. Passing one value through one prop is what keeps them from
121
+ * drifting.
122
+ *
123
+ * Absent means the CLI's own 13/18. A host that follows an editor font size
124
+ * (VS Code) hands that down instead.
125
+ */
126
+ export type TerminalMetrics = { fontSize?: number; lineHeight?: number }
127
+
62
128
  export interface SessionPanelProps {
63
129
  client: WorkerDeckClient
64
130
  sessionId: string | undefined
@@ -119,12 +185,55 @@ export interface SessionPanelProps {
119
185
  * internal ref, so an inline closure is fine). */
120
186
  onVitals?: (vitals: SessionVitals) => void
121
187
  /**
122
- * How the transcript draws a turn — `'cards'` (default) or `'lines'`, the
123
- * space-efficient terminal treatment: no boxes, no bubbles, one full-width
124
- * hover-highlit row per event behind a gutter glyph. An embedder in a dock
125
- * (the VS Code panel) wants `'lines'`; a full-width dashboard usually doesn't.
188
+ * How the transcript draws a turn — `'cards'` (default, the chat convention)
189
+ * or `'terminal'`, the CLI's own form: every row on a character cell, no boxes
190
+ * anywhere, diffs as full-width bands. An embedder in a dock (the VS Code
191
+ * panel) wants `'terminal'`; a full-width dashboard may prefer cards.
126
192
  */
127
193
  transcriptVariant?: TranscriptVariant
194
+ /**
195
+ * Terminal theme only: the pointer affordances a real terminal cannot offer —
196
+ * the hover fill, the hover-revealed copy. `false` for none. None of them
197
+ * costs layout, so turning them off changes no glyph's position. See
198
+ * {@link TerminalAffordances}.
199
+ */
200
+ affordances?: TerminalAffordances | boolean
201
+ /**
202
+ * Terminal theme only: the character cell, in whole pixels. See
203
+ * {@link TerminalMetrics} — it reaches all three of the panel's terminal
204
+ * surfaces, which is why it is one prop and not two per surface.
205
+ */
206
+ terminalMetrics?: TerminalMetrics
207
+ /**
208
+ * Terminal theme only: replace the scrollbar with the **overview ruler** — a
209
+ * `2ch` rail of coloured marks in three lanes (what you typed, the answer and
210
+ * its turn end, errors and a waiting approval), which you can hover to peek,
211
+ * click to jump, and drag to scrub.
212
+ *
213
+ * Its premise is the terminal theme's own: one line height and one cell make
214
+ * every row's height derivable, so a mark's position is *computed* rather than
215
+ * guessed from rows that have not mounted. That is why it is not offered under
216
+ * `cards` — there the flag is inert.
217
+ *
218
+ * `false` keeps the native scrollbar. So does `affordances={false}`, which
219
+ * leaves the marks painted but inert rather than removing a reader's only way
220
+ * to scroll.
221
+ */
222
+ scrubber?: boolean
223
+ /**
224
+ * Bookmarked **item indices**, painted full-width on the rail. Paint only —
225
+ * the panel neither stores bookmarks nor offers a way to set one, because who
226
+ * owns that store is the embedder's question (a private pin belongs with the
227
+ * client's watermarks; a shared one is session metadata on the gateway).
228
+ */
229
+ scrubberMarks?: readonly number[]
230
+ /**
231
+ * Terminal theme only: hold the prompt of the turn you are reading at the top
232
+ * of the transcript, as the Claude Code CLI does. The **real row** is pinned
233
+ * rather than a copy drawn above it, so it lines up with the rows beneath by
234
+ * construction — see `TranscriptRows`.
235
+ */
236
+ stickyPrompt?: boolean
128
237
  /**
129
238
  * How much air the transcript gives each row — `'comfortable'` (default: a
130
239
  * blank line between messages, as the Claude Code CLI leaves) or `'compact'`
@@ -224,6 +333,14 @@ export interface SessionPanelProps {
224
333
  * a wildcard.
225
334
  */
226
335
  toolHost?: UseToolCallHostOptions | false
336
+ /**
337
+ * Keep this session's transcript warm across remounts (default true), so
338
+ * switching back to a recently viewed session paints instantly and replays
339
+ * only what it missed — `UseClaudeSessionOptions.cacheTranscript`. Set
340
+ * `false` for an embedder whose principal varies on one gateway URL by means
341
+ * the client cannot see.
342
+ */
343
+ cacheTranscript?: boolean
227
344
  className?: string
228
345
  }
229
346
 
@@ -320,12 +437,18 @@ export function SessionPanel({
320
437
  transcriptVariant = 'cards',
321
438
  transcriptDensity = 'comfortable',
322
439
  transcriptFont = 'sans',
440
+ affordances,
441
+ terminalMetrics,
442
+ scrubber = false,
443
+ scrubberMarks,
444
+ stickyPrompt = false,
323
445
  controlsSurface = 'internal',
324
446
  onControls,
325
447
  focusComposerOnClick = false,
326
448
  unseen,
327
449
  readOnly = false,
328
450
  toolHost,
451
+ cacheTranscript,
329
452
  className,
330
453
  }: SessionPanelProps) {
331
454
  const external = panelSurface === 'external'
@@ -344,6 +467,7 @@ export function SessionPanel({
344
467
  const {
345
468
  state,
346
469
  connection,
470
+ replaying,
347
471
  protocolMismatch,
348
472
  models,
349
473
  effectiveModel,
@@ -355,7 +479,7 @@ export function SessionPanel({
355
479
  setModel,
356
480
  setPermissionMode,
357
481
  reconnectNow,
358
- } = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError })
482
+ } = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError, cacheTranscript })
359
483
  // Callers are told to remount on a session switch, but a changed prop must not leave
360
484
  // the previous session's failure on screen.
361
485
  useEffect(() => setProtocolError(undefined), [sessionId])
@@ -398,8 +522,39 @@ export function SessionPanel({
398
522
  // client. Free for Claude sessions: the guest loads lazily on the first call,
399
523
  // which for them never comes.
400
524
  useToolCallHost(handle, toolHost === false ? { enabled: false } : toolHost)
525
+ const terminal = transcriptVariant === 'terminal'
401
526
  const capabilities = state.capabilities
402
527
 
528
+ // Plan usage as the *gateway* knows it, merged over this session's own
529
+ // reading — one derivation, feeding the bar, the terminal status line, the
530
+ // Usage panel and the vitals an external chrome renders from, because four
531
+ // surfaces disagreeing about the same percentage is worse than any of them
532
+ // being stale. A session's own `rate_limit` readings land only at a turn's
533
+ // edges, so an idle session's numbers age silently and a sibling session's
534
+ // spend never shows up here at all; `mergeUsage` is where that rule is
535
+ // written down. Skipped entirely for an engine that reports no windows.
536
+ const { usage: profileUsage } = useProfileUsage(client, state.session?.profile, {
537
+ enabled: capabilities.rateLimits,
538
+ })
539
+ const usage = useMemo(
540
+ () =>
541
+ mergeUsage(
542
+ { rateLimits: state.rateLimits, updatedAt: state.rateLimitsUpdatedAt },
543
+ profileUsage,
544
+ ),
545
+ [state.rateLimits, state.rateLimitsUpdatedAt, profileUsage],
546
+ )
547
+ // Undefined rather than an empty map when nothing has been reported: absent is
548
+ // *unknown*, and a surface that can't tell the two apart draws 0%.
549
+ const rateLimits: Record<string, RateLimitInfo> | undefined = useMemo(
550
+ () => (Object.keys(usage).length > 0 ? usageInfos(usage) : undefined),
551
+ [usage],
552
+ )
553
+ const usageUpdatedAt = useMemo(() => {
554
+ const stamps = Object.values(usage).map((w) => w.updatedAt)
555
+ return stamps.length > 0 ? Math.max(...stamps) : undefined
556
+ }, [usage])
557
+
403
558
  // Vitals out to the embedder, keyed on the readings themselves so an inline
404
559
  // closure prop doesn't retrigger it every render.
405
560
  const onVitalsRef = useRef(onVitals)
@@ -423,7 +578,7 @@ export function SessionPanel({
423
578
  permissionModes,
424
579
  cwd: state.cwd,
425
580
  contextUsage: state.contextUsage,
426
- rateLimits: state.rateLimits,
581
+ rateLimits,
427
582
  itemCount: state.items.length,
428
583
  })
429
584
  }, [
@@ -437,7 +592,7 @@ export function SessionPanel({
437
592
  permissionModes,
438
593
  state.cwd,
439
594
  state.contextUsage,
440
- state.rateLimits,
595
+ rateLimits,
441
596
  state.items.length,
442
597
  ])
443
598
 
@@ -468,7 +623,9 @@ export function SessionPanel({
468
623
  // inert for the moment before it does, and stays inert on a gateway that
469
624
  // serves no host files.
470
625
  const hostFiles = useHostFileSearch(client, state.cwd)
471
- const windows = useMemo(() => rateLimitWindows(state), [state])
626
+ // Protocol's ordering, over the merged readings — each row keeping its own
627
+ // date, since they no longer share one clock.
628
+ const windows = useMemo(() => orderUsageWindows(usage), [usage])
472
629
  // Reads a picture the engine left on the host (codex's `image_gen` reports a
473
630
  // path, never bytes). Stable and memoized per path: transcript rows re-render
474
631
  // on every delta, and a fresh function would re-fetch each time.
@@ -477,6 +634,8 @@ export function SessionPanel({
477
634
  // The catch-up strip's way of scrolling the (virtualized, usually unmounted)
478
635
  // recap row into view — the transcript fills it in. See TranscriptProps.
479
636
  const jumpToRecap = useRef<(() => void) | null>(null)
637
+ // Filled by the transcript; pressed on send. See `handleSend`.
638
+ const repinTranscript = useRef<(() => void) | null>(null)
480
639
 
481
640
  // "/model" is handled panel-side (see handleSend) — surface it in the autocomplete
482
641
  // even though the CLI's command list doesn't include it.
@@ -510,6 +669,13 @@ export function SessionPanel({
510
669
  // Typing into a session is the clearest possible statement that you have
511
670
  // read it — nothing left to catch up on.
512
671
  setCaughtUp(true)
672
+ // ...and sending is the statement that you want to watch what happens next,
673
+ // so following resumes here too. Scrolling up escapes the bottom lock, and
674
+ // without this the reply streams in off screen: the transcript stays parked
675
+ // where you were reading and sending looks like it did nothing. Ordered
676
+ // before `send` only for readability — the re-pin is instant and the first
677
+ // row is a round trip away either way.
678
+ repinTranscript.current?.()
513
679
  send(text, attachmentIds)
514
680
  }
515
681
 
@@ -613,6 +779,7 @@ export function SessionPanel({
613
779
  const statusBar = statusExternal ? null : (
614
780
  <StatusBar
615
781
  state={state}
782
+ rateLimits={rateLimits}
616
783
  connection={connection}
617
784
  placement={statusPlacement}
618
785
  controls={controlsInStatus && !readOnly ? sessionControls : undefined}
@@ -635,9 +802,9 @@ export function SessionPanel({
635
802
  }
636
803
 
637
804
  return (
638
- // The variant is a panel-wide fact, not a transcript-only one: the approval
639
- // and question prompts live outside the scroller but are line items in the
640
- // same run, and they read `useLines()` like every other row.
805
+ // The variant is a panel-wide fact, not a transcript-only one: the composer
806
+ // and the pending prompts live outside the scroller but belong to the same
807
+ // run, and they read it from this context rather than a prop chain.
641
808
  <TranscriptVariantProvider value={transcriptVariant}>
642
809
  <TranscriptDensityProvider value={transcriptDensity}>
643
810
  <div
@@ -669,27 +836,34 @@ export function SessionPanel({
669
836
  hostImage={hostImage}
670
837
  variant={transcriptVariant}
671
838
  density={transcriptDensity}
839
+ fontSize={terminalMetrics?.fontSize}
840
+ lineHeight={terminalMetrics?.lineHeight}
841
+ affordances={affordances}
842
+ stickyPrompt={stickyPrompt}
843
+ scrubber={scrubber}
844
+ scrubberMarks={scrubberMarks}
845
+ replaying={replaying}
672
846
  catchUp={
673
847
  catchUp && newCount > 0
674
848
  ? { from: catchUp.itemCount, since: catchUp.since }
675
849
  : undefined
676
850
  }
677
851
  jumpToRecapRef={jumpToRecap}
852
+ repinRef={repinTranscript}
678
853
  />
679
854
  {/* The way back into what you missed. Above the composer because that is
680
855
  where the eye already is on returning, and because the transcript
681
- itself opens pinned to the newest row. */}
682
- {catchUp && newCount > 0 ? (
856
+ itself opens pinned to the newest row. Held with the transcript
857
+ while the replay lands its count is `state.items.length`, which
858
+ during the replay is a number visibly climbing toward its answer. */}
859
+ {catchUp && newCount > 0 && !replaying ? (
683
860
  <div className='px-3 pb-1'>
684
861
  <div
685
862
  data-slot='catch-up'
686
863
  className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-fg-3'>
687
864
  <span
688
865
  aria-hidden
689
- className={cn(
690
- 'select-none',
691
- transcriptVariant === 'lines' ? 'text-fg-3' : 'text-accent',
692
- )}>
866
+ className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
693
867
 
694
868
  </span>
695
869
  <span className='min-w-0 flex-1 truncate'>
@@ -718,11 +892,30 @@ export function SessionPanel({
718
892
  `awaiting approval`, and the place that can act on it is wherever the
719
893
  session is actually driven. */}
720
894
  {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
721
- <div className='px-3 pb-2'>
722
- <div className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] flex-col gap-2'>
723
- {state.pendingApprovals.map((request) =>
724
- request.toolName === 'AskUserQuestion' &&
725
- parseUserQuestions(request.input).length > 0 ? (
895
+ <div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
896
+ <PromptSurface terminal={terminal} metrics={terminalMetrics} affordances={affordances}>
897
+ {state.pendingApprovals.map((request) => {
898
+ const isQuestion =
899
+ request.toolName === 'AskUserQuestion' &&
900
+ parseUserQuestions(request.input).length > 0
901
+ if (terminal) {
902
+ return isQuestion ? (
903
+ <TerminalQuestionPrompt
904
+ key={request.id}
905
+ request={request}
906
+ onAnswer={approve}
907
+ onDismiss={(id) => deny(id, 'Question dismissed by user')}
908
+ />
909
+ ) : (
910
+ <TerminalPermissionPrompt
911
+ key={request.id}
912
+ request={request}
913
+ onApprove={approve}
914
+ onDeny={deny}
915
+ />
916
+ )
917
+ }
918
+ return isQuestion ? (
726
919
  <QuestionPrompt
727
920
  key={request.id}
728
921
  request={request}
@@ -736,9 +929,9 @@ export function SessionPanel({
736
929
  onApprove={approve}
737
930
  onDeny={deny}
738
931
  />
739
- ),
740
- )}
741
- </div>
932
+ )
933
+ })}
934
+ </PromptSurface>
742
935
  </div>
743
936
  ) : null}
744
937
  {readOnly ? null : (
@@ -758,6 +951,9 @@ export function SessionPanel({
758
951
  }
759
952
  layout={controlsExternal ? 'inline' : 'stacked'}
760
953
  toolbar={controlsExternal ? undefined : sessionControls}
954
+ fontSize={terminalMetrics?.fontSize}
955
+ lineHeight={terminalMetrics?.lineHeight}
956
+ affordances={affordances}
761
957
  />
762
958
  )}
763
959
  {/* Below the composer, along the foot of the panel — the editor
@@ -785,7 +981,7 @@ export function SessionPanel({
785
981
  subscriptionType={state.subscriptionType}
786
982
  engine={state.engine ?? 'claude'}
787
983
  totalCostUsd={state.totalCostUsd}
788
- updatedAt={state.rateLimitsUpdatedAt}
984
+ updatedAt={usageUpdatedAt}
789
985
  open={panel === 'usage'}
790
986
  onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
791
987
  />
@@ -17,6 +17,7 @@ import { EditorTabs } from './EditorTabs.tsx'
17
17
  import { FileTree } from './FileTree.tsx'
18
18
  import { FileViewer } from './FileViewer.tsx'
19
19
  import { SessionPanel, type SessionPanelProps } from './SessionPanel.tsx'
20
+ import { resolveAgainstCwd, usePathLinks } from './use-path-links.ts'
20
21
 
21
22
  export interface SessionWorkspaceProps {
22
23
  client: WorkerDeckClient
@@ -35,6 +36,11 @@ export interface SessionWorkspaceProps {
35
36
  transcriptVariant?: SessionPanelProps['transcriptVariant']
36
37
  transcriptDensity?: SessionPanelProps['transcriptDensity']
37
38
  transcriptFont?: SessionPanelProps['transcriptFont']
39
+ /** The overview ruler in place of the scrollbar — see `SessionPanel`. */
40
+ scrubber?: SessionPanelProps['scrubber']
41
+ scrubberMarks?: SessionPanelProps['scrubberMarks']
42
+ /** The last prompt pinned above the transcript — see `SessionPanel`. */
43
+ stickyPrompt?: SessionPanelProps['stickyPrompt']
38
44
  /** Which end of the panel the status bar sits at — see `SessionPanel`. */
39
45
  statusPlacement?: SessionPanelProps['statusPlacement']
40
46
  controlsSurface?: SessionPanelProps['controlsSurface']
@@ -94,6 +100,9 @@ export function SessionWorkspace({
94
100
  transcriptVariant,
95
101
  transcriptDensity,
96
102
  transcriptFont,
103
+ scrubber,
104
+ scrubberMarks,
105
+ stickyPrompt,
97
106
  statusPlacement,
98
107
  controlsSurface,
99
108
  unseen,
@@ -118,6 +127,7 @@ export function SessionWorkspace({
118
127
  // editor asks before it offers to save anything.
119
128
  const { canWrite } = useHostFileRoots(client)
120
129
 
130
+
121
131
  // Nothing here can save on the user's behalf when the tab is going away, so
122
132
  // the browser's own guard is the last line. Registered only while there is
123
133
  // something to lose — an unconditional handler makes every navigation prompt.
@@ -172,6 +182,22 @@ export function SessionWorkspace({
172
182
 
173
183
  const column = useRef<HTMLDivElement>(null)
174
184
  const columnHeight = useElementHeight(column)
185
+ // Cmd/Ctrl+click a file the agent named and it opens in the editor above —
186
+ // the workspace is the only surface that *has* an editor, which is why this
187
+ // lives here and not in the panel. Gated on the tree being available: without
188
+ // a host filesystem there is nothing to open, and a link that cannot resolve
189
+ // is worse than plain text. Monaco is excluded because Cmd+click already
190
+ // means go-to-definition in there, and every identifier would match.
191
+ const openPath = useCallback(
192
+ ({ path }: { path: string }) => files.open(resolveAgainstCwd(path, cwd)),
193
+ [files.open, cwd],
194
+ )
195
+ usePathLinks({
196
+ container: column,
197
+ onOpen: openPath,
198
+ enabled: tree.available,
199
+ ignore: '.monaco-editor',
200
+ })
175
201
  const editorMax = Math.max(EDITOR_MIN, columnHeight - AGENT_MIN)
176
202
 
177
203
  // The embedder's header is app chrome and belongs above everything — but only
@@ -278,6 +304,9 @@ export function SessionWorkspace({
278
304
  transcriptVariant={transcriptVariant}
279
305
  transcriptDensity={transcriptDensity}
280
306
  transcriptFont={transcriptFont}
307
+ scrubber={scrubber}
308
+ scrubberMarks={scrubberMarks}
309
+ stickyPrompt={stickyPrompt}
281
310
  controlsSurface={controlsSurface}
282
311
  statusPlacement={statusPlacement}
283
312
  unseen={unseen}
@@ -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
@@ -167,6 +177,7 @@ function Slot({ onClick, hint, children }: { onClick?: () => void; hint: string;
167
177
 
168
178
  export function StatusBar({
169
179
  state,
180
+ rateLimits,
170
181
  connected,
171
182
  connection,
172
183
  onOpenStatus,
@@ -179,9 +190,11 @@ export function StatusBar({
179
190
  }: StatusBarProps) {
180
191
  const meta = STATUS_META[state.status]
181
192
  const now = useNow()
182
- const session = state.rateLimits?.five_hour
183
- const weekly = state.rateLimits?.seven_day
193
+ const windows = rateLimits ?? state.rateLimits
194
+ const session = windows?.five_hour
195
+ const weekly = windows?.seven_day
184
196
  const link: ConnectionState = connection ?? (connected === false ? 'reconnecting' : 'live')
197
+ const terminal = useTranscriptVariant() === 'terminal'
185
198
  return (
186
199
  <div
187
200
  data-slot='status-bar'
@@ -198,8 +211,11 @@ export function StatusBar({
198
211
  // one piece of chrome, and a pixel of drift between them shows.
199
212
  'flex h-[38px] items-baseline gap-2 border-border bg-surface p-1.5',
200
213
  // The rule goes between the bar and the content, so which edge it sits
201
- // on follows the placement.
202
- 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',
203
219
  className,
204
220
  )}>
205
221
  {/* One slot, two meanings: connection trouble wins it, because a session