clauderipple 0.9.4 → 0.10.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 (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
@@ -32,16 +32,18 @@ import { t } from '../i18n'
32
32
  import type { Lang } from '../i18n'
33
33
  import type { RenderChildren, RenderNode } from 'claude-code'
34
34
 
35
- import type { Effort } from '../../types'
36
- import { AGENTS_W, CELL_PX, DEPTH, ICON_PX, INK, METER_H, METER_W, MUTE, ROW_PX, SUB, TONE, agentsArt, clockArt, gaugeArt, iconArt, meterArt, pillArt, plateArt, ringArt, ruleArt, textPx } from './art'
35
+ import type { Effort, LimitItem } from '../../types'
36
+ import { AGENTS_W, CELL_PX, DEPTH, ICON_PX, METER_H, METER_W, MUTE, ROW_PX, SUB, TONE, barArt, textPx } from './art'
37
+ import { lookOf } from './look'
38
+ import type { Word } from './look'
37
39
  import type { Sea, Tone } from './art'
38
- import { CLOSE_CELLS, capsule, closeButton, lift, lit, pic, pill, press, ripple, rowsOf, segmented, word, wordCells } from './kit'
40
+ import { CLOSE_CELLS, capsule, closeButton, hoverLight, lift, lit, pic, pill, press, ripple, rowsOf, segmented, word, wordCells } from './kit'
39
41
  import { textButton } from './parts'
40
42
  import type { Els } from './parts'
41
43
  import type { Face } from './ripple'
42
44
  import { cacheTone, chOf, clip, contextTone, displayWidth, limitTone, providerColor, textTone } from './theme'
43
- import type { BandActions, DashboardView, Frame } from './view'
44
- import { EFFORTS, compactNumber, effortIndex, effortLabel, effortName, isRich, limitName, minutesLeft, modelName, resetsLabel, seconds } from './view'
45
+ import type { BandActions, DashboardView, Frame, LimitGroup } from './view'
46
+ import { EFFORTS, compactNumber, effortIndex, effortLabel, effortName, isRich, limitGroupName, limitGroups, limitItemName, limitItemTip, limitName, minutesLeft, modelName, narrowLimitGroups, resetsLabel, seconds } from './view'
45
47
 
46
48
  /** Growing ripples: low → max. */
47
49
  const RIPPLE_CHAR = ['·', '∘', '○', '◎', '◉']
@@ -88,7 +90,7 @@ const WHY_CELLS = 28
88
90
  type Run = { text: string; color?: string; dim?: boolean; bold?: boolean }
89
91
 
90
92
  /** One fact of the band: a slot of fixed width holding a glyph, a value and quiet trailing runs. */
91
- type Fact = {
93
+ export type Fact = {
92
94
  key: string
93
95
  /** Cells the slot takes, whatever it shows. */
94
96
  width: number
@@ -130,7 +132,7 @@ function effortWords(lang: Lang, rich: boolean): string[] {
130
132
 
131
133
  // ---- Facts -------------------------------------------------------------------------------------
132
134
 
133
- function effortFact(frame: Frame, view: DashboardView, onPress: () => void): Fact {
135
+ export function effortFact(frame: Frame, view: DashboardView, onPress: () => void): Fact {
134
136
  const rich = isRich(frame)
135
137
  const accent = textTone(frame.appearance).accent
136
138
  const { level, mode, why } = view.effort
@@ -173,7 +175,7 @@ export function modeLabel(name: string): string {
173
175
  * The active operating mode, when one is on: "Mode Burn GPT" in the accent, its slot the same
174
176
  * width whatever the name (cut past MODE_CELLS), the whole name in the hover card.
175
177
  */
176
- function modeFact(frame: Frame, view: DashboardView): Fact | null {
178
+ export function modeFact(frame: Frame, view: DashboardView): Fact | null {
177
179
  if (!view.mode) return null
178
180
  const rich = isRich(frame)
179
181
  const name = t(frame.lang, 'band.mode')
@@ -189,7 +191,7 @@ function pie(frac: number): string {
189
191
  return frac > 0.875 ? '●' : frac > 0.625 ? '◕' : frac > 0.375 ? '◑' : frac > 0.125 ? '◔' : '○'
190
192
  }
191
193
 
192
- function cacheFact(frame: Frame, view: DashboardView): Fact | null {
194
+ export function cacheFact(frame: Frame, view: DashboardView): Fact | null {
193
195
  if (view.hide.includes('timer')) return null
194
196
  const accent = textTone(frame.appearance).accent
195
197
  const cache = view.cache
@@ -215,7 +217,7 @@ function cacheFact(frame: Frame, view: DashboardView): Fact | null {
215
217
  return { key: 'cache', width: Math.ceil(chOf(name) + 1 + Math.max(...words.map(chOf))), label: { text: name, dim: true }, value, card: [tip] }
216
218
  }
217
219
 
218
- function contextFact(frame: Frame, view: DashboardView): Fact | null {
220
+ export function contextFact(frame: Frame, view: DashboardView): Fact | null {
219
221
  const ctx = view.context
220
222
  if (!ctx || !view.extras.includes('context')) return null
221
223
  const tone = contextTone(ctx.percent, view.swampAt)
@@ -228,7 +230,7 @@ function contextFact(frame: Frame, view: DashboardView): Fact | null {
228
230
  return { key: 'context', width, ...(rich ? { label: { text: name, dim: true } } : {}), value, card: [t(frame.lang, 'band.contextAlt', { value: Math.round(ctx.percent) + '%', window: compactNumber(ctx.window), tokens: compactNumber(ctx.tokens) })] }
229
231
  }
230
232
 
231
- function limitFact(frame: Frame, view: DashboardView): Fact | null {
233
+ export function limitFact(frame: Frame, view: DashboardView): Fact | null {
232
234
  const limit = view.limit
233
235
  if (!limit || !view.extras.includes('limits')) return null
234
236
  const tone = limitTone(limit.percent)
@@ -252,7 +254,7 @@ function limitFact(frame: Frame, view: DashboardView): Fact | null {
252
254
  }
253
255
 
254
256
  /** The last route in `room` cells, or the router's notice; null when there is neither or no room. */
255
- function routeFact(frame: Frame, view: DashboardView, room: number, onPress: () => void): Fact | null {
257
+ export function routeFact(frame: Frame, view: DashboardView, room: number, onPress: () => void): Fact | null {
256
258
  const rich = isRich(frame)
257
259
  if (view.hide.includes('route') || room < (rich ? RICH_ROUTE_MIN : ROUTE_MIN)) return null
258
260
  const r = view.route
@@ -352,6 +354,9 @@ function factsFor(frame: Frame, view: DashboardView, actions: BandActions, room:
352
354
  // model the way into the log); the judge's reason in what is left. Then the controls, each lit
353
355
  // under the pointer: Handoff when advised, Auto's switch, keep-warm's flame, the agents, settings,
354
356
  // and the way out. Pointing at a fact shows what it means in the reason's place.
357
+ // With the `limits` extra on and any window known, a second row under the first (starting where
358
+ // the effort does) gauges every plan window — Claude's, then each ChatGPT account's — and the
359
+ // first row's limit fact steps aside for the route.
355
360
 
356
361
  /** 리플이 in the band, px: the mini drawing (ripple.ts), a little taller than a line of text. */
357
362
  export const BAND_RIPPLE = 24
@@ -406,12 +411,14 @@ function drawnFact(el: Els, key: string, cells: number, children: RenderNode[],
406
411
  }
407
412
 
408
413
  /** A figure's colour by its tone: bright ink while all is well, amber, then red. */
409
- function figure(tone: 'ink' | 'ok' | 'warn' | 'bad'): string {
410
- return tone === 'warn' ? TONE.amber.text : tone === 'bad' ? TONE.red.text : INK
414
+ function figure(frame: Frame, tone: 'ink' | 'ok' | 'warn' | 'bad'): Word {
415
+ const k = lookOf(frame)
416
+ return tone === 'warn' ? { color: k.tone('amber') } : tone === 'bad' ? { color: k.tone('red') } : k.ink
411
417
  }
412
418
 
413
419
  function effortDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActions): DrawnFact {
414
420
  const { Box, Text } = el
421
+ const k = lookOf(frame)
415
422
  const { level, mode } = view.effort
416
423
  const idx = effortIndex(level)
417
424
  const word = mode === 'deciding' ? t(frame.lang, 'band.deciding') : level ? effortName(level, frame.lang) : '—'
@@ -425,9 +432,9 @@ function effortDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandAc
425
432
  'effort',
426
433
  cells,
427
434
  [
428
- pic(el, 'meter', meterArt(mode === 'deciding' ? 5 : idx + 1, look), t(frame.lang, 'band.effort'), METER_W, METER_H),
435
+ pic(el, 'meter', k.art.meter(mode === 'deciding' ? 5 : idx + 1, look), t(frame.lang, 'band.effort'), METER_W, METER_H),
429
436
  <Box key="effort-word" flexShrink={0}>
430
- <Text bold color={mode === 'deciding' ? TONE.violet.text : look === 'quiet' ? MUTE : INK}>{word}</Text>
437
+ <Text bold {...(mode === 'deciding' ? { color: k.tone('violet') } : look === 'quiet' ? k.mute : k.ink)}>{word}</Text>
431
438
  </Box>,
432
439
  press(el, 'effort', cells * CELL_PX, actions.toggleEffortMenu),
433
440
  ],
@@ -443,8 +450,8 @@ function contextDrawn(el: Els, frame: Frame, view: DashboardView): DrawnFact | n
443
450
  const tip = contextFact(frame, view)?.card?.join(' · ')
444
451
  const cells = Math.ceil((16 + CELL_PX + textPx('100%')) / CELL_PX)
445
452
  return drawnFact(el, 'context', cells, [
446
- pic(el, 'ring', ringArt(ctx.percent, tone === 'ink' ? 'ink' : tone === 'warn' ? 'amber' : 'red'), t(frame.lang, 'band.context'), 16, 16),
447
- <Text key="ctx-value" color={figure(tone)}>{Math.round(ctx.percent) + '%'}</Text>,
453
+ pic(el, 'ring', lookOf(frame).art.ring(ctx.percent, tone === 'ink' ? 'ink' : tone === 'warn' ? 'amber' : 'red'), t(frame.lang, 'band.context'), 16, 16),
454
+ <Text key="ctx-value" {...figure(frame, tone)}>{Math.round(ctx.percent) + '%'}</Text>,
448
455
  ], tip)
449
456
  }
450
457
 
@@ -455,6 +462,7 @@ function cacheDrawn(el: Els, frame: Frame, view: DashboardView): DrawnFact | nul
455
462
  const left = cache ? cache.expiresAt - frame.now : 0
456
463
  const shown = cache && left > 0 ? Math.ceil(left / 60_000) : 0
457
464
  const held = view.warm.on && !view.warm.stopped
465
+ const k = lookOf(frame)
458
466
  const drowsy = !!cache && shown > 0 && !held && shown * 60_000 <= Math.min(DROWSY_MS, cache.ttlMs / 5)
459
467
  const tone: Tone | 'soft' = shown === 0 ? 'soft' : drowsy ? 'amber' : held ? 'violet' : 'soft'
460
468
  const text = cache ? minutesLeft(left, frame.lang) : '—'
@@ -462,8 +470,8 @@ function cacheDrawn(el: Els, frame: Frame, view: DashboardView): DrawnFact | nul
462
470
  const cells = Math.ceil((16 + CELL_PX + Math.max(...words.map(textPx))) / CELL_PX)
463
471
  const tip = cacheFact(frame, view)?.card?.join(' · ')
464
472
  return drawnFact(el, 'cache', cells, [
465
- pic(el, 'clock', clockArt(cache ? (shown * 60_000) / cache.ttlMs : 0, tone), t(frame.lang, 'band.cache'), 16, 16),
466
- <Text key="cache-value" color={drowsy ? TONE.amber.text : shown === 0 ? MUTE : held ? TONE.violet.text : INK}>{text}</Text>,
473
+ pic(el, 'clock', k.art.clock(cache ? (shown * 60_000) / cache.ttlMs : 0, tone), t(frame.lang, 'band.cache'), 16, 16),
474
+ <Text key="cache-value" {...(drowsy ? { color: k.tone('amber') } : shown === 0 ? k.mute : held ? { color: k.tone('violet') } : k.ink)}>{text}</Text>,
467
475
  ], tip)
468
476
  }
469
477
 
@@ -478,9 +486,9 @@ function limitDrawn(el: Els, frame: Frame, view: DashboardView): DrawnFact | nul
478
486
  // band near 95 columns (2026-10-14).
479
487
  const cells = Math.ceil((16 + CELL_PX + textPx('100%') + (limit.save ? CELL_PX + textPx(save) : 0)) / CELL_PX)
480
488
  return drawnFact(el, 'limit', cells, [
481
- pic(el, 'limit', gaugeArt(limit.kind), t(frame.lang, 'band.limit'), 16, 16),
482
- <Text key="limit-value" color={figure(limitTone(limit.percent))}>{Math.round(limit.percent) + '%'}</Text>,
483
- limit.save ? <Text key="limit-save" color={TONE.violet.text}>{save}</Text> : '',
489
+ pic(el, 'limit', lookOf(frame).art.gauge(limit.kind), t(frame.lang, 'band.limit'), 16, 16),
490
+ <Text key="limit-value" {...figure(frame, limitTone(limit.percent))}>{Math.round(limit.percent) + '%'}</Text>,
491
+ limit.save ? <Text key="limit-save" color={lookOf(frame).tone('violet')}>{save}</Text> : '',
484
492
  ], limitFact(frame, view)?.card?.join(' · '))
485
493
  }
486
494
 
@@ -488,15 +496,161 @@ function modeDrawn(el: Els, frame: Frame, view: DashboardView): DrawnFact | null
488
496
  if (!view.mode) return null
489
497
  const { Text } = el
490
498
  // One slot whatever the name (cut past MODE_CELLS; Latin capitals run ~1.1 ch).
491
- const amber = TONE.amber.text
499
+ const amber = lookOf(frame).tone('amber')
492
500
  return drawnFact(el, 'mode', 2 + Math.ceil(MODE_CELLS * 1.1), [<Text key="mode-mark" color={amber}>◆</Text>, <Text key="mode-name" color={amber}>{modeLabel(view.mode)}</Text>], t(frame.lang, 'band.modeTip', { name: view.mode }))
493
501
  }
494
502
 
503
+ // ---- Limits row ----------------------------------------------------------------------------------
504
+ //
505
+ // The capsule's second row, under the first and starting where the effort does (a CREST_CELLS
506
+ // indent): every plan window as a small gauge — Claude's 5-hour, weekly and per-model weeklies,
507
+ // then each ChatGPT account's. A thinner row than the first (a line of text or so); the reset is
508
+ // in the hover tip, which shows in the first row's tip place (see whyDrawn). Widths alone decide,
509
+ // never values: the bar is one width, the figure's slot fits "100%", and only the name takes what
510
+ // it takes (a changing figure never moves a neighbour). Narrow, the GPT accounts go first (the
511
+ // last one first), then the per-model windows; what is left not fitting means no second row, and
512
+ // the first row keeps its limit fact as before.
513
+
514
+ /** A limit window's bar, px: beside each window's name and figure, in the row's own line. */
515
+ const BAR_W = 36
516
+ const BAR_H = 6
517
+ /** The air between the row's groups, in cells (a rule stands in it, as on the first row). */
518
+ const LIMITS_GAP = 2
519
+
520
+ /** Cells a window's figure takes: whatever it shows, the slot fits the widest ("100%"). */
521
+ function figureCells(): number {
522
+ return Math.ceil(textPx('100%') / CELL_PX)
523
+ }
524
+
525
+ /**
526
+ * How much each window takes, widest first; the row takes the first that fits all its groups.
527
+ * `gauge`: name, bar, figure. `figures`: name and figure. `compact`: a group's figures alone,
528
+ * "1 / 88 / 2%" in its windows' order (the menu-bar and old footer idiom), each in its own tone, the
529
+ * names and resets in the group's tip. At ~100 columns four providers fit only compact
530
+ * (2026-10-15: Claude, GPT, Gemini and Go took ~207 cells as gauges against 96).
531
+ */
532
+ type LimitDensity = 'gauge' | 'figures' | 'compact'
533
+ const DENSITIES: readonly LimitDensity[] = ['gauge', 'figures', 'compact']
534
+
535
+ /** Cells one window takes at a density (compact: its number's slot and the separator after it). */
536
+ function limitItemCells(item: LimitItem, density: LimitDensity, lang: Lang): number {
537
+ // A figure's slot and the " / " after it, in fractions of a cell: summed per group, then rounded up.
538
+ // Two digits: a window at 100% is spent and runs into the air after its group, which is wider.
539
+ if (density === 'compact') return textPx('00 / ') / CELL_PX
540
+ const name = Math.ceil(textPx(limitItemName(item, lang)) / CELL_PX) + 1
541
+ return name + (density === 'gauge' ? Math.ceil(BAR_W / CELL_PX) + 1 : 0) + figureCells()
542
+ }
543
+
544
+ function limitItemDrawn(el: Els, frame: Frame, item: LimitItem, key: string, tip: string, density: Exclude<LimitDensity, 'compact'>): DrawnFact {
545
+ const { Text } = el
546
+ const tone = limitTone(item.percent)
547
+ const name = limitItemName(item, frame.lang)
548
+ return drawnFact(
549
+ el,
550
+ key,
551
+ limitItemCells(item, density, frame.lang),
552
+ [
553
+ <Text key="limit-name" {...lookOf(frame).mute}>{name}</Text>,
554
+ density === 'gauge' ? pic(el, key + '-bar', barArt(item.percent, tone === 'ink' ? 'ink' : tone === 'warn' ? 'amber' : 'red'), name, BAR_W, BAR_H) : '',
555
+ <Text key="limit-value" {...figure(frame, tone)}>{Math.round(item.percent) + '%'}</Text>,
556
+ ],
557
+ tip,
558
+ )
559
+ }
560
+
561
+ /** A group's windows as one run of figures, "1 / 88 / 2%", each figure in its own tone. */
562
+ function compactDrawn(el: Els, frame: Frame, group: LimitGroup, key: string, tip: string, lang: Lang): DrawnFact {
563
+ const { Text } = el
564
+ const runs: RenderNode[] = []
565
+ group.items.forEach((item, i) => {
566
+ runs.push(<Text key={'n' + i} {...figure(frame, limitTone(item.percent))}>{String(Math.round(item.percent))}</Text>)
567
+ runs.push(<Text key={'s' + i} {...lookOf(frame).mute}>{i < group.items.length - 1 ? ' / ' : '%'}</Text>)
568
+ })
569
+ const cells = Math.ceil(group.items.reduce((n, item) => n + limitItemCells(item, 'compact', lang), 0))
570
+ return drawnFact(el, key, cells, [<Text key="limit-figures">{runs}</Text>], tip)
571
+ }
572
+
573
+ /** Save mode's word after Claude's windows while it is on (it holds Auto down until Claude's reset). */
574
+ function saveWord(group: LimitGroup, save: boolean, lang: Lang): string {
575
+ return save && (group.type === 'anthropic' || group.provider === 'claude') ? t(lang, 'band.save') : ''
576
+ }
577
+
578
+ /** Cells a group takes at a density: its head (the provider's dot and short name), its windows, save's word. */
579
+ function limitGroupCells(group: LimitGroup, name: string, lang: Lang, density: LimitDensity, save = false): number {
580
+ const head = Math.ceil(textPx('•') / CELL_PX) + 1 + Math.ceil(textPx(name) / CELL_PX)
581
+ const items = Math.ceil(group.items.reduce((n, item) => n + limitItemCells(item, density, lang), 0))
582
+ const gaps = density === 'compact' ? 0 : Math.max(0, group.items.length - 1)
583
+ const word = saveWord(group, save, lang)
584
+ return head + 1 + items + gaps + (word ? 1 + Math.ceil(textPx(word) / CELL_PX) : 0)
585
+ }
586
+
587
+ function limitGroupDrawn(el: Els, frame: Frame, group: LimitGroup, name: string, save: boolean, density: LimitDensity): { node: RenderNode; tips: { key: string; text: string }[] } {
588
+ const { Box, Text } = el
589
+ const dot = providerColor(group.type ?? group.provider)
590
+ const word = saveWord(group, save, frame.lang)
591
+ const tipOf = (item: LimitItem) => limitItemTip(item, name, frame.lang, frame.now)
592
+ // Compact: one figure run for the group, its tip naming every window in turn.
593
+ const items = density === 'compact'
594
+ ? [compactDrawn(el, frame, group, 'limits-' + group.key, group.items.map(tipOf).join(' · '), frame.lang)]
595
+ : group.items.map((item, ii) => limitItemDrawn(el, frame, item, 'limits-' + group.key + '-' + ii, tipOf(item), density))
596
+ return {
597
+ node: (
598
+ <Box key={'limits-' + group.key} flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
599
+ <Text key="limits-dot" color={dot}>•</Text>
600
+ <Text key="limits-name" {...lookOf(frame).sub}>{name}</Text>
601
+ {items.map(f => f.node)}
602
+ {word ? <Text key="limits-save" color={TONE.violet.text}>{word}</Text> : null}
603
+ </Box>
604
+ ),
605
+ tips: items.map(f => ({ key: f.key, text: f.tip! })),
606
+ }
607
+ }
608
+
609
+ /**
610
+ * The second row, or null: no `limits` extra, no windows at all, or not even Claude's own windows
611
+ * fitting. Narrow, the scoped windows (Fable, 3P, …) go first, then a later provider's secondary
612
+ * accounts (the last one first), then a later provider's whole group — Claude's own group stays to
613
+ * the last. A provider with several accounts numbers them ("GPT 1", "GPT 2"); the number is the
614
+ * account's, not its place in this row, so it holds even when only one of them fits.
615
+ */
616
+ function limitsRowDrawn(el: Els, frame: Frame, view: DashboardView, budget: number): { node: RenderNode; tips: { key: string; text: string }[] } | null {
617
+ const { Box } = el
618
+ const items = view.limits ?? []
619
+ if (!items.length || !view.extras.includes('limits')) return null
620
+ const all = limitGroups(items)
621
+ // Numbering follows the whole set of accounts a provider has, decided before any narrowing.
622
+ const accounts = new Map<string, number>()
623
+ for (const g of all) accounts.set(g.provider, (accounts.get(g.provider) ?? 0) + 1)
624
+ const numbered = (g: LimitGroup) => (accounts.get(g.provider) ?? 1) > 1
625
+ // Save mode's word sat beside the first row's limit; with this row drawn it follows Claude's windows.
626
+ const save = !!view.limit?.save
627
+ const width = (density: LimitDensity) => (gs: readonly LimitGroup[]) => gs.reduce((n, g) => n + limitGroupCells(g, limitGroupName(g, numbered(g)), frame.lang, density, save), 0) + RULE_CELLS * Math.max(0, gs.length - 1) + LIMITS_GAP * Math.max(0, 2 * gs.length - 2)
628
+ // Every provider at the roomiest density that holds them all; past compact, windows and then
629
+ // groups give way (narrowLimitGroups), compact as the row is then.
630
+ const density = DENSITIES.find(d => width(d)(all) <= budget) ?? 'compact'
631
+ const groups = narrowLimitGroups(all, budget, width(density))
632
+ if (!groups) return null
633
+ const drawn = groups.map(g => limitGroupDrawn(el, frame, g, limitGroupName(g, numbered(g)), save, density))
634
+ const children: RenderNode[] = []
635
+ drawn.forEach((d, i) => {
636
+ if (i > 0) children.push(rule(el, frame, 'limits-' + groups[i]!.key))
637
+ children.push(d.node)
638
+ })
639
+ return {
640
+ node: (
641
+ <Box key="band-limits" flexDirection="row" alignItems="center" columnGap={LIMITS_GAP} paddingLeft={CREST_CELLS} paddingTop={0.5}>
642
+ {children}
643
+ </Box>
644
+ ),
645
+ tips: drawn.flatMap(d => d.tips),
646
+ }
647
+ }
648
+
495
649
  /**
496
650
  * The routed model as the band names it: what was asked dropped, and Claude's ids by their
497
651
  * family and version ("claude-opus-5-5" → "Opus 5.5"); the whole route is in the tip.
498
652
  */
499
- function routeName(model: string): string {
653
+ export function routeName(model: string): string {
500
654
  return modelName(model.replace(/\s*\(.*\)$/, ''))
501
655
  }
502
656
 
@@ -509,28 +663,41 @@ function routeDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandAct
509
663
  if (!view.notice) return ''
510
664
  return (
511
665
  <Box key="fact-route" position="relative" flexShrink={1} minWidth={0} overflow="hidden">
512
- <Text color={MUTE} wrap="truncate">{view.notice.replace(/^ClaudeRipple:\s*/, '')}</Text>
666
+ <Text {...lookOf(frame).mute} wrap="truncate">{view.notice.replace(/^ClaudeRipple:\s*/, '')}</Text>
513
667
  </Box>
514
668
  )
515
669
  }
516
670
  const tone = r.cachePct !== undefined ? cacheTone(r.cachePct) : 'ok'
517
- const tip = routeFact(frame, view, 200, actions.toggleLog)?.card?.join(' · ')
671
+ const k = lookOf(frame)
672
+ const name = routeName(r.model)
673
+ const tail = !r.ok ? (r.resent ? t(frame.lang, 'band.resent', { status: r.status }) : r.status) : r.cachePct !== undefined ? r.cachePct + '%' : ''
674
+ // The light is as wide as what it holds (its air, the dot, the name, the figure), giving way only
675
+ // when the row has less, and the blank press laid over it is that wide too: a press wider is cut
676
+ // with an ellipsis (three dark dots on flat's light page), and a narrower one lights a smaller box
677
+ // inside the route's own light (2026-10-16).
678
+ const width = Math.max(Math.min(ROUTE_ROOM, room), Math.ceil(1 + cells(frame, '•') + 0.5 + cells(frame, name) + (tail ? 0.5 + cells(frame, tail) : 0)))
679
+ // The route is a button (it opens the log), so it lights under the pointer like the controls do.
680
+ // It has no tip: the light says it is a button, and its card is what the log shows (asked 2026-10-14).
518
681
  return (
519
- <Box key="fact-route" position="relative" flexDirection="row" alignItems="center" columnGap={0.5} flexShrink={1} minWidth={Math.min(ROUTE_ROOM, room)} overflow="hidden" {...(tip ? { hover: { scope: tipScope('route') } } : {})}>
682
+ // The slot keeps no width of the value's (what the band holds never depends on one); the light
683
+ // inside it is as wide as the value.
684
+ <Box key="fact-route" flexDirection="row" flexShrink={1} minWidth={Math.min(ROUTE_ROOM, room)} overflow="hidden">
685
+ <Box key="route-light" position="relative" flexDirection="row" alignItems="center" columnGap={0.5} paddingX={0.5} width={width} flexShrink={1} minWidth={0} overflow="hidden" {...hoverLight(frame)}>
520
686
  <Box flexShrink={0}>
521
- {r.ok ? <Text color={r.resent ? MUTE : providerColor(r.provider)}>{r.resent ? '↻' : '•'}</Text> : <Text color={TONE.red.text}>✕</Text>}
687
+ {r.ok ? <Text {...(r.resent ? k.mute : { color: providerColor(r.provider) })}>{r.resent ? '↻' : '•'}</Text> : <Text color={k.tone('red')}>✕</Text>}
522
688
  </Box>
523
689
  <Box flexShrink={1} minWidth={0}>
524
- <Text color={SUB} wrap="truncate">{routeName(r.model)}</Text>
690
+ <Text {...k.sub} wrap="truncate">{name}</Text>
525
691
  </Box>
526
692
  <Box flexShrink={0}>
527
693
  {!r.ok ? (
528
- <Text color={r.resent ? MUTE : TONE.red.text}>{r.resent ? t(frame.lang, 'band.resent', { status: r.status }) : r.status}</Text>
694
+ <Text {...(r.resent ? k.mute : { color: k.tone('red') })}>{r.resent ? t(frame.lang, 'band.resent', { status: r.status }) : r.status}</Text>
529
695
  ) : r.cachePct !== undefined ? (
530
- <Text color={tone === 'ok' ? TONE.green.text : figure(tone === 'warn' ? 'warn' : 'bad')}>{r.cachePct + '%'}</Text>
696
+ <Text {...(tone === 'ok' ? { color: k.tone('green') } : figure(frame, tone === 'warn' ? 'warn' : 'bad'))}>{r.cachePct + '%'}</Text>
531
697
  ) : null}
532
698
  </Box>
533
- {press(el, 'route', Math.max(40, frame.columns) * CELL_PX * 0.4, actions.toggleLog)}
699
+ {press(el, 'route', width * CELL_PX, actions.toggleLog)}
700
+ </Box>
534
701
  </Box>
535
702
  )
536
703
  }
@@ -540,42 +707,42 @@ function routeDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandAct
540
707
  * reason is no longer written here (cut to a word or two it said nothing, 2026-10-13); it is in the
541
708
  * effort's tip.
542
709
  *
543
- * The layer is placed absolutely over the facts (columns `left` to the facts' right edge, which is
544
- * just before the controls) rather than drawn in what the row leaves free: at the band's real width
710
+ * Each tip is placed absolutely over the row from column `left` to `right` columns short of its end
711
+ * (just before the controls) rather than drawn in what the row leaves free: at the band's real width
545
712
  * (2026-10-14: 100 columns with every fact and control drawn) nothing is left free, and a tip drawn
546
- * in a zero-width box never showed. Covering the facts while a tip shows is expected. The tips rest
547
- * `display: "none"` (so they take no pointer) and carry no `key`: a keyed Box drawn `display: "none"`
548
- * is refused as a hover scope (the pointer could never be over it), so each tip reveals through its
549
- * own `hover.scope`, which the fact's or control's hover lights.
713
+ * in a zero-width box never showed. Covering the facts while a tip shows is expected.
714
+ *
715
+ * The tips are the row's own children with no Box spanning them: the pointer on an absolute Box is
716
+ * on its parent, so a standing layer over the facts took every hover and press from what lay under
717
+ * it (2026-10-14, the route stopped opening the log). A tip resting `display: "none"` takes no
718
+ * pointer. Tips carry no `key`: a keyed Box drawn `display: "none"` is refused as a hover scope (the
719
+ * pointer could never be over it), so each tip reveals through its own `hover.scope`, which the
720
+ * fact's or control's hover lights.
550
721
  */
551
- function whyDrawn(el: Els, frame: Frame, tips: { key: string; text: string }[], at: number): RenderNode {
722
+ function whyDrawn(el: Els, frame: Frame, tips: { key: string; text: string }[], at: number, right: number): RenderNode[] {
552
723
  const { Box, Text } = el
553
- // Placed Box offsets are whole cells; the layer starts at the first cell past 리플이 and the effort.
724
+ // Placed Box offsets are whole cells; a tip starts at the first cell past 리플이 and the effort.
554
725
  const left = Math.ceil(at)
555
726
  const width = Math.max(40, frame.columns) * CELL_PX
556
- return (
557
- <Box key="band-why" position="absolute" top={0} bottom={0} left={left} right={0} minWidth={0} overflow="hidden">
558
- {tips.map(tip => (
559
- <Box position="absolute" top={0} bottom={0} left={0} right={0} display="none" hover={{ scope: tipScope(tip.key), display: 'flex' }} overflow="hidden">
560
- <Box position="absolute" top={0} left={0}>
561
- {pic(el, 'tip-plate-' + tip.key, plateArt(width, ROW_PX), tip.text, width, ROW_PX)}
562
- </Box>
563
- <Box position="relative" flexShrink={1} minWidth={0}>
564
- {lift(el)}
565
- <Text color={INK} wrap="truncate">{tip.text}</Text>
566
- </Box>
567
- </Box>
568
- ))}
727
+ return tips.map(tip => (
728
+ <Box position="absolute" top={0} bottom={0} left={left} right={Math.ceil(right)} display="none" hover={{ scope: tipScope(tip.key), display: 'flex' }} overflow="hidden">
729
+ <Box position="absolute" top={0} left={0}>
730
+ {pic(el, 'tip-plate-' + tip.key, lookOf(frame).art.plate(width, ROW_PX), tip.text, width, ROW_PX)}
731
+ </Box>
732
+ <Box position="relative" flexShrink={1} minWidth={0}>
733
+ {lift(el)}
734
+ <Text {...lookOf(frame).ink} wrap="truncate">{tip.text}</Text>
735
+ </Box>
569
736
  </Box>
570
- )
737
+ ))
571
738
  }
572
739
 
573
740
  /** A thin rule between the band's groups. */
574
- function rule(el: Els, key: string): RenderNode {
741
+ function rule(el: Els, frame: Frame, key: string): RenderNode {
575
742
  const { Box } = el
576
743
  return (
577
744
  <Box key={'rule-' + key} width={RULE_CELLS} flexShrink={0} alignItems="center">
578
- {pic(el, 'rule-' + key, ruleArt(), '|', 9, 16)}
745
+ {pic(el, 'rule-' + key, lookOf(frame).art.rule(), '|', 9, 16)}
579
746
  </Box>
580
747
  )
581
748
  }
@@ -583,7 +750,7 @@ function rule(el: Els, key: string): RenderNode {
583
750
  type DrawnControl = { key: string; cells: number; node: RenderNode; tip: string }
584
751
 
585
752
  /** An icon control, lit under the pointer: its picture, its word when the band has room, its press. */
586
- function iconControl(el: Els, key: string, source: string, artPx: number, alt: string, tip: string, onPress: () => void, label?: string, color = SUB): DrawnControl {
753
+ function iconControl(el: Els, frame: Frame, key: string, source: string, artPx: number, alt: string, tip: string, onPress: () => void, label?: string, word?: Word): DrawnControl {
587
754
  const { Text } = el
588
755
  const pad = label ? 0.5 : 0.25
589
756
  const cells = Math.ceil((artPx + (label ? CELL_PX + chOf(label) * CELL_PX * 1.04 : 0)) / CELL_PX + pad * 2)
@@ -591,7 +758,7 @@ function iconControl(el: Els, key: string, source: string, artPx: number, alt: s
591
758
  key,
592
759
  cells,
593
760
  tip,
594
- node: lit(el, key, cells, [pic(el, key, source, alt, artPx, 16), label ? <Text key={key + '-word'} color={color}>{label}</Text> : '', press(el, key, cells * CELL_PX, onPress)], pad, tipScope(key)),
761
+ node: lit(el, frame, key, cells, [pic(el, key, source, alt, artPx, 16), label ? <Text key={key + '-word'} {...(word ?? lookOf(frame).sub)}>{label}</Text> : '', press(el, key, cells * CELL_PX, onPress)], pad, tipScope(key)),
595
762
  }
596
763
  }
597
764
 
@@ -599,30 +766,31 @@ function iconControl(el: Els, key: string, source: string, artPx: number, alt: s
599
766
  function controlsDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActions, words: readonly string[]): DrawnControl[] {
600
767
  const L = frame.lang
601
768
  const out: DrawnControl[] = []
769
+ const k = lookOf(frame)
602
770
  const state = (on: boolean) => t(L, on ? 'band.on' : 'band.off')
603
771
  if (view.handoff !== 'off') {
604
772
  const advised = view.handoff === 'advised'
605
773
  const label = t(L, 'band.handoff')
606
774
  const look = advised ? 'primary' : 'ghost'
607
- out.push({ key: 'handoff', cells: pillArt(label, look, 'violet').cells + 1, node: pill(el, 'handoff', label, look, actions.handoff, 'violet'), tip: t(L, 'alerts.handoffTitle') })
775
+ out.push({ key: 'handoff', cells: k.art.pill(label, look, 'violet').cells + 1, node: pill(el, frame, 'handoff', label, look, actions.handoff, 'violet'), tip: t(L, 'alerts.handoffTitle') })
608
776
  }
609
777
  // Auto's word is the control (art.ts `wordArt`): lit violet while on, a quiet ghost while off,
610
778
  // pulsing while the judge decides. No switch: the state is in the word's own light (2026-10-14).
611
779
  const autoLabel = t(L, 'band.auto')
612
780
  const autoTip = t(L, 'band.effortAutoAlt', { state: state(view.auto) }) + ' · ' + t(L, 'settings.autoLine')
613
- out.push({ key: 'auto', cells: wordCells(autoLabel), node: word(el, 'auto', view.auto, autoLabel, actions.toggleAuto, view.effort.mode === 'deciding', tipScope('auto')), tip: autoTip })
781
+ out.push({ key: 'auto', cells: wordCells(frame, autoLabel), node: word(el, frame, 'auto', view.auto, autoLabel, actions.toggleAuto, view.effort.mode === 'deciding', tipScope('auto')), tip: autoTip })
614
782
  const warmLabel = t(L, 'band.warm')
615
- out.push(iconControl(el, 'warm', iconArt('flame', view.warm.on, 'amber'), ICON_PX, warmLabel, t(L, 'band.warmAlt', { state: state(view.warm.on) }) + ' · ' + t(L, view.warm.here ? 'band.warmHere' : 'settings.warmLine'), actions.toggleWarm, words.includes('warm') ? warmLabel : undefined, view.warm.on ? TONE.amber.text : SUB))
783
+ out.push(iconControl(el, frame, 'warm', k.art.icon('flame', view.warm.on, 'amber'), ICON_PX, warmLabel, t(L, 'band.warmAlt', { state: state(view.warm.on) }) + ' · ' + t(L, view.warm.here ? 'band.warmHere' : 'settings.warmLine'), actions.toggleWarm, words.includes('warm') ? warmLabel : undefined, view.warm.on ? { color: k.tone('amber') } : k.sub))
616
784
  const agentsLabel = t(L, 'band.agents')
617
785
  const live = view.agents.live
618
786
  // The live count sits on the icon's shoulder, in one picture of one width: no width change.
619
- out.push(iconControl(el, 'agents', agentsArt(live, view.agentsOpen || live > 0), AGENTS_W, live > 0 ? agentsLabel + ' ' + live : agentsLabel, t(L, 'band.agentsAlt', { count: live }), actions.toggleAgents, words.includes('agents') ? agentsLabel : undefined, view.agentsOpen ? INK : SUB))
787
+ out.push(iconControl(el, frame, 'agents', k.art.agents(live, view.agentsOpen || live > 0), AGENTS_W, live > 0 ? agentsLabel + ' ' + live : agentsLabel, t(L, 'band.agentsAlt', { count: live }), actions.toggleAgents, words.includes('agents') ? agentsLabel : undefined, view.agentsOpen ? k.ink : k.sub))
620
788
  const compactLabel = t(L, 'band.compact')
621
- out.push(iconControl(el, 'compact', iconArt('compact', false), ICON_PX, compactLabel, t(L, 'band.compactAlt'), actions.compact, words.includes('compact') ? compactLabel : undefined))
789
+ out.push(iconControl(el, frame, 'compact', k.art.icon('compact', false), ICON_PX, compactLabel, t(L, 'band.compactAlt'), actions.compact, words.includes('compact') ? compactLabel : undefined))
622
790
  const clearLabel = t(L, 'band.clear')
623
- out.push(iconControl(el, 'clear', iconArt('clear', false), ICON_PX, clearLabel, t(L, 'band.clearAlt'), actions.clear, words.includes('clear') ? clearLabel : undefined))
791
+ out.push(iconControl(el, frame, 'clear', k.art.icon('clear', false), ICON_PX, clearLabel, t(L, 'band.clearAlt'), actions.clear, words.includes('clear') ? clearLabel : undefined))
624
792
  const settingsLabel = t(L, 'band.settings')
625
- out.push(iconControl(el, 'settings', iconArt('gear', false), ICON_PX, settingsLabel, t(L, 'band.settingsAlt'), actions.openSettings, words.includes('settings') ? settingsLabel : undefined))
793
+ out.push(iconControl(el, frame, 'settings', k.art.icon('gear', false), ICON_PX, settingsLabel, t(L, 'band.settingsAlt'), actions.openSettings, words.includes('settings') ? settingsLabel : undefined))
626
794
  return out
627
795
  }
628
796
 
@@ -631,8 +799,8 @@ function effortMenuDrawn(el: Els, frame: Frame, view: DashboardView, actions: Ba
631
799
  const current = view.effort.mode === 'manual' || view.effort.mode === 'off' ? view.effort.level : null
632
800
  return (
633
801
  <Box key="effort-menu" flexDirection="row" alignItems="center" flexWrap="wrap" columnGap={2} rowGap={1} paddingTop={1} paddingLeft={CREST_CELLS}>
634
- {word(el, 'pick-auto', view.auto, t(frame.lang, 'band.auto'), actions.toggleAuto)}
635
- {segmented(el, 'pick', EFFORTS.map(effort => ({ value: effort, label: effortName(effort, frame.lang) })), current, effort => actions.pickEffort(effort), 'violet', DEPTH)}
802
+ {word(el, frame, 'pick-auto', view.auto, t(frame.lang, 'band.auto'), actions.toggleAuto)}
803
+ {segmented(el, frame, 'pick', EFFORTS.map(effort => ({ value: effort, label: effortName(effort, frame.lang) })), current, effort => actions.pickEffort(effort), 'violet', DEPTH)}
636
804
  </Box>
637
805
  )
638
806
  }
@@ -647,7 +815,10 @@ function bandDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActi
647
815
  // then the quietest facts go.
648
816
  const close = actions.hide ? CLOSE_CELLS : 0
649
817
  const effort = effortDrawn(el, frame, view, actions)
650
- const optional = [modeDrawn(el, frame, view), contextDrawn(el, frame, view), cacheDrawn(el, frame, view), limitDrawn(el, frame, view)].filter((f): f is DrawnFact => f !== null)
818
+ // The second row is decided before the first row's budget: when it draws, the first row's limit
819
+ // fact steps aside and the route takes its room.
820
+ const limitsRow = limitsRowDrawn(el, frame, view, cols - CREST_CELLS)
821
+ const optional = [modeDrawn(el, frame, view), contextDrawn(el, frame, view), cacheDrawn(el, frame, view), ...(limitsRow ? [] : [limitDrawn(el, frame, view)])].filter((f): f is DrawnFact => f !== null)
651
822
  // The Auto word is its control: it never loses its shape, so no word of it is dropped, and the
652
823
  // spare words (the icons' beside it) go first when the row narrows.
653
824
  let words: readonly string[] = [...WORDLESS]
@@ -681,40 +852,49 @@ function bandDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActi
681
852
  }
682
853
  const controls = controlsDrawn(el, frame, view, actions, words)
683
854
  const tips = [effort, ...facts].filter(f => f.tip).map(f => ({ key: f.key, text: f.tip! }))
684
- const routeTip = route > 0 && view.route ? routeFact(frame, view, 200, actions.toggleLog)?.card?.join(' · ') : undefined
685
- if (routeTip) tips.push({ key: 'route', text: routeTip })
855
+ if (limitsRow) for (const tip of limitsRow.tips) tips.push(tip)
686
856
  for (const c of controls) if (c.key !== 'handoff') tips.push({ key: c.key, text: c.tip })
687
857
  if (actions.hide) tips.push({ key: 'hide', text: t(frame.lang, 'band.hideBand') })
688
858
  const crest = (
689
859
  <Box key="band-crest" width={CREST_CELLS} flexShrink={0} alignItems="center">
690
- {ripple(el, 'band-' + mood.face + (mood.dim ? '-dim' : ''), mood.face, 'ClaudeRipple', BAND_RIPPLE, undefined, mood.dim)}
860
+ {ripple(el, frame, 'band-' + mood.face + (mood.dim ? '-dim' : ''), mood.face, 'ClaudeRipple', BAND_RIPPLE, undefined, mood.dim)}
691
861
  </Box>
692
862
  )
693
863
  const routeNode = route > 0 ? routeDrawn(el, frame, view, actions, route) : ''
694
864
  const row = (
695
865
  <Box key="band-row" flexDirection="row" alignItems="center" width="100%" minWidth={0} height={rowsOf(ROW_H)}>
696
- <Box key="band-facts" flexDirection="row" alignItems="center" columnGap={DRAWN_GAP} flexGrow={1} flexShrink={1} width={0} minWidth={0}>
866
+ {/* The facts keep their own width (the drop rules above already made them fit); the route
867
+ follows them directly, and what the row leaves free is after it, before the controls. The
868
+ route is what gives way, down to its own minimum, truncating the model's name: a facts box
869
+ allowed to shrink let the route draw over them, and a facts box that grew left a short
870
+ model name far right with a gap before it (2026-10-14). */}
871
+ <Box key="band-facts" flexDirection="row" alignItems="center" columnGap={DRAWN_GAP} flexShrink={0}>
697
872
  {crest}
698
873
  {effort.node}
699
- {ruled && facts.length ? rule(el, 'facts') : null}
874
+ {ruled && facts.length ? rule(el, frame, 'facts') : null}
700
875
  {facts.map(f => f.node)}
701
- {ruled && routeNode ? rule(el, 'route') : null}
702
- {routeNode}
703
- {whyDrawn(el, frame, tips, CREST_CELLS + DRAWN_GAP + effort.cells + DRAWN_GAP)}
704
876
  </Box>
877
+ {routeNode ? (
878
+ <Box key="band-route" flexDirection="row" alignItems="center" columnGap={DRAWN_GAP} marginLeft={DRAWN_GAP} flexShrink={1} minWidth={0}>
879
+ {ruled ? rule(el, frame, 'route') : null}
880
+ {routeNode}
881
+ </Box>
882
+ ) : null}
883
+ <Box key="band-free" flexGrow={1} flexShrink={1} minWidth={0} />
884
+ {whyDrawn(el, frame, tips, CREST_CELLS + DRAWN_GAP + effort.cells + DRAWN_GAP, controlCells + close + 0.5)}
705
885
  <Box key="band-controls" flexDirection="row" alignItems="center" columnGap={0.15} flexShrink={0} marginLeft={0.5}>
706
886
  {controls.map(c => c.node)}
707
- {actions.hide ? closeButton(el, 'hide', t(frame.lang, 'band.hideBand'), () => actions.hide!(), tipScope('hide')) : null}
887
+ {actions.hide ? closeButton(el, frame, 'hide', t(frame.lang, 'band.hideBand'), () => actions.hide!(), tipScope('hide')) : null}
708
888
  </Box>
709
889
  </Box>
710
890
  )
711
891
  return capsule(el, frame, {
712
892
  key: 'band',
713
893
  sea: { ...bandSea(mood), reach: 120, at: { x: PAD_X * CELL_PX + (CREST_CELLS * CELL_PX) / 2, y: PAD_Y * ROW_PX * 0.5 + ROW_H / 2 } },
714
- rows: view.effortMenu ? 3.4 : 1.8,
894
+ rows: view.effortMenu ? (limitsRow ? 4.6 : 3.4) : limitsRow ? 3 : 1.8,
715
895
  paddingX: PAD_X,
716
896
  paddingY: PAD_Y,
717
- children: [row, view.effortMenu ? effortMenuDrawn(el, frame, view, actions) : ''],
897
+ children: [row, ...(limitsRow ? [limitsRow.node] : []), view.effortMenu ? effortMenuDrawn(el, frame, view, actions) : ''],
718
898
  })
719
899
  }
720
900
 
@@ -823,7 +1003,8 @@ export function besideBand(frame: Frame): number {
823
1003
 
824
1004
  /** The dashboard band, `frame.columns` being its own width (see besideBand). */
825
1005
  export function renderBand(el: Els, frame: Frame, view: DashboardView, actions: BandActions): RenderNode {
826
- return isRich(frame) && 'Svg' in el ? bandDrawn(el, frame, view, actions) : bandText(el, frame, view, actions)
1006
+ if (!isRich(frame) || !('Svg' in el)) return bandText(el, frame, view, actions)
1007
+ return bandDrawn(el, frame, view, actions)
827
1008
  }
828
1009
 
829
1010
  // ---- Minimal: the footer -----------------------------------------------------------------------