@workerdeck/ui 0.16.0 → 0.18.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 (47) hide show
  1. package/README.md +7 -0
  2. package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnU_IJ3-.d.mts} +36 -9
  3. package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DPx8Iz8a.mjs} +1432 -384
  4. package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
  5. package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
  6. package/build/format.d.mts +16 -5
  7. package/build/format.mjs +2 -3
  8. package/build/index.d.mts +288 -8
  9. package/build/index.mjs +620 -165
  10. package/build/index.mjs.map +1 -1
  11. package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
  12. package/build/status-BE-zg88x.mjs.map +1 -0
  13. package/build/workspace.d.mts +4 -1
  14. package/build/workspace.mjs +4 -3
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +8 -6
  17. package/src/components/agent/ContextRing.tsx +41 -0
  18. package/src/components/agent/EngineIcon.tsx +40 -0
  19. package/src/components/agent/ProjectIcon.tsx +119 -0
  20. package/src/components/agent/SessionBrowser.tsx +191 -17
  21. package/src/components/agent/SessionPanel.tsx +177 -2
  22. package/src/components/agent/SessionSteps.tsx +233 -0
  23. package/src/components/agent/SessionWorkspace.tsx +4 -0
  24. package/src/components/agent/StatusBar.tsx +4 -2
  25. package/src/components/agent/SubagentStrip.tsx +134 -0
  26. package/src/components/agent/ToolCallCard.tsx +72 -5
  27. package/src/components/agent/Transcript.tsx +212 -23
  28. package/src/components/agent/tool-result-fetch.tsx +36 -0
  29. package/src/components/agent/tool-result-image.tsx +209 -0
  30. package/src/components/agent/transcript-rows.ts +122 -23
  31. package/src/components/terminal/TerminalTranscript.tsx +154 -2
  32. package/src/components/terminal/affordances.tsx +34 -0
  33. package/src/components/terminal/blocks.ts +260 -0
  34. package/src/components/terminal/height.ts +73 -6
  35. package/src/components/terminal/image-box.ts +53 -0
  36. package/src/components/terminal/items.tsx +116 -79
  37. package/src/components/terminal/result-preview.ts +20 -6
  38. package/src/components/terminal/scrubber.tsx +172 -26
  39. package/src/components/terminal/tool-run.ts +177 -0
  40. package/src/index.ts +20 -1
  41. package/src/lib/status.ts +16 -3
  42. package/src/styles/terminal.css +85 -5
  43. package/src/styles/theme.css +42 -0
  44. package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
  45. package/build/format-DqR56Y8l.mjs.map +0 -1
  46. package/build/status-Ydzi7n6j.mjs +0 -143
  47. package/build/status-Ydzi7n6j.mjs.map +0 -1
@@ -17,6 +17,7 @@
17
17
  * `result-preview.ts` exists. Two spellings would be two different heights.
18
18
  */
19
19
  import type { TranscriptItem } from '@workerdeck/react'
20
+ import { toolInputPreview } from '../../lib/format.ts'
20
21
  import { isShellTool } from '../../lib/tool-icon.ts'
21
22
 
22
23
  type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
@@ -89,3 +90,179 @@ export function runSummary(items: readonly ToolCallItem[], busy: boolean): strin
89
90
  // read, 1 shell" reads as though the sentence ended and then carried on.
90
91
  return `${verb}${n} tool${n === 1 ? '' : 's'} · ${breakdown}${tail}`
91
92
  }
93
+
94
+ /* ── The task block's one line ─────────────────────────────────────────────
95
+ *
96
+ * A `Task` call and everything its subagent produced collapse to one row (see
97
+ * `blocks.ts`), and these are that row's words. Same contract as `runSummary`:
98
+ * `height.ts` wraps these exact strings to predict the row's pixel height with
99
+ * no DOM, so the component must render them verbatim — two spellings would be
100
+ * two different heights.
101
+ */
102
+
103
+ const clip = (text: string, max = 80): string =>
104
+ text.length > max ? text.slice(0, max - 1) + '…' : text
105
+
106
+ const trimmed = (value: unknown): string | undefined =>
107
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
108
+
109
+ /**
110
+ * The row's identity half: which task this is.
111
+ *
112
+ * The Claude SDK's `Task` input carries `subagent_type` (e.g. "Explore") and a
113
+ * 3–5 word `description`, and both are worth the line: parallel tasks are the
114
+ * whole reason the block exists, and two rows both reading `Task(…)` answer
115
+ * nothing. `Task(Explore · find the auth check)` — falling back to the
116
+ * ordinary input preview when an engine sends neither, so the header is never
117
+ * emptier than a plain tool row's.
118
+ */
119
+ export function taskLabel(task: ToolCallItem): string {
120
+ return `${task.name}(${taskIdentity(task)})`
121
+ }
122
+
123
+ /**
124
+ * The inner half of {@link taskLabel} — `Explore · find the auth check` — without
125
+ * the `Task(…)` wrapper.
126
+ *
127
+ * Split out for the sub-agent takeover's header, which names the agent it is
128
+ * showing and has no room (or reason) to repeat the tool's own name: the whole
129
+ * surface *is* that Task. Extracted rather than re-spelled so the header and the
130
+ * row it was opened from cannot drift, and so both keep matching protocol's
131
+ * `subagentLabel`, which reads the same two input fields for the sessions list.
132
+ */
133
+ export function taskIdentity(task: ToolCallItem): string {
134
+ const input = task.input as { description?: unknown; subagent_type?: unknown } | null
135
+ const description = trimmed(input?.description)
136
+ const agent = trimmed(input?.subagent_type)
137
+ return agent && description
138
+ ? `${agent} · ${clip(description)}`
139
+ : (agent ?? (description ? clip(description) : toolInputPreview(task.input)))
140
+ }
141
+
142
+ /**
143
+ * **What this agent was actually asked** — the sub-agent's brief, or undefined
144
+ * when the engine did not give us one.
145
+ *
146
+ * Measured against a live session rather than assumed: the Agent SDK's call
147
+ * carries `{description, subagent_type, run_in_background, prompt}`, and
148
+ * `prompt` is the instruction.
149
+ *
150
+ * **Whether it also arrives in the stream depends on the agent.** A foreground
151
+ * `Task` forwards its brief as a real nested `user_message` (that is what
152
+ * `forwardSubagentText` buys and what the reducer stamps a `parentToolUseId`
153
+ * on), so `subagentItems` picks it up and the frame already has it. A
154
+ * **background** agent does not: measured on a session with eight of them,
155
+ * zero items carried a `parentToolUseId` on a `user` kind. Those runs are
156
+ * exactly the ones you open a takeover on — an agent working while you read
157
+ * something else — and their brief was nowhere at all.
158
+ *
159
+ * So this is the *fallback*, not the source: the callers splice it in only when
160
+ * the frame carries no brief of its own, or the same instruction would be drawn
161
+ * twice.
162
+ *
163
+ * `description` is deliberately not a fallback: it is the 3–5 word label the
164
+ * header already prints, and repeating it as a brief would claim we know the
165
+ * instruction when we do not. **Codex genuinely has none** — its `spawn_agent`
166
+ * message is an encrypted blob on the wire — so on that engine this is
167
+ * undefined and the row is not drawn, rather than drawn empty.
168
+ */
169
+ export function taskBrief(task: ToolCallItem): string | undefined {
170
+ const input = task.input as { prompt?: unknown } | null
171
+ return trimmed(input?.prompt)
172
+ }
173
+
174
+ const callBusy = (call: ToolCallItem): boolean =>
175
+ call.status === 'running' || call.status === 'pending'
176
+
177
+ /** Did this one call fail? Both spellings are needed: an out-of-loop execution
178
+ * failure sets `status` with no `is_error` block to read, and an engine can flag
179
+ * `is_error` on a call the reducer has not settled yet. */
180
+ export const callFailed = (call: ToolCallItem): boolean =>
181
+ call.status === 'failed' || call.result?.isError === true
182
+
183
+ /**
184
+ * Does a folded run colour red? **Only when its last call failed.**
185
+ *
186
+ * It used to be `some`, on the argument that a failure should colour the block
187
+ * rather than fragment it. The argument was right about not fragmenting and
188
+ * wrong about `some`: a run is a sequence the model worked through, and a
189
+ * failure it recovered from two calls later is how work goes — a grep that
190
+ * matched nothing, a build fixed on the second go. Reddening the whole run for
191
+ * it means a normal working session is painted red, which spends the colour
192
+ * that should have been left for the one thing still broken.
193
+ *
194
+ * The last call is the run's *outcome*, and an outcome is what a collapsed row
195
+ * can honestly claim. The failures inside it are not hidden — they are one
196
+ * press away, each red on its own row, and the recap counts every one. The
197
+ * **scrubber agrees with this rule** rather than overriding it: it marks a
198
+ * failed call only when the call is its row's outcome, which for a run is
199
+ * exactly this one. It used to mark every member on the argument that the
200
+ * rail asks a different question; against a real session that was nine alarms
201
+ * on the rail for a transcript reddening one row.
202
+ */
203
+ export function runFailed(items: readonly ToolCallItem[]): boolean {
204
+ const last = items[items.length - 1]
205
+ return last !== undefined && callFailed(last)
206
+ }
207
+
208
+ /** Is anything inside still going? The call itself, normally — the Task
209
+ * settles only when its subagent finishes — but a bridged or deferred child
210
+ * can outlive it, and a pulse that stopped while a child still worked would
211
+ * read as a hang. */
212
+ export function taskBusy(task: ToolCallItem, children: readonly TranscriptItem[]): boolean {
213
+ return callBusy(task) || children.some((child) => child.kind === 'tool_call' && callBusy(child))
214
+ }
215
+
216
+ /**
217
+ * Does the row colour red? **The task's own outcome, and nothing else.**
218
+ *
219
+ * It used to be "or any child call's", which does not survive contact with a
220
+ * real subagent: an agent that ran a hundred calls, one of them a grep that
221
+ * matched nothing, came back with a red line saying it had failed. It had not —
222
+ * it had done exactly what it was asked, and the transcript said otherwise in
223
+ * the one colour reserved for things that need a human.
224
+ *
225
+ * This is the call `SubagentInfo.status` already makes, and it made it for this
226
+ * reason (see `packages/protocol`): the sub-agent's **own** `tool_result`
227
+ * `is_error`, deliberately not `taskFailed`. The argument there was that a
228
+ * nothing-matched grep must not read as a failed run *beside a session name*;
229
+ * what a hundred-call agent shows is that it must not read that way beside the
230
+ * `Task` row either. Two surfaces, one rule, one spelling.
231
+ *
232
+ * Nothing is concealed by this. A failed child is red on its own row, one press
233
+ * away, and the recap counts it. The **scrubber follows this rule too** and no
234
+ * longer marks such a child: a red tick on the rail says precisely what this
235
+ * row is forbidden from saying. The sub-agent band still says an agent ran
236
+ * here, and the task's own red tick still says it came back broken — which is
237
+ * what the two channels are for.
238
+ */
239
+ export function taskFailed(task: ToolCallItem): boolean {
240
+ return callFailed(task)
241
+ }
242
+
243
+ /**
244
+ * The collapsed task row's one line: identity, then scale.
245
+ *
246
+ * `Task(Explore · find the auth check) · 7 tools…` while the subagent works —
247
+ * the count grows as it does, which is the row's progress reading, and the
248
+ * trailing ellipsis is the same in-flight signal `runSummary` uses (the pulse
249
+ * in the gutter carries the beat). Settled, the ellipsis drops:
250
+ * `… · 7 tools`. "Tools" and not "tool calls" because `runSummary` already
251
+ * chose that word for the same count one row over.
252
+ *
253
+ * The counts are counted from the absorbed children, never read from the
254
+ * engine's structured Task output — WorkerDeck does not plumb structured tool
255
+ * results to clients, so a transcript replayed tomorrow must spell the same
256
+ * line from the same items it holds today.
257
+ *
258
+ * With no tool calls yet — the subagent thinking, or only its brief arrived —
259
+ * the line says `working…`, because `0 tools…` reads as a stall; settled with
260
+ * none it says `done`.
261
+ */
262
+ export function taskSummary(task: ToolCallItem, children: readonly TranscriptItem[]): string {
263
+ const busy = taskBusy(task, children)
264
+ const calls = children.reduce((n, child) => n + (child.kind === 'tool_call' ? 1 : 0), 0)
265
+ const label = taskLabel(task)
266
+ if (calls === 0) return busy ? `${label} · working…` : `${label} · done`
267
+ return `${label} · ${calls} tool${calls === 1 ? '' : 's'}${busy ? '…' : ''}`
268
+ }
package/src/index.ts CHANGED
@@ -180,13 +180,32 @@ export {
180
180
  export { SessionStatusIcon } from './components/agent/SessionBrowser.tsx'
181
181
  // Lifted out of the VS Code sidebar once the dashboard grew a collapsed rail
182
182
  // that needs the same glyph — two copies of a trademark set is one too many.
183
- export { EngineIcon } from './components/agent/EngineIcon.tsx'
183
+ export {
184
+ EngineIcon,
185
+ engineMark,
186
+ vendorMarkClass,
187
+ vendorTextClass,
188
+ } from './components/agent/EngineIcon.tsx'
189
+ // Lifted out of the sidebar for the same reason: the work under a row is a
190
+ // protocol fact, so every list annotates it with one component.
191
+ export {
192
+ type Step,
193
+ StepRow,
194
+ StepToggle,
195
+ runningSteps,
196
+ sessionSteps,
197
+ } from './components/agent/SessionSteps.tsx'
198
+ // The sub-agent takeover's one line. Exported for hosts that draw their own
199
+ // panel chrome; the panel raises it itself.
200
+ export { SubagentStrip } from './components/agent/SubagentStrip.tsx'
201
+ export { ProjectIcon } from './components/agent/ProjectIcon.tsx'
184
202
  export {
185
203
  SessionEmptyState,
186
204
  type SessionEmptyStateProps,
187
205
  } from './components/agent/SessionEmptyState.tsx'
188
206
  export { PromptTokenText } from './components/agent/PromptTokenText.tsx'
189
207
  export { STATUS_META } from './components/agent/status.ts'
208
+ export { ContextRing } from './components/agent/ContextRing.tsx'
190
209
 
191
210
  // Utilities
192
211
  export { cn } from './lib/utils.ts'
package/src/lib/status.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ContextUsage, ModelOption, RateLimitInfo, SessionStatus } from '@workerdeck/protocol'
1
+ import type { ModelOption, RateLimitInfo, SessionStatus } from '@workerdeck/protocol'
2
2
 
3
3
  /**
4
4
  * How a session's live readings become a status line — the pure half, so every
@@ -174,7 +174,20 @@ export function modelLabel(vitals: ModelReadings | undefined): string {
174
174
  }
175
175
 
176
176
  /** Context percentage as its meter severity — the reading and the colour come
177
- * from one place so a panel and a status bar never disagree. */
178
- export function contextSeverity(usage: ContextUsage | undefined): StatusSeverity {
177
+ * from one place so a panel and a status bar never disagree.
178
+ *
179
+ * Takes only the number it reads, so the compact `ContextReading` that rides
180
+ * the sessions list and the full `ContextUsage` on the event stream are coloured
181
+ * by one rule rather than two. */
182
+ export function contextSeverity(usage: { percentage: number } | undefined): StatusSeverity {
179
183
  return meterSeverity(usage?.percentage)
180
184
  }
185
+
186
+ /** {@link meterSeverity} as a text colour class. Here rather than beside each
187
+ * meter because the status bar, the context dialog and a sessions-list row all
188
+ * paint the same reading, and a fourth copy of the thresholds is a fourth
189
+ * chance for one surface to call 81% orange and another call it grey. */
190
+ export function meterColorClass(pct: number | undefined): string {
191
+ const severity = meterSeverity(pct)
192
+ return severity === 'error' ? 'text-danger' : severity === 'warning' ? 'text-warning' : 'text-fg-3'
193
+ }
@@ -99,6 +99,13 @@
99
99
  /* The user's own prompt row. */
100
100
  --term-user-bg: rgb(255 255 255 / 0.05);
101
101
  --term-row-hover: rgb(255 255 255 / 0.05);
102
+ /* Behind an OPEN block. Yellow, not neutral, and the theme's one deliberate
103
+ reuse of that tone for something other than "waiting on you": an open block
104
+ is a state the reader put the transcript into, and on the phone it wants
105
+ the same colour as the rail mark that says so. Kept very low — this washes
106
+ whole regions, and at band strength an opened run would shout louder than
107
+ anything inside it. */
108
+ --term-open-wash: rgb(215 186 125 / 0.1);
102
109
  }
103
110
 
104
111
  [data-theme='light'] [data-terminal],
@@ -125,6 +132,7 @@
125
132
  --term-band-bg: rgb(0 0 0 / 0.04);
126
133
  --term-user-bg: rgb(0 0 0 / 0.05);
127
134
  --term-row-hover: rgb(0 0 0 / 0.04);
135
+ --term-open-wash: rgb(215 186 125 / 0.25);
128
136
  }
129
137
 
130
138
  /* ── Rows ────────────────────────────────────────────────────────────────────
@@ -284,11 +292,30 @@
284
292
  stack without a step. (The other half is `useRevealOnOpen`, which brings that
285
293
  first line back when it has gone above the fold.) */
286
294
  [data-terminal] .term-open {
287
- background: var(--term-row-hover);
295
+ background: var(--term-open-wash);
288
296
  margin-inline: calc(-1 * var(--term-bleed, 0px));
289
297
  padding-inline: var(--term-bleed, 0px);
290
298
  }
291
299
 
300
+ /* Another frame of reference: the rows a subagent produced, inside the `Task`
301
+ that spawned them.
302
+
303
+ Two cells of indent, whole — the theme's rule is that horizontal measures are
304
+ `ch`, and these rows carry markers in a gutter that has to keep landing on the
305
+ grid the rows above it use. The rule itself is drawn *inside* that padding
306
+ with an inset shadow rather than as a border, which is what makes the 2ch
307
+ exact: a 1px border is layout, and would put every nested glyph one pixel off
308
+ the column for the sake of chrome.
309
+
310
+ `--term-faint` rather than the cards `--border` token, and that is the whole
311
+ reason this class exists: the nested block sits on the *open* block's wash,
312
+ and a border colour tuned for the base ground resolved to rgb(31,31,31) on it
313
+ — a rule nobody could see. The same trap `row-hover` is alpha to avoid. */
314
+ [data-terminal] .term-nested {
315
+ padding-left: 2ch;
316
+ box-shadow: inset 1px 0 0 var(--term-faint);
317
+ }
318
+
292
319
  .term-hoverable {
293
320
  position: relative;
294
321
  }
@@ -920,8 +947,8 @@
920
947
  as long without a tall solid bar shouting over the rail. A minimum-height
921
948
  mark shows only the solid segment, so short and long marks need no separate
922
949
  rules; the *lane* stays the pointer's target, so a 2px mark is still
923
- findable. Full-width annotations (error, approval) stay solid: they are
924
- alarms, not extents. */
950
+ findable. The alarms (error, approval) stay solid: they are alarms, not
951
+ extents. */
925
952
  .term-scrubber .term-scrub-mark[data-kind='user'] {
926
953
  background: linear-gradient(
927
954
  to bottom,
@@ -929,6 +956,19 @@
929
956
  color-mix(in srgb, var(--term-blue) 25%, transparent) 2px
930
957
  );
931
958
  }
959
+ /* A sub-agent's stretch of the transcript, in the input lane: green, because
960
+ every other colour on this rail is already spoken for and none of them means
961
+ "somebody else's working" — blue is you, white is the answer, red is an
962
+ alarm, magenta is your bookmark, yellow is the session waiting on you. Drawn
963
+ as an extent (2px head, 25% tail) like the two marks it shares its geometry
964
+ with: collapsed it is a tick, expanded it is the band the sub-agent covers. */
965
+ .term-scrubber .term-scrub-mark[data-kind='subagent'] {
966
+ background: linear-gradient(
967
+ to bottom,
968
+ var(--term-green) 0 2px,
969
+ color-mix(in srgb, var(--term-green) 25%, transparent) 2px
970
+ );
971
+ }
932
972
  .term-scrubber .term-scrub-mark[data-kind='turn'] {
933
973
  background: linear-gradient(
934
974
  to bottom,
@@ -944,8 +984,8 @@
944
984
  );
945
985
  }
946
986
  .term-scrubber .term-scrub-mark[data-kind='error'] { background: var(--term-red); }
947
- /* A failed tool call is an alarm, so it is full-width and solid like the rest of
948
- them — but at 55%, which is the one thing keeping the rail readable. A session
987
+ /* A failed tool call is an alarm, so it is solid like the rest of them — in the
988
+ response lane, since it is something the run produced — but at 55%, which is the one thing keeping the rail readable. A session
949
989
  error is rare and a turn failure rarer; a tool that failed and was recovered
950
990
  from is routine (a grep that matched nothing, a build fixed on the second go),
951
991
  and at full strength a normal working session paints the rail solid red and
@@ -992,6 +1032,16 @@
992
1032
  pointer-events: none;
993
1033
  z-index: 11;
994
1034
  }
1035
+ /* The sub-agent's brief, collapsed: clipped on the same wrapped lines
1036
+ `briefPx` counts, so the height the virtualizer reserved and the height the
1037
+ row draws cannot disagree. The count itself comes from the component
1038
+ (`BRIEF_LINES`), one number rather than one here and one there. */
1039
+ .term-brief-clip {
1040
+ display: -webkit-box;
1041
+ -webkit-box-orient: vertical;
1042
+ overflow: hidden;
1043
+ }
1044
+
995
1045
  .term-scrubber .term-scrub-ex {
996
1046
  display: -webkit-box;
997
1047
  -webkit-line-clamp: 4;
@@ -1009,3 +1059,33 @@
1009
1059
  [data-term-scrubber-host]::-webkit-scrollbar {
1010
1060
  display: none;
1011
1061
  }
1062
+
1063
+ /* ── Images ──────────────────────────────────────────────────────────────────
1064
+ *
1065
+ * A picture a tool returned, drawn in a box of whole lines (`IMAGE_BOX_LINES`,
1066
+ * set inline by `TerminalImage` so the constant has one spelling across the
1067
+ * renderer and the height calculator). The height is on the element and nothing
1068
+ * here may add to it: no margin, no border, no padding — a box that is not
1069
+ * exactly N lines is a row the virtualizer was told the wrong size for.
1070
+ *
1071
+ * `object-fit: contain` with a top-left origin rather than a centred one: the
1072
+ * grid's origin is the top-left of the body cell, and a screenshot floating in
1073
+ * the middle of its box reads as a figure in a document instead of output on a
1074
+ * line. */
1075
+ .term-image {
1076
+ display: block;
1077
+ overflow: hidden;
1078
+ }
1079
+ .term-image > img {
1080
+ max-width: 100%;
1081
+ height: 100%;
1082
+ object-fit: contain;
1083
+ object-position: left top;
1084
+ }
1085
+ /* The wash is the placeholder's whole visual: it says "something is reserved
1086
+ here" for as long as the bytes are in flight, and it says it at the same
1087
+ height the picture will occupy. */
1088
+ .term-image[data-state='pending'],
1089
+ .term-image[data-state='failed'] {
1090
+ background: var(--term-band-bg);
1091
+ }
@@ -51,6 +51,37 @@
51
51
  --fg-4: #a1a1a1;
52
52
  --fg-disabled: #c9c9c9;
53
53
 
54
+ /* ---------- Vendor ----------
55
+ * The engine's own colour, worn by its mark and (where the brand allows) by
56
+ * the model name beside it in a sessions list. Nowhere else.
57
+ *
58
+ * This is the one place a brand colour is allowed in the product, and it earns
59
+ * it by doing a job nothing else on the row does: the list already spends blue
60
+ * on running, amber on waiting, red on failed and muted on idle, so none of
61
+ * those can also say *whose engine this is* — and the mark alone is a 12px
62
+ * silhouette.
63
+ *
64
+ * Two values per vendor, because that glyph has to hold against both grounds:
65
+ * the dark-ground value is lifted and the light-ground one darkened.
66
+ *
67
+ * **OpenAI's is monochrome, and that is their rule rather than our taste**:
68
+ * their guidelines forbid adding colour to the mark, so it is pure white on
69
+ * dark and a near-black on light — both sanctioned, and the only kind of pair
70
+ * legible on both grounds. A green was tried first and is wrong, however well
71
+ * it reads. Near-black rather than `#000` because pure black would sit *harder*
72
+ * than the session title above it, so the mark would out-weigh the name it is
73
+ * labelling.
74
+ *
75
+ * That makes the two vendors asymmetric, which is the honest outcome: coral is
76
+ * Anthropic's brand and monochrome is OpenAI's, so a rule that gave both an
77
+ * accent would misrepresent one of them. It also means OpenAI's token is at
78
+ * full contrast, which is why only the **mark** wears it — see
79
+ * `vendorTextClass` in `EngineIcon.tsx`.
80
+ *
81
+ * A third vendor is two declarations here and one map entry there. */
82
+ --vendor-claude: #b3573a;
83
+ --vendor-openai: #373737;
84
+
54
85
  /* ---------- Accent ----------
55
86
  * VS Code blue (`#0078d4`) — the product's primary. It is the *same* hue in
56
87
  * both themes rather than the inverse of the canvas: a primary that flips
@@ -122,6 +153,10 @@
122
153
  --fg-4: #6e6e6e;
123
154
  --fg-disabled: #454545;
124
155
 
156
+ /* ---------- Vendor ---------- (see the light block for the whole argument) */
157
+ --vendor-claude: #cc7c5e;
158
+ --vendor-openai: #ffffff;
159
+
125
160
  /* ---------- Accent ----------
126
161
  * The same blue as light. `--accent` stays `#0078d4` because it is a *fill*
127
162
  * carrying white text (4.6:1) — the brighter `#3794ff` would drop that to
@@ -275,6 +310,13 @@
275
310
  --color-info: var(--info);
276
311
  --color-info-bg: var(--info-bg);
277
312
 
313
+ /* Vendor colours as real utilities (`text-vendor-claude`), not hand-written
314
+ classes: `cn`'s tailwind-merge only knows to *replace* a colour it can parse,
315
+ and `EngineIcon` ships its own `text-fg-3` that the vendor class has to win
316
+ against rather than merely follow in source order. */
317
+ --color-vendor-claude: var(--vendor-claude);
318
+ --color-vendor-openai: var(--vendor-openai);
319
+
278
320
  /* Stock-name bridge → utilities (bg-primary / text-muted-foreground / ...). */
279
321
  --color-primary: var(--primary);
280
322
  --color-primary-foreground: var(--primary-foreground);