clauderipple 0.8.10 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.es.md +0 -4
  3. package/README.ja.md +0 -4
  4. package/README.ko.md +5 -1
  5. package/README.md +5 -1
  6. package/README.pt-BR.md +0 -4
  7. package/README.zh-CN.md +0 -4
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/codex.js +3 -1
  10. package/dist/cli/src/settings.js +5 -2
  11. package/dist/mod/.claude-plugin/plugin.json +9 -1
  12. package/dist/mod/hooks/cache.ts +60 -0
  13. package/dist/mod/hooks/compact.ts +40 -0
  14. package/dist/mod/hooks/handoff.ts +43 -0
  15. package/dist/mod/hooks/i18n.ts +2716 -0
  16. package/dist/mod/hooks/judge.ts +226 -0
  17. package/dist/mod/hooks/ladder.js +179 -0
  18. package/dist/mod/hooks/ladder.ts +174 -0
  19. package/dist/mod/hooks/modes.ts +93 -0
  20. package/dist/mod/hooks/register.tsx +1458 -256
  21. package/dist/mod/hooks/session.ts +62 -0
  22. package/dist/mod/hooks/settings.js +342 -0
  23. package/dist/mod/hooks/settings.ts +321 -0
  24. package/dist/mod/hooks/stats.ts +59 -0
  25. package/dist/mod/hooks/ui/agents.tsx +138 -0
  26. package/dist/mod/hooks/ui/alerts.tsx +370 -0
  27. package/dist/mod/hooks/ui/art.ts +628 -0
  28. package/dist/mod/hooks/ui/band.tsx +836 -0
  29. package/dist/mod/hooks/ui/kit.tsx +258 -0
  30. package/dist/mod/hooks/ui/log.tsx +109 -0
  31. package/dist/mod/hooks/ui/parts.tsx +217 -0
  32. package/dist/mod/hooks/ui/ripple.ts +614 -0
  33. package/dist/mod/hooks/ui/settings.tsx +376 -0
  34. package/dist/mod/hooks/ui/setup.tsx +148 -0
  35. package/dist/mod/hooks/ui/theme.ts +217 -0
  36. package/dist/mod/hooks/ui/view.ts +371 -0
  37. package/dist/mod/hooks/worker.ts +1 -1
  38. package/dist/mod/hooks/workers.ts +217 -0
  39. package/dist/mod/tests/register.test.tsx +1730 -0
  40. package/dist/mod/tests/ui.test.tsx +853 -0
  41. package/dist/mod/tsconfig.json +2 -1
  42. package/dist/mod/types/index.d.ts +320 -1
  43. package/dist/router/src/admin.js +170 -52
  44. package/dist/router/src/agents.js +24 -5
  45. package/dist/router/src/bootstrap.js +4 -2
  46. package/dist/router/src/config.js +3 -0
  47. package/dist/router/src/health.js +33 -0
  48. package/dist/router/src/index.js +1 -0
  49. package/dist/router/src/ingress/models.js +4 -1
  50. package/dist/router/src/mod-settings.js +119 -0
  51. package/dist/router/src/model-policy.js +6 -0
  52. package/dist/router/src/model-scores.js +159 -0
  53. package/dist/router/src/picker.js +7 -0
  54. package/dist/router/src/providers/chatgpt/index.js +6 -1
  55. package/dist/router/src/providers/chatgpt/translate.js +1 -1
  56. package/dist/router/src/providers/google/index.js +6 -2
  57. package/dist/router/src/providers/openai/index.js +4 -1
  58. package/dist/router/src/providers/openai/translate.js +1 -1
  59. package/dist/router/src/proxy.js +14 -3
  60. package/dist/router/src/version.js +1 -1
  61. package/dist/ui/app.js +908 -209
  62. package/dist/ui/favicon-32.png +0 -0
  63. package/dist/ui/favicon.svg +12 -0
  64. package/dist/ui/i18n.js +919 -233
  65. package/dist/ui/index.html +159 -87
  66. package/dist/ui/style.css +789 -410
  67. package/docs/ARCHITECTURE.md +128 -0
  68. package/package.json +7 -3
@@ -0,0 +1,376 @@
1
+ // Quick settings in the band. Each control applies immediately; the router dashboard owns
2
+ // the full settings page. Setup reuses the judge and keep-warm controls below.
3
+ //
4
+ // The terminal draws 리플이's column (parts.tsx `speak`): one row per setting, its options as ●/○
5
+ // words, then the way out to the dashboard. The desktop draws a capsule of the deep (kit.tsx): 리플이
6
+ // at the head saying in one line what the settings do right now, the two ways out (all settings,
7
+ // close) as drawn buttons at the top right, and four tiles, each a setting with its own picture of
8
+ // how it stands and its control right there, so nothing is a tab away.
9
+
10
+ import { t } from '../i18n'
11
+ import type { RenderNode } from 'claude-code'
12
+
13
+ import type { Settings } from '../../types'
14
+ import { CELL_PX, INK, MUTE, SUB, TILE_PX, TONE, tileArt } from './art'
15
+ import type { TileArt } from './art'
16
+ import { capsule, pill, pic, ripple, segmented, slider, toggle, well } from './kit'
17
+ import { action, actions, canDraw, dismiss, segmented as textSegmented, speak, toggle as textToggle } from './parts'
18
+ import type { Els } from './parts'
19
+ import { chOf, displayWidth, textTone } from './theme'
20
+ import type { Frame, SettingsActions, SettingsView } from './view'
21
+ import { isRich } from './view'
22
+
23
+ /** Every name a row of quick settings or setup shows: the name column is the widest of them. */
24
+ const ROW_NAMES = ['settings.effortAuto', 'settings.lean', 'settings.judge', 'settings.key', 'settings.keepWarm', 'settings.workers', 'settings.mode', 'band.effort', 'band.cache',
25
+ 'workers.rung.light', 'workers.rung.standard', 'workers.rung.hard', 'workers.rung.plan'] as const
26
+
27
+ /** The name column's width in cells: snug to this language's names, so controls sit close to them. */
28
+ function nameCells(frame: Frame): number {
29
+ const names = ROW_NAMES.map(key => t(frame.lang, key))
30
+ return isRich(frame) ? Math.ceil(Math.max(...names.map(chOf))) + 1 : Math.max(...names.map(displayWidth)) + 1
31
+ }
32
+
33
+ /** A labelled row: name column, the control, an optional quiet hint. */
34
+ export function row(el: Els, frame: Frame, key: string, label: string, control: RenderNode, hint?: string): RenderNode {
35
+ const { Box, Text } = el
36
+ return (
37
+ <Box key={'row-' + key} flexDirection="row" alignItems="center" columnGap={2} flexWrap="wrap">
38
+ <Box width={nameCells(frame)} flexShrink={0}>
39
+ <Text dimColor>{label}</Text>
40
+ </Box>
41
+ {control}
42
+ {hint ? <Text dimColor wrap="wrap">{hint}</Text> : null}
43
+ </Box>
44
+ )
45
+ }
46
+
47
+ // ---- The lean: five stops between cheaper and smarter -------------------------------------------
48
+
49
+ const BIAS: readonly Settings['bias'][] = [-2, -1, 0, 1, 2]
50
+
51
+ /** Cheaper ○ ○ ● ○ ○ Smarter on the editor; the terminal names the stops (−2 … +2). */
52
+ function biasStops(el: Els, frame: Frame, bias: Settings['bias'], onPick: (bias: Settings['bias']) => void): RenderNode {
53
+ const { Box, Button, Text } = el
54
+ const name = (b: number) => (b > 0 ? '+' + b : b < 0 ? '−' + -b : '0')
55
+ if (!isRich(frame)) return textSegmented(el, frame, 'bias', BIAS.map(b => ({ value: String(b), label: name(b) })), String(bias), picked => onPick(Number(picked) as Settings['bias']))
56
+ return (
57
+ <Box flexDirection="row" alignItems="center" columnGap={1}>
58
+ <Text dimColor>{t(frame.lang, 'settings.cheaper')}</Text>
59
+ <Box flexDirection="row">
60
+ {BIAS.map(b => (b === bias ? <Button key={'bias-' + b} plain label="●" onPress={() => onPick(b)} /> : <Button key={'bias-' + b} plain dimColor label="○" onPress={() => onPick(b)} />))}
61
+ </Box>
62
+ <Text dimColor>{t(frame.lang, 'settings.smarter')}</Text>
63
+ </Box>
64
+ )
65
+ }
66
+
67
+ // ---- The terminal's rows (setup on the terminal lends them too) ---------------------------------
68
+
69
+ export function effortRows(el: Els, frame: Frame, d: Settings, a: SettingsActions): RenderNode[] {
70
+ const out = [row(el, frame, 'auto', t(frame.lang, 'settings.effortAuto'), textToggle(el, frame, 'set-auto', d.effortAuto ? t(frame.lang, 'band.on') : t(frame.lang, 'band.off'), d.effortAuto, () => a.patch({ effortAuto: !d.effortAuto })), t(frame.lang, 'settings.autoHint'))]
71
+ if (d.effortAuto) out.push(row(el, frame, 'bias', t(frame.lang, 'settings.lean'), biasStops(el, frame, d.bias, bias => a.patch({ bias })), t(frame.lang, 'settings.leanHint')))
72
+ return out
73
+ }
74
+
75
+ /** The Jev key field and its test, when TypeSafe's Jev judges and no key is known. */
76
+ function needsKey(view: SettingsView): boolean {
77
+ return view.settings.judge === 'jev' && view.settings.jevSource === 'typesafe' && !['env', 'file', 'pasted'].includes(view.jev.source ?? '')
78
+ }
79
+
80
+ export function judgeRows(el: Els, frame: Frame, view: SettingsView, a: SettingsActions): RenderNode[] {
81
+ const d = view.settings
82
+ const tone = textTone(frame.appearance)
83
+ const { Box, Text } = el
84
+ const out: RenderNode[] = [
85
+ row(
86
+ el,
87
+ frame,
88
+ 'judge',
89
+ t(frame.lang, 'settings.judge'),
90
+ textSegmented(
91
+ el,
92
+ frame,
93
+ 'judge',
94
+ [
95
+ { value: 'haiku', label: t(frame.lang, 'settings.haiku') },
96
+ { value: 'jev', label: t(frame.lang, 'settings.jev') },
97
+ { value: 'model', label: t(frame.lang, 'settings.routerModel') },
98
+ ] as const,
99
+ d.judge,
100
+ judge => a.patch({ judge }),
101
+ ),
102
+ ),
103
+ ]
104
+ if (needsKey(view)) {
105
+ const jev = view.jev
106
+ const placeholder = t(frame.lang, 'settings.keyPaste')
107
+ const field = 'Input' in el ? (
108
+ <el.Input key="jev-key" placeholder={placeholder} value="" submitLabel={t(frame.lang, 'settings.saveKey')} onSubmit={(key: string) => a.jevKey(key)} />
109
+ ) : (
110
+ <Text dimColor>{placeholder}</Text>
111
+ )
112
+ const result =
113
+ jev.test.state === 'testing' ? (
114
+ <Text dimColor>{t(frame.lang, 'settings.testing')}</Text>
115
+ ) : jev.test.state === 'ok' ? (
116
+ <Text color={tone.ok} wrap="wrap">{'✓ ' + (jev.test.message ?? t(frame.lang, 'settings.keyWorks'))}</Text>
117
+ ) : jev.test.state === 'fail' ? (
118
+ <Text color={tone.bad} wrap="wrap">{'✕ ' + (jev.test.message ?? t(frame.lang, 'settings.keyRejected'))}</Text>
119
+ ) : null
120
+ out.push(
121
+ row(
122
+ el,
123
+ frame,
124
+ 'jev-key',
125
+ t(frame.lang, 'settings.key'),
126
+ <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={1}>
127
+ {field}
128
+ {action(el, frame, 'jev-test', t(frame.lang, 'settings.test'), a.testJev, { hotkey: 't' })}
129
+ {result}
130
+ </Box>,
131
+ ),
132
+ )
133
+ }
134
+ return out
135
+ }
136
+
137
+ /**
138
+ * The active saved worker set: Base followed by user-named modes. Rungs are edited on the dashboard.
139
+ */
140
+ export function modeRows(el: Els, frame: Frame, d: Settings, a: SettingsActions): RenderNode[] {
141
+ const { Text } = el
142
+ const items = modeItems(frame, d)
143
+ const current = d.activeMode ?? ''
144
+ const control = 'Select' in el && a.setMode
145
+ ? <el.Select key="mode" options={items} value={current} onSelect={(value: string) => a.setMode!(value)} />
146
+ : <Text>{items.find(item => item.value === current)?.label ?? '—'}</Text>
147
+ return [row(el, frame, 'mode', t(frame.lang, 'settings.mode'), control)]
148
+ }
149
+
150
+ export function keepWarmRow(el: Els, frame: Frame, d: Settings, a: SettingsActions): RenderNode {
151
+ return row(el, frame, 'warm', t(frame.lang, 'settings.keepWarm'), textToggle(el, frame, 'set-warm', d.keepWarm ? t(frame.lang, 'band.on') : t(frame.lang, 'band.off'), d.keepWarm, () => a.patch({ keepWarm: !d.keepWarm })), t(frame.lang, 'settings.keepWarmHint'))
152
+ }
153
+
154
+ function modeItems(frame: Frame, d: Settings): { value: string; label: string }[] {
155
+ return [{ value: '', label: t(frame.lang, 'settings.modeNone') }, ...d.modes.map(m => ({ value: m.id, label: m.name }))]
156
+ }
157
+
158
+ function judgeName(frame: Frame, judge: Settings['judge']): string {
159
+ return t(frame.lang, judge === 'haiku' ? 'settings.haiku' : judge === 'jev' ? 'settings.jev' : 'settings.routerModel')
160
+ }
161
+
162
+ // ---- Desktop: the deep's tiles -------------------------------------------------------------------
163
+ //
164
+ // A tile is a setting: its picture (art.ts `tileArt`, how the setting stands, alive while on), its
165
+ // name in bright ink with its switch at the right when it has one, one line on what it does, then
166
+ // its control. The pictures replace the old line icons, which said nothing at 15 px.
167
+
168
+ /** A tile's edge and glass: light on the water, brighter under the pointer. */
169
+ const TILE_EDGE = 'rgba(255,255,255,0.1)'
170
+ const TILE_EDGE_HOT = 'rgba(255,255,255,0.26)'
171
+ const TILE_GLASS = 'rgba(255,255,255,0.045)'
172
+ const TILE_GLASS_HOT = 'rgba(255,255,255,0.075)'
173
+ /** The least a tile takes, in cells: two side by side from about 84 cells, one a row below. */
174
+ const TILE_MIN = 40
175
+
176
+ /** Tiles side by side: two when the frame holds two at their least, else one a row. */
177
+ function tilesAcross(frame: Frame): 1 | 2 {
178
+ return frame.columns >= 2 * TILE_MIN + 6 ? 2 : 1
179
+ }
180
+
181
+ /** Cells a tile's words and controls have beside its picture, about. */
182
+ function tileInner(frame: Frame): number {
183
+ const tile = tilesAcross(frame) === 2 ? Math.floor((frame.columns - 5) / 2) : frame.columns - 4
184
+ return Math.max(18, tile - 3 - Math.ceil(TILE_PX / CELL_PX) - 3)
185
+ }
186
+
187
+ type Tile = { id: string; art: TileArt; title: string; head?: RenderNode; line: string; body: RenderNode[] }
188
+
189
+ function tile(el: Els, x: Tile): RenderNode {
190
+ const { Box, Text } = el
191
+ return (
192
+ <Box key={'tile-' + x.id} position="relative" flexDirection="row" alignItems="flex-start" columnGap={2} flexGrow={1} width={0} minWidth={TILE_MIN} paddingX={1.5} paddingY={1} borderStyle="round" borderColor={TILE_EDGE} backgroundColor={TILE_GLASS} hover={{ borderColor: TILE_EDGE_HOT, backgroundColor: TILE_GLASS_HOT }}>
193
+ {pic(el, 'tile-art-' + x.id, tileArt(x.art), x.title, TILE_PX, TILE_PX)}
194
+ <Box key={'tile-body-' + x.id} flexDirection="column" rowGap={1} flexGrow={1} flexShrink={1} minWidth={0}>
195
+ <Box flexDirection="row" alignItems="center" columnGap={1} width="100%" minWidth={0}>
196
+ <Box flexGrow={1} flexShrink={1} minWidth={0}>
197
+ <Text bold color={INK} wrap="wrap">{x.title}</Text>
198
+ </Box>
199
+ {x.head ?? null}
200
+ </Box>
201
+ <Text color={SUB} wrap="wrap">{x.line}</Text>
202
+ {x.body}
203
+ </Box>
204
+ </Box>
205
+ )
206
+ }
207
+
208
+ /** A quiet line under a control. */
209
+ function note(el: Els, key: string, text: string, color = MUTE): RenderNode {
210
+ const { Text } = el
211
+ return <Text key={key} color={color} wrap="wrap">{text}</Text>
212
+ }
213
+
214
+ /** Automatic effort: its switch, and while on the lean between cheaper and smarter. */
215
+ export function effortTile(el: Els, frame: Frame, view: SettingsView, a: SettingsActions): RenderNode {
216
+ const { Box, Text } = el
217
+ const L = frame.lang
218
+ const d = view.settings
219
+ const body = d.effortAuto
220
+ ? [
221
+ <Box key="lean" flexDirection="row" alignItems="center" columnGap={1} flexWrap="wrap">
222
+ <Text color={MUTE}>{t(L, 'settings.cheaper')}</Text>
223
+ {slider(el, 'bias', BIAS, d.bias, bias => a.patch({ bias }), 3)}
224
+ <Text color={MUTE}>{t(L, 'settings.smarter')}</Text>
225
+ </Box>,
226
+ note(el, 'lean-note', t(L, 'settings.leanHint')),
227
+ ]
228
+ : [note(el, 'auto-off', t(L, 'settings.autoOffNote'))]
229
+ return tile(el, {
230
+ id: 'effort',
231
+ art: { kind: 'effort', on: d.effortAuto },
232
+ title: t(L, 'settings.effortAuto'),
233
+ head: toggle(el, 'set-auto', d.effortAuto, t(L, d.effortAuto ? 'band.on' : 'band.off'), () => a.patch({ effortAuto: !d.effortAuto })),
234
+ line: t(L, 'settings.autoLine'),
235
+ body,
236
+ })
237
+ }
238
+
239
+ /** Who judges: Haiku, Jev or a router model; Jev's key field when it needs one. */
240
+ export function judgeTile(el: Els, frame: Frame, view: SettingsView, a: SettingsActions): RenderNode {
241
+ const { Box, Text } = el
242
+ const L = frame.lang
243
+ const d = view.settings
244
+ const body: RenderNode[] = [
245
+ segmented(el, 'judge', [
246
+ { value: 'haiku', label: t(L, 'settings.haiku') },
247
+ { value: 'jev', label: t(L, 'settings.jev') },
248
+ { value: 'model', label: t(L, 'settings.routerModel') },
249
+ ] as const, d.judge, judge => a.patch({ judge }), 'teal'),
250
+ ]
251
+ if (d.judge === 'model' && d.judgeModel) body.push(note(el, 'judge-model-name', t(L, 'settings.routerModel') + ' · ' + d.judgeModel))
252
+ if (needsKey(view)) {
253
+ const jev = view.jev
254
+ const placeholder = t(L, 'settings.keyPaste')
255
+ const field = 'Input' in el
256
+ ? well(el, 'jev-key', tileInner(frame), <el.Input key="jev-key" placeholder={placeholder} value="" submitLabel={t(L, 'settings.saveKey')} onSubmit={(key: string) => a.jevKey(key)} />, placeholder)
257
+ : <Text color={MUTE}>{placeholder}</Text>
258
+ const result =
259
+ jev.test.state === 'testing' ? <Text color={SUB}>{t(L, 'settings.testing')}</Text>
260
+ : jev.test.state === 'ok' ? <Text color={TONE.green.text} wrap="wrap">{'✓ ' + (jev.test.message ?? t(L, 'settings.keyWorks'))}</Text>
261
+ : jev.test.state === 'fail' ? <Text color={TONE.red.text} wrap="wrap">{'✕ ' + (jev.test.message ?? t(L, 'settings.keyRejected'))}</Text>
262
+ : null
263
+ body.push(
264
+ field,
265
+ <Box key="jev-row" flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={1}>
266
+ {pill(el, 'jev-test', t(L, 'settings.test'), 'ghost', a.testJev, 'teal')}
267
+ {result}
268
+ </Box>,
269
+ )
270
+ }
271
+ return tile(el, { id: 'judge', art: { kind: 'judge', judge: d.judge }, title: t(L, 'settings.judge'), line: t(L, 'settings.judgeLine'), body })
272
+ }
273
+
274
+ /** The worker set that is on: Base or a named mode. */
275
+ export function modeTile(el: Els, frame: Frame, view: SettingsView, a: SettingsActions): RenderNode {
276
+ const L = frame.lang
277
+ const d = view.settings
278
+ const items = modeItems(frame, d)
279
+ const current = d.activeMode ?? ''
280
+ const control = items.length <= 5 || !('Select' in el)
281
+ ? segmented(el, 'mode', items, current, id => a.setMode?.(id), 'amber')
282
+ : <el.Select key="mode" options={items} value={current} onSelect={(value: string) => a.setMode?.(value)} />
283
+ return tile(el, { id: 'mode', art: { kind: 'mode', on: current !== '' }, title: t(L, 'settings.mode'), line: t(L, 'settings.modeLine'), body: [control] })
284
+ }
285
+
286
+ /** Keep warm: its switch, and what each ping costs. */
287
+ export function warmTile(el: Els, frame: Frame, view: SettingsView, a: SettingsActions): RenderNode {
288
+ const L = frame.lang
289
+ const d = view.settings
290
+ return tile(el, {
291
+ id: 'warm',
292
+ art: { kind: 'warm', on: d.keepWarm },
293
+ title: t(L, 'settings.keepWarm'),
294
+ head: toggle(el, 'set-warm', d.keepWarm, t(L, d.keepWarm ? 'band.on' : 'band.off'), () => a.patch({ keepWarm: !d.keepWarm })),
295
+ line: t(L, 'settings.warmLine'),
296
+ body: [note(el, 'warm-cost', t(L, 'settings.warmCostShort'))],
297
+ })
298
+ }
299
+
300
+ /** What 리플이 says the settings do right now, in a sentence or two. */
301
+ export function settingsSay(frame: Frame, d: Settings): string {
302
+ const L = frame.lang
303
+ const effort = d.effortAuto ? t(L, 'settings.sayAuto', { judge: judgeName(frame, d.judge) }) : t(L, 'settings.sayManual')
304
+ return d.keepWarm ? effort + ' ' + t(L, 'settings.sayWarm') : effort
305
+ }
306
+
307
+ /** Rows the quick settings take on the desktop, about: for the sea's height. */
308
+ function panelRows(frame: Frame, view: SettingsView): number {
309
+ return (tilesAcross(frame) === 2 ? 20 : 36) + (needsKey(view) ? 4 : 0)
310
+ }
311
+
312
+ function settingsDrawn(el: Els, frame: Frame, view: SettingsView, a: SettingsActions): RenderNode {
313
+ const { Box, Text } = el
314
+ const L = frame.lang
315
+ const d = view.settings
316
+ const face = 48
317
+ const head = (
318
+ <Box key="settings-head" flexDirection="row" alignItems="center" columnGap={2} width="100%" minWidth={0}>
319
+ {ripple(el, 'settings', d.effortAuto ? 'calm' : 'drowsy', t(L, 'band.brand'), face)}
320
+ <Box key="settings-say" flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
321
+ <Box flexDirection="row" alignItems="center" columnGap={1} flexWrap="wrap">
322
+ <Text bold color={INK}>{t(L, 'settings.quick')}</Text>
323
+ <Text color={MUTE}>{t(L, 'band.brand')}</Text>
324
+ </Box>
325
+ <Text color={SUB} wrap="wrap">{settingsSay(frame, d)}</Text>
326
+ </Box>
327
+ <Box key="settings-exits" flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
328
+ {pill(el, 'all-settings', t(L, 'settings.openAll'), 'ghost', a.openAllSettings, 'violet', 'open')}
329
+ {pill(el, 'close', t(L, 'settings.close'), 'ghost', a.close, 'violet', 'close')}
330
+ </Box>
331
+ </Box>
332
+ )
333
+ const tiles = (
334
+ <Box key="settings-tiles" flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1} width="100%" minWidth={0}>
335
+ {effortTile(el, frame, view, a)}
336
+ {judgeTile(el, frame, view, a)}
337
+ {modeTile(el, frame, view, a)}
338
+ {warmTile(el, frame, view, a)}
339
+ </Box>
340
+ )
341
+ return capsule(el, frame, {
342
+ key: 'settings',
343
+ // 리플이 floats on the surface: the wave runs just under the head, the tiles below it.
344
+ sea: { tone: 'violet', ripples: 'calm', reach: 170, motes: true, wave: 19 * 0.5 + face + 9, at: { x: 1.5 * CELL_PX + face / 2, y: 19 * 0.5 + face / 2 } },
345
+ rows: panelRows(frame, view),
346
+ paddingY: 1,
347
+ gap: 1,
348
+ children: [head, tiles],
349
+ })
350
+ }
351
+
352
+ // ---- The panel ---------------------------------------------------------------------------------
353
+
354
+ /** Session-time controls only; the full settings live on the router dashboard. */
355
+ export function renderSettings(el: Els, frame: Frame, view: SettingsView, a: SettingsActions): RenderNode {
356
+ if (canDraw(el, frame)) return settingsDrawn(el, frame, view, a)
357
+ const { Box } = el
358
+ const rich = isRich(frame)
359
+ const close = dismiss(el, frame, 'close', t(frame.lang, 'settings.close'), a.close, 'x')
360
+ return speak(el, frame, {
361
+ key: 'settings',
362
+ face: 'calm',
363
+ title: t(frame.lang, 'band.brand'),
364
+ aside: t(frame.lang, 'settings.quick'),
365
+ blocks: [
366
+ <Box key="controls" flexDirection="column" rowGap={rich ? 1 : 0}>
367
+ {effortRows(el, frame, view.settings, a)}
368
+ {judgeRows(el, frame, view, a)}
369
+ {modeRows(el, frame, view.settings, a)}
370
+ {keepWarmRow(el, frame, view.settings, a)}
371
+ </Box>,
372
+ actions(el, frame, [action(el, frame, 'all-settings', t(frame.lang, 'settings.openAll'), a.openAllSettings, { hotkey: 's', primary: true }), rich ? '' : close]),
373
+ ],
374
+ ...(rich ? { dismiss: close } : {}),
375
+ })
376
+ }
@@ -0,0 +1,148 @@
1
+ // First run: three short steps (who judges, workers on the dashboard, keep warm) and a
2
+ // done card. Shown once, in the band; detailed settings live on the router dashboard.
3
+ //
4
+ // 리플이 asks each step as a card asks: the step's question is the title, the steps beside it, one
5
+ // line of why, the step's control, then Next and the quiet ways back or out. It asks who should
6
+ // judge, waits calmly, and is pleased when all is set. The terminal draws 리플이's column (parts.tsx
7
+ // `speak`); the desktop a capsule of the deep, its controls the quick settings' own tiles.
8
+
9
+ import { t } from '../i18n'
10
+ import type { RenderNode } from 'claude-code'
11
+ import { RUNGS } from '../settings'
12
+
13
+ import { CELL_PX, INK, MUTE, SUB, TONE, beadsArt } from './art'
14
+ import { capsule, pic, pill, ripple } from './kit'
15
+ import { GREET_SIZE, action, actions, canDraw, speak } from './parts'
16
+ import type { Els } from './parts'
17
+ import { judgeRows, judgeTile, keepWarmRow, row, warmTile } from './settings'
18
+ import { chOf } from './theme'
19
+ import type { Frame, SettingsActions, SettingsView, SetupActions, SetupView } from './view'
20
+ import { targetLabel } from './view'
21
+
22
+ const STEPS = [
23
+ { title: 'setup.judgeTitle', blurb: 'setup.judgeBlurb' },
24
+ { title: 'setup.workersTitle', blurb: 'setup.workersNote' },
25
+ { title: 'setup.cacheTitle', blurb: 'setup.cacheBlurb' },
26
+ ] as const
27
+
28
+ /** `Sol · Astra · …`: the four workers' models, lowest first. */
29
+ function workersSummary(lang: Frame['lang'], d: SetupView['draft']): string {
30
+ const general = d.rules.general
31
+ return RUNGS.map(rung => general.rungs[rung]?.map(step => targetLabel(step.target, lang)).join(' → ') || '—').join(' · ')
32
+ }
33
+
34
+ /** The steps as rings, the current one filled: ● ○ ○. */
35
+ function dots(step: number): string {
36
+ return [1, 2, 3].map(i => (i <= step ? '●' : '○')).join(' ')
37
+ }
38
+
39
+ /** The first-run setup in the band. */
40
+ export function renderSetup(el: Els, frame: Frame, view: SetupView, a: SetupActions): RenderNode {
41
+ const { Text } = el
42
+ // Setup lends the same small controls its draft and handlers.
43
+ const asSettings: SettingsView = { settings: view.draft, jev: view.jev }
44
+ const asActions: SettingsActions = { ...a, close: () => undefined }
45
+ const d = view.draft
46
+ const done = view.step === 4
47
+ const step = done ? null : STEPS[view.step - 1]!
48
+ const body: RenderNode[] = done
49
+ ? [
50
+ row(el, frame, 'sum-effort', t(frame.lang, 'band.effort'), <Text wrap="wrap">{t(frame.lang, 'setup.effortSummary', { state: t(frame.lang, d.effortAuto ? 'band.on' : 'band.off').toLowerCase(), judge: d.judge === 'haiku' ? t(frame.lang, 'settings.haiku') : d.judge === 'jev' ? t(frame.lang, 'settings.jev') : d.judgeModel })}</Text>),
51
+ row(el, frame, 'sum-workers', t(frame.lang, 'settings.workers'), <Text wrap="wrap">{workersSummary(frame.lang, d)}</Text>),
52
+ row(el, frame, 'sum-cache', t(frame.lang, 'band.cache'), <Text wrap="wrap">{d.keepWarm ? t(frame.lang, 'setup.cacheSummary', { hours: d.keepWarmHours }) : t(frame.lang, 'setup.cacheOff')}</Text>),
53
+ ]
54
+ : view.step === 1
55
+ ? judgeRows(el, frame, asSettings, asActions)
56
+ : view.step === 2
57
+ ? [] // Its line says it all: workers start on Auto, the rest is on the dashboard.
58
+ : [keepWarmRow(el, frame, view.draft, asActions)]
59
+ const { Box } = el
60
+ const buttons = done
61
+ ? [action(el, frame, 'finish', t(frame.lang, 'setup.start'), a.finish, { hotkey: 'g', primary: true }), action(el, frame, 'back', t(frame.lang, 'setup.back'), a.back, { hotkey: 'b' })]
62
+ : [
63
+ action(el, frame, 'next', view.step === 3 ? t(frame.lang, 'setup.finish') : t(frame.lang, 'setup.next'), a.next, { hotkey: 'n', primary: true }),
64
+ view.step === 2 ? action(el, frame, 'all-settings', t(frame.lang, 'settings.openAll'), a.openAllSettings, { hotkey: 's' }) : '',
65
+ view.step > 1 ? action(el, frame, 'back', t(frame.lang, 'setup.back'), a.back, { hotkey: 'b' }) : '',
66
+ action(el, frame, 'skip', t(frame.lang, 'setup.skip'), a.skip, { hotkey: 'k' }),
67
+ ]
68
+ const face = done ? 'pleased' : view.step === 1 ? 'asking' : 'calm'
69
+ // It greets first; the first step's question is its one row (who judges).
70
+ const title = done ? t(frame.lang, 'setup.ready') : view.step === 1 ? t(frame.lang, 'setup.welcome') : t(frame.lang, step!.title)
71
+ const line = done ? t(frame.lang, 'setup.changeDashboard') : t(frame.lang, step!.blurb)
72
+ if (canDraw(el, frame)) {
73
+ // The desktop: a capsule of the deep, the step's control the quick settings' own tile (who
74
+ // judges, keep warm), what was chosen when done, Next lit and the rest ghost buttons.
75
+ const L = frame.lang
76
+ const beads = beadsArt(view.step, 3)
77
+ const summary = done
78
+ ? [
79
+ [t(L, 'band.effort'), t(L, 'setup.effortSummary', { state: t(L, d.effortAuto ? 'band.on' : 'band.off').toLowerCase(), judge: d.judge === 'haiku' ? t(L, 'settings.haiku') : d.judge === 'jev' ? t(L, 'settings.jev') : d.judgeModel })],
80
+ [t(L, 'settings.workers'), workersSummary(L, d)],
81
+ [t(L, 'band.cache'), d.keepWarm ? t(L, 'setup.cacheSummary', { hours: d.keepWarmHours }) : t(L, 'setup.cacheOff')],
82
+ ]
83
+ : []
84
+ const nameCells = Math.ceil(Math.max(0, ...summary.map(([name]) => chOf(name!)))) + 1
85
+ const content = done
86
+ ? [
87
+ <Box key="summary" flexDirection="column" rowGap={1} paddingLeft={Math.ceil(GREET_SIZE / CELL_PX) + 2}>
88
+ {summary.map(([name, value], i) => (
89
+ <Box key={'sum-' + i} flexDirection="row" columnGap={2} alignItems="flex-start">
90
+ <Box width={nameCells} flexShrink={0}><Text color={MUTE}>{name}</Text></Box>
91
+ <Text color={INK} wrap="wrap">{value}</Text>
92
+ </Box>
93
+ ))}
94
+ </Box>,
95
+ ]
96
+ : view.step === 1 || view.step === 3
97
+ ? [
98
+ <Box key="step-tile" flexDirection="row" width="100%" minWidth={0}>
99
+ {view.step === 1 ? judgeTile(el, frame, asSettings, asActions) : warmTile(el, frame, asSettings, asActions)}
100
+ </Box>,
101
+ ]
102
+ : []
103
+ const next = done ? pill(el, 'finish', t(L, 'setup.start'), 'primary', a.finish, 'green', 'next') : pill(el, 'next', view.step === 3 ? t(L, 'setup.finish') : t(L, 'setup.next'), 'primary', a.next, 'violet', 'next')
104
+ return capsule(el, frame, {
105
+ key: 'setup',
106
+ sea: { tone: done ? 'green' : 'violet', ripples: 'calm', reach: 170, motes: true, wave: 19 * 0.5 + GREET_SIZE + 9, ...(done ? { tick: true, weather: 'sparkles' as const } : {}), at: { x: 1.5 * CELL_PX + GREET_SIZE / 2, y: 19 * 0.5 + GREET_SIZE / 2 } },
107
+ rows: 8 + (done ? 6 : view.step === 1 ? 12 : view.step === 3 ? 8 : 0) + (frame.columns < 70 ? 4 : 0),
108
+ paddingY: 1,
109
+ gap: 1,
110
+ children: [
111
+ <Box key="setup-head" flexDirection="row" alignItems="center" columnGap={2} width="100%" minWidth={0}>
112
+ {ripple(el, 'setup', face, title, GREET_SIZE)}
113
+ <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
114
+ <Box flexDirection="row" alignItems="center" columnGap={2} flexWrap="wrap">
115
+ <Text bold color={done ? TONE.green.text : INK} wrap="wrap">{title}</Text>
116
+ {done ? null : pic(el, 'setup-beads', beads.source, view.step + ' / 3', beads.width, 16)}
117
+ </Box>
118
+ <Text color={SUB} wrap="wrap">{line}</Text>
119
+ </Box>
120
+ </Box>,
121
+ ...content,
122
+ <Box key="actions" flexDirection="row" alignItems="center" columnGap={1} flexWrap="wrap" rowGap={1}>
123
+ {next}
124
+ {!done && view.step === 2 ? pill(el, 'all-settings', t(L, 'settings.openAll'), 'ghost', a.openAllSettings, 'violet', 'open') : null}
125
+ {done || view.step > 1 ? pill(el, 'back', t(L, 'setup.back'), 'ghost', a.back, 'violet', 'back') : null}
126
+ {done ? null : pill(el, 'skip', t(L, 'setup.skip'), 'ghost', a.skip)}
127
+ </Box>,
128
+ ],
129
+ })
130
+ }
131
+ return speak(el, frame, {
132
+ key: 'setup',
133
+ face,
134
+ // It greets here, the first thing a new session shows: at its greeting size.
135
+ size: GREET_SIZE,
136
+ title,
137
+ aside: done ? '' : dots(view.step),
138
+ line,
139
+ blocks: [
140
+ body.length ? (
141
+ <Box key="body" flexDirection="column">
142
+ {body}
143
+ </Box>
144
+ ) : '',
145
+ actions(el, frame, buttons),
146
+ ],
147
+ })
148
+ }