clauderipple 0.8.10 → 0.9.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 (68) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.es.md +0 -4
  3. package/README.ja.md +0 -4
  4. package/README.ko.md +5 -1
  5. package/README.md +5 -1
  6. package/README.pt-BR.md +0 -4
  7. package/README.zh-CN.md +0 -4
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/codex.js +3 -1
  10. package/dist/cli/src/settings.js +5 -2
  11. package/dist/mod/.claude-plugin/plugin.json +9 -1
  12. package/dist/mod/hooks/cache.ts +60 -0
  13. package/dist/mod/hooks/compact.ts +40 -0
  14. package/dist/mod/hooks/handoff.ts +43 -0
  15. package/dist/mod/hooks/i18n.ts +2716 -0
  16. package/dist/mod/hooks/judge.ts +226 -0
  17. package/dist/mod/hooks/ladder.js +179 -0
  18. package/dist/mod/hooks/ladder.ts +174 -0
  19. package/dist/mod/hooks/modes.ts +93 -0
  20. package/dist/mod/hooks/register.tsx +1458 -256
  21. package/dist/mod/hooks/session.ts +62 -0
  22. package/dist/mod/hooks/settings.js +342 -0
  23. package/dist/mod/hooks/settings.ts +321 -0
  24. package/dist/mod/hooks/stats.ts +59 -0
  25. package/dist/mod/hooks/ui/agents.tsx +138 -0
  26. package/dist/mod/hooks/ui/alerts.tsx +370 -0
  27. package/dist/mod/hooks/ui/art.ts +628 -0
  28. package/dist/mod/hooks/ui/band.tsx +836 -0
  29. package/dist/mod/hooks/ui/kit.tsx +258 -0
  30. package/dist/mod/hooks/ui/log.tsx +109 -0
  31. package/dist/mod/hooks/ui/parts.tsx +217 -0
  32. package/dist/mod/hooks/ui/ripple.ts +614 -0
  33. package/dist/mod/hooks/ui/settings.tsx +376 -0
  34. package/dist/mod/hooks/ui/setup.tsx +148 -0
  35. package/dist/mod/hooks/ui/theme.ts +217 -0
  36. package/dist/mod/hooks/ui/view.ts +371 -0
  37. package/dist/mod/hooks/worker.ts +1 -1
  38. package/dist/mod/hooks/workers.ts +217 -0
  39. package/dist/mod/tests/register.test.tsx +1730 -0
  40. package/dist/mod/tests/ui.test.tsx +853 -0
  41. package/dist/mod/tsconfig.json +2 -1
  42. package/dist/mod/types/index.d.ts +320 -1
  43. package/dist/router/src/admin.js +170 -52
  44. package/dist/router/src/agents.js +24 -5
  45. package/dist/router/src/bootstrap.js +4 -2
  46. package/dist/router/src/config.js +3 -0
  47. package/dist/router/src/health.js +33 -0
  48. package/dist/router/src/index.js +1 -0
  49. package/dist/router/src/ingress/models.js +4 -1
  50. package/dist/router/src/mod-settings.js +119 -0
  51. package/dist/router/src/model-policy.js +6 -0
  52. package/dist/router/src/model-scores.js +159 -0
  53. package/dist/router/src/picker.js +7 -0
  54. package/dist/router/src/providers/chatgpt/index.js +6 -1
  55. package/dist/router/src/providers/chatgpt/translate.js +1 -1
  56. package/dist/router/src/providers/google/index.js +6 -2
  57. package/dist/router/src/providers/openai/index.js +4 -1
  58. package/dist/router/src/providers/openai/translate.js +1 -1
  59. package/dist/router/src/proxy.js +14 -3
  60. package/dist/router/src/version.js +1 -1
  61. package/dist/ui/app.js +908 -209
  62. package/dist/ui/favicon-32.png +0 -0
  63. package/dist/ui/favicon.svg +12 -0
  64. package/dist/ui/i18n.js +919 -233
  65. package/dist/ui/index.html +159 -87
  66. package/dist/ui/style.css +789 -410
  67. package/docs/ARCHITECTURE.md +128 -0
  68. package/package.json +7 -3
@@ -0,0 +1,836 @@
1
+ // The dashboard band above the prompt, and its minimal form in the footer.
2
+ //
3
+ // Desktop: one capsule of the deep (see "Desktop" below, art.ts and kit.tsx): 리플이 glowing at its
4
+ // left, acting out what the band is doing (thinking while Auto picks, busy while a request is out,
5
+ // drowsy as the cache runs out, asleep once it has, otherwise its idle life), the effort as five
6
+ // bars rising shallow to deep, the context ring, the cache clock in whole minutes, the plan limit and
7
+ // the mode when on, the last route, the judge's reason; then the drawn controls and the way out.
8
+ // What each fact means shows in the reason's place on hover. The terminal draws the same facts as
9
+ // one quiet row of text, without 리플이.
10
+ //
11
+ // Facts of the desktop host that shape it (claude.ai assets, read 2026-10-10 and 2026-10-12):
12
+ // - Each answer the band gives is drawn as a freshly built DOM tree swapped in for the last one,
13
+ // and an Svg becomes a new <img> of a data URL or, interactive, a new sandboxed <iframe srcdoc>.
14
+ // The frame paints blank until loaded, so an interactive Svg blinks on every redraw; the image
15
+ // does not (seen in the owner's app, 2026-10-11), and its CSS motion runs. So every picture is
16
+ // a plain image whose source is fixed per state: a redraw hands the host the same document and
17
+ // only starts its motion over.
18
+ // - The surface asks again on every write of state the band read and on every invalidate, and
19
+ // takes each answer as new. So the countdown shows whole minutes, and register.tsx invalidates
20
+ // only when the minute shown changes.
21
+ // - The host's own buttons are its idiom (its primary button is the app's clay orange, and a plain
22
+ // one's label takes the theme's ink), so the desktop draws its controls (art.ts) and lays a blank
23
+ // native button over each (kit.tsx); a state is in the drawing (a switch's track, a lit flame).
24
+ //
25
+ // No item's width depends on its value: each fact has a slot sized for the widest value its
26
+ // language can show; the route is counted at a fixed room and takes what the row leaves; Handoff
27
+ // is counted whether it shows or not. The controls sit at the right end with the one
28
+ // that comes and goes (Handoff) first, so a changing value never moves a neighbour.
29
+
30
+ import { t } from '../i18n'
31
+ import type { Lang } from '../i18n'
32
+ import type { RenderChildren, RenderNode } from 'claude-code'
33
+
34
+ import type { Effort } from '../../types'
35
+ import { AGENTS_W, CELL_PX, DEPTH, ICON_PX, INK, METER_H, METER_W, MUTE, ROW_PX, SUB, TONE, agentsArt, clockArt, iconArt, meterArt, pillArt, plateArt, ringArt, ruleArt, switchArt, textPx } from './art'
36
+ import type { Sea, Tone } from './art'
37
+ import { CLOSE_CELLS, capsule, closeButton, lift, lit, pic, pill, press, ripple, rowsOf, segmented, toggle, toggleCells } from './kit'
38
+ import { textButton } from './parts'
39
+ import type { Els } from './parts'
40
+ import type { Face } from './ripple'
41
+ import { cacheTone, chOf, clip, contextTone, displayWidth, limitTone, providerColor, textTone } from './theme'
42
+ import type { BandActions, DashboardView, Frame } from './view'
43
+ import { EFFORTS, compactNumber, effortIndex, effortLabel, effortName, isRich, limitName, minutesLeft, modelName, resetsLabel, seconds } from './view'
44
+
45
+ /** Growing ripples: low → max. */
46
+ const RIPPLE_CHAR = ['·', '∘', '○', '◎', '◉']
47
+ /** Theme keys: they follow the person's light or dark theme on every surface. */
48
+ const WARN = 'warning'
49
+ const BAD = 'error'
50
+ /** Cells between facts, and between controls. */
51
+ const GAP = 2
52
+ const CTRL_GAP = 1
53
+ /** The least room a route is drawn in; on the desktop the host cuts the name to what is left. */
54
+ const ROUTE_MIN = 14
55
+ const RICH_ROUTE_MIN = 6
56
+ /** Desktop: the slot of the live agents' count after its control. */
57
+ const COUNT_CELLS = 2
58
+ /** The cache's last stretch, when 리플이 grows drowsy: five minutes, or a fifth of a short life. */
59
+ const DROWSY_MS = 5 * 60_000
60
+
61
+ /** What 리플이 acts out in the band, and so which of its fixed sources the band shows. */
62
+ export type BandMood = { face: Extract<Face, 'thinking' | 'working' | 'drowsy' | 'sleepy' | 'calm'>; dim: boolean }
63
+
64
+ /**
65
+ * Thinking while Auto picks, working while a request is out, asleep once the cache has run out
66
+ * and drowsy in its last minutes (not while keep-warm holds it), else its idle life; dimmed at
67
+ * rest when Auto is off or paused. Read from whole minutes, as the countdown is, so the mood
68
+ * changes no more often than what the band shows.
69
+ */
70
+ export function bandMood(frame: Frame, view: DashboardView): BandMood {
71
+ const quiet = view.effort.mode === 'off' || view.effort.mode === 'paused'
72
+ if (view.effort.mode === 'deciding') return { face: 'thinking', dim: false }
73
+ if (view.isWorking) return { face: 'working', dim: false }
74
+ const cache = view.cache
75
+ if (cache && !view.hide.includes('timer')) {
76
+ const left = cache.expiresAt - frame.now
77
+ if (left <= 0) return { face: 'sleepy', dim: quiet }
78
+ const shown = Math.ceil(left / 60_000) * 60_000
79
+ const held = view.warm.on && !view.warm.stopped
80
+ if (!held && shown <= Math.min(DROWSY_MS, cache.ttlMs / 5)) return { face: 'drowsy', dim: quiet }
81
+ }
82
+ return { face: 'calm', dim: quiet }
83
+ }
84
+ /** The reason's slot on a wide terminal. */
85
+ const WHY_CELLS = 28
86
+
87
+ type Run = { text: string; color?: string; dim?: boolean; bold?: boolean }
88
+
89
+ /** One fact of the band: a slot of fixed width holding a glyph, a value and quiet trailing runs. */
90
+ type Fact = {
91
+ key: string
92
+ /** Cells the slot takes, whatever it shows. */
93
+ width: number
94
+ /** Desktop: a quiet name before the value, so a bare "60 min" says what it is. */
95
+ label?: Run
96
+ glyph?: Run
97
+ value: Run
98
+ /** The value is a button (desktop): the effort opens its menu, the route the log. */
99
+ onPress?: () => void
100
+ /** Desktop: the slot takes the room the row leaves instead of `width` (the route). */
101
+ grow?: true
102
+ tail?: Run[]
103
+ /** What a hover card says, a line each (desktop). */
104
+ card?: string[]
105
+ }
106
+
107
+ /**
108
+ * Cells `text` takes: terminal columns, or on the desktop the host's `ch`, the width of "0" in its
109
+ * 14 px sans. Measured 2026-10-10 in Chromium on macOS: ch 8.8 px, Hangul and kana ~12 px (1.36 ch),
110
+ * "Medium" 5.9 ch, "X-High" 5.1 ch. Each class of character is taken a little wide of that; the
111
+ * terminal's 2 cells for a wide character would leave a gap after every short Korean value.
112
+ */
113
+ function cells(frame: Frame, text: string): number {
114
+ return isRich(frame) ? Math.ceil(chOf(text)) : displayWidth(text)
115
+ }
116
+
117
+ function widest(frame: Frame, texts: readonly string[]): number {
118
+ return Math.max(1, ...texts.map(text => cells(frame, text)))
119
+ }
120
+
121
+ /** The host's padding either side of a Button's label in the band, in ch (about 8 px each). */
122
+ const PAD = 2
123
+
124
+ /** What the effort slot can show: the names and "Deciding…" (a dash when there is no level). */
125
+ function effortWords(lang: Lang, rich: boolean): string[] {
126
+ const words = [...EFFORTS.map(e => effortName(e, lang)), t(lang, 'band.deciding')]
127
+ return rich ? words : words.map(w => w.toLowerCase())
128
+ }
129
+
130
+ // ---- Facts -------------------------------------------------------------------------------------
131
+
132
+ function effortFact(frame: Frame, view: DashboardView, onPress: () => void): Fact {
133
+ const rich = isRich(frame)
134
+ const accent = textTone(frame.appearance).accent
135
+ const { level, mode, why } = view.effort
136
+ const idx = effortIndex(level)
137
+ const quiet = mode === 'off' || mode === 'paused'
138
+ const glyph: Run =
139
+ mode === 'deciding' ? { text: '◌', color: accent } : mode === 'paused' ? { text: '‖', dim: true } : { text: idx >= 0 ? RIPPLE_CHAR[idx]! : '○', ...(mode === 'auto' ? { color: accent } : quiet ? { dim: true } : {}) }
140
+ // Paused or off with no level known: a dash; the glyph, the Auto toggle and the card say why.
141
+ const word = mode === 'deciding' ? t(frame.lang, 'band.deciding') : level ? effortName(level, frame.lang) : '—'
142
+ const label = rich ? word : word.toLowerCase()
143
+ const value: Run = mode === 'deciding' ? { text: label, color: accent } : quiet ? { text: label, dim: true } : { text: label, bold: !rich }
144
+ const tag = t(frame.lang, mode === 'auto' || mode === 'deciding' ? 'band.auto' : mode === 'manual' ? 'band.manual' : mode === 'paused' ? 'band.paused' : 'band.autoOff')
145
+ const hint =
146
+ mode === 'paused' ? t(frame.lang, 'band.pausedHint') : mode === 'off' ? t(frame.lang, 'band.offHint') : mode === 'deciding' ? t(frame.lang, 'band.decidingHint') : why ?? ''
147
+ // Terminal: glyph, a cell, the label. Desktop: the glyph, then the label's button, whose own
148
+ // padding is the air on both sides.
149
+ // The desktop names the slot instead of a glyph: a ripple a few pixels across read as noise, not
150
+ // as a level. A cell to spare: a host font a little wider than the estimate must not wrap it.
151
+ const name = t(frame.lang, 'band.effortShort')
152
+ const width = rich ? Math.ceil(chOf(name) + PAD + 1 + Math.max(...effortWords(frame.lang, rich).map(chOf))) : 2 + widest(frame, effortWords(frame.lang, rich))
153
+ return {
154
+ key: 'effort',
155
+ width,
156
+ ...(rich ? { label: { text: name, dim: true } } : { glyph }),
157
+ value,
158
+ ...(rich ? { onPress } : {}),
159
+ card: [[view.model, tag].filter(Boolean).join(' · '), t(frame.lang, 'band.effortTitle', { value: word, hint: hint ? ' — ' + hint : '' })],
160
+ }
161
+ }
162
+
163
+ /** Cells a mode's name may take in the band; a longer one is cut with an ellipsis. */
164
+ export const MODE_CELLS = 12
165
+
166
+ /** The active mode's name as the band shows it: whole, or cut to its slot. */
167
+ export function modeLabel(name: string): string {
168
+ return clip(name, MODE_CELLS)
169
+ }
170
+
171
+ /**
172
+ * The active operating mode, when one is on: "Mode Burn GPT" in the accent, its slot the same
173
+ * width whatever the name (cut past MODE_CELLS), the whole name in the hover card.
174
+ */
175
+ function modeFact(frame: Frame, view: DashboardView): Fact | null {
176
+ if (!view.mode) return null
177
+ const rich = isRich(frame)
178
+ const name = t(frame.lang, 'band.mode')
179
+ const value: Run = { text: rich ? modeLabel(view.mode) : name + ' ' + modeLabel(view.mode), color: textTone(frame.appearance).accent }
180
+ const card = [t(frame.lang, 'band.modeTip', { name: view.mode })]
181
+ // Desktop: a cell is about a ch; Latin capitals run a little wider, so the slot takes 1.1 ch each.
182
+ if (rich) return { key: 'mode', width: Math.ceil(chOf(name) + 1 + MODE_CELLS * 1.1), label: { text: name, dim: true }, value, card }
183
+ return { key: 'mode', width: cells(frame, name) + 1 + MODE_CELLS, value, card }
184
+ }
185
+
186
+ /** The pie a share of the cache's life reads as. */
187
+ function pie(frac: number): string {
188
+ return frac > 0.875 ? '●' : frac > 0.625 ? '◕' : frac > 0.375 ? '◑' : frac > 0.125 ? '◔' : '○'
189
+ }
190
+
191
+ function cacheFact(frame: Frame, view: DashboardView): Fact | null {
192
+ if (view.hide.includes('timer')) return null
193
+ const accent = textTone(frame.appearance).accent
194
+ const cache = view.cache
195
+ const left = cache ? cache.expiresAt - frame.now : 0
196
+ // Whole minutes, as shown: the slot changes once a minute and no more.
197
+ const shown = cache && left > 0 ? Math.ceil(left / 60_000) : 0
198
+ const warm = view.warm
199
+ const lit = warm.on && !warm.stopped
200
+ const last = shown === 1
201
+ const glyph: Run = { text: shown > 0 && cache ? pie(Math.min(1, (shown * 60_000) / cache.ttlMs)) : '○', ...(last ? { color: WARN } : lit && shown > 0 ? { color: accent } : { dim: true }) }
202
+ const text = cache ? minutesLeft(left, frame.lang) : '—'
203
+ const value: Run = last ? { text, color: WARN } : { text, dim: shown === 0 }
204
+ const tip = cache
205
+ ? t(frame.lang, 'band.cacheTip', {
206
+ state: shown > 0 ? t(frame.lang, 'band.warmFor', { time: text }) : t(frame.lang, 'band.expired'),
207
+ warm: warm.on ? (warm.stopped ? t(frame.lang, 'band.warmStoppedTip', { reason: warm.stopped }) : t(frame.lang, 'band.warmOnTip', { count: warm.pings })) : '',
208
+ })
209
+ : t(frame.lang, 'band.noReply')
210
+ const words = [t(frame.lang, 'units.minutes', { n: 60 }), t(frame.lang, 'band.cold'), '—']
211
+ if (!isRich(frame)) return { key: 'cache', width: 2 + widest(frame, words), glyph, value, card: [tip] }
212
+ // Desktop: "Cache 58 min", the last minute in the warning colour; the pie read as a status dot.
213
+ const name = t(frame.lang, 'band.cache')
214
+ return { key: 'cache', width: Math.ceil(chOf(name) + 1 + Math.max(...words.map(chOf))), label: { text: name, dim: true }, value, card: [tip] }
215
+ }
216
+
217
+ function contextFact(frame: Frame, view: DashboardView): Fact | null {
218
+ const ctx = view.context
219
+ if (!ctx || !view.extras.includes('context')) return null
220
+ const tone = contextTone(ctx.percent, view.swampAt)
221
+ const rich = isRich(frame)
222
+ // Desktop: "Context 38%" in the facts' idiom; the terminal keeps its short "ctx 38%".
223
+ const text = rich ? Math.round(ctx.percent) + '%' : t(frame.lang, 'band.footerContext', { percent: Math.round(ctx.percent) })
224
+ const value: Run = tone === 'ink' ? { text, dim: !rich } : { text, color: tone === 'warn' ? WARN : BAD }
225
+ const name = t(frame.lang, 'band.context')
226
+ const width = rich ? Math.ceil(chOf(name) + 1 + chOf('100%')) : cells(frame, t(frame.lang, 'band.footerContext', { percent: 100 }))
227
+ 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) })] }
228
+ }
229
+
230
+ function limitFact(frame: Frame, view: DashboardView): Fact | null {
231
+ const limit = view.limit
232
+ if (!limit || !view.extras.includes('limits')) return null
233
+ const tone = limitTone(limit.percent)
234
+ const rich = isRich(frame)
235
+ const save = t(frame.lang, 'band.save')
236
+ const tail: Run[] = limit.save ? [{ text: save, color: textTone(frame.appearance).accent }] : []
237
+ const resets = resetsLabel(limit.resetsAt, frame.now, frame.lang)
238
+ const card = [t(frame.lang, 'band.limitAlt', { window: limitName(limit.kind, frame.lang), value: Math.round(limit.percent) + '%', reset: resets ? ', ' + resets : '' })]
239
+ if (rich) {
240
+ // Desktop: "5h limit 64%", not a bare "5h 64%".
241
+ const names = (['five_hour', 'seven_day'] as const).map(kind => t(frame.lang, 'band.limitCaption', { window: limitName(kind, frame.lang), save: '' }))
242
+ const text = Math.round(limit.percent) + '%'
243
+ const value: Run = tone === 'ink' ? { text } : { text, color: tone === 'warn' ? WARN : BAD }
244
+ const width = Math.ceil(Math.max(...names.map(chOf)) + 1 + chOf('100%') + 1 + chOf(save))
245
+ return { key: 'limit', width, label: { text: names[limit.kind === 'five_hour' ? 0 : 1]!, dim: true }, value, tail, card }
246
+ }
247
+ const text = limitName(limit.kind, frame.lang) + ' ' + Math.round(limit.percent) + '%'
248
+ const value: Run = tone === 'ink' ? { text, dim: true } : { text, color: tone === 'warn' ? WARN : BAD }
249
+ const width = widest(frame, [limitName('five_hour', frame.lang), limitName('seven_day', frame.lang)]) + cells(frame, ' 100%') + 1 + cells(frame, save)
250
+ return { key: 'limit', width, value, tail, card: [t(frame.lang, 'band.limitAlt', { window: limitName(limit.kind, frame.lang), value: Math.round(limit.percent) + '%', reset: resets ? ', ' + resets : '' })] }
251
+ }
252
+
253
+ /** The last route in `room` cells, or the router's notice; null when there is neither or no room. */
254
+ function routeFact(frame: Frame, view: DashboardView, room: number, onPress: () => void): Fact | null {
255
+ const rich = isRich(frame)
256
+ if (view.hide.includes('route') || room < (rich ? RICH_ROUTE_MIN : ROUTE_MIN)) return null
257
+ const r = view.route
258
+ if (!r) {
259
+ if (!view.notice) return null
260
+ return { key: 'route', width: room, ...(rich ? { grow: true as const } : {}), value: { text: clip(view.notice.replace(/^ClaudeRipple:\s*/, ''), room), dim: true } }
261
+ }
262
+ const glyph: Run = r.resent ? { text: '↻', dim: true } : r.ok ? { text: rich ? '→' : '↳', dim: true } : { text: '✕', color: BAD }
263
+ // The desktop says what the figure is ("hit 100%"); a bare percentage beside a model name read as anything.
264
+ const hit = r.cachePct !== undefined ? (rich ? t(frame.lang, 'band.hit', { percent: r.cachePct }) : r.cachePct + '%') : ''
265
+ const tail: Run[] = !r.ok
266
+ ? [r.resent ? { text: t(frame.lang, 'band.resent', { status: r.status }), dim: true } : { text: r.status, color: BAD }]
267
+ : r.cachePct !== undefined
268
+ ? [cacheTone(r.cachePct) === 'ok' ? { text: hit, dim: true } : { text: hit, color: cacheTone(r.cachePct) === 'warn' ? WARN : BAD }]
269
+ : []
270
+ const tailCells = tail.reduce((n, run) => n + 1 + cells(frame, run.text), 0)
271
+ const modelRoom = room - 2 - tailCells
272
+ if (!rich && modelRoom < 4) return null
273
+ const secs = seconds(r.seconds, frame.lang)
274
+ const card = [
275
+ t(frame.lang, 'band.routeAlt', { model: r.model, provider: r.provider, effort: r.effort ? ', ' + effortLabel(r.effort, frame.lang) : '', cache: r.cachePct !== undefined ? t(frame.lang, 'band.routeCache', { percent: r.cachePct }) : '', time: secs, status: r.ok ? '' : t(frame.lang, r.resent ? 'band.routeResent' : 'band.routeFailed', { status: r.status }) }),
276
+ ...(r.note ? [r.note] : []),
277
+ t(frame.lang, 'band.logHint'),
278
+ ]
279
+ // The desktop shows the whole name in whatever the row leaves; the host cuts it only past that.
280
+ if (rich) return { key: 'route', width: room, grow: true, glyph, value: { text: r.model, dim: true }, onPress, tail, card }
281
+ return { key: 'route', width: room, glyph, value: { text: clip(r.model, modelRoom) }, tail, card }
282
+ }
283
+
284
+ // ---- Controls ----------------------------------------------------------------------------------
285
+
286
+ /**
287
+ * A control: its label, the terminal's hotkey, and whether it is on (a toggle that is on, an open
288
+ * pane, the advised Handoff). The terminal says it in the label (● / ○), as its plain Buttons take
289
+ * no colour.
290
+ */
291
+ type Control = { key: string; label: string; hotkey: string; onPress: () => void; on: boolean; count?: string }
292
+
293
+ function controls(frame: Frame, view: DashboardView, actions: BandActions): Control[] {
294
+ const rich = isRich(frame)
295
+ const L = frame.lang
296
+ const mark = (on: boolean) => (rich ? '' : on ? ' ●' : ' ○')
297
+ const out: Control[] = []
298
+ // Handoff comes and goes; first in the row, it moves nothing when it does.
299
+ if (view.handoff !== 'off') {
300
+ const advised = view.handoff === 'advised'
301
+ out.push({ key: 'handoff', label: t(L, 'band.handoff') + (!rich && advised ? ' ◆' : ''), hotkey: 'h', onPress: actions.handoff, on: advised })
302
+ }
303
+ out.push(
304
+ { key: 'auto', label: t(L, 'band.auto') + mark(view.auto), hotkey: 'a', onPress: actions.toggleAuto, on: view.auto },
305
+ { key: 'warm', label: t(L, 'band.warm') + mark(view.warm.on), hotkey: 'w', onPress: actions.toggleWarm, on: view.warm.on },
306
+ // The desktop opens the log from the route itself (its value is that button), so it has no Log of its own.
307
+ ...(rich ? [] : [{ key: 'log', label: t(L, 'band.log') + mark(view.logOpen), hotkey: 'l', onPress: actions.toggleLog, on: view.logOpen }]),
308
+ // The count has a slot of its own (desktop) or is always a number (terminal): no width change.
309
+ { key: 'agents', label: t(L, 'band.agents') + (rich ? '' : ' ' + (view.agents.live || '·')) + mark(view.agentsOpen), hotkey: 'g', onPress: actions.toggleAgents, on: view.agentsOpen, ...(rich ? { count: view.agents.live > 0 ? String(view.agents.live) : '' } : {}) },
310
+ // A word on the desktop: the host's 14 px font draws ⚙ as a small ring that reads as "o".
311
+ { key: 'settings', label: rich ? t(L, 'band.settings') : '⚙', hotkey: 's', onPress: actions.openSettings, on: false },
312
+ )
313
+ return out
314
+ }
315
+
316
+ /** Cells a control takes. Desktop: the label in the button's padding, then the count's slot. */
317
+ function controlCells(frame: Frame, c: Control): number {
318
+ if (!isRich(frame)) return cells(frame, c.hotkey) + 2 + cells(frame, c.label)
319
+ return cells(frame, c.label) + PAD + (c.count !== undefined ? COUNT_CELLS : 0)
320
+ }
321
+
322
+ /** Every control's cells, Handoff counted whether shown or not, so the route's room stays put. */
323
+ function controlsCells(frame: Frame, view: DashboardView, actions: BandActions): number {
324
+ const list = controls(frame, { ...view, handoff: 'advised' }, actions)
325
+ return list.reduce((n, c) => n + controlCells(frame, c), 0) + CTRL_GAP * (list.length - 1) * (isRich(frame) ? 1 : 2)
326
+ }
327
+
328
+ // ---- Layout ------------------------------------------------------------------------------------
329
+
330
+ const span = (facts: readonly Fact[]) => facts.reduce((n, f) => n + f.width, 0) + GAP * Math.max(0, facts.length - 1)
331
+
332
+ /** The facts in order, the route in what the row leaves it. */
333
+ function factsFor(frame: Frame, view: DashboardView, actions: BandActions, room: number, drop: readonly string[] = []): Fact[] {
334
+ const fixed = [effortFact(frame, view, actions.toggleEffortMenu), modeFact(frame, view), contextFact(frame, view), cacheFact(frame, view), limitFact(frame, view)].filter(
335
+ (f): f is Fact => f !== null && !drop.includes(f.key),
336
+ )
337
+ const left = room - span(fixed) - (fixed.length ? GAP : 0)
338
+ // The desktop's route is a flex item: it takes what the row really leaves (the room counted here
339
+ // keeps Handoff's place, which it mostly does not need) and the host cuts its name past that.
340
+ const route = drop.includes('route') ? null : routeFact(frame, view, isRich(frame) ? Math.max(left, RICH_ROUTE_MIN) : left, actions.toggleLog)
341
+ return route ? [...fixed, route] : fixed
342
+ }
343
+
344
+ // ---- Desktop -----------------------------------------------------------------------------------
345
+ //
346
+ // One capsule of the deep (kit.tsx `capsule`): 리플이 at its left in a glow, ripples spreading from
347
+ // it (quick while a request is out, a current running along the floor), a sonar sweep while the
348
+ // judge sounds how deep to think, amber light as the cache runs out, frost and snow once it has. On
349
+ // it, left to right: the effort as five bars rising shallow to deep and its word; a rule; the
350
+ // context ring, the cache clock, the plan limit and the mode when on; a rule; the last route (its
351
+ // model the way into the log); the judge's reason in what is left. Then the controls, each lit
352
+ // under the pointer: Handoff when advised, Auto's switch, keep-warm's flame, the agents, settings,
353
+ // and the way out. Pointing at a fact shows what it means in the reason's place.
354
+
355
+ /** 리플이 in the band, px: the mini drawing (ripple.ts), a little taller than a line of text. */
356
+ export const BAND_RIPPLE = 24
357
+ /** 리플이's slot at the row's start, in cells: 24 px and its air. */
358
+ export const CREST_CELLS = 4
359
+ /** The row's height, px: a pill's, whether or not one shows, so Handoff never moves the band. */
360
+ const ROW_H = 26
361
+ /** The air between facts, in cells (the host takes fractions of a cell). */
362
+ const DRAWN_GAP = 1.25
363
+ /** A rule between the band's groups, in cells. */
364
+ const RULE_CELLS = 1
365
+ /** The room the route is counted at (a model name and its hit); it takes more when the row has it. */
366
+ const ROUTE_ROOM = 10
367
+ /** The controls whose words go first when the band is narrow, in that order; Auto's goes last. */
368
+ const WORDLESS = ['settings', 'agents', 'warm'] as const
369
+ /** Cells Auto's switch takes without its word. */
370
+ const SWITCH_ONLY = Math.ceil(36 / CELL_PX) + 1
371
+ /** The capsule's own air, in cells across and rows down. */
372
+ const PAD_X = 1
373
+ const PAD_Y = 0.5
374
+
375
+ /** The water's light and motion for what 리플이 is acting out. */
376
+ function bandSea(mood: BandMood): Omit<Sea, 'at'> {
377
+ const strength = mood.dim ? 0.5 : 1
378
+ if (mood.face === 'thinking') return { tone: 'violet', ripples: 'calm', sweep: true, motes: true }
379
+ if (mood.face === 'working') return { tone: 'violet', ripples: 'busy', current: true, strength }
380
+ if (mood.face === 'drowsy') return { tone: 'amber', ripples: 'calm', motes: true, strength }
381
+ if (mood.face === 'sleepy') return { tone: 'ice', weather: 'snow', strength: 0.8 }
382
+ return { tone: 'violet', ripples: 'calm', motes: true, strength }
383
+ }
384
+
385
+ /** A fact's hover group: pointing at it shows its tip in the reason's place. */
386
+ const tipScope = (key: string) => 'ripple-tip-' + key
387
+
388
+ type DrawnFact = { key: string; cells: number; node: RenderNode; tip?: string }
389
+
390
+ function drawnFact(el: Els, key: string, cells: number, children: RenderNode[], tip?: string): DrawnFact {
391
+ const { Box } = el
392
+ return {
393
+ key,
394
+ cells,
395
+ tip,
396
+ node: (
397
+ <Box key={'fact-' + key} position="relative" flexDirection="row" alignItems="center" columnGap={1} width={cells} flexShrink={0} {...(tip ? { hover: { scope: tipScope(key) } } : {})}>
398
+ {children}
399
+ </Box>
400
+ ),
401
+ }
402
+ }
403
+
404
+ /** A figure's colour by its tone: bright ink while all is well, amber, then red. */
405
+ function figure(tone: 'ink' | 'ok' | 'warn' | 'bad'): string {
406
+ return tone === 'warn' ? TONE.amber.text : tone === 'bad' ? TONE.red.text : INK
407
+ }
408
+
409
+ function effortDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActions): DrawnFact {
410
+ const { Box, Text } = el
411
+ const { level, mode } = view.effort
412
+ const idx = effortIndex(level)
413
+ const word = mode === 'deciding' ? t(frame.lang, 'band.deciding') : level ? effortName(level, frame.lang) : '—'
414
+ const look = mode === 'deciding' ? 'deciding' : mode === 'auto' ? 'auto' : mode === 'manual' ? 'manual' : 'quiet'
415
+ // The word's slot is sized for the widest word (bold Hangul runs ~15% wide of the estimate).
416
+ const wordPx = Math.max(...effortWords(frame.lang, true).map(w => textPx(w) * 1.15))
417
+ const cells = Math.ceil((METER_W + CELL_PX + wordPx) / CELL_PX)
418
+ const tip = effortFact(frame, view, actions.toggleEffortMenu).card?.join(' · ')
419
+ return drawnFact(
420
+ el,
421
+ 'effort',
422
+ cells,
423
+ [
424
+ pic(el, 'meter', meterArt(mode === 'deciding' ? 5 : idx + 1, look), t(frame.lang, 'band.effort'), METER_W, METER_H),
425
+ <Box key="effort-word" flexShrink={0}>
426
+ <Text bold color={mode === 'deciding' ? TONE.violet.text : look === 'quiet' ? MUTE : INK}>{word}</Text>
427
+ </Box>,
428
+ press(el, 'effort', cells * CELL_PX, actions.toggleEffortMenu),
429
+ ],
430
+ tip,
431
+ )
432
+ }
433
+
434
+ function contextDrawn(el: Els, frame: Frame, view: DashboardView): DrawnFact | null {
435
+ const ctx = view.context
436
+ if (!ctx || !view.extras.includes('context')) return null
437
+ const { Text } = el
438
+ const tone = contextTone(ctx.percent, view.swampAt)
439
+ const tip = contextFact(frame, view)?.card?.join(' · ')
440
+ const cells = Math.ceil((16 + CELL_PX + textPx('100%')) / CELL_PX)
441
+ return drawnFact(el, 'context', cells, [
442
+ pic(el, 'ring', ringArt(ctx.percent, tone === 'ink' ? 'ink' : tone === 'warn' ? 'amber' : 'red'), t(frame.lang, 'band.context'), 16, 16),
443
+ <Text key="ctx-value" color={figure(tone)}>{Math.round(ctx.percent) + '%'}</Text>,
444
+ ], tip)
445
+ }
446
+
447
+ function cacheDrawn(el: Els, frame: Frame, view: DashboardView): DrawnFact | null {
448
+ if (view.hide.includes('timer')) return null
449
+ const { Text } = el
450
+ const cache = view.cache
451
+ const left = cache ? cache.expiresAt - frame.now : 0
452
+ const shown = cache && left > 0 ? Math.ceil(left / 60_000) : 0
453
+ const held = view.warm.on && !view.warm.stopped
454
+ const drowsy = !!cache && shown > 0 && !held && shown * 60_000 <= Math.min(DROWSY_MS, cache.ttlMs / 5)
455
+ const tone: Tone | 'soft' = shown === 0 ? 'soft' : drowsy ? 'amber' : held ? 'violet' : 'soft'
456
+ const text = cache ? minutesLeft(left, frame.lang) : '—'
457
+ const words = [t(frame.lang, 'units.minutes', { n: 60 }), t(frame.lang, 'band.cold'), '—']
458
+ const cells = Math.ceil((16 + CELL_PX + Math.max(...words.map(textPx))) / CELL_PX)
459
+ const tip = cacheFact(frame, view)?.card?.join(' · ')
460
+ return drawnFact(el, 'cache', cells, [
461
+ pic(el, 'clock', clockArt(cache ? (shown * 60_000) / cache.ttlMs : 0, tone), t(frame.lang, 'band.cache'), 16, 16),
462
+ <Text key="cache-value" color={drowsy ? TONE.amber.text : shown === 0 ? MUTE : held ? TONE.violet.text : INK}>{text}</Text>,
463
+ ], tip)
464
+ }
465
+
466
+ function limitDrawn(el: Els, frame: Frame, view: DashboardView): DrawnFact | null {
467
+ const limit = view.limit
468
+ if (!limit || !view.extras.includes('limits')) return null
469
+ const { Text } = el
470
+ const names = [limitName('five_hour', frame.lang), limitName('seven_day', frame.lang)]
471
+ const save = t(frame.lang, 'band.save')
472
+ // Save's word is counted whether it shows or not: turning save mode on moves nothing.
473
+ const cells = Math.ceil((Math.max(...names.map(textPx)) + CELL_PX + textPx('100%') + CELL_PX + textPx(save)) / CELL_PX)
474
+ return drawnFact(el, 'limit', cells, [
475
+ <Text key="limit-name" color={SUB}>{limitName(limit.kind, frame.lang)}</Text>,
476
+ <Text key="limit-value" color={figure(limitTone(limit.percent))}>{Math.round(limit.percent) + '%'}</Text>,
477
+ limit.save ? <Text key="limit-save" color={TONE.violet.text}>{save}</Text> : '',
478
+ ], limitFact(frame, view)?.card?.join(' · '))
479
+ }
480
+
481
+ function modeDrawn(el: Els, frame: Frame, view: DashboardView): DrawnFact | null {
482
+ if (!view.mode) return null
483
+ const { Text } = el
484
+ // One slot whatever the name (cut past MODE_CELLS; Latin capitals run ~1.1 ch).
485
+ const amber = TONE.amber.text
486
+ 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 }))
487
+ }
488
+
489
+ /**
490
+ * The routed model as the band names it: what was asked dropped, and Claude's ids by their
491
+ * family and version ("claude-opus-5-5" → "Opus 5.5"); the whole route is in the tip.
492
+ */
493
+ function routeName(model: string): string {
494
+ return modelName(model.replace(/\s*\(.*\)$/, ''))
495
+ }
496
+
497
+ /** The last route: where it went (the provider's dot), the model, its cache hit; the way into the log. */
498
+ function routeDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActions): RenderNode {
499
+ const { Box, Text } = el
500
+ if (view.hide.includes('route')) return ''
501
+ const r = view.route
502
+ if (!r) {
503
+ if (!view.notice) return ''
504
+ return (
505
+ <Box key="fact-route" position="relative" flexShrink={1} minWidth={0} overflow="hidden">
506
+ <Text color={MUTE} wrap="truncate">{view.notice.replace(/^ClaudeRipple:\s*/, '')}</Text>
507
+ </Box>
508
+ )
509
+ }
510
+ const tone = r.cachePct !== undefined ? cacheTone(r.cachePct) : 'ok'
511
+ const tip = routeFact(frame, view, 200, actions.toggleLog)?.card?.join(' · ')
512
+ return (
513
+ <Box key="fact-route" position="relative" flexDirection="row" alignItems="center" columnGap={1} flexShrink={1} minWidth={ROUTE_ROOM} overflow="hidden" {...(tip ? { hover: { scope: tipScope('route') } } : {})}>
514
+ <Box flexShrink={0}>
515
+ {r.ok ? <Text color={r.resent ? MUTE : providerColor(r.provider)}>{r.resent ? '↻' : '•'}</Text> : <Text color={TONE.red.text}>✕</Text>}
516
+ </Box>
517
+ <Box flexShrink={1} minWidth={0}>
518
+ <Text color={SUB} wrap="truncate">{routeName(r.model)}</Text>
519
+ </Box>
520
+ <Box flexShrink={0}>
521
+ {!r.ok ? (
522
+ <Text color={r.resent ? MUTE : TONE.red.text}>{r.resent ? t(frame.lang, 'band.resent', { status: r.status }) : r.status}</Text>
523
+ ) : r.cachePct !== undefined ? (
524
+ <Text color={tone === 'ok' ? TONE.green.text : figure(tone === 'warn' ? 'warn' : 'bad')}>{r.cachePct + '%'}</Text>
525
+ ) : null}
526
+ </Box>
527
+ {press(el, 'route', Math.max(40, frame.columns) * CELL_PX * 0.4, actions.toggleLog)}
528
+ </Box>
529
+ )
530
+ }
531
+
532
+ /**
533
+ * What the row leaves, blank, and each fact's or control's tip laid over it on hover. The judge's
534
+ * reason is no longer written here (cut to a word or two it said nothing, 2026-10-13); it is in the
535
+ * effort's tip.
536
+ */
537
+ function whyDrawn(el: Els, frame: Frame, tips: { key: string; text: string }[]): RenderNode {
538
+ const { Box, Text } = el
539
+ const width = Math.max(40, frame.columns) * CELL_PX
540
+ return (
541
+ <Box key="band-why" position="relative" flexDirection="row" flexGrow={1} flexShrink={1} width={0} minWidth={0} overflow="hidden">
542
+ {tips.map(tip => (
543
+ <Box key={'tip-' + tip.key} position="absolute" top={0} bottom={0} left={0} right={0} display="none" hover={{ scope: tipScope(tip.key), display: 'flex' }} overflow="hidden">
544
+ <Box position="absolute" top={0} left={0}>
545
+ {pic(el, 'tip-plate-' + tip.key, plateArt(width, ROW_PX), tip.text, width, ROW_PX)}
546
+ </Box>
547
+ <Box position="relative" flexShrink={1} minWidth={0}>
548
+ {lift(el)}
549
+ <Text color={INK} wrap="truncate">{tip.text}</Text>
550
+ </Box>
551
+ </Box>
552
+ ))}
553
+ </Box>
554
+ )
555
+ }
556
+
557
+ /** A thin rule between the band's groups. */
558
+ function rule(el: Els, key: string): RenderNode {
559
+ const { Box } = el
560
+ return (
561
+ <Box key={'rule-' + key} width={RULE_CELLS} flexShrink={0} alignItems="center">
562
+ {pic(el, 'rule-' + key, ruleArt(), '|', 9, 16)}
563
+ </Box>
564
+ )
565
+ }
566
+
567
+ type DrawnControl = { key: string; cells: number; node: RenderNode; tip: string }
568
+
569
+ /** An icon control, lit under the pointer: its picture, its word when the band has room, its press. */
570
+ function iconControl(el: Els, key: string, source: string, artPx: number, alt: string, tip: string, onPress: () => void, label?: string, color = SUB): DrawnControl {
571
+ const { Text } = el
572
+ const pad = label ? 0.5 : 0.25
573
+ const cells = Math.ceil((artPx + (label ? CELL_PX + chOf(label) * CELL_PX * 1.04 : 0)) / CELL_PX + pad * 2)
574
+ return {
575
+ key,
576
+ cells,
577
+ tip,
578
+ 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)),
579
+ }
580
+ }
581
+
582
+ /** The controls at the right end, each naming what it does on hover; `words` the ones with their word. */
583
+ function controlsDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActions, words: readonly string[]): DrawnControl[] {
584
+ const L = frame.lang
585
+ const out: DrawnControl[] = []
586
+ const state = (on: boolean) => t(L, on ? 'band.on' : 'band.off')
587
+ if (view.handoff !== 'off') {
588
+ const advised = view.handoff === 'advised'
589
+ const label = t(L, 'band.handoff')
590
+ const look = advised ? 'primary' : 'ghost'
591
+ 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') })
592
+ }
593
+ const autoLabel = t(L, 'band.auto')
594
+ const autoTip = t(L, 'band.effortAutoAlt', { state: state(view.auto) }) + ' · ' + t(L, 'settings.autoLine')
595
+ out.push(words.includes('auto')
596
+ ? { key: 'auto', cells: toggleCells(autoLabel), node: toggle(el, 'auto', view.auto, autoLabel, actions.toggleAuto, view.effort.mode === 'deciding', tipScope('auto')), tip: autoTip }
597
+ : { key: 'auto', cells: SWITCH_ONLY, node: lit(el, 'auto', SWITCH_ONLY, [pic(el, 'auto', switchArt(view.auto, view.effort.mode === 'deciding'), autoLabel, 36, 22), press(el, 'auto', SWITCH_ONLY * CELL_PX, actions.toggleAuto)], 0.25, tipScope('auto')), tip: autoTip })
598
+ const warmLabel = t(L, 'band.warm')
599
+ 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, 'settings.warmLine'), actions.toggleWarm, words.includes('warm') ? warmLabel : undefined, view.warm.on ? TONE.amber.text : SUB))
600
+ const agentsLabel = t(L, 'band.agents')
601
+ const live = view.agents.live
602
+ // The live count sits on the icon's shoulder, in one picture of one width: no width change.
603
+ 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))
604
+ const settingsLabel = t(L, 'band.settings')
605
+ out.push(iconControl(el, 'settings', iconArt('gear', false), ICON_PX, settingsLabel, t(L, 'band.settingsAlt'), actions.openSettings, words.includes('settings') ? settingsLabel : undefined))
606
+ return out
607
+ }
608
+
609
+ function effortMenuDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActions): RenderNode {
610
+ const { Box } = el
611
+ const current = view.effort.mode === 'manual' || view.effort.mode === 'off' ? view.effort.level : null
612
+ return (
613
+ <Box key="effort-menu" flexDirection="row" alignItems="center" flexWrap="wrap" columnGap={2} rowGap={1} paddingTop={1} paddingLeft={CREST_CELLS}>
614
+ {toggle(el, 'pick-auto', view.auto, t(frame.lang, 'band.auto'), actions.toggleAuto)}
615
+ {segmented(el, 'pick', EFFORTS.map(effort => ({ value: effort, label: effortName(effort, frame.lang) })), current, effort => actions.pickEffort(effort), 'violet', DEPTH)}
616
+ </Box>
617
+ )
618
+ }
619
+
620
+ function bandDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActions): RenderNode {
621
+ const { Box } = el
622
+ // The capsule's air and edge take their share of the frame.
623
+ const cols = Math.max(30, frame.columns) - 2 * PAD_X - 1
624
+ const mood = bandMood(frame, view)
625
+ // The row's budget, widths alone (never values): 리플이, the facts at their slots, the rules, a
626
+ // route at its least, the controls, the way out. Narrow, the controls drop their words first,
627
+ // then the quietest facts go.
628
+ const close = actions.hide ? CLOSE_CELLS : 0
629
+ const effort = effortDrawn(el, frame, view, actions)
630
+ 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)
631
+ let words: readonly string[] = ['auto', ...WORDLESS]
632
+ // Counted with Handoff in its place whether it shows or not, and the route at a fixed room
633
+ // (a long model name is cut, a short one leaves air): what the band holds never depends on a value.
634
+ const counted = (w: readonly string[]) => controlsDrawn(el, frame, { ...view, handoff: 'advised' }, actions, w).reduce((n, c) => n + c.cells + 0.5, 0)
635
+ let controlCells = counted(words)
636
+ let route = view.hide.includes('route') ? 0 : ROUTE_ROOM
637
+ let ruled = true
638
+ const span = (facts: DrawnFact[]) => facts.reduce((n, f) => n + f.cells + DRAWN_GAP, 0)
639
+ const rules = (facts: DrawnFact[]) => (ruled ? (facts.length ? RULE_CELLS + DRAWN_GAP : 0) + (route ? RULE_CELLS + DRAWN_GAP : 0) : 0)
640
+ const need = (facts: DrawnFact[]) => CREST_CELLS + DRAWN_GAP + effort.cells + DRAWN_GAP + rules(facts) + span(facts) + (route ? route + DRAWN_GAP : 0) + controlCells + close + 1
641
+ let facts = optional
642
+ // Narrow, things go in this order until the row fits: the controls' words (settings, agents,
643
+ // keep-warm), the rules, the standing facts (the plan limit, the mode, context), Auto's word, then
644
+ // the route and last the cache. A changing figure outlasts a standing state.
645
+ for (const drop of [...WORDLESS, 'rules', 'limit', 'mode', 'context', 'auto', 'route', 'cache']) {
646
+ if (need(facts) <= cols) break
647
+ if ((WORDLESS as readonly string[]).includes(drop) || drop === 'auto') {
648
+ words = words.filter(w => w !== drop)
649
+ controlCells = counted(words)
650
+ } else if (drop === 'rules') ruled = false
651
+ else if (drop === 'route') route = 0
652
+ else facts = facts.filter(f => f.key !== drop)
653
+ }
654
+ const controls = controlsDrawn(el, frame, view, actions, words)
655
+ const tips = [effort, ...facts].filter(f => f.tip).map(f => ({ key: f.key, text: f.tip! }))
656
+ const routeTip = route > 0 && view.route ? routeFact(frame, view, 200, actions.toggleLog)?.card?.join(' · ') : undefined
657
+ if (routeTip) tips.push({ key: 'route', text: routeTip })
658
+ for (const c of controls) if (c.key !== 'handoff') tips.push({ key: c.key, text: c.tip })
659
+ if (actions.hide) tips.push({ key: 'hide', text: t(frame.lang, 'band.hideBand') })
660
+ const crest = (
661
+ <Box key="band-crest" width={CREST_CELLS} flexShrink={0} alignItems="center">
662
+ {ripple(el, 'band-' + mood.face + (mood.dim ? '-dim' : ''), mood.face, 'ClaudeRipple', BAND_RIPPLE, undefined, mood.dim)}
663
+ </Box>
664
+ )
665
+ const routeNode = route > 0 ? routeDrawn(el, frame, view, actions) : ''
666
+ const row = (
667
+ <Box key="band-row" flexDirection="row" alignItems="center" width="100%" minWidth={0} height={rowsOf(ROW_H)}>
668
+ <Box key="band-facts" flexDirection="row" alignItems="center" columnGap={DRAWN_GAP} flexGrow={1} flexShrink={1} width={0} minWidth={0}>
669
+ {crest}
670
+ {effort.node}
671
+ {ruled && facts.length ? rule(el, 'facts') : null}
672
+ {facts.map(f => f.node)}
673
+ {ruled && routeNode ? rule(el, 'route') : null}
674
+ {routeNode}
675
+ {whyDrawn(el, frame, tips)}
676
+ </Box>
677
+ <Box key="band-controls" flexDirection="row" alignItems="center" columnGap={0.5} flexShrink={0} marginLeft={1}>
678
+ {controls.map(c => c.node)}
679
+ {actions.hide ? closeButton(el, 'hide', t(frame.lang, 'band.hideBand'), () => actions.hide!(), tipScope('hide')) : null}
680
+ </Box>
681
+ </Box>
682
+ )
683
+ return capsule(el, frame, {
684
+ key: 'band',
685
+ 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 } },
686
+ rows: view.effortMenu ? 3.4 : 1.8,
687
+ paddingX: PAD_X,
688
+ paddingY: PAD_Y,
689
+ children: [row, view.effortMenu ? effortMenuDrawn(el, frame, view, actions) : ''],
690
+ })
691
+ }
692
+
693
+ // ---- Terminal ----------------------------------------------------------------------------------
694
+
695
+ function runText(el: Els, key: string, run: Run): RenderNode {
696
+ const { Text } = el
697
+ return (
698
+ <Text key={key} color={run.color} dimColor={run.dim} bold={run.bold}>
699
+ {run.text}
700
+ </Text>
701
+ )
702
+ }
703
+
704
+ function factText(el: Els, f: Fact): RenderNode {
705
+ const { Box } = el
706
+ return (
707
+ <Box key={'fact-' + f.key} flexDirection="row" columnGap={1} width={f.width} flexShrink={0}>
708
+ {f.glyph ? runText(el, 'glyph', f.glyph) : null}
709
+ {runText(el, 'value', f.value)}
710
+ {(f.tail ?? []).map((run, i) => runText(el, 'tail-' + i, run))}
711
+ </Box>
712
+ )
713
+ }
714
+
715
+ /**
716
+ * The judge's reason in a slot of its own on a wide terminal, cut to it; the slot stays, blank,
717
+ * while there is no reason, so the route beside it never changes width.
718
+ */
719
+ function whyFact(view: DashboardView): Fact | null {
720
+ if (view.hide.includes('reason')) return null
721
+ const why = view.effort.mode === 'auto' ? view.effort.why : undefined
722
+ return { key: 'why', width: WHY_CELLS, value: { text: why ? clip('“' + why + '”', WHY_CELLS) : '', dim: true } }
723
+ }
724
+
725
+ function bandText(el: Els, frame: Frame, view: DashboardView, actions: BandActions): RenderNode {
726
+ const { Box } = el
727
+ const cols = Math.max(20, frame.columns)
728
+ const ctrls = controls(frame, view, actions)
729
+ const ctrlCells = controlsCells(frame, view, actions)
730
+ const budget = Math.max(1, frame.maxRows)
731
+ // One row when the facts (a route at its least) and the controls fit; else facts, then controls.
732
+ // Too tall for the band: the quietest facts go first. Widths alone decide, never values.
733
+ let drop: string[] = []
734
+ const plan = () => {
735
+ const routeCells = view.hide.includes('route') || drop.includes('route') ? 0 : GAP + ROUTE_MIN
736
+ const oneRow = span(factsFor(frame, view, actions, cols, [...drop, 'route'])) + routeCells + GAP + ctrlCells <= cols
737
+ const room = oneRow ? cols - ctrlCells - GAP : cols
738
+ return { oneRow, facts: factsFor(frame, view, actions, room, drop), room }
739
+ }
740
+ let p = plan()
741
+ for (const key of ['limit', 'context', 'route', 'mode', 'cache']) {
742
+ if ((p.oneRow ? 1 : 2) <= budget) break
743
+ drop = [...drop, key]
744
+ p = plan()
745
+ }
746
+ let facts = p.facts
747
+ // The reason only where the row has room for its whole slot and a roomy route beside it.
748
+ const why = whyFact(view)
749
+ const routeNeed = view.hide.includes('route') || drop.includes('route') ? 0 : GAP + 2 * WHY_CELLS
750
+ if (why && p.room - span(factsFor(frame, view, actions, p.room, [...drop, 'route'])) - GAP >= WHY_CELLS + routeNeed) {
751
+ const room = p.room - WHY_CELLS - GAP
752
+ facts = factsFor(frame, view, actions, room, drop)
753
+ const at = facts.findIndex(f => f.key === 'route')
754
+ facts = at < 0 ? [...facts, why] : [...facts.slice(0, at), why, ...facts.slice(at)]
755
+ }
756
+ const factRow = (
757
+ <Box key="facts" flexDirection="row" columnGap={GAP} flexShrink={0}>
758
+ {facts.map(f => factText(el, f))}
759
+ </Box>
760
+ )
761
+ const ctrlRow = (
762
+ <Box key="controls" flexDirection="row" flexWrap="wrap" columnGap={GAP} flexShrink={0}>
763
+ {ctrls.map(c => textButton(el, c.key, c.label, c.onPress, c.hotkey))}
764
+ </Box>
765
+ )
766
+ const menu = view.effortMenu ? (
767
+ <Box key="effort-menu" flexDirection="row" flexWrap="wrap" columnGap={GAP}>
768
+ {textButton(el, 'pick-auto', t(frame.lang, 'band.auto') + ' ' + (view.auto ? '●' : '○'), actions.toggleAuto, '0')}
769
+ {EFFORTS.map((effort: Effort, i) => textButton(el, 'pick-' + effort, RIPPLE_CHAR[i]! + ' ' + effortName(effort, frame.lang).toLowerCase(), () => actions.pickEffort(effort), String(i + 1)))}
770
+ </Box>
771
+ ) : null
772
+ return (
773
+ <Box flexDirection="column" width="100%">
774
+ {p.oneRow ? (
775
+ <Box key="row" flexDirection="row" width="100%">
776
+ {factRow}
777
+ <Box flexGrow={1} minWidth={GAP} />
778
+ {ctrlRow}
779
+ </Box>
780
+ ) : (
781
+ [factRow, ctrlRow]
782
+ )}
783
+ {menu}
784
+ </Box>
785
+ )
786
+ }
787
+
788
+ /**
789
+ * Cells the caller's controls beside the band take (the close control, and the terminal's effort
790
+ * button `e: Effort`), with the gap before them: what to take off the frame's columns.
791
+ */
792
+ export function besideBand(frame: Frame): number {
793
+ return isRich(frame) ? 5 : 2 + displayWidth('e: ' + t(frame.lang, 'band.effort')) + 2 + 1
794
+ }
795
+
796
+ /** The dashboard band, `frame.columns` being its own width (see besideBand). */
797
+ export function renderBand(el: Els, frame: Frame, view: DashboardView, actions: BandActions): RenderNode {
798
+ return isRich(frame) && 'Svg' in el ? bandDrawn(el, frame, view, actions) : bandText(el, frame, view, actions)
799
+ }
800
+
801
+ // ---- Minimal: the footer -----------------------------------------------------------------------
802
+
803
+ export type FooterActions = Pick<BandActions, 'toggleAuto' | 'openSettings'>
804
+
805
+ /**
806
+ * The minimal layout: one quiet line in the footer's mode area (SessionMode), the engine's own
807
+ * modes kept beside it. `engine` is what `next(e)` drew there.
808
+ */
809
+ export function renderFooter(el: Els, frame: Frame, view: DashboardView, actions: FooterActions, engine?: RenderChildren): RenderNode {
810
+ const { Box, Text } = el
811
+ const tone = textTone(frame.appearance)
812
+ const { level, mode } = view.effort
813
+ const idx = effortIndex(level)
814
+ const effortText =
815
+ mode === 'deciding' ? t(frame.lang, 'band.decidingText') : (idx >= 0 ? RIPPLE_CHAR[idx]! : '○') + ' ' + (level ? effortName(level, frame.lang).toLowerCase() : '—') + (mode === 'auto' ? '' : mode === 'paused' ? t(frame.lang, 'band.footerPaused') : mode === 'off' ? t(frame.lang, 'band.footerOff') : t(frame.lang, 'band.footerManual'))
816
+ const left = view.cache ? view.cache.expiresAt - frame.now : 0
817
+ const ctxTone = view.context ? contextTone(view.context.percent, view.swampAt) : 'ink'
818
+ return (
819
+ <Box flexDirection="row" flexWrap="wrap" columnGap={2} alignItems="center">
820
+ <Text color={mode === 'deciding' || mode === 'auto' ? tone.accent : undefined} dimColor={mode === 'paused' || mode === 'off'}>
821
+ {effortText}
822
+ </Text>
823
+ {view.mode ? <Text color={tone.accent}>{t(frame.lang, 'band.mode') + ' ' + modeLabel(view.mode)}</Text> : null}
824
+ {view.context && view.extras.includes('context') ? (
825
+ <Text dimColor color={ctxTone === 'ink' ? undefined : ctxTone === 'warn' ? WARN : BAD}>
826
+ {t(frame.lang, 'band.footerContext', { percent: Math.round(view.context.percent) })}
827
+ </Text>
828
+ ) : null}
829
+ {view.cache && !view.hide.includes('timer') ? <Text dimColor>{'⧗ ' + minutesLeft(left, frame.lang) + (view.warm.on ? ' ≈' : '')}</Text> : null}
830
+ {view.limit && view.extras.includes('limits') ? <Text dimColor>{limitName(view.limit.kind, frame.lang) + ' ' + Math.round(view.limit.percent) + '%'}</Text> : null}
831
+ {textButton(el, 'auto', t(frame.lang, 'band.auto') + (view.auto ? ' ●' : ' ○'), actions.toggleAuto)}
832
+ {textButton(el, 'settings', '⚙', actions.openSettings)}
833
+ {engine ?? null}
834
+ </Box>
835
+ )
836
+ }