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.
- package/CHANGELOG.md +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- 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,
|
|
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'):
|
|
410
|
-
|
|
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',
|
|
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
|
|
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',
|
|
447
|
-
<Text key="ctx-value"
|
|
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',
|
|
466
|
-
<Text key="cache-value"
|
|
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',
|
|
482
|
-
<Text key="limit-value"
|
|
483
|
-
limit.save ? <Text key="limit-save" color={
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
690
|
+
<Text {...k.sub} wrap="truncate">{name}</Text>
|
|
525
691
|
</Box>
|
|
526
692
|
<Box flexShrink={0}>
|
|
527
693
|
{!r.ok ? (
|
|
528
|
-
<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
|
|
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',
|
|
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
|
-
*
|
|
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.
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
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;
|
|
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
|
|
558
|
-
{
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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,
|
|
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,
|
|
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'}
|
|
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:
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 -----------------------------------------------------------------------
|