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,853 @@
1
+ // The drawings in hooks/ui, mounted through the engine on the terminal and the desktop.
2
+ //
3
+ // register.tsx does not draw them yet, so each test answers `ui.render` for a Pane of its own with
4
+ // the renderer under test (a test's own hooks may close over this file's imports; an inline
5
+ // plugin's may not). The engine validates every tree against the surface's element table, and
6
+ // presses reach the callbacks below, so these check both the trees and their wiring.
7
+
8
+ import { expect, test } from 'claude-code/testing'
9
+ import type { Engine } from 'claude-code/testing'
10
+ import type { On, RenderElement, RenderPropsOf } from 'claude-code'
11
+
12
+ import type { AgentRec, Settings, StatusRequest } from '../types'
13
+ import { STRINGS } from '../hooks/i18n'
14
+ import { normalizeSettings } from '../hooks/settings'
15
+ import { renderAgents } from '../hooks/ui/agents'
16
+ import { renderAlert } from '../hooks/ui/alerts'
17
+ import { BAND_RIPPLE, CREST_CELLS, bandMood, besideBand, modeLabel, renderBand, renderFooter } from '../hooks/ui/band'
18
+ import { rippleSource } from '../hooks/ui/parts'
19
+ import { renderLog } from '../hooks/ui/log'
20
+ import { renderSettings } from '../hooks/ui/settings'
21
+ import { renderSetup } from '../hooks/ui/setup'
22
+ import { countdown, elapsed, minutesLeft, modelName, nextMinuteChange, resetsLabel, routeOf, seconds, targetOf, targetValue } from '../hooks/ui/view'
23
+ import type { AlertView, DashboardView, Frame, JevState, SettingsActions, Surface } from '../hooks/ui/view'
24
+
25
+ const SURFACES = ['terminal', 'desktop'] as const
26
+ const NOW = Date.parse('2026-10-10T13:20:00')
27
+ const PRESSER = 'test'
28
+
29
+ function paneProps(columns: number, rows = 30): RenderPropsOf['Pane'] {
30
+ return { title: 'ui', isFocused: true, bodyColumns: columns, placement: 'inline', scroll: { offset: 0, bodyRows: rows }, view: {} } as RenderPropsOf['Pane']
31
+ }
32
+
33
+ function frameOf(surface: Surface, columns = 140, maxRows = 12): Frame {
34
+ return { surface, columns, maxRows, now: NOW, appearance: 'auto', lang: 'en' }
35
+ }
36
+
37
+ /** Records which callback ran, with what. */
38
+ function recorder() {
39
+ const calls: string[] = []
40
+ const fn = (name: string) => (...args: unknown[]) => {
41
+ calls.push(args.length ? name + ':' + JSON.stringify(args) : name)
42
+ }
43
+ return { calls, fn }
44
+ }
45
+
46
+ const DASH: DashboardView = {
47
+ effort: { level: 'high', mode: 'auto', why: 'multi-file refactor' },
48
+ model: 'Opus 5.5',
49
+ context: { percent: 38, tokens: 76_000, window: 200_000 },
50
+ cache: { expiresAt: NOW + 41 * 60_000, ttlMs: 3_600_000 },
51
+ warm: { on: false, pings: 0, cost: 0 },
52
+ limit: null,
53
+ route: { model: 'gpt-6.1-sol', provider: 'chatgpt', ok: true, resent: false, status: '200', effort: 'high', cachePct: 96, seconds: 2.1 },
54
+ notice: null,
55
+ auto: true,
56
+ handoff: 'off',
57
+ agents: { live: 0, total: 0 },
58
+ logOpen: false,
59
+ agentsOpen: false,
60
+ effortMenu: false,
61
+ swampAt: 50,
62
+ hide: [],
63
+ extras: ['context'],
64
+ isWorking: false,
65
+ }
66
+
67
+ const SETTINGS: Settings = normalizeSettings({
68
+ effortAuto: true,
69
+ judge: 'jev',
70
+ judgeModel: 'deepseek-v4.1-flash',
71
+ bias: 0,
72
+ floor: 'low',
73
+ ceiling: 'max',
74
+ agentAuto: 'auto',
75
+ tiers: { light: { agent: 'deepseek-v4-1-flash' }, standard: { agent: 'gpt-6-1-sol' }, deep: { agent: 'gpt-6-astra' }, design: { agent: 'general-purpose', model: 'sonnet' } },
76
+ fallback: { agent: 'general-purpose', model: 'sonnet' },
77
+ keepWarm: false,
78
+ keepWarmHours: 2,
79
+ keepWarmMinTokens: 30_000,
80
+ compactWith: 'haiku',
81
+ handoffSkill: '',
82
+ handoffAfter: 'continue',
83
+ handoffButton: 'advised',
84
+ swampAt: 50,
85
+ layout: 'dashboard',
86
+ appearance: 'auto',
87
+ language: 'auto',
88
+ hide: [],
89
+ setupDone: false,
90
+ })
91
+
92
+ const QUICK_ACTIONS: SettingsActions = { patch: () => undefined, jevKey: () => undefined, testJev: () => undefined, openAllSettings: () => undefined, close: () => undefined }
93
+
94
+ type Draw = (el: Parameters<typeof renderBand>[0], surface: Surface) => ReturnType<typeof renderBand>
95
+
96
+ /** Answers ui.render for the Pane `id` with `draw`. */
97
+ function drawPane(on: On, id: string, draw: Draw) {
98
+ on('ui.render', { component: 'Pane', requestId: id }, ($, e) => draw($.ui.resolve(e), e.surface) as RenderElement)
99
+ }
100
+
101
+ function mount($: Engine, id: string, surface: (typeof SURFACES)[number], columns = 140) {
102
+ return $.ui.mount({ plugin: PRESSER, surface, component: 'Pane', requestId: id, props: paneProps(columns), viewport: { columns, rows: 40 } })
103
+ }
104
+
105
+ // ---- Pure helpers ------------------------------------------------------------------------------
106
+
107
+ test('view helpers', () => {
108
+ expect(modelName('claude-opus-5-5[1m]')).toBe('Opus 5.5')
109
+ expect(modelName('claude-fable-5-1')).toBe('Fable 5.1')
110
+ expect(modelName('sonnet')).toBe('Sonnet')
111
+ expect(modelName('gpt-6.1-sol')).toBe('gpt-6.1-sol')
112
+ expect(countdown(41 * 60_000, 'en')).toBe('41m')
113
+ expect(countdown(105_000, 'en')).toBe('1:45')
114
+ expect(countdown(0, 'en')).toBe('cold')
115
+ expect(countdown(41 * 60_000, 'ko')).toBe('41분')
116
+ expect(countdown(105_000, 'ko')).toBe('1:45')
117
+ expect(countdown(0, 'ko')).toBe(STRINGS.ko['band.cold'])
118
+ expect(countdown(41 * 60_000, 'es')).toBe('41 min')
119
+ expect(elapsed(12_000, 'en')).toBe('12s')
120
+ expect(elapsed(245_000, 'ko')).toBe('4분 05초')
121
+ expect(elapsed(3_720_000, 'ja')).toBe('1時間02分')
122
+ expect(resetsLabel('2026-10-10T13:45:00', NOW, 'en')).toBe('resets in 25m')
123
+ expect(resetsLabel('2026-10-10T13:45:00', NOW, 'ko')).toBe('25분 뒤 초기화')
124
+ expect(resetsLabel('2026-10-13T13:20:00', NOW, 'zh')).toBe('3天后重置')
125
+ expect(seconds(2.14, 'ko')).toBe('2.1초')
126
+ expect(targetOf(targetValue({ agent: 'general-purpose', model: 'sonnet' }))).toEqual({ agent: 'general-purpose', model: 'sonnet' })
127
+ expect(targetOf('gpt-6-1-sol')).toEqual({ agent: 'gpt-6-1-sol' })
128
+ const record: StatusRequest = { at: '2026-10-10T13:19:10', source: 'gpt-6-1-sol@high', target: 'gpt-6.1-sol', provider: 'chatgpt', status: 200, ok: true, ms: 2140, usage: { input: 3000, cached: 41_000, output: 900 } }
129
+ expect(routeOf(record)).toMatchObject({ model: 'gpt-6.1-sol (gpt-6-1-sol)', cachePct: 93, seconds: 2.14 })
130
+ expect(minutesLeft(41 * 60_000 + 1, 'en')).toBe('42m')
131
+ expect(minutesLeft(41 * 60_000, 'en')).toBe('41m')
132
+ expect(minutesLeft(30_000, 'ko')).toBe('1분')
133
+ expect(minutesLeft(0, 'en')).toBe('cold')
134
+ // The shown minute changes exactly when a whole minute is left, and at expiry.
135
+ expect(nextMinuteChange(NOW + 41 * 60_000 + 30_000, NOW)).toBe(NOW + 30_000)
136
+ expect(nextMinuteChange(NOW + 41 * 60_000, NOW)).toBe(NOW + 60_000)
137
+ expect(nextMinuteChange(NOW + 20_000, NOW)).toBe(NOW + 20_000)
138
+ expect(nextMinuteChange(NOW, NOW)).toBe(null)
139
+ })
140
+
141
+ // ---- Band --------------------------------------------------------------------------------------
142
+
143
+ test('the band draws on both surfaces and its controls reach their callbacks', async ($, on) => {
144
+ const { calls, fn } = recorder()
145
+ const actions = {
146
+ toggleAuto: fn('auto'), toggleWarm: fn('warm'), compact: fn('compact'), handoff: fn('handoff'), toggleLog: fn('log'),
147
+ toggleAgents: fn('agents'), openSettings: fn('settings'), toggleEffortMenu: fn('menu'), pickEffort: fn('pick'),
148
+ }
149
+ drawPane(on, 'band', (el, surface) => renderBand(el, frameOf(surface), DASH, actions))
150
+ drawPane(on, 'band-busy', (el, surface) =>
151
+ renderBand(el, frameOf(surface), { ...DASH, effort: { level: null, mode: 'deciding', since: NOW - 500 }, warm: { on: true, pings: 2, cost: 9000 }, limit: { kind: 'five_hour', percent: 86, resetsAt: '2026-10-10T15:40:00', save: true }, handoff: 'advised', agents: { live: 2, total: 3 }, effortMenu: true }, actions),
152
+ )
153
+ for (const surface of SURFACES) {
154
+ const ui = await mount($, 'band', surface)
155
+ // Compact left the band (it is /compact and the alerts' choice).
156
+ expect(await ui.find({ key: 'compact' })).toBeUndefined()
157
+ // The desktop's Log is the route itself.
158
+ for (const key of surface === 'terminal' ? ['auto', 'warm', 'log', 'agents', 'settings'] : ['auto', 'warm', 'agents', 'settings']) {
159
+ expect(await ui.find({ type: 'Button', key }), "ui { type: 'Button', key } " + surface).toBeDefined()
160
+ await ui.press({ key })
161
+ }
162
+ // Every picture a plain image: the desktop draws each answer afresh, and an interactive picture
163
+ // (a frame) reloads blank each time; an image of the same source does not.
164
+ const pictures = await ui.findAll({ type: 'Svg' })
165
+ if (surface === 'terminal') expect(pictures.length).toBe(0)
166
+ for (const picture of pictures) expect(picture.props.isInteractive).toBeFalsy()
167
+ if (surface === 'terminal') {
168
+ expect((await ui.find({ key: 'auto' }))?.props.hotkey).toBe('a')
169
+ expect(await ui.find({ type: 'Text', text: 'gpt-6.1-sol' }), "ui { type: 'Text', text: 'gpt-6.1-sol' } " + surface).toBeDefined()
170
+ } else {
171
+ // The route is words in the capsule's own ink with its press laid over them.
172
+ expect(await ui.find({ type: 'Button', key: 'route' })).toBeDefined()
173
+ expect(await ui.find({ type: 'Text', text: 'gpt-6.1-sol' })).toBeDefined()
174
+ expect(await ui.find({ type: 'Text', text: 'High' })).toBeDefined()
175
+ // Pointing at a control says what it does, in the reason's place.
176
+ expect(await ui.find({ type: 'Text', text: /^Keep cache warm Off · / })).toBeDefined()
177
+ // What captions said is in the hover tips.
178
+ expect(await ui.find({ type: 'Text', text: /^Opus 5\.5 · Auto · Effort High — multi-file refactor/ })).toBeDefined()
179
+ expect(await ui.find({ type: 'Text', text: /^Last route: gpt-6.1-sol via chatgpt, high, cache 96%/ })).toBeDefined()
180
+ // The route opens the log too; the effort its menu.
181
+ await ui.press({ key: 'route' })
182
+ await ui.press({ key: 'effort' })
183
+ }
184
+ await ui.unmount()
185
+
186
+ const busy = await mount($, 'band-busy', surface)
187
+ expect(await busy.find({ key: 'handoff' }), "busy { key: 'handoff' } " + surface).toBeDefined()
188
+ expect(await busy.find({ key: 'compact' })).toBeUndefined()
189
+ await busy.press({ key: 'handoff' })
190
+ await busy.press({ key: 'pick-low' })
191
+ if (surface === 'desktop') {
192
+ expect(await busy.find({ type: 'Text', text: 'Deciding…' })).toBeDefined()
193
+ expect(await busy.find({ type: 'Text', text: /keep-warm on, 2 pings/ })).toBeDefined()
194
+ // The live agents' count, drawn in its badge.
195
+ expect((await busy.findAll({ type: 'Svg' })).some(svg => svg.props.alt === 'Agents 2')).toBe(true)
196
+ } else {
197
+ expect(await busy.find({ type: 'Text', text: /deciding/ }), "busy { type: 'Text', text: /deciding/ } " + surface).toBeDefined()
198
+ }
199
+ await busy.unmount()
200
+ }
201
+ expect(calls.filter(c => c === 'auto').length).toBe(2)
202
+ expect(calls.filter(c => c === 'log').length).toBe(2)
203
+ expect(calls.filter(c => c === 'menu').length).toBe(1)
204
+ expect(calls.filter(c => c === 'pick:["low"]').length).toBe(2)
205
+ expect(calls.filter(c => c === 'handoff').length).toBe(2)
206
+ })
207
+
208
+ test('the band draws the same markup for the same view', async ($, on) => {
209
+ const noop = () => undefined
210
+ const actions = { toggleAuto: noop, toggleWarm: noop, compact: noop, handoff: noop, toggleLog: noop, toggleAgents: noop, openSettings: noop, toggleEffortMenu: noop, pickEffort: noop }
211
+ drawPane(on, 'band', (el, surface) => renderBand(el, frameOf(surface), { ...DASH, effort: { level: 'medium', mode: 'deciding', since: NOW - 300 } }, actions))
212
+ const once = await mount($, 'band', 'desktop')
213
+ const first = JSON.stringify(await once.drawn())
214
+ await once.unmount()
215
+ const second = JSON.stringify(await (await mount($, 'band', 'desktop')).drawn())
216
+ expect(first.replace(/"handle":\d+/g, '')).toBe(second.replace(/"handle":\d+/g, ''))
217
+ })
218
+
219
+ test('the terminal band fits its rows at narrow widths', async ($, on) => {
220
+ const noop = () => undefined
221
+ const actions = { toggleAuto: noop, toggleWarm: noop, compact: noop, handoff: noop, toggleLog: noop, toggleAgents: noop, openSettings: noop, toggleEffortMenu: noop, pickEffort: noop }
222
+ const busy: DashboardView = { ...DASH, warm: { on: true, pings: 3, cost: 0 }, limit: { kind: 'seven_day', percent: 91, save: false } }
223
+ drawPane(on, 'wide', el => renderBand(el, frameOf('terminal', 200, 3), busy, actions))
224
+ drawPane(on, 'narrow', el => renderBand(el, frameOf('terminal', 60, 4), busy, actions))
225
+ const wide = (await (await mount($, 'wide', 'terminal', 200)).drawn()) as { children: unknown[] }
226
+ const narrow = (await (await mount($, 'narrow', 'terminal', 60)).drawn()) as { children: unknown[] }
227
+ expect(wide.children.length <= 3).toBe(true)
228
+ expect(narrow.children.length <= 4).toBe(true)
229
+ })
230
+
231
+ test('the minimal footer draws on both surfaces', async ($, on) => {
232
+ const { calls, fn } = recorder()
233
+ drawPane(on, 'footer', (el, surface) => renderFooter(el, frameOf(surface), DASH, { toggleAuto: fn('auto'), openSettings: fn('settings') }, 'focus'))
234
+ for (const surface of SURFACES) {
235
+ const ui = await mount($, 'footer', surface)
236
+ expect(await ui.find({ type: 'Text', text: /high/ }), "ui { type: 'Text', text: /high/ } " + surface).toBeDefined()
237
+ await ui.press({ key: 'settings' })
238
+ }
239
+ expect(calls).toEqual(['settings', 'settings'])
240
+ })
241
+
242
+ test('a ko frame resolves temporary Korean text in labels and hover cards', async ($, on) => {
243
+ const original = { ...STRINGS.ko }
244
+ const noop = () => undefined
245
+ const actions = { toggleAuto: noop, toggleWarm: noop, compact: noop, handoff: noop, toggleLog: noop, toggleAgents: noop, openSettings: noop, toggleEffortMenu: noop, pickEffort: noop }
246
+ try {
247
+ STRINGS.ko['band.auto'] = '자동'
248
+ STRINGS.ko['band.high'] = '높음'
249
+ STRINGS.ko['band.effortTitle'] = '강도 {value}{hint} · 선택'
250
+ STRINGS.ko['band.effortAlt'] = '강도 {value} ({mode}){why}'
251
+ drawPane(on, 'ko-band', (el, surface) => renderBand(el, { ...frameOf(surface), lang: 'ko' }, DASH, actions))
252
+ const terminal = await mount($, 'ko-band', 'terminal')
253
+ expect((await terminal.find({ key: 'auto' }))?.props.label).toBe('자동 ●')
254
+ expect(await terminal.find({ type: 'Text', text: /높음/ })).toBeDefined()
255
+ await terminal.unmount()
256
+ const desktop = await mount($, 'ko-band', 'desktop')
257
+ expect(await desktop.find({ type: 'Text', text: '높음' })).toBeDefined()
258
+ expect(await desktop.find({ type: 'Text', text: '자동' })).toBeDefined()
259
+ expect(await desktop.find({ type: 'Text', text: /강도 높음 — multi-file refactor · 선택$/ })).toBeDefined()
260
+ await desktop.unmount()
261
+ } finally { Object.assign(STRINGS.ko, original) }
262
+ })
263
+
264
+ // ---- Alerts ------------------------------------------------------------------------------------
265
+
266
+ const ALERTS: AlertView[] = [
267
+ { kind: 'cold', tokens: 182_000, idleMinutes: 74 },
268
+ { kind: 'swamp', percent: 63, tokens: 126_000 },
269
+ { kind: 'hot', limitKind: 'five_hour', percent: 86, resetsAt: '2026-10-10T15:40:00', save: false },
270
+ { kind: 'judgeDown', judge: 'jev', reason: 'key rejected (401)' },
271
+ { kind: 'result', stage: 'handingOff', handoffKind: 'quick' },
272
+ { kind: 'result', stage: 'handoffDone' },
273
+ { kind: 'result', stage: 'compacting' },
274
+ { kind: 'result', stage: 'compacted' },
275
+ { kind: 'result', stage: 'failed', detail: 'Haiku timed out' },
276
+ { kind: 'compactNote', draft: '' },
277
+ { kind: 'handoffChoice', handoffKind: 'quick', after: 'continue' },
278
+ ]
279
+
280
+ test('every alert draws on both surfaces', async ($, on) => {
281
+ const noop = () => undefined
282
+ const actions = { compact: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
283
+ ALERTS.forEach((alert, i) => drawPane(on, 'alert-' + i, (el, surface) => renderAlert(el, frameOf(surface), alert, actions)))
284
+ for (const surface of SURFACES) {
285
+ for (let i = 0; i < ALERTS.length; i++) {
286
+ const ui = await mount($, 'alert-' + i, surface)
287
+ expect(await ui.drawn()).toBeDefined()
288
+ await ui.unmount()
289
+ }
290
+ }
291
+ })
292
+
293
+ test('alert choices reach their callbacks', async ($, on) => {
294
+ const { calls, fn } = recorder()
295
+ const actions = {
296
+ compact: fn('compact'), handoff: fn('handoff'), dismiss: fn('dismiss'), toggleSave: fn('save'), openSettings: fn('settings'), noteInput: fn('note'),
297
+ setHandoffKind: fn('kind'), setHandoffAfter: fn('after'), go: fn('go'), copy: fn('copy'), openNew: fn('open-new'),
298
+ }
299
+ drawPane(on, 'cold', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[0]!, actions))
300
+ drawPane(on, 'hot', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[2]!, actions))
301
+ drawPane(on, 'note', (el, surface) => renderAlert(el, frameOf(surface), { kind: 'compactNote', draft: '' }, actions))
302
+ drawPane(on, 'choice', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[10]!, actions))
303
+ for (const surface of SURFACES) {
304
+ const cold = await mount($, 'cold', surface)
305
+ await cold.press({ key: 'compact' })
306
+ await cold.press({ key: 'handoff' })
307
+ await cold.press({ key: 'dismiss' })
308
+ const hot = await mount($, 'hot', surface)
309
+ await hot.press({ key: 'save' })
310
+ const note = await mount($, 'note', surface)
311
+ await note.input({ key: 'note', text: 'keep the API decision' })
312
+ const choice = await mount($, 'choice', surface)
313
+ await choice.press({ key: 'kind-full' })
314
+ await choice.press({ key: 'after-copy' })
315
+ await choice.press({ key: 'go' })
316
+ }
317
+ const once = ['compact', 'handoff', 'dismiss', 'save', 'compact:["keep the API decision"]', 'kind:["full"]', 'after:["copy"]', 'go']
318
+ expect(calls).toEqual([...once, ...once])
319
+ })
320
+
321
+ /** The mascot pictures in a drawn tree: the Svgs on its 56 grid. */
322
+ function mascots(tree: unknown): Drawn[] {
323
+ const out: Drawn[] = []
324
+ walk(tree, n => {
325
+ if (n.type === 'Svg' && String(n.props?.source).includes('viewBox="0 0 56 56"')) out.push(n)
326
+ })
327
+ return out
328
+ }
329
+
330
+ test('each alert shows 리플이 with the face of its state, its gauge in 5% steps; the terminal keeps a text mark', async ($, on) => {
331
+ const noop = () => undefined
332
+ const actions = { compact: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
333
+ const variants: [string, AlertView][] = [
334
+ ['swamp-59', { kind: 'swamp', percent: 59, tokens: 118_000 }],
335
+ ['swamp-61', { kind: 'swamp', percent: 61, tokens: 122_000 }],
336
+ ['swamp-66', { kind: 'swamp', percent: 66, tokens: 132_000 }],
337
+ ['hot-94', { kind: 'hot', limitKind: 'five_hour', percent: 94, save: false }],
338
+ ['hot-95', { kind: 'hot', limitKind: 'five_hour', percent: 95, save: false }],
339
+ ]
340
+ ALERTS.forEach((alert, i) => drawPane(on, 'face-' + i, (el, surface) => renderAlert(el, frameOf(surface), alert, actions)))
341
+ for (const [id, alert] of variants) drawPane(on, id, (el, surface) => renderAlert(el, frameOf(surface), alert, actions))
342
+ const sources: string[] = []
343
+ for (let i = 0; i < ALERTS.length; i++) {
344
+ const ui = await mount($, 'face-' + i, 'desktop')
345
+ const found = mascots(await ui.drawn())
346
+ expect(found.length, 'alert ' + i).toBe(1)
347
+ // The full drawing (34 px up): brows and mouth say what the card is about.
348
+ expect(found[0]!.props?.width).toBe(34)
349
+ // An image, not a frame: the desktop rebuilds a frame blank on each redraw.
350
+ expect(found[0]!.props?.isInteractive, 'alert ' + i).toBeFalsy()
351
+ sources.push(String(found[0]!.props?.source))
352
+ await ui.unmount()
353
+ const terminal = await mount($, 'face-' + i, 'terminal')
354
+ expect(mascots(await terminal.drawn()).length).toBe(0)
355
+ await terminal.unmount()
356
+ }
357
+ // handoffDone and compacted share the pleased face, compactNote and handoffChoice the asking
358
+ // one; every other state has its own.
359
+ expect(new Set(sources).size).toBe(ALERTS.length - 2)
360
+ const source = async (id: string) => {
361
+ const ui = await mount($, id, 'desktop')
362
+ const s = String(mascots(await ui.drawn())[0]?.props?.source)
363
+ await ui.unmount()
364
+ return s
365
+ }
366
+ expect(await source('swamp-59')).toBe(await source('swamp-61'))
367
+ expect(await source('swamp-61') === await source('swamp-66')).toBe(false)
368
+ expect((await source('hot-94')).includes('fill="var(--warn)"')).toBe(true)
369
+ expect((await source('hot-95')).includes('fill="var(--bad)"')).toBe(true)
370
+ const terminal = await mount($, 'hot-95', 'terminal')
371
+ expect(await terminal.find({ type: 'Text', text: /^✕ / })).toBeDefined()
372
+ })
373
+
374
+ test('리플이 greets in setup and quick settings and waits in empty panes, never among running agents', async ($, on) => {
375
+ const noop = () => undefined
376
+ const setupActions = { patch: noop, jevKey: noop, testJev: noop, openAllSettings: noop, back: noop, next: noop, finish: noop, skip: noop }
377
+ drawPane(on, 'm-setup-1', (el, surface) => renderSetup(el, frameOf(surface), { step: 1, draft: SETTINGS, jev: { source: 'env', test: { state: 'idle' } } }, setupActions))
378
+ drawPane(on, 'm-setup-4', (el, surface) => renderSetup(el, frameOf(surface), { step: 4, draft: SETTINGS, jev: { source: 'env', test: { state: 'idle' } } }, setupActions))
379
+ drawPane(on, 'm-quick', (el, surface) => renderSettings(el, frameOf(surface), { settings: SETTINGS, jev: { source: null, test: { state: 'idle' } } }, QUICK_ACTIONS))
380
+ drawPane(on, 'm-agents', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: true }, { toggleDone: noop }))
381
+ drawPane(on, 'm-agents-empty', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: [], now: NOW, showDone: false }, { toggleDone: noop }))
382
+ drawPane(on, 'm-log-empty', (el, surface) => renderLog(el, frameOf(surface, 64), { rows: [], mineOnly: true }, { toggleScope: noop }))
383
+ // It greets and waits at 56 px, and heads the quick settings at 48.
384
+ const expected: [string, number, string, number][] = [['m-setup-1', 1, 'var(--accent)', 56], ['m-setup-4', 1, 'var(--ok)', 56], ['m-quick', 1, 'var(--accent)', 48], ['m-agents', 0, '', 0], ['m-agents-empty', 1, 'var(--accent)', 56], ['m-log-empty', 1, 'var(--accent)', 56]]
385
+ for (const [id, count, tone, size] of expected) {
386
+ const ui = await mount($, id, 'desktop', 64)
387
+ const found = mascots(await ui.drawn())
388
+ expect(found.length, id).toBe(count)
389
+ if (tone) expect(String(found[0]!.props?.source).includes('stroke="' + tone + '"'), id).toBe(true)
390
+ if (size) expect(found[0]!.props?.width, id).toBe(size)
391
+ await ui.unmount()
392
+ const terminal = await mount($, id, 'terminal', 64)
393
+ expect(mascots(await terminal.drawn()).length, id).toBe(0)
394
+ await terminal.unmount()
395
+ }
396
+ })
397
+
398
+ // ---- Settings and setup ------------------------------------------------------------------------
399
+
400
+ /** Every quick control, on one screen on both surfaces (the desktop's tiles, the terminal's rows). */
401
+ const QUICK_KEYS = ['set-auto', 'bias-2', 'judge-haiku', 'judge-jev', 'judge-model', 'set-warm']
402
+
403
+ test('quick settings draw on both surfaces with only session controls', async ($, on) => {
404
+ drawPane(on, 'quick', (el, surface) => renderSettings(el, frameOf(surface), { settings: SETTINGS, jev: { source: null, test: { state: 'idle' } } }, QUICK_ACTIONS))
405
+ drawPane(on, 'quick-off', (el, surface) => renderSettings(el, frameOf(surface), { settings: { ...SETTINGS, effortAuto: false }, jev: { source: null, test: { state: 'idle' } } }, QUICK_ACTIONS))
406
+ for (const surface of SURFACES) {
407
+ const ui = await mount($, 'quick', surface)
408
+ // The terminal names the brand and the panel quietly; the desktop's head is the panel's name.
409
+ if (surface === 'terminal') {
410
+ expect((await ui.find({ type: 'Text', text: 'ClaudeRipple' }))?.props.bold).toBe(true)
411
+ expect((await ui.find({ type: 'Text', text: 'Quick settings' }))?.props.dimColor).toBe(true)
412
+ } else {
413
+ expect((await ui.find({ type: 'Text', text: 'Quick settings' }))?.props.bold).toBe(true)
414
+ expect(await ui.find({ type: 'Text', text: 'ClaudeRipple' })).toBeDefined()
415
+ // 리플이 says what the settings do now.
416
+ expect(await ui.find({ type: 'Text', text: 'Jev picks the effort for every prompt.' })).toBeDefined()
417
+ }
418
+ for (const key of [...QUICK_KEYS, 'all-settings', 'close']) expect(await ui.find({ key }), surface + ' ' + key).toBeDefined()
419
+ for (const key of ['save', 'tab-workers', 'floor', 'judge-model', 'jev-source', 'warm-hours', 'worker-next']) {
420
+ if (key === 'judge-model') expect(await ui.find({ type: 'Select', key })).toBeUndefined()
421
+ else expect(await ui.find({ key })).toBeUndefined()
422
+ }
423
+ expect(await ui.find({ type: 'Text', text: '● unsaved' })).toBeUndefined()
424
+ expect(await ui.find({ type: 'Text', text: 'not affiliated with Anthropic' })).toBeUndefined()
425
+ await ui.unmount()
426
+ const off = await mount($, 'quick-off', surface)
427
+ expect(await off.find({ key: 'bias-2' })).toBeUndefined()
428
+ await off.unmount()
429
+ }
430
+ })
431
+
432
+ test('quick settings controls reach their callbacks on both surfaces', async ($, on) => {
433
+ const { calls, fn } = recorder()
434
+ const actions = { patch: fn('patch'), jevKey: fn('key'), testJev: fn('test'), openAllSettings: fn('all'), close: fn('close') }
435
+ drawPane(on, 'quick', (el, surface) => renderSettings(el, frameOf(surface), { settings: SETTINGS, jev: { source: null, test: { state: 'idle' } } }, actions))
436
+ for (const surface of SURFACES) {
437
+ const ui = await mount($, 'quick', surface)
438
+ await ui.press({ key: 'set-auto' })
439
+ await ui.press({ key: 'bias-2' })
440
+ await ui.press({ key: 'judge-model' })
441
+ await ui.input({ key: 'jev-key', text: 'ts-key' })
442
+ await ui.press({ key: 'jev-test' })
443
+ await ui.press({ key: 'set-warm' })
444
+ await ui.press({ key: 'all-settings' })
445
+ await ui.press({ key: 'close' })
446
+ await ui.unmount()
447
+ }
448
+ const once = ['patch:[{"effortAuto":false}]', 'patch:[{"bias":2}]', 'patch:[{"judge":"model"}]', 'key:["ts-key"]', 'test', 'patch:[{"keepWarm":true}]', 'all', 'close']
449
+ expect(calls).toEqual([...once, ...once])
450
+ })
451
+
452
+ test('Jev key and Test row appear only for TypeSafe Jev with no known key', async ($, on) => {
453
+ const sources: JevState['source'][] = [null, 'zen', 'env', 'file', 'pasted']
454
+ for (const judge of ['haiku', 'jev', 'model'] as const) {
455
+ for (const jevSource of ['typesafe', 'zen'] as const) {
456
+ for (const source of sources) drawPane(on, [judge, jevSource, source].join('-'), (el, surface) => renderSettings(el, frameOf(surface), { settings: { ...SETTINGS, judge, jevSource }, jev: { source, test: { state: 'idle' } } }, QUICK_ACTIONS))
457
+ }
458
+ }
459
+ for (const surface of SURFACES) {
460
+ for (const judge of ['haiku', 'jev', 'model'] as const) {
461
+ for (const jevSource of ['typesafe', 'zen'] as const) {
462
+ for (const source of sources) {
463
+ const ui = await mount($, [judge, jevSource, source].join('-'), surface)
464
+ const shown = judge === 'jev' && jevSource === 'typesafe' && (source === null || source === 'zen')
465
+ expect(!!await ui.find({ type: 'Input', key: 'jev-key' }), surface + ' ' + [judge, jevSource, source]).toBe(shown)
466
+ expect(!!await ui.find({ key: 'jev-test' })).toBe(shown)
467
+ await ui.unmount()
468
+ }
469
+ }
470
+ }
471
+ }
472
+ })
473
+
474
+ test('setup steps draw on both surfaces and move on', async ($, on) => {
475
+ const { calls, fn } = recorder()
476
+ const actions = { patch: fn('patch'), jevKey: fn('key'), testJev: fn('test'), openAllSettings: fn('all'), back: fn('back'), next: fn('next'), finish: fn('finish'), skip: fn('skip') }
477
+ for (const step of [1, 2, 3, 4] as const) {
478
+ drawPane(on, 'setup-' + step, (el, surface) =>
479
+ renderSetup(el, frameOf(surface), { step, draft: SETTINGS, jev: { source: 'env', test: { state: 'idle' } } }, actions),
480
+ )
481
+ }
482
+ for (const surface of SURFACES) {
483
+ await (await mount($, 'setup-1', surface)).press({ key: 'next' })
484
+ const workers = await mount($, 'setup-2', surface)
485
+ expect(await workers.find({ type: 'Text', text: 'Workers start on the ladder; set it up on the dashboard.' })).toBeDefined()
486
+ expect(await workers.find({ key: 'rule-mode-auto' })).toBeUndefined()
487
+ await workers.press({ key: 'all-settings' })
488
+ await workers.press({ key: 'back' })
489
+ await (await mount($, 'setup-3', surface)).press({ key: 'set-warm' })
490
+ const done = await mount($, 'setup-4', surface)
491
+ // The four workers' models, lowest first (the old tiers become the ladder; Max takes the Hard model).
492
+ expect(await done.find({ type: 'Text', text: 'deepseek-v4-1-flash · gpt-6-1-sol · gpt-6-astra · gpt-6-astra' }), 'done workers ' + surface).toBeDefined()
493
+ expect(await done.find({ type: 'Text', text: 'not affiliated with Anthropic' })).toBeUndefined()
494
+ await done.press({ key: 'finish' })
495
+ }
496
+ const once = ['next', 'all', 'back', 'patch:[{"keepWarm":true}]', 'finish']
497
+ expect(calls).toEqual([...once, ...once])
498
+ })
499
+
500
+ // ---- Panes -------------------------------------------------------------------------------------
501
+
502
+ const AGENTS: AgentRec[] = [
503
+ {
504
+ id: 'a1',
505
+ description: 'Port the request log renderer',
506
+ type: 'gpt-6-1-sol',
507
+ model: 'gpt-6.1-sol@high',
508
+ pick: { domain: 'frontend', level: 'standard', effort: 'high', sure: 0.8, why: 'designed refactor', target: { agent: 'gpt-6-1-sol' }, by: 'haiku', ms: 900 },
509
+ state: 'running',
510
+ now: 'Edit hooks/ui/log.tsx',
511
+ toolSince: NOW - 14_000,
512
+ startedAt: NOW - 192_000,
513
+ cost: 38_400,
514
+ },
515
+ { id: 'a2', description: 'Summarize the docs', type: 'deepseek-v4-1-flash', state: 'done', startedAt: NOW - 900_000, endedAt: NOW - 800_000, cost: 9000 },
516
+ ]
517
+
518
+ test('the Agents pane draws live, finished and empty', async ($, on) => {
519
+ const { calls, fn } = recorder()
520
+ drawPane(on, 'agents', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: false }, { toggleDone: fn('done') }))
521
+ drawPane(on, 'agents-open', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: true }, { toggleDone: fn('done') }))
522
+ drawPane(on, 'agents-empty', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: [], now: NOW, showDone: false }, { toggleDone: fn('done') }))
523
+ for (const surface of SURFACES) {
524
+ const ui = await mount($, 'agents', surface, 64)
525
+ expect(await ui.find({ type: 'Text', text: 'Port the request log renderer' }), "ui { type: 'Text', text: 'Port the request log renderer' } " + surface).toBeDefined()
526
+ // The desktop leaves what an agent is doing to the host's task list above the pane.
527
+ if (surface === 'terminal') expect(await ui.find({ type: 'Text', text: /Edit hooks\/ui\/log.tsx/ }), surface).toBeDefined()
528
+ else expect(await ui.find({ type: 'Text', text: /Edit hooks\/ui\/log.tsx/ }), surface).toBeUndefined()
529
+ expect(await ui.find({ type: 'Text', text: 'Summarize the docs' })).toBeUndefined()
530
+ expect(await ui.find({ type: 'Text', text: /Frontend \/ Standard/ })).toBeDefined()
531
+ await ui.press({ key: 'done' })
532
+ const open = await mount($, 'agents-open', surface, 64)
533
+ expect(await open.find({ type: 'Text', text: 'Summarize the docs' }), "open { type: 'Text', text: 'Summarize the docs' } " + surface).toBeDefined()
534
+ const empty = await mount($, 'agents-empty', surface, 64)
535
+ expect(await empty.find({ type: 'Text', text: 'No agents yet' }), "empty { type: 'Text', text: 'No agents yet' } " + surface).toBeDefined()
536
+ }
537
+ expect(calls).toEqual(['done', 'done'])
538
+ })
539
+
540
+ test('the log pane lists this session\'s verdicts among its requests, a fallback in plain words', async ($, on) => {
541
+ const rows: StatusRequest[] = [{ at: '2026-10-10T13:19:10', source: 'claude-opus-5-5', target: 'claude-opus-5-5', provider: 'anthropic', status: 200, ok: true, ms: 19_400, effort: 'low', mine: true }]
542
+ const judges = [
543
+ { at: Date.parse('2026-10-10T13:19:09'), for: 'prompt' as const, asked: 'jev' as const, by: 'jev' as const, ms: 610, effort: 'low' as const, sure: 0.94 },
544
+ { at: Date.parse('2026-10-10T13:20:00'), for: 'prompt' as const, asked: 'jev' as const, by: 'haiku' as const, ms: 3400, effort: 'medium' as const, error: 'Jev timed out' },
545
+ ]
546
+ drawPane(on, 'log-judges', (el, surface) => renderLog(el, frameOf(surface, 80), { rows, mineOnly: true, judges }, { toggleScope: () => undefined }))
547
+ drawPane(on, 'log-all', (el, surface) => renderLog(el, frameOf(surface, 80), { rows, mineOnly: false, judges }, { toggleScope: () => undefined }))
548
+ for (const surface of SURFACES) {
549
+ const ui = await mount($, 'log-judges', surface, 80)
550
+ expect(await ui.find({ type: 'Text', text: 'Effort judge' }), surface).toBeDefined()
551
+ expect(await ui.find({ type: 'Text', text: /sure 94%/ }), surface).toBeDefined()
552
+ expect(await ui.find({ type: 'Text', text: 'Jev failed → Haiku' }), surface).toBeDefined()
553
+ expect(await ui.find({ type: 'Text', text: 'Jev timed out' }), surface).toBeDefined()
554
+ // Newest first: the fallback, the request, then the verdict that preceded it.
555
+ const order: string[] = []
556
+ walk(await ui.drawn(), n => { const text = (n.children ?? []).join(''); if (n.type === 'Text' && /^(Jev failed → Haiku|claude-opus-5-5|Jev)$/.test(text)) order.push(text) })
557
+ expect(order).toEqual(['Jev failed → Haiku', 'claude-opus-5-5', 'Jev'])
558
+ // Another session's requests have no verdicts of ours beside them.
559
+ const all = await mount($, 'log-all', surface, 80)
560
+ expect(await all.find({ type: 'Text', text: 'Effort judge' }), surface).toBeUndefined()
561
+ }
562
+ })
563
+
564
+ test('the log pane keeps its content', async ($, on) => {
565
+ const { calls, fn } = recorder()
566
+ const rows: StatusRequest[] = [
567
+ { at: '2026-10-10T13:19:10', source: 'gpt-6-1-sol@high', target: 'gpt-6.1-sol', provider: 'chatgpt', status: 200, ok: true, ms: 2140, effort: 'high', usage: { input: 3000, cached: 41_000, output: 900 }, mine: true },
568
+ { at: '2026-10-10T13:18:55', source: 'deepseek-v4.1-flash', target: 'deepseek-v4.1-flash', provider: 'opencode-go', status: 429, ok: false, ms: 310, note: 'rate limited', mine: false },
569
+ ]
570
+ drawPane(on, 'log', (el, surface) => renderLog(el, frameOf(surface, 64), { rows, mineOnly: false }, { toggleScope: fn('scope') }))
571
+ drawPane(on, 'log-empty', (el, surface) => renderLog(el, frameOf(surface, 64), { rows: [], mineOnly: true }, { toggleScope: fn('scope') }))
572
+ for (const surface of SURFACES) {
573
+ const ui = await mount($, 'log', surface, 64)
574
+ expect(await ui.find({ type: 'Text', text: 'gpt-6.1-sol (gpt-6-1-sol)' })).toBeDefined()
575
+ expect(await ui.find({ type: 'Text', text: 'cache 93%' }), "ui { type: 'Text', text: 'cache 93%' } " + surface).toBeDefined()
576
+ expect(await ui.find({ type: 'Text', text: 'in 44.0k' }), "ui { type: 'Text', text: 'in 44.0k' } " + surface).toBeDefined()
577
+ expect(await ui.find({ type: 'Text', text: '✕ 429' }), "ui { type: 'Text', text: '✕ 429' } " + surface).toBeDefined()
578
+ expect(await ui.find({ type: 'Text', text: 'rate limited' }), "ui { type: 'Text', text: 'rate limited' } " + surface).toBeDefined()
579
+ await ui.press({ key: 'scope' })
580
+ const empty = await mount($, 'log-empty', surface, 64)
581
+ expect(await empty.find({ type: 'Text', text: 'No model requests yet.' }), "empty { type: 'Text', text: 'No model requests yet.' } " + surface).toBeDefined()
582
+ }
583
+ expect(calls).toEqual(['scope', 'scope'])
584
+ })
585
+
586
+ // ---- Nothing cut, nothing reloaded (2026-10-10) -------------------------------------------------
587
+
588
+ type Drawn = { type?: string; props?: Record<string, unknown>; children?: unknown[] }
589
+ function walk(node: unknown, visit: (n: Drawn) => void): void {
590
+ if (!node || typeof node !== 'object') return
591
+ if (Array.isArray(node)) { for (const child of node) walk(child, visit); return }
592
+ const n = node as Drawn
593
+ visit(n)
594
+ for (const child of n.children ?? []) walk(child, visit)
595
+ }
596
+ const isBlank = (n: Drawn) => n.type === 'Button' && typeof n.props?.label === 'string' && /^ +$/.test(n.props.label)
597
+
598
+ /**
599
+ * Each blank press and whether it lies over something drawn: the nearest Box around it (at most
600
+ * three levels up) that holds a picture or words besides the press itself.
601
+ */
602
+ function presses(tree: unknown): { key: string; over: boolean }[] {
603
+ const out: { key: string; over: boolean }[] = []
604
+ const drawnIn = (n: unknown) => {
605
+ let found = false
606
+ walk(n, d => { if (d.type === 'Svg' || (d.type === 'Text' && (d.children ?? []).join('').trim())) found = true })
607
+ return found
608
+ }
609
+ const visit = (n: Drawn, up: Drawn[]) => {
610
+ if (isBlank(n)) out.push({ key: String(n.props?.key), over: up.slice(-4).some(a => drawnIn(a)) })
611
+ for (const child of n.children ?? []) if (child && typeof child === 'object') visit(child as Drawn, [...up, n])
612
+ }
613
+ visit(tree as Drawn, [])
614
+ return out
615
+ }
616
+
617
+ test('every picture is a plain image, 리플이 the one face, and every blank press lies over a drawn control', async ($, on) => {
618
+ // Each Svg is a new <img> (or, interactive, a frame that reloads blank) on every desktop redraw
619
+ // (parts.tsx). A control is drawn (art.ts) and takes its press from a blank native button laid
620
+ // over it (kit.tsx), never from a blank button over nothing.
621
+ const noop = () => undefined
622
+ const setupActions = { patch: noop, jevKey: noop, testJev: noop, openAllSettings: noop, back: noop, next: noop, finish: noop, skip: noop }
623
+ const alertActions = { compact: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
624
+ const cases: [string, Draw][] = [
625
+ ['one-quick', (el, surface) => renderSettings(el, frameOf(surface, 64, 30), { settings: { ...SETTINGS, jevSource: 'typesafe' }, jev: { source: null, test: { state: 'idle' } } }, QUICK_ACTIONS)],
626
+ ...([1, 2, 3, 4] as const).map((step): [string, Draw] => ['one-setup-' + step, (el, surface) => renderSetup(el, frameOf(surface, 64, 30), { step, draft: SETTINGS, jev: { source: null, test: { state: 'idle' } } }, setupActions)]),
627
+ ...ALERTS.map((alert, i): [string, Draw] => ['one-alert-' + i, (el, surface) => renderAlert(el, frameOf(surface, 60), alert, alertActions)]),
628
+ ['one-hot-save', (el, surface) => renderAlert(el, frameOf(surface, 60), { kind: 'hot', limitKind: 'five_hour', percent: 97, save: true }, alertActions)],
629
+ ]
630
+ for (const [id, draw] of cases) drawPane(on, id, draw)
631
+ for (const [id] of cases) {
632
+ const ui = await mount($, id, 'desktop', 64)
633
+ const tree = await ui.drawn()
634
+ walk(tree, n => { if (n.type === 'Svg') expect(n.props?.isInteractive, id).toBeFalsy() })
635
+ expect(mascots(tree).length, id).toBe(1)
636
+ for (const press of presses(tree)) expect(press.over, id + ' ' + press.key).toBe(true)
637
+ await ui.unmount()
638
+ }
639
+ })
640
+
641
+ test('a card says one thing: at most one main action, the others quiet, one way to let it go', async ($, on) => {
642
+ const noop = () => undefined
643
+ const alertActions = { compact: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
644
+ const all: AlertView[] = [...ALERTS, { kind: 'hot', limitKind: 'five_hour', percent: 97, save: true }]
645
+ all.forEach((alert, i) => drawPane(on, 'say-' + i, (el, surface) => renderAlert(el, frameOf(surface), alert, alertActions)))
646
+ for (let i = 0; i < all.length; i++) {
647
+ const ui = await mount($, 'say-' + i, 'desktop')
648
+ const buttons: Drawn[] = []
649
+ walk(await ui.drawn(), n => { if (n.type === 'Button') buttons.push(n) })
650
+ // The main action is a lit pill (a picture with its press); the rest are ghost pills, the
651
+ // options a drawn segmented control, the way out a drawn ×: every press a blank one.
652
+ let pills = 0
653
+ walk(await ui.drawn(), n => { if (n.type === 'Box' && String(n.props?.key ?? '').startsWith('pill-')) pills++ })
654
+ expect(buttons.every(b => isBlank(b)), 'alert ' + i).toBe(true)
655
+ const main = buttons.filter(b => b.props?.variant === 'primary')
656
+ const closes = buttons.filter(b => b.props?.role === 'dismiss')
657
+ const others = buttons.filter(b => b.props?.variant !== 'primary' && b.props?.role !== 'dismiss' && !isBlank(b))
658
+ expect(main.length + pills <= 1, 'alert ' + i).toBe(true)
659
+ expect(closes.length <= 1, 'alert ' + i).toBe(true)
660
+ // Quiet: dim at rest (the note's field submit is the host's own).
661
+ for (const b of others) expect(b.props?.dimColor === true, 'alert ' + i + ' ' + String(b.props?.key)).toBe(true)
662
+ await ui.unmount()
663
+ }
664
+ // Save mode on: nothing left to press but turning it off, quietly (a ghost pill, no lit one).
665
+ const on_ = await mount($, 'say-' + (all.length - 1), 'desktop')
666
+ expect(await on_.find({ type: 'Box', key: 'ghost-save' })).toBeDefined()
667
+ expect(await on_.find({ type: 'Box', key: 'pill-save' })).toBeUndefined()
668
+ expect((await on_.findAll({ type: 'Svg' })).some(svg => svg.props.alt === 'Turn save mode off')).toBe(true)
669
+ expect(await on_.find({ type: 'Button', key: 'dismiss' })).toBeDefined()
670
+ })
671
+
672
+ test('no drawing cuts its text: no truncating Text on any surface', async ($, on) => {
673
+ const noop = () => undefined
674
+ const settingsActions = QUICK_ACTIONS
675
+ const cases: [string, Draw][] = [
676
+ ['cut-quick', (el, surface) => renderSettings(el, frameOf(surface, 60, 30), { settings: SETTINGS, jev: { source: null, test: { state: 'fail', message: 'key rejected (401) '.repeat(4) } } }, settingsActions)],
677
+ ['cut-agents', (el, surface) => renderAgents(el, frameOf(surface, 40), { agents: AGENTS.map(a => ({ ...a, description: a.description + ' and a much longer tail of words that runs past any pane width', state: 'running' as const })), now: NOW, showDone: true }, { toggleDone: noop })],
678
+ ['cut-agents-done', (el, surface) => renderAgents(el, frameOf(surface, 40), { agents: AGENTS, now: NOW, showDone: true }, { toggleDone: noop })],
679
+ ['cut-log', (el, surface) => renderLog(el, frameOf(surface, 40), { rows: [{ at: '2026-10-10T13:19:10', source: 'a-very-long-requested-model-name@high', target: 'another-very-long-routed-model-name', provider: 'opencode-go', status: 200, ok: true, ms: 2140 }], mineOnly: false }, { toggleScope: noop })],
680
+ ...ALERTS.map((alert, i): [string, Draw] => ['cut-alert-' + i, (el, surface) => renderAlert(el, frameOf(surface, 40), alert, { compact: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop })]),
681
+ ]
682
+ for (const [id, draw] of cases) drawPane(on, id, draw)
683
+ for (const surface of SURFACES) {
684
+ for (const [id] of cases) {
685
+ const ui = await mount($, id, surface, 40)
686
+ walk(await ui.drawn(), n => {
687
+ if (n.type === 'Text') expect(String(n.props?.wrap ?? 'wrap').startsWith('truncate'), id + ' ' + surface).toBe(false)
688
+ })
689
+ await ui.unmount()
690
+ }
691
+ }
692
+ })
693
+
694
+ // ---- The band holds still (2026-10-10) ----------------------------------------------------------
695
+
696
+ const BAND_ACTIONS = { toggleAuto: () => undefined, toggleWarm: () => undefined, compact: () => undefined, handoff: () => undefined, toggleLog: () => undefined, toggleAgents: () => undefined, openSettings: () => undefined, toggleEffortMenu: () => undefined, pickEffort: () => undefined }
697
+ const unhandled = (tree: unknown) => JSON.stringify(tree).replace(/"handle":\d+/g, '')
698
+
699
+ test('within a minute the band draws the same tree; a minute on, only the countdown changes', async ($, on) => {
700
+ const view: DashboardView = { ...DASH, cache: { expiresAt: NOW + 41 * 60_000 + 30_000, ttlMs: 3_600_000 }, effort: { level: null, mode: 'deciding', since: NOW - 300 } }
701
+ for (const surface of SURFACES) {
702
+ drawPane(on, 'now-' + surface, el => renderBand(el, frameOf(surface), view, BAND_ACTIONS))
703
+ // Twenty-nine seconds on, the judge further along: the same minute shown, so the same tree.
704
+ drawPane(on, 'later-' + surface, el => renderBand(el, { ...frameOf(surface), now: NOW + 29_000 }, { ...view, effort: { ...view.effort, since: NOW - 2_000 } }, BAND_ACTIONS))
705
+ drawPane(on, 'minute-' + surface, el => renderBand(el, { ...frameOf(surface), now: NOW + 31_000 }, view, BAND_ACTIONS))
706
+ }
707
+ for (const surface of SURFACES) {
708
+ const drawn = async (id: string) => unhandled(await (await mount($, id, surface)).drawn())
709
+ const now = await drawn('now-' + surface)
710
+ expect(now).toContain('"42m"')
711
+ expect(await drawn('later-' + surface)).toBe(now)
712
+ expect((await drawn('minute-' + surface)).replace(/\b41m\b/g, '42m')).toBe(now)
713
+ }
714
+ })
715
+
716
+ test('리플이 acts out the band: thinking, working, drowsy, asleep or idle, one fixed source per mood in a slot that never moves', async ($, on) => {
717
+ const cache = (minutes: number) => ({ cache: { expiresAt: NOW + minutes * 60_000 - 1, ttlMs: 3_600_000 } })
718
+ const cases: [string, DashboardView, ReturnType<typeof bandMood>][] = [
719
+ ['idle', DASH, { face: 'calm', dim: false }],
720
+ ['deciding', { ...DASH, effort: { level: null, mode: 'deciding' }, isWorking: true }, { face: 'thinking', dim: false }],
721
+ ['working', { ...DASH, isWorking: true, ...cache(2) }, { face: 'working', dim: false }],
722
+ ['drowsy-4', { ...DASH, ...cache(4) }, { face: 'drowsy', dim: false }],
723
+ ['drowsy-2', { ...DASH, ...cache(2) }, { face: 'drowsy', dim: false }],
724
+ ['held', { ...DASH, ...cache(2), warm: { on: true, pings: 1, cost: 0 } }, { face: 'calm', dim: false }],
725
+ ['short-life', { ...DASH, cache: { expiresAt: NOW + 3 * 60_000, ttlMs: 300_000 } }, { face: 'calm', dim: false }],
726
+ ['asleep', { ...DASH, cache: { expiresAt: NOW - 1, ttlMs: 3_600_000 } }, { face: 'sleepy', dim: false }],
727
+ ['off', { ...DASH, effort: { level: 'high', mode: 'off' } }, { face: 'calm', dim: true }],
728
+ ]
729
+ for (const [id, view, mood] of cases) {
730
+ expect(bandMood(frameOf('desktop'), view), id).toEqual(mood)
731
+ drawPane(on, 'mood-' + id, el => renderBand(el, frameOf('desktop'), view, BAND_ACTIONS))
732
+ }
733
+ const sources: Record<string, string> = {}
734
+ for (const [id, , mood] of cases) {
735
+ const ui = await mount($, 'mood-' + id, 'desktop')
736
+ const tree = await ui.drawn()
737
+ const found = mascots(tree)
738
+ expect(found.length, id).toBe(1)
739
+ expect(found[0]!.props?.width, id).toBe(BAND_RIPPLE)
740
+ expect(found[0]!.props?.isInteractive, id).toBeFalsy()
741
+ sources[id] = String(found[0]!.props?.source)
742
+ // Drawn for the capsule's dark water whatever the app's theme.
743
+ expect(sources[id], id).toBe(rippleSource(mood.face, BAND_RIPPLE, 'dark', undefined, mood.dim))
744
+ // The mini drawing below 34 px: every part in the tone, no ink face.
745
+ expect(/="var\(--ink\)"/.test(sources[id]!), id).toBe(false)
746
+ expect((await ui.find({ type: 'Box', key: 'band-crest' }))?.props.width, id).toBe(CREST_CELLS)
747
+ await ui.unmount()
748
+ }
749
+ // The source changes with the mood alone: four minutes or two, drowsy is one picture.
750
+ expect(sources['drowsy-4']).toBe(sources['drowsy-2'])
751
+ expect(new Set(Object.values(sources)).size).toBe(6)
752
+ })
753
+
754
+ /** Each fact's slot width and each control's label, by key, as drawn. */
755
+ function layoutOf(tree: unknown): Record<string, unknown> {
756
+ const out: Record<string, unknown> = {}
757
+ walk(tree, n => {
758
+ const key = String(n.props?.key ?? '')
759
+ if (n.type === 'Box' && key.startsWith('fact-')) out[key] = n.props?.width
760
+ if (n.type === 'Button' && ['auto', 'warm', 'log', 'agents', 'settings'].includes(key)) out[key] = typeof n.props?.label === 'string' ? [...n.props.label].map(c => (/[●○·0-9]/.test(c) ? '#' : c)).join('') : n.props?.label
761
+ })
762
+ return out
763
+ }
764
+
765
+ test('no value moves a neighbour: every slot keeps its width whatever it shows', async ($, on) => {
766
+ const levels: DashboardView['effort'][] = [
767
+ ...(['low', 'medium', 'high', 'xhigh', 'max'] as const).flatMap(level => [{ level, mode: 'auto' as const, why: 'w' }, { level, mode: 'manual' as const }]),
768
+ { level: null, mode: 'deciding' }, { level: 'high', mode: 'paused' }, { level: null, mode: 'off' },
769
+ ]
770
+ const caches: DashboardView['cache'][] = [{ expiresAt: NOW + 3_600_000, ttlMs: 3_600_000 }, { expiresAt: NOW + 9 * 60_000, ttlMs: 3_600_000 }, { expiresAt: NOW + 20_000, ttlMs: 300_000 }, { expiresAt: NOW - 1, ttlMs: 300_000 }, null]
771
+ const routes: Pick<DashboardView, 'route' | 'notice'>[] = [
772
+ { route: DASH.route, notice: null },
773
+ { route: { ...DASH.route!, model: 'a-very-long-routed-model-name (and-what-was-asked)', cachePct: 100 }, notice: null },
774
+ { route: { ...DASH.route!, ok: false, status: '429', cachePct: undefined }, notice: null },
775
+ { route: { ...DASH.route!, ok: false, resent: true, status: '529' }, notice: null },
776
+ { route: null, notice: 'ClaudeRipple: router not answering' },
777
+ ]
778
+ const views: DashboardView[] = [
779
+ ...levels.map(effort => ({ ...DASH, effort })),
780
+ ...caches.map(cache => ({ ...DASH, cache })),
781
+ ...routes.map(r => ({ ...DASH, ...r })),
782
+ { ...DASH, agents: { live: 2, total: 3 }, agentsOpen: true, logOpen: true, auto: false, warm: { on: true, pings: 12, cost: 0, stopped: 'no cache' } },
783
+ { ...DASH, handoff: 'advised', context: { percent: 100, tokens: 200_000, window: 200_000 } },
784
+ ]
785
+ const LANGS = ['en', 'ko', 'ja', 'es'] as const
786
+ for (const lang of LANGS) {
787
+ for (const surface of SURFACES) {
788
+ // A terminal wide enough for the reason's slot, a desktop band of ~1000 px.
789
+ for (const [i, view] of views.entries()) drawPane(on, lang + '-' + surface + '-' + i, el => renderBand(el, { ...frameOf(surface, surface === 'terminal' ? 200 : 110), lang }, view, BAND_ACTIONS))
790
+ }
791
+ }
792
+ for (const lang of LANGS) {
793
+ for (const surface of SURFACES) {
794
+ const seen: Record<string, unknown> = {}
795
+ for (let i = 0; i < views.length; i++) {
796
+ const ui = await mount($, lang + '-' + surface + '-' + i, surface, 110)
797
+ for (const [key, value] of Object.entries(layoutOf(await ui.drawn()))) {
798
+ if (key in seen) expect(value, lang + ' ' + surface + ' view ' + i + ' ' + key).toEqual(seen[key])
799
+ else seen[key] = value
800
+ }
801
+ await ui.unmount()
802
+ }
803
+ // A wide enough terminal keeps a slot for the reason, blank while there is none.
804
+ const why = surface === 'terminal' ? ['fact-why'] : []
805
+ expect(Object.keys(seen).sort()).toEqual(['agents', 'auto', 'fact-cache', 'fact-context', 'fact-effort', 'fact-route', ...why, ...(surface === 'terminal' ? ['log'] : []), 'settings', 'warm'])
806
+ }
807
+ }
808
+ })
809
+
810
+ test('the band names the mode that is on, cut to a slot of its own that keeps its width', async ($, on) => {
811
+ expect(modeLabel('Burn GPT')).toBe('Burn GPT')
812
+ expect(modeLabel('Burn the GPT quota before it resets')).toBe('Burn the GP…')
813
+ expect(modeLabel('클로드는 지휘만 하기')).toBe('클로드는 지…')
814
+ const names = ['Burn GPT', 'Burn the GPT quota before it resets', '클로드는 지휘만 하기']
815
+ for (const surface of SURFACES) {
816
+ for (const [i, mode] of names.entries()) drawPane(on, 'mode-' + surface + '-' + i, el => renderBand(el, frameOf(surface), { ...DASH, mode }, BAND_ACTIONS))
817
+ drawPane(on, 'mode-' + surface + '-none', el => renderBand(el, frameOf(surface), DASH, BAND_ACTIONS))
818
+ }
819
+ for (const surface of SURFACES) {
820
+ const widths = new Set<unknown>()
821
+ for (const [i, name] of names.entries()) {
822
+ const ui = await mount($, 'mode-' + surface + '-' + i, surface)
823
+ const shown = surface === 'desktop' ? modeLabel(name) : 'Mode ' + modeLabel(name)
824
+ expect(await ui.find({ type: 'Text', text: shown }), surface + ' ' + name).toBeDefined()
825
+ // The whole name is in the hover card on the desktop.
826
+ if (surface === 'desktop') expect(await ui.find({ type: 'Text', text: 'Mode “' + name + '” is on: the workers follow it. Change it in quick settings.' })).toBeDefined()
827
+ widths.add(layoutOf(await ui.drawn())['fact-mode'])
828
+ await ui.unmount()
829
+ }
830
+ expect(widths.size, surface).toBe(1)
831
+ const none = await mount($, 'mode-' + surface + '-none', surface)
832
+ expect(layoutOf(await none.drawn())['fact-mode']).toBeUndefined()
833
+ await none.unmount()
834
+ }
835
+ })
836
+
837
+ test('Handoff advised, the desktop band keeps the route and every control in one row at 700 px (Korean, Chinese), 770 px (English, Japanese), 870 px (Spanish, Portuguese)', async ($, on) => {
838
+ // The band counts every slot at a fixed width (never by value), drops the controls' words, then
839
+ // the quietest facts, until the route and the controls fit; the reason takes what is left.
840
+ const busy: DashboardView = { ...DASH, effort: { level: 'xhigh', mode: 'manual' }, handoff: 'advised', agents: { live: 2, total: 3 }, warm: { on: true, pings: 3, cost: 0 } }
841
+ const langs = Object.keys(STRINGS) as (keyof typeof STRINGS)[]
842
+ // Cells counted at 8.8 px (the 14 px measure the band used before, 2026-10-10): a margin over
843
+ // the host's 7.917 px for the site's padding and the band's own border.
844
+ const columns = (lang: string) => Math.floor((lang === 'es' || lang === 'pt' ? 870 : lang === 'en' || lang === 'ja' ? 770 : 700) / 8.8)
845
+ for (const lang of langs) drawPane(on, 'row-' + lang, el => renderBand(el, { ...frameOf('desktop', columns(lang)), lang }, busy, { ...BAND_ACTIONS, hide: () => undefined }))
846
+ for (const lang of langs) {
847
+ const ui = await mount($, 'row-' + lang, 'desktop', columns(lang))
848
+ expect(await ui.find({ type: 'Box', key: 'fact-route' }), lang).toBeDefined()
849
+ expect(await ui.find({ type: 'Box', key: 'fact-effort' }), lang).toBeDefined()
850
+ for (const key of ['handoff', 'auto', 'warm', 'agents', 'settings', 'hide']) expect(await ui.find({ type: 'Button', key }), lang + ' ' + key).toBeDefined()
851
+ await ui.unmount()
852
+ }
853
+ })