clauderipple 0.8.9 → 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 (75) hide show
  1. package/CHANGELOG.md +62 -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/dist/main.js +23 -3
  9. package/dist/app/package.json +1 -1
  10. package/dist/cli/src/codex.js +3 -1
  11. package/dist/cli/src/index.js +1 -1
  12. package/dist/cli/src/settings.js +5 -2
  13. package/dist/cli/src/tray.js +41 -5
  14. package/dist/mod/.claude-plugin/plugin.json +9 -1
  15. package/dist/mod/hooks/cache.ts +60 -0
  16. package/dist/mod/hooks/compact.ts +40 -0
  17. package/dist/mod/hooks/handoff.ts +43 -0
  18. package/dist/mod/hooks/i18n.ts +2716 -0
  19. package/dist/mod/hooks/judge.ts +226 -0
  20. package/dist/mod/hooks/ladder.js +179 -0
  21. package/dist/mod/hooks/ladder.ts +174 -0
  22. package/dist/mod/hooks/modes.ts +93 -0
  23. package/dist/mod/hooks/register.tsx +1458 -256
  24. package/dist/mod/hooks/session.ts +62 -0
  25. package/dist/mod/hooks/settings.js +342 -0
  26. package/dist/mod/hooks/settings.ts +321 -0
  27. package/dist/mod/hooks/stats.ts +59 -0
  28. package/dist/mod/hooks/ui/agents.tsx +138 -0
  29. package/dist/mod/hooks/ui/alerts.tsx +370 -0
  30. package/dist/mod/hooks/ui/art.ts +628 -0
  31. package/dist/mod/hooks/ui/band.tsx +836 -0
  32. package/dist/mod/hooks/ui/kit.tsx +258 -0
  33. package/dist/mod/hooks/ui/log.tsx +109 -0
  34. package/dist/mod/hooks/ui/parts.tsx +217 -0
  35. package/dist/mod/hooks/ui/ripple.ts +614 -0
  36. package/dist/mod/hooks/ui/settings.tsx +376 -0
  37. package/dist/mod/hooks/ui/setup.tsx +148 -0
  38. package/dist/mod/hooks/ui/theme.ts +217 -0
  39. package/dist/mod/hooks/ui/view.ts +371 -0
  40. package/dist/mod/hooks/worker.ts +1 -1
  41. package/dist/mod/hooks/workers.ts +217 -0
  42. package/dist/mod/tests/register.test.tsx +1730 -0
  43. package/dist/mod/tests/ui.test.tsx +853 -0
  44. package/dist/mod/tsconfig.json +2 -1
  45. package/dist/mod/types/index.d.ts +320 -1
  46. package/dist/router/src/admin.js +195 -53
  47. package/dist/router/src/agents.js +24 -5
  48. package/dist/router/src/bootstrap.js +4 -2
  49. package/dist/router/src/compat.js +10 -2
  50. package/dist/router/src/config.js +3 -0
  51. package/dist/router/src/health.js +33 -0
  52. package/dist/router/src/index.js +1 -0
  53. package/dist/router/src/ingress/models.js +4 -1
  54. package/dist/router/src/mod-settings.js +119 -0
  55. package/dist/router/src/model-policy.js +6 -0
  56. package/dist/router/src/model-scores.js +159 -0
  57. package/dist/router/src/picker.js +7 -0
  58. package/dist/router/src/providers/anthropic-account-pool.js +10 -4
  59. package/dist/router/src/providers/anthropic-accounts.js +26 -1
  60. package/dist/router/src/providers/chatgpt/catalog.js +45 -14
  61. package/dist/router/src/providers/chatgpt/index.js +9 -3
  62. package/dist/router/src/providers/chatgpt/translate.js +9 -5
  63. package/dist/router/src/providers/google/index.js +6 -2
  64. package/dist/router/src/providers/openai/index.js +4 -1
  65. package/dist/router/src/providers/openai/translate.js +1 -1
  66. package/dist/router/src/proxy.js +14 -3
  67. package/dist/router/src/version.js +1 -1
  68. package/dist/ui/app.js +932 -209
  69. package/dist/ui/favicon-32.png +0 -0
  70. package/dist/ui/favicon.svg +12 -0
  71. package/dist/ui/i18n.js +923 -237
  72. package/dist/ui/index.html +159 -87
  73. package/dist/ui/style.css +789 -408
  74. package/docs/ARCHITECTURE.md +134 -2
  75. package/package.json +7 -3
@@ -0,0 +1,217 @@
1
+ // Colours and SVG plumbing shared by every drawing of the mod. Pure: no engine calls.
2
+ //
3
+ // The look is calm at rest: neutral ink on the host's own background, the ClaudeRipple accent
4
+ // (#6a56d6, packages/ui/style.css) only where something wants attention, and green/amber/red kept
5
+ // for results. SVG chips do not know the host's background, so they are drawn in translucent ink
6
+ // (a few percent of the text colour) and sit on the Code tab's warm or cool greys alike.
7
+
8
+ export type Appearance = 'auto' | 'dark' | 'light'
9
+
10
+ export type Palette = {
11
+ /** Body text. */
12
+ ink: string
13
+ /** Secondary text. */
14
+ muted: string
15
+ /** Hairlines, unlit glyph parts. */
16
+ faint: string
17
+ /** Chip fill and stroke: ink at a few percent. */
18
+ fill: string
19
+ stroke: string
20
+ accent: string
21
+ /** The accent as a wash behind a lit control. */
22
+ accentWash: string
23
+ accentLine: string
24
+ /** Fill of a primary action, white text on it. */
25
+ primary: string
26
+ ok: string
27
+ warn: string
28
+ bad: string
29
+ }
30
+
31
+ export const ACCENT = '#6a56d6'
32
+
33
+ export const DARK: Palette = {
34
+ ink: '#ecebf2',
35
+ muted: '#a19fae',
36
+ faint: '#55535f',
37
+ fill: 'rgba(236,235,242,0.045)',
38
+ stroke: 'rgba(236,235,242,0.13)',
39
+ accent: '#a594f5',
40
+ accentWash: 'rgba(150,130,245,0.17)',
41
+ accentLine: 'rgba(165,148,245,0.62)',
42
+ primary: '#6f5bdc',
43
+ ok: '#3ecb7d',
44
+ warn: '#e0b436',
45
+ bad: '#ef5a5a',
46
+ }
47
+
48
+ export const LIGHT: Palette = {
49
+ ink: '#1d1b26',
50
+ muted: '#64616f',
51
+ faint: '#c9c6d2',
52
+ fill: 'rgba(29,27,38,0.035)',
53
+ stroke: 'rgba(29,27,38,0.13)',
54
+ accent: '#6a56d6',
55
+ accentWash: 'rgba(106,86,214,0.10)',
56
+ accentLine: 'rgba(106,86,214,0.55)',
57
+ primary: '#6a56d6',
58
+ ok: '#1a8a4a',
59
+ warn: '#b5820a',
60
+ bad: '#c73434',
61
+ }
62
+
63
+ /**
64
+ * Colours for native Text, which the host draws on its own background. With appearance auto the
65
+ * mod cannot know that background, so these are mid tones that read on both.
66
+ */
67
+ export const TEXT = {
68
+ accent: '#7c69e0',
69
+ ok: '#16a34a',
70
+ warn: '#d97706',
71
+ bad: '#dc2626',
72
+ }
73
+
74
+ export function textTone(appearance: Appearance): typeof TEXT {
75
+ if (appearance === 'dark') return { accent: DARK.accent, ok: DARK.ok, warn: DARK.warn, bad: DARK.bad }
76
+ if (appearance === 'light') return { accent: LIGHT.accent, ok: LIGHT.ok, warn: LIGHT.warn, bad: LIGHT.bad }
77
+ return TEXT
78
+ }
79
+
80
+ // A provider is named by whoever added it, so most names are not known here. The presets' default
81
+ // names keep their vendor's hue; any other name gets one from PALETTE picked by a hash of the name,
82
+ // so it keeps that colour from one session to the next. The palette leaves out the status
83
+ // red/green/amber, so a provider is never read as a result.
84
+ const KNOWN: Record<string, string> = {
85
+ anthropic: '#d97757',
86
+ chatgpt: '#10a37f',
87
+ 'google-gemini': '#4285f4',
88
+ deepseek: '#4d6bfe',
89
+ 'opencode-go': '#9333ea',
90
+ 'opencode-zen': '#9333ea',
91
+ }
92
+ const PALETTE = ['#0891b2', '#db2777', '#7c3aed', '#0d9488', '#c026d3', '#4f46e5', '#0284c7', '#be185d']
93
+
94
+ export function providerColor(provider: string): string {
95
+ if (provider === 'refused') return TEXT.bad
96
+ const known = KNOWN[provider]
97
+ if (known) return known
98
+ let hash = 0
99
+ for (const char of provider) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
100
+ return PALETTE[hash % PALETTE.length]!
101
+ }
102
+
103
+ /** Worker tiers keep one hue each, away from the status colours and from each other. */
104
+ export const TIER_HUE: Record<'light' | 'standard' | 'deep' | 'design', string> = {
105
+ light: '#0891b2',
106
+ standard: '#6a56d6',
107
+ deep: '#be185d',
108
+ design: '#d97757',
109
+ }
110
+
111
+ // The acceptance bar for translated providers is a 90% cache hit.
112
+ export function cacheTone(percent: number): 'ok' | 'warn' | 'bad' {
113
+ return percent >= 90 ? 'ok' : percent >= 50 ? 'warn' : 'bad'
114
+ }
115
+
116
+ /** Context fill: neutral until the swamp line, amber past it, red from 85%. */
117
+ export function contextTone(percent: number, swampAt: number): 'ink' | 'warn' | 'bad' {
118
+ return percent >= 85 ? 'bad' : percent >= swampAt ? 'warn' : 'ink'
119
+ }
120
+
121
+ /** Plan window: neutral under 80%, amber to 95%, red above. */
122
+ export function limitTone(percent: number): 'ink' | 'warn' | 'bad' {
123
+ return percent >= 95 ? 'bad' : percent >= 80 ? 'warn' : 'ink'
124
+ }
125
+
126
+ // ---- SVG ------------------------------------------------------------------------------------
127
+
128
+ const VARS: (keyof Palette)[] = ['ink', 'muted', 'faint', 'fill', 'stroke', 'accent', 'accentWash', 'accentLine', 'primary', 'ok', 'warn', 'bad']
129
+
130
+ function varsOf(p: Palette): string {
131
+ return VARS.map(k => '--' + k + ':' + p[k]).join(';')
132
+ }
133
+
134
+ /** `var(--name)` for a palette key: what SVG markup paints with. */
135
+ export function v(key: keyof Palette): string {
136
+ return 'var(--' + key + ')'
137
+ }
138
+
139
+ const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',system-ui,sans-serif"
140
+
141
+ /**
142
+ * One SVG document. The palette is set as CSS variables on the root: fixed for dark or light, and
143
+ * for auto switched by the frame's `prefers-color-scheme`. `viewBox` draws a grid of its own at
144
+ * this size (the mascot's 56 units); absent, one unit is one pixel.
145
+ */
146
+ export function svgDoc(width: number, height: number, appearance: Appearance, body: string, css = '', viewBox = '0 0 ' + width + ' ' + height): string {
147
+ const vars =
148
+ appearance === 'dark'
149
+ ? 'svg{' + varsOf(DARK) + '}'
150
+ : appearance === 'light'
151
+ ? 'svg{' + varsOf(LIGHT) + '}'
152
+ : 'svg{' + varsOf(LIGHT) + '}@media (prefers-color-scheme:dark){svg{' + varsOf(DARK) + '}}'
153
+ const base =
154
+ 'text{font-family:' + FONT + ';font-variant-numeric:tabular-nums;fill:var(--ink)}' +
155
+ '.cap{font-size:8.5px;letter-spacing:.09em;font-weight:600;fill:var(--muted)}' +
156
+ '.val{font-size:12.5px;font-weight:600}' +
157
+ '.sub{font-size:11.5px;fill:var(--muted)}' +
158
+ '.lbl{font-size:12px;font-weight:500}'
159
+ return (
160
+ '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height +
161
+ '" viewBox="' + viewBox + '"><style>' + vars + base + css + '</style>' + body + '</svg>'
162
+ )
163
+ }
164
+
165
+ // East Asian Wide and Fullwidth ranges (UAX #11), and the emoji planes terminals draw two wide.
166
+ const WIDE = /[ᄀ-ᅟ⌚⌛〈〉⏩-⏬⏰⏳◽◾☔☕♈-♓♿⚓⚡⚪⚫⚽⚾⛄⛅⛎⛔⛪⛲⛳⛵⛺⛽✅✊✋✨❌❎❓-❕❗➕-➗➰➿⬛⬜⭐⭕⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏ꥠ-꥿가-힣豈-﫿︐-︙︰-﹯＀-⦆¢-₩\u{16FE0}-\u{16FE4}\u{17000}-\u{18CFF}\u{1B000}-\u{1B2FF}\u{1F004}\u{1F0CF}\u{1F18E}\u{1F191}-\u{1F19A}\u{1F200}-\u{1F251}\u{1F300}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F7E0}-\u{1F7EB}\u{1F90C}-\u{1F9FF}\u{1FA70}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
167
+ // Combining marks, zero-width spaces and joiners, variation selectors: no cell of their own.
168
+ const ZERO = /[\p{Mn}\p{Me}​-‏⁠-⁤︀-️]/u
169
+
170
+ /** Terminal cells a code point takes: 2 for East Asian Wide/Fullwidth, 0 for combining and controls, else 1. */
171
+ function cellWidth(char: string): number {
172
+ const code = char.codePointAt(0)!
173
+ if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0
174
+ if (ZERO.test(char)) return 0
175
+ return WIDE.test(char) ? 2 : 1
176
+ }
177
+
178
+ /** Terminal columns `text` takes: East Asian Wide/Fullwidth count 2, combining marks 0. */
179
+ export function displayWidth(text: string): number {
180
+ let width = 0
181
+ for (const char of text) width += cellWidth(char)
182
+ return width
183
+ }
184
+
185
+ /**
186
+ * The desktop width of `text` in `ch`, unrounded: the host's `ch` is the width of "0" in its 14 px
187
+ * sans. Measured 2026-10-10 in Chromium on macOS: ch 8.8 px, Hangul and kana ~12 px (1.36 ch),
188
+ * "Medium" 5.9 ch, "X-High" 5.1 ch. Each class of character is taken a little wide of that.
189
+ */
190
+ export function chOf(text: string): number {
191
+ let ch = 0
192
+ for (const char of text) {
193
+ if (displayWidth(char) === 2) ch += 1.45
194
+ else if (char === ' ') ch += 0.45
195
+ else if (/[ilIj.,:;|'!]/.test(char)) ch += 0.4
196
+ else if (/[mwMW@%]/.test(char)) ch += 1.35
197
+ else if (/[A-Z]/.test(char)) ch += 1.1
198
+ else if (/[0-9]/.test(char)) ch += 1
199
+ else ch += 0.85
200
+ }
201
+ return ch
202
+ }
203
+
204
+ /** Cuts text to at most `max` terminal columns, the ellipsis included. */
205
+ export function clip(text: string, max: number): string {
206
+ if (displayWidth(text) <= max) return text
207
+ const room = Math.max(1, max - 1)
208
+ let out = ''
209
+ let used = 0
210
+ for (const char of text) {
211
+ const w = cellWidth(char)
212
+ if (used + w > room) break
213
+ out += char
214
+ used += w
215
+ }
216
+ return out + '…'
217
+ }
@@ -0,0 +1,371 @@
1
+ // What the drawings take: view models register.tsx fills from the mod's state atoms, and the
2
+ // callbacks it hands in. Every renderer in ./ is a pure function of (elements, view, actions);
3
+ // none of them calls the engine, so they can be drawn and tested anywhere.
4
+
5
+ import type { AgentRec, Effort, HandoffKind, JudgeKind, JudgeRun, LadderOptions, Pick, Settings, StatusRequest, TierTarget } from '../../types'
6
+ import type { Appearance } from './theme'
7
+ import { t } from '../i18n'
8
+ import type { Lang } from '../i18n'
9
+
10
+ export type Surface = 'terminal' | 'desktop' | 'vscode' | 'mobile'
11
+
12
+ /** Where and how big: from `e.surface`, `e.props.bodyColumns` / `maxRows`, the clock and settings. */
13
+ export type Frame = {
14
+ surface: Surface
15
+ /** Cells across (`bodyColumns`). */
16
+ columns: number
17
+ /** Rows the band may take (`maxRows`); a pane passes its `scroll.bodyRows`. */
18
+ maxRows: number
19
+ /** Epoch ms when drawn; countdowns are computed from it. */
20
+ now: number
21
+ appearance: Appearance
22
+ lang: Lang
23
+ }
24
+
25
+ /** Desktop and the editor draw SVG; the terminal and mobile draw text. */
26
+ export function isRich(frame: Frame): boolean {
27
+ return frame.surface === 'desktop' || frame.surface === 'vscode'
28
+ }
29
+
30
+ // ---- Dashboard -------------------------------------------------------------------------------
31
+
32
+ /**
33
+ * auto: Auto picked it · deciding: the judge is thinking · paused: Auto cannot run on this model ·
34
+ * off: Auto is off (the level is the app's) · manual: the person picked the level.
35
+ */
36
+ export type EffortMode = 'auto' | 'deciding' | 'paused' | 'off' | 'manual'
37
+
38
+ export type RouteView = {
39
+ /** `target`, or `target (requested)` when the router swapped it. */
40
+ model: string
41
+ provider: string
42
+ ok: boolean
43
+ resent: boolean
44
+ status: string
45
+ effort?: string
46
+ /** Prompt tokens read from cache, in percent. */
47
+ cachePct?: number
48
+ seconds: number
49
+ note?: string
50
+ }
51
+
52
+ export type DashboardView = {
53
+ effort: {
54
+ level: Effort | null
55
+ mode: EffortMode
56
+ /** The judge's reason, a few words. */
57
+ why?: string
58
+ /** Who gave it. */
59
+ by?: Pick['by']
60
+ /** When the judge started (deciding): keeps the glow's phase across redraws. */
61
+ since?: number
62
+ }
63
+ /** Display name of the main chat's model ("Opus 5.5"); see modelName. */
64
+ model: string | null
65
+ context: { percent: number; tokens: number; window: number } | null
66
+ /** The last main reply's cache; null when unknown. */
67
+ cache: { expiresAt: number; ttlMs: number } | null
68
+ warm: { on: boolean; pings: number; cost: number; stopped?: string }
69
+ /** The hottest plan window, shown when present. */
70
+ limit: { kind: string; percent: number; resetsAt?: string; save: boolean } | null
71
+ route: RouteView | null
72
+ /** A line from the router side ("router not answering"), shown in place of the route. */
73
+ notice: string | null
74
+ auto: boolean
75
+ /** Compact turns into a lit Handoff when advice says so, or always with handoffButton 'always'. */
76
+ handoff: 'off' | 'advised' | 'always'
77
+ agents: { live: number; total: number }
78
+ logOpen: boolean
79
+ agentsOpen: boolean
80
+ /** The manual effort row is open. */
81
+ effortMenu: boolean
82
+ /** Context percent at which the swamp colour starts (settings.swampAt). */
83
+ swampAt: number
84
+ /** Band parts switched off: 'timer' | 'reason' | 'route'. */
85
+ hide: readonly string[]
86
+ /** Optional chips switched on: 'context' | 'limits'. */
87
+ extras: readonly string[]
88
+ isWorking: boolean
89
+ /** The active operating mode's name; absent when none is on. */
90
+ mode?: string
91
+ }
92
+
93
+ export type BandActions = {
94
+ toggleAuto: () => void
95
+ toggleWarm: () => void
96
+ compact: () => void
97
+ handoff: () => void
98
+ toggleLog: () => void
99
+ toggleAgents: () => void
100
+ openSettings: () => void
101
+ /** Opens or closes the manual effort row. */
102
+ toggleEffortMenu: () => void
103
+ /** A manual pick: Auto steps aside for it (spec §1 "Person wins"). */
104
+ pickEffort: (effort: Effort) => void
105
+ /** Hides the band (desktop: its × is drawn inside the band). */
106
+ hide?: () => void
107
+ }
108
+
109
+ // ---- Alerts and cards ------------------------------------------------------------------------
110
+
111
+ export type ResultStage = 'handingOff' | 'handoffReady' | 'handoffDone' | 'compacting' | 'compacted' | 'failed'
112
+
113
+ export type AlertView =
114
+ /** The cache expired on a big chat: the next prompt re-reads it all. */
115
+ | { kind: 'cold'; tokens: number; idleMinutes: number }
116
+ /** Context is heavy. */
117
+ | { kind: 'swamp'; percent: number; tokens: number }
118
+ /** A plan window at or above 80%. */
119
+ | { kind: 'hot'; limitKind: string; percent: number; resetsAt?: string; save: boolean }
120
+ /** The judge failed and Auto fell back. */
121
+ | { kind: 'judgeDown'; judge: JudgeKind; reason: string; network?: boolean }
122
+ | { kind: 'result'; stage: ResultStage; handoffKind?: HandoffKind; detail?: string }
123
+ /** Compact with an optional note of what to keep. */
124
+ | { kind: 'compactNote'; draft: string }
125
+ /** Quick or full handoff, and what happens after. */
126
+ | { kind: 'handoffChoice'; handoffKind: HandoffKind; after: Settings['handoffAfter'] }
127
+
128
+ export type AlertActions = {
129
+ compact: (note?: string) => void
130
+ /** Opens the handoff choice bar. */
131
+ handoff: () => void
132
+ dismiss: () => void
133
+ toggleSave: () => void
134
+ openSettings: () => void
135
+ /** Each change of the compact note's field. */
136
+ noteInput: (text: string) => void
137
+ setHandoffKind: (kind: HandoffKind) => void
138
+ setHandoffAfter: (after: Settings['handoffAfter']) => void
139
+ go: () => void
140
+ /** Copies a handoff's text again (result card, after: copy). */
141
+ copy: () => void
142
+ /** Opens a new session in this folder with the written handoff (Confirm first's card). */
143
+ openNew: () => void
144
+ }
145
+
146
+ // ---- Settings and setup ----------------------------------------------------------------------
147
+
148
+ export type JevState = {
149
+ /** Where the key comes from; null when there is none. */
150
+ source: 'env' | 'file' | 'pasted' | 'zen' | null
151
+ test: { state: 'idle' | 'testing' | 'ok' | 'fail'; message?: string }
152
+ }
153
+
154
+ export type SettingsView = {
155
+ settings: Settings
156
+ jev: JevState
157
+ /** What each rung can run (the ticked models); null until the router has answered. */
158
+ ladder?: LadderOptions | null
159
+ }
160
+
161
+ export type SettingsActions = {
162
+ /** Applies and saves each change immediately. */
163
+ patch: (change: Partial<Settings>) => void
164
+ /** Turns a mode on by id, or every mode off with ''. */
165
+ setMode?: (id: string) => void
166
+ /** The Jev key typed into the field (Enter). */
167
+ jevKey: (key: string) => void
168
+ testJev: () => void
169
+ openAllSettings: () => void
170
+ close: () => void
171
+ }
172
+
173
+ export type SetupStep = 1 | 2 | 3 | 4
174
+
175
+ export type SetupView = {
176
+ step: SetupStep
177
+ draft: Settings
178
+ jev: JevState
179
+ }
180
+
181
+ export type SetupActions = {
182
+ patch: (change: Partial<Settings>) => void
183
+ jevKey: (key: string) => void
184
+ testJev: () => void
185
+ openAllSettings: () => void
186
+ back: () => void
187
+ next: () => void
188
+ /** Saves the draft with setupDone and closes. */
189
+ finish: () => void
190
+ /** Closes with the defaults, setupDone. */
191
+ skip: () => void
192
+ }
193
+
194
+ // ---- Panes -----------------------------------------------------------------------------------
195
+
196
+ export type AgentsView = {
197
+ agents: readonly AgentRec[]
198
+ now: number
199
+ showDone: boolean
200
+ }
201
+
202
+ export type AgentsActions = {
203
+ toggleDone: () => void
204
+ }
205
+
206
+ export type LogView = {
207
+ rows: readonly StatusRequest[]
208
+ mineOnly: boolean
209
+ /** This session's verdicts, listed among its requests. */
210
+ judges?: readonly JudgeRun[]
211
+ }
212
+
213
+ export type LogActions = {
214
+ toggleScope: () => void
215
+ }
216
+
217
+ // ---- Helpers register.tsx and the drawings share ----------------------------------------------
218
+
219
+ export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
220
+
221
+ export function effortIndex(effort: Effort | null | undefined): number {
222
+ return effort ? EFFORTS.indexOf(effort) : -1
223
+ }
224
+
225
+ export function effortName(effort: Effort, lang: Lang): string {
226
+ return t(lang, 'band.' + effort)
227
+ }
228
+
229
+ /** Route records can contain unknown effort ids; translate only the known display values. */
230
+ export function effortLabel(value: string, lang: Lang): string {
231
+ return EFFORTS.includes(value as Effort) ? effortName(value as Effort, lang).toLowerCase() : value
232
+ }
233
+
234
+ /** "claude-opus-5-5[1m]" → "Opus 5.5"; ids it does not know pass through, trimmed. */
235
+ export function modelName(id: string): string {
236
+ const bare = id.replace(/\[.*?\]/g, '').replace(/^claude-/, '')
237
+ const m = /^(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?/.exec(bare)
238
+ if (m) return m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1) + ' ' + m[2] + (m[3] ? '.' + m[3] : '')
239
+ // A bare alias ("sonnet") reads as its family's name.
240
+ if (/^(opus|sonnet|haiku|fable)\b/.test(bare) && !bare.includes('-')) return bare.charAt(0).toUpperCase() + bare.slice(1)
241
+ return bare
242
+ }
243
+
244
+ /** A TierTarget as one Select value: `agent` or `agent+model`. */
245
+ export function targetValue(target: TierTarget): string {
246
+ return target.model ? target.agent + '+' + target.model : target.agent
247
+ }
248
+
249
+ export function targetOf(value: string): TierTarget {
250
+ const plus = value.indexOf('+')
251
+ return plus < 0 ? { agent: value } : { agent: value.slice(0, plus), model: value.slice(plus + 1) }
252
+ }
253
+
254
+ /**
255
+ * A target as a person reads it: the model for Claude's own agents, else the agent; Claude's agent
256
+ * with no model runs on the session's model and says so.
257
+ */
258
+ export function targetLabel(target: TierTarget, lang?: Lang): string {
259
+ if (target.model) return 'Claude ' + modelName(target.model)
260
+ return lang && target.agent === 'general-purpose' ? t(lang, 'workers.sessionModel') : target.agent
261
+ }
262
+
263
+ type Usage = NonNullable<StatusRequest['usage']>
264
+
265
+ export function promptTokens(usage: Usage): number {
266
+ return usage.input + usage.cached + (usage.cacheWrite ?? 0)
267
+ }
268
+
269
+ export function cachePercent(usage: Usage | undefined): number | undefined {
270
+ if (!usage) return undefined
271
+ const prompt = promptTokens(usage)
272
+ return prompt > 0 ? Math.round((usage.cached / prompt) * 100) : undefined
273
+ }
274
+
275
+ // The requested name's `@effort` is the agent file's default, not what was sent; the sent effort is
276
+ // its own field.
277
+ export function modelOf(record: StatusRequest): string {
278
+ const at = record.source.lastIndexOf('@')
279
+ const source = at < 0 ? record.source : record.source.slice(0, at)
280
+ // A dated id of the same model (claude-haiku-5-5-20260815 for claude-haiku-5-5) says nothing new.
281
+ return source.startsWith(record.target) || record.target.startsWith(source) ? record.target : record.target + ' (' + source + ')'
282
+ }
283
+
284
+ export function routeOf(record: StatusRequest): RouteView {
285
+ const route: RouteView = {
286
+ model: modelOf(record),
287
+ provider: record.provider,
288
+ ok: record.ok,
289
+ resent: record.resent === true,
290
+ status: String(record.status),
291
+ seconds: record.ms / 1000,
292
+ }
293
+ const cache = cachePercent(record.usage)
294
+ if (record.effort) route.effort = record.effort
295
+ if (cache !== undefined) route.cachePct = cache
296
+ if (record.note) route.note = record.note
297
+ return route
298
+ }
299
+
300
+ /** 1234 → "1.2k", 1_200_000 → "1.2M". */
301
+ export function compactNumber(n: number): string {
302
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
303
+ if (n >= 10_000) return Math.round(n / 1000) + 'k'
304
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
305
+ return String(Math.round(n))
306
+ }
307
+
308
+ /** A countdown as the band shows it: "58m" above two minutes, "1:45" below, "cold" at zero. */
309
+ export function countdown(ms: number, lang: Lang): string {
310
+ if (ms <= 0) return t(lang, 'band.cold')
311
+ const s = Math.ceil(ms / 1000)
312
+ if (s >= 120) return t(lang, 'units.minutes', { n: Math.ceil(s / 60) })
313
+ return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
314
+ }
315
+
316
+ /**
317
+ * The cache countdown as the band and the footer show it: whole minutes rounded up ("42m" … "1m"),
318
+ * "cold" at zero. It changes once a minute, so the band redraws at most once a minute for it.
319
+ */
320
+ export function minutesLeft(ms: number, lang: Lang): string {
321
+ if (ms <= 0) return t(lang, 'band.cold')
322
+ return t(lang, 'units.minutes', { n: Math.ceil(ms / 60_000) })
323
+ }
324
+
325
+ /** When minutesLeft next changes for a cache expiring at `expiresAt` (epoch ms); null once cold. */
326
+ export function nextMinuteChange(expiresAt: number, now: number): number | null {
327
+ const left = expiresAt - now
328
+ if (left <= 0) return null
329
+ return expiresAt - (Math.ceil(left / 60_000) - 1) * 60_000
330
+ }
331
+
332
+ /** A duration the Agents pane shows: "12s", "4m 05s", "1h 02m". */
333
+ export function elapsed(ms: number, lang: Lang): string {
334
+ const s = Math.max(0, Math.floor(ms / 1000))
335
+ if (s < 60) return t(lang, 'units.seconds', { n: s })
336
+ const m = Math.floor(s / 60)
337
+ if (m < 60) return t(lang, 'units.minSec', { m, s: String(s % 60).padStart(2, '0') })
338
+ return t(lang, 'units.hourMin', { h: Math.floor(m / 60), m: String(m % 60).padStart(2, '0') })
339
+ }
340
+
341
+ /** Seconds with one decimal, as routes and log entries show them: "1.2s". */
342
+ export function seconds(value: number, lang: Lang): string {
343
+ return t(lang, 'units.seconds', { n: value.toFixed(1) })
344
+ }
345
+
346
+ export function clockTime(at: string | number): string {
347
+ const date = new Date(at)
348
+ return [date.getHours(), date.getMinutes(), date.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
349
+ }
350
+
351
+ /** "resets 14:20" or "resets in 3h" from an ISO time. */
352
+ export function resetsLabel(resetsAt: string | undefined, now: number, lang: Lang): string | undefined {
353
+ if (!resetsAt) return undefined
354
+ const at = Date.parse(resetsAt)
355
+ if (!Number.isFinite(at)) return undefined
356
+ const left = at - now
357
+ if (left <= 0) return t(lang, 'band.resetting')
358
+ if (left < 3_600_000) return t(lang, 'band.resetsIn', { time: t(lang, 'units.minutes', { n: Math.max(1, Math.round(left / 60_000)) }) })
359
+ if (left < 86_400_000) {
360
+ const d = new Date(at)
361
+ return t(lang, 'band.resetsAt', { time: String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') })
362
+ }
363
+ return t(lang, 'band.resetsIn', { time: t(lang, 'units.days', { n: Math.round(left / 86_400_000) }) })
364
+ }
365
+
366
+ /** "five_hour" → "5-hour", "seven_day" → "Weekly". */
367
+ export function limitName(kind: string, lang: Lang): string {
368
+ if (kind === 'five_hour') return t(lang, 'band.fiveHour')
369
+ if (kind === 'seven_day') return t(lang, 'band.weekly')
370
+ return kind.replace(/_/g, ' ')
371
+ }
@@ -11,7 +11,7 @@
11
11
  // `deepseek-v4.1-flash@low` runs the worker on that id, and the router records effort low.
12
12
 
13
13
  /** The router's marker, found anywhere in the prompt rather than only at its top. */
14
- const MARKER = /\[\[\s*(?:ripple|gpt)\s*:\s*([A-Za-z0-9.\-]+)\s*(?:@\s*([A-Za-z]+))?\s*\]\]/
14
+ export const MARKER = /\[\[\s*(?:ripple|gpt)\s*:\s*([A-Za-z0-9.\-]+)\s*(?:@\s*([A-Za-z]+))?\s*\]\]/
15
15
 
16
16
  export type Spawn = { subagentType: string; prompt: string; model?: string }
17
17
  export type WorkerFix = { prompt: string; model: string; dropped?: string }