@workerdeck/ui 2.10.0 → 2.11.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.
@@ -1,4 +1,5 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+ import type { MessageOrigin } from '@workerdeck/protocol'
2
3
  import type { TranscriptItem } from '@workerdeck/react'
3
4
  import { compactionText, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
4
5
  import { isMutatingTool } from '../../lib/tool-icon.ts'
@@ -7,13 +8,14 @@ import { PromptTokenText } from '../agent/PromptTokenText.tsx'
7
8
  import { BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
8
9
  import { TerminalDiff } from './diff.tsx'
9
10
  import { TerminalMarkdown } from './markdown.tsx'
11
+ import { usePeerNames } from './peer-names.tsx'
10
12
  import { Pressable, useRevealOnOpen } from './press.tsx'
11
13
  import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, imagePlaceholder } from './image-box.ts'
12
14
  import { collapsedResult } from './result-preview.ts'
13
15
  import { useToolResultFetcher } from '../agent/tool-result-fetch.tsx'
14
16
  import { useToolTitle } from '../agent/tool-titles.tsx'
15
17
  import { useToolResultImageSrc } from '../agent/tool-result-image.tsx'
16
- import { runFailed, runSummary } from './tool-run.ts'
18
+ import { isPeerSend, peerName, peerOneLine, peerSendTarget, peerSendText, planRun, runFailed, runSummary } from './tool-run.ts'
17
19
  import { todoLine, todoPreview, type TodoPreview, type TodoStatus } from './todos.ts'
18
20
  import { type ToolCallItem } from './blocks.ts'
19
21
  import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
@@ -35,21 +37,97 @@ function clipToChars(lines: string[], maxChars: number): string[] {
35
37
  return out
36
38
  }
37
39
 
40
+ export function peerLabel(origin: MessageOrigin): string {
41
+ const who = origin.name ? `${origin.name} (${origin.sessionId.slice(0, 8)})` : origin.sessionId.slice(0, 8)
42
+ return `message from ${origin.engine ? `${origin.engine} session ` : 'session '}${who}`
43
+ }
44
+
45
+ export const PEER_IN_GLYPH = '↦'
46
+ export const PEER_OUT_GLYPH = '↤'
47
+
48
+ // The row peer traffic draws on, both directions. Never folded into a tool run and never summarised
49
+ // away: one agent talking to another is the transcript's headline, not its evidence.
50
+ function PeerRow({ glyph, who, body, tone = 'peer', clip }: { glyph: string; who: string; body: ReactNode; tone?: Tone; clip?: boolean }) {
51
+ const line = (
52
+ <>
53
+ <Ink tone={tone} bold>
54
+ {who}
55
+ </Ink>
56
+ {': '}
57
+ {body}
58
+ </>
59
+ )
60
+ return (
61
+ <Row glyph={glyph} glyphTone={tone} tone={tone}>
62
+ {clip ? <span className="term-clip-1">{line}</span> : line}
63
+ </Row>
64
+ )
65
+ }
66
+
67
+ export function PeerSendRow({ item }: { item: ToolCallItem }) {
68
+ const [open, setOpen] = useState(false)
69
+ const names = usePeerNames()
70
+ const reveal = useRevealOnOpen(open)
71
+ const lines = peerSendText(item).split('\n')
72
+ const failed = item.status === 'failed' || item.result?.isError === true
73
+ const tone: Tone = failed ? 'red' : 'peer'
74
+
75
+ return (
76
+ <WithActions actions={<BookmarkAction id={item.id} />}>
77
+ <div ref={reveal} className={open ? 'term-open' : undefined}>
78
+ <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
79
+ <PeerRow
80
+ glyph={PEER_OUT_GLYPH}
81
+ who={peerSendTarget(item, names)}
82
+ tone={tone}
83
+ clip={!open}
84
+ body={open ? lines[0] || ' ' : peerOneLine(lines.join(' '))}
85
+ />
86
+ </Pressable>
87
+ {open ? (
88
+ <div>
89
+ {lines.slice(1).map((line, index) => (
90
+ <Row key={index} tone={tone}>
91
+ {line || ' '}
92
+ </Row>
93
+ ))}
94
+ {item.result ? <Row tone="faint">{peerOneLine(item.result.text)}</Row> : null}
95
+ </div>
96
+ ) : null}
97
+ </div>
98
+ </WithActions>
99
+ )
100
+ }
101
+
38
102
  export function UserRow({ item }: { item: Extract<TranscriptItem, { kind: 'user' }> }) {
39
103
  return (
40
104
  <WithActions actions={<BookmarkAction id={item.id} />}>
41
- <div className="term-user">
105
+ <div className="term-user" data-peer={item.origin ? '' : undefined}>
42
106
  {item.attachments?.length ? (
43
107
  <Row glyph={PROMPT_GLYPH} glyphTone="dim" tone="dim">
44
108
  {item.attachments.map((attachment) => attachment.name).join(', ')}
45
109
  </Row>
46
110
  ) : null}
47
111
  {item.text
48
- ? item.text.split('\n').map((line, index) => (
49
- <Row key={index} glyph={index === 0 ? PROMPT_GLYPH : undefined} glyphTone="dim" tone="fg">
50
- {line ? <PromptTokenText text={line} /> : ' '}
51
- </Row>
52
- ))
112
+ ? item.text.split('\n').map((line, index) =>
113
+ item.origin && index === 0 ? (
114
+ <PeerRow
115
+ key={index}
116
+ glyph={PEER_IN_GLYPH}
117
+ who={peerName(item.origin)}
118
+ body={line ? <PromptTokenText text={line} /> : ' '}
119
+ />
120
+ ) : (
121
+ <Row
122
+ key={index}
123
+ glyph={!item.origin && index === 0 ? PROMPT_GLYPH : undefined}
124
+ glyphTone="dim"
125
+ tone={item.origin ? 'peer' : 'fg'}
126
+ >
127
+ {line ? <PromptTokenText text={line} /> : ' '}
128
+ </Row>
129
+ ),
130
+ )
53
131
  : null}
54
132
  </div>
55
133
  </WithActions>
@@ -128,7 +206,7 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
128
206
  >
129
207
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
130
208
  <Row glyph={busy ? pulse : '●'} glyphTone={tone} tone="fg" title={title ? item.name : undefined}>
131
- <Ink bold tone="bright">
209
+ <Ink bold tone="bright" className={busy ? 'term-shimmer' : undefined}>
132
210
  {title ?? item.name}
133
211
  </Ink>
134
212
  <Ink tone="dim">({todos ? todos.summary : toolInputPreview(item.input)})</Ink>
@@ -253,7 +331,7 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
253
331
  <div ref={reveal} className={open ? 'term-open' : undefined}>
254
332
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
255
333
  <Row glyph={busy ? pulse : undefined} glyphTone={busy ? 'mark' : undefined} tone={failed ? 'red' : 'dim'}>
256
- {runSummary(items, busy)}
334
+ {busy ? <span className="term-shimmer">{runSummary(items, busy)}</span> : runSummary(items, busy)}
257
335
  </Row>
258
336
  </Pressable>
259
337
  {open ? (
@@ -267,6 +345,14 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
267
345
  )
268
346
  }
269
347
 
348
+ export function RunRow({ items }: { items: ToolCallItem[] }) {
349
+ const plan = planRun(items)
350
+ if (plan.kind === 'summary') {
351
+ return <ToolRunRow items={plan.items} />
352
+ }
353
+ return isPeerSend(plan.item) ? <PeerSendRow item={plan.item} /> : <ToolRow item={plan.item} />
354
+ }
355
+
270
356
  export function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
271
357
  return (
272
358
  <div>
@@ -335,7 +421,7 @@ export function WorkingRow({ label, startedAt, tokens }: { label: string; starte
335
421
  const readings = [elapsed, tokens ? `↓ ${(tokens / 1000).toFixed(1)}k tokens` : undefined].filter(Boolean)
336
422
  return (
337
423
  <Row glyph={pulse} glyphTone="mark" tone="mark">
338
- {label}
424
+ <span className="term-shimmer">{label}</span>
339
425
  {readings.length ? <Ink tone="faint"> ({readings.join(' · ')})</Ink> : null}
340
426
  </Row>
341
427
  )
@@ -0,0 +1,16 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react'
2
+ import type { TranscriptItem } from '@workerdeck/react'
3
+ import { peerNamesOf } from './tool-run.ts'
4
+
5
+ const EMPTY: ReadonlyMap<string, string> = new Map()
6
+
7
+ const PeerNamesContext = createContext<ReadonlyMap<string, string>>(EMPTY)
8
+
9
+ export function PeerNamesProvider({ items, children }: { items: readonly TranscriptItem[]; children: ReactNode }) {
10
+ const names = useMemo(() => peerNamesOf(items), [items])
11
+ return <PeerNamesContext.Provider value={names}>{children}</PeerNamesContext.Provider>
12
+ }
13
+
14
+ export function usePeerNames(): ReadonlyMap<string, string> {
15
+ return useContext(PeerNamesContext)
16
+ }
@@ -1,7 +1,7 @@
1
1
  import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
2
2
  import { cn } from '../../lib/utils.ts'
3
3
 
4
- export type Tone = 'fg' | 'bright' | 'dim' | 'faint' | 'mark' | 'blue' | 'green' | 'red' | 'yellow' | 'magenta'
4
+ export type Tone = 'fg' | 'bright' | 'dim' | 'faint' | 'mark' | 'blue' | 'green' | 'red' | 'yellow' | 'magenta' | 'peer'
5
5
 
6
6
  export interface RowProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
7
7
  glyph?: ReactNode
@@ -1,10 +1,69 @@
1
+ import { isPeerSendTool, peerDeliveredTo, type MessageOrigin } from '@workerdeck/protocol'
1
2
  import type { TranscriptItem } from '@workerdeck/react'
2
3
  import { toolInputPreview } from '../../lib/format.ts'
3
4
  import { isShellTool } from '../../lib/tool-icon.ts'
4
5
 
5
6
  type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
6
7
 
8
+ // A message to another session draws as itself, always. Folded into a run it becomes one tick of
9
+ // `Ran 4 tools`, which is exactly the collapse that hid the conversation the reader came for.
10
+ export function isPeerSend(item: TranscriptItem): boolean {
11
+ return item.kind === 'tool_call' && isPeerSendTool(item.name)
12
+ }
13
+
14
+ export function peerName(origin: MessageOrigin): string {
15
+ return origin.name ?? origin.sessionId.slice(0, 8)
16
+ }
17
+
18
+ // Every peer this transcript has named, by session id. A conversation names its peers twice over: an
19
+ // arriving message carries the sender's name, a delivery receipt carries the recipient's. Either one
20
+ // answers for the other direction, so a send that was only ever acknowledged by id still draws as a
21
+ // name once the peer speaks, and a transcript recorded before the receipts carried names heals itself.
22
+ export function peerNamesOf(items: readonly TranscriptItem[]): ReadonlyMap<string, string> {
23
+ const names = new Map<string, string>()
24
+ for (const item of items) {
25
+ if (item.kind === 'user') {
26
+ if (item.origin?.name) {
27
+ names.set(item.origin.sessionId, item.origin.name)
28
+ }
29
+ continue
30
+ }
31
+ if (item.kind === 'tool_call' && isPeerSendTool(item.name) && item.result && !item.result.isError) {
32
+ const delivered = peerDeliveredTo(item.result.text)
33
+ if (delivered?.name) {
34
+ names.set(delivered.sessionId, delivered.name)
35
+ }
36
+ }
37
+ }
38
+ return names
39
+ }
40
+
41
+ // Who a `peers_send` went to, read back out of the tool's own reply. Until the call settles there is
42
+ // no reply to read, so the row falls back to what the rest of the transcript knows, then to the id the
43
+ // model addressed.
44
+ export function peerSendTarget(item: ToolCallItem, names?: ReadonlyMap<string, string>): string {
45
+ const delivered = item.result && !item.result.isError ? peerDeliveredTo(item.result.text) : undefined
46
+ const addressed = (item.input as { sessionId?: unknown } | null)?.sessionId
47
+ const sessionId = delivered?.sessionId ?? (typeof addressed === 'string' ? addressed : undefined)
48
+ if (sessionId === undefined) {
49
+ return 'peer'
50
+ }
51
+ return delivered?.name ?? names?.get(sessionId) ?? sessionId.slice(0, 8)
52
+ }
53
+
54
+ export function peerSendText(item: ToolCallItem): string {
55
+ const text = (item.input as { text?: unknown } | null)?.text
56
+ return typeof text === 'string' ? text : ''
57
+ }
58
+
59
+ export function peerOneLine(text: string): string {
60
+ return text.replace(/\s+/g, ' ').trim()
61
+ }
62
+
7
63
  export function foldsTogether(a: ToolCallItem, b: ToolCallItem): boolean {
64
+ if (isPeerSend(a) || isPeerSend(b)) {
65
+ return false
66
+ }
8
67
  return a.parentToolUseId === b.parentToolUseId
9
68
  }
10
69
 
@@ -19,6 +78,15 @@ export function toolFamily(name: string): string {
19
78
  return name.toLowerCase()
20
79
  }
21
80
 
81
+ export type RunPlan = { kind: 'call'; item: ToolCallItem } | { kind: 'summary'; items: ToolCallItem[] }
82
+
83
+ // Matches iOS's `planRun` (`TerminalPlanner.swift`): a run of exactly one call draws as the call
84
+ // itself, because the summary row would occupy the same one row while throwing away the tool's
85
+ // name, input and result preview.
86
+ export function planRun(items: ToolCallItem[]): RunPlan {
87
+ return items.length === 1 ? { kind: 'call', item: items[0]! } : { kind: 'summary', items }
88
+ }
89
+
22
90
  export function runSummary(items: readonly ToolCallItem[], busy: boolean): string {
23
91
  const verb = busy ? 'Running ' : 'Ran '
24
92
  const tail = busy ? '…' : ''
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  type ByModel,
3
+ type CostBasis,
3
4
  type CostBreakdown,
5
+ type PricingTable,
4
6
  type TokenUsage,
5
7
  UNPRICED_WARN_SHARE,
6
8
  canonicalModel,
@@ -8,6 +10,7 @@ import {
8
10
  costOfByModel,
9
11
  rateFor,
10
12
  totalTokens,
13
+ unknownBasisModels,
11
14
  } from '@workerdeck/protocol'
12
15
 
13
16
  export type SessionCostRow = {
@@ -15,6 +18,7 @@ export type SessionCostRow = {
15
18
  usage: TokenUsage
16
19
  tokens: number
17
20
  costUsd: number | undefined
21
+ costBasis: CostBasis | undefined
18
22
  }
19
23
 
20
24
  export type SessionCost = {
@@ -25,6 +29,8 @@ export type SessionCost = {
25
29
  rows: SessionCostRow[]
26
30
  unpricedShare: number
27
31
  showUnpricedWarning: boolean
32
+ // Models the engine itself could not match to a rate table, so the figure *it* reports for them is a guess.
33
+ unknownBasisModels: string[]
28
34
  // Both figures price the same tokens, one by the engine and one by us, so a gap is the thing worth reading.
29
35
  gapUsd: number | undefined
30
36
  }
@@ -33,13 +39,15 @@ export type SessionCostInput = {
33
39
  costUsd?: number
34
40
  totalCostUsd?: number
35
41
  usageByModel?: ByModel
42
+ pricing?: PricingTable
36
43
  }
37
44
 
38
45
  const GAP_SHARE = 0.02
39
46
 
40
47
  export function sessionCost(input: SessionCostInput): SessionCost {
41
48
  const byModel = input.usageByModel ?? {}
42
- const breakdown = costOfByModel(byModel)
49
+ const pricing = input.pricing
50
+ const breakdown = costOfByModel(byModel, pricing)
43
51
  const computedUsd = input.costUsd ?? (breakdown.unpriced || Object.keys(byModel).length === 0 ? undefined : breakdown.total)
44
52
  const reportedUsd = input.totalCostUsd
45
53
  const rows = Object.entries(byModel)
@@ -47,7 +55,8 @@ export function sessionCost(input: SessionCostInput): SessionCost {
47
55
  model: canonicalModel(model),
48
56
  usage,
49
57
  tokens: totalTokens(usage),
50
- costUsd: rateFor(model) ? costOf(usage, model).total : undefined,
58
+ costUsd: rateFor(model, pricing) ? costOf(usage, model, pricing).total : undefined,
59
+ costBasis: usage.costBasis,
51
60
  }))
52
61
  .sort((a, b) => b.tokens - a.tokens)
53
62
  const gap = computedUsd !== undefined && reportedUsd !== undefined ? reportedUsd - computedUsd : undefined
@@ -60,6 +69,7 @@ export function sessionCost(input: SessionCostInput): SessionCost {
60
69
  rows,
61
70
  unpricedShare: breakdown.unpricedShare,
62
71
  showUnpricedWarning: breakdown.unpricedShare > UNPRICED_WARN_SHARE,
72
+ unknownBasisModels: unknownBasisModels(byModel),
63
73
  gapUsd: worthShowing ? gap : undefined,
64
74
  }
65
75
  }
@@ -52,7 +52,11 @@ export function toolIcon(toolName: string): LucideIcon {
52
52
  case 'Agent': {
53
53
  return UsersRound
54
54
  }
55
- case 'TodoWrite': {
55
+ case 'TodoWrite':
56
+ case 'TaskCreate':
57
+ case 'TaskUpdate':
58
+ case 'TaskGet':
59
+ case 'TaskList': {
56
60
  return CheckSquare
57
61
  }
58
62
  case 'Skill': {
@@ -79,11 +79,24 @@
79
79
  --term-faint: #6a6a6a;
80
80
 
81
81
  --term-mark: #d97757;
82
+ /* The working mark in motion. Five stops travelling violet to cyan and back,
83
+ lifted from box's `paint()` so an in-progress line reads the same here as
84
+ it does in the terminal. Deliberately not the clay accent: the mark states
85
+ whose turn it is, the shimmer states that something is still moving. */
86
+ --term-shimmer-1: #c5a3e8;
87
+ --term-shimmer-2: #a3a4e8;
88
+ --term-shimmer-3: #7b7ef5;
89
+ --term-shimmer-4: #8aaeea;
90
+ --term-shimmer-5: #a8e5ed;
82
91
  --term-blue: #afb9fe;
83
92
  --term-green: #4ec9a0;
84
93
  --term-red: #f14c4c;
85
94
  --term-yellow: #d7ba7d;
86
95
  --term-magenta: #c586c0;
96
+ /* Peer traffic - a message from or to another session. Cyan is the one ANSI slot
97
+ the theme had left, and peer rows must not be confusable with a tool call
98
+ (blue), a success (green) or a wait (yellow). */
99
+ --term-peer: #56d4dd;
87
100
 
88
101
  /* Diff bands. Full-bleed backgrounds, so they are tuned to sit under body
89
102
  text rather than to be looked at: the CLI's added block is a dark green
@@ -118,11 +131,19 @@
118
131
  --term-faint: #8d8d8d;
119
132
 
120
133
  --term-mark: #bf5b3d;
134
+ /* Same five stops walked down for a light ground; the dark set's cyan is
135
+ invisible on white. */
136
+ --term-shimmer-1: #7a4fb0;
137
+ --term-shimmer-2: #5457b8;
138
+ --term-shimmer-3: #4347c9;
139
+ --term-shimmer-4: #3f6fb5;
140
+ --term-shimmer-5: #1f7d8c;
121
141
  --term-blue: #0a66c2;
122
142
  --term-green: #16794a;
123
143
  --term-red: #c62828;
124
144
  --term-yellow: #8a6d00;
125
145
  --term-magenta: #8b3a8b;
146
+ --term-peer: #0e7490;
126
147
 
127
148
  --term-add-fg: #12492a;
128
149
  --term-add-bg: #e2f6e5;
@@ -262,6 +283,63 @@
262
283
  [data-terminal] [data-tone='magenta'] {
263
284
  color: var(--term-magenta);
264
285
  }
286
+ [data-terminal] [data-tone='peer'] {
287
+ color: var(--term-peer);
288
+ }
289
+
290
+ /* Work in progress. One element, never a span per character: the transcript
291
+ measures its own rows and supports text selection, and both break the moment
292
+ a line is shredded into glyphs. The tile is periodic (it ends on the colour
293
+ it starts on) and the animation travels exactly one tile width, so the loop
294
+ has no seam. Scoped under `[data-terminal]` to outrank the tone rules, which
295
+ would otherwise repaint the clipped text opaque. */
296
+ [data-terminal] .term-shimmer {
297
+ background-image: linear-gradient(
298
+ 90deg,
299
+ var(--term-shimmer-1),
300
+ var(--term-shimmer-2),
301
+ var(--term-shimmer-3),
302
+ var(--term-shimmer-4),
303
+ var(--term-shimmer-5),
304
+ var(--term-shimmer-4),
305
+ var(--term-shimmer-3),
306
+ var(--term-shimmer-2),
307
+ var(--term-shimmer-1)
308
+ );
309
+ background-size: 200% 100%;
310
+ -webkit-background-clip: text;
311
+ background-clip: text;
312
+ -webkit-text-fill-color: transparent;
313
+ color: transparent;
314
+ animation: term-shimmer 3s linear infinite;
315
+ }
316
+
317
+ @keyframes term-shimmer {
318
+ from {
319
+ background-position: 0% 0;
320
+ }
321
+ to {
322
+ background-position: 200% 0;
323
+ }
324
+ }
325
+
326
+ /* Clipped text with a transparent fill selects invisibly - the highlight paints
327
+ under glyphs that have no colour of their own. */
328
+ [data-terminal] .term-shimmer::selection {
329
+ color: var(--term-bright);
330
+ -webkit-text-fill-color: var(--term-bright);
331
+ }
332
+
333
+ @media (prefers-reduced-motion: reduce) {
334
+ [data-terminal] .term-shimmer {
335
+ animation: none;
336
+ background-image: none;
337
+ -webkit-background-clip: border-box;
338
+ background-clip: border-box;
339
+ -webkit-text-fill-color: var(--term-shimmer-3);
340
+ color: var(--term-shimmer-3);
341
+ }
342
+ }
265
343
 
266
344
  /* Weight is the only other axis. A terminal has one type size, so emphasis has
267
345
  to be carried by weight and colour - never by a larger glyph. */
@@ -411,6 +489,12 @@
411
489
  cursor: text;
412
490
  }
413
491
 
492
+ /* A peer's message is not what you typed, so it drops the prompt band: the peer
493
+ tone and the arrow carry it, and the band is reserved for the human's turn. */
494
+ [data-terminal] .term-user[data-peer] {
495
+ background: none;
496
+ }
497
+
414
498
  [data-terminal] .term-link {
415
499
  text-decoration: underline;
416
500
  text-underline-offset: 2px;
@@ -1130,6 +1214,15 @@ button.term-link {
1130
1214
  overflow: hidden;
1131
1215
  }
1132
1216
 
1217
+ /* A closed peer send: one row, ellipsised. The row must stay exactly one line
1218
+ tall or the virtualizer's estimate for it is wrong. */
1219
+ .term-clip-1 {
1220
+ display: block;
1221
+ overflow: hidden;
1222
+ white-space: nowrap;
1223
+ text-overflow: ellipsis;
1224
+ }
1225
+
1133
1226
  .term-scrubber .term-scrub-ex {
1134
1227
  display: -webkit-box;
1135
1228
  -webkit-line-clamp: 4;
@@ -271,7 +271,7 @@
271
271
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
272
272
 
273
273
  --cw-font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
274
- --cw-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
274
+ --cw-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
275
275
 
276
276
  --motion-fast: 120ms;
277
277
  --motion-base: 150ms;