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,258 @@
1
+ // The desktop's capsules and drawn controls, put together: a picture from art.ts, the host's text in
2
+ // our own colours beside it, and a blank native button laid over both to take the press. Desktop and
3
+ // the editor only (an Svg in the table); the terminal keeps the text controls of parts.tsx.
4
+ //
5
+ // Host facts this is built on (2026-10):
6
+ // - A Box drawn `position: "absolute"` is painted over every sibling that is not itself positioned,
7
+ // whatever comes after it in the tree (CSS stacking). The 2026-10-12 surfaces put their backdrop in
8
+ // such a Box and their words in plain ones: in the app the backdrop hid the panel's title, its ×
9
+ // and every row's name, and only the controls with a press laid over them showed. The app does
10
+ // not take `position: "relative"` from a Box either: it positions a Box only when one of its own
11
+ // children is absolute. So a capsule paints its sea first, absolute, and holds everything else in
12
+ // one layer after it that starts with an empty absolute Box (`lift`), which makes it positioned.
13
+ // - A blank button's label is no-break spaces (U+00A0): the host sets a button's label
14
+ // `white-space: nowrap`, which would fold ordinary spaces into one. It is sized a little short of
15
+ // the drawing (an ellipsis would show) and sits in an absolute Box over the control, clipped to it.
16
+ // - A plain Button's label takes the host's ink, which a dark capsule would swallow; nothing on a
17
+ // capsule is a labelled host Button. A keyed Box's `hover` lights a control on our own terms.
18
+
19
+ import type { RenderNode } from 'claude-code'
20
+
21
+ import { rippleSource } from './parts'
22
+ import type { Els } from './parts'
23
+ import type { Face } from './ripple'
24
+ import type { Frame } from './view'
25
+ import { CELL_PX, CLOSE_PX, INK, ROW_PX, SUB, SWITCH_H, SWITCH_W, WELL_PX, closeArt, labelPx, pillArt, seaArt, segmentedArt, sliderArt, switchArt, wellArt } from './art'
26
+ import type { Glyph, PillLook, Sea, Tone } from './art'
27
+ import { chOf } from './theme'
28
+
29
+ /** Width of one U+00A0 in the host's 12 px button font, px, taken a little wide. */
30
+ const NBSP_PX = 3.6
31
+ /** The host's padding either side of a plain Button's label, px. */
32
+ const BUTTON_PAD = 6
33
+ /** The capsule's edge: a warm grey that reads against a light page and a dark one alike. */
34
+ const EDGE = 'rgba(128,124,116,0.55)'
35
+ /** A control under the pointer: a soft light on the water. */
36
+ const HOVER = 'rgba(255,255,255,0.09)'
37
+ const HOVER_EDGE = 'rgba(255,255,255,0.16)'
38
+ /** A drawn control's height, px: pills, segments. */
39
+ const CONTROL_PX = 26
40
+
41
+ /** Cells a run of px takes, rounded up. */
42
+ function cellsOf(px: number): number {
43
+ return Math.ceil(px / CELL_PX)
44
+ }
45
+
46
+ /** Rows a run of px takes, unrounded (the host takes fractions of a row). */
47
+ export function rowsOf(px: number): number {
48
+ return Math.round((px / ROW_PX) * 1000) / 1000
49
+ }
50
+
51
+ /** A no-break space, spelled by its code: the bare character is invisible in source and was once lost. */
52
+ const NBSP = String.fromCharCode(0xa0)
53
+
54
+ /** A blank label as wide as a button over `px` should be. */
55
+ export function blank(px: number): string {
56
+ return NBSP.repeat(Math.max(1, Math.floor((px - BUTTON_PAD * 2) / NBSP_PX)))
57
+ }
58
+
59
+ /** The blank button over a control `px` wide: last child of a `position: relative` Box. */
60
+ export function press(el: Els, key: string, px: number, onPress: () => void): RenderNode {
61
+ const { Box, Button } = el
62
+ return (
63
+ <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center" overflow="hidden">
64
+ <Button key={key} plain label={blank(px)} onPress={() => onPress()} />
65
+ </Box>
66
+ )
67
+ }
68
+
69
+ /**
70
+ * An empty absolute Box: the first child of a Box that must paint above an absolute picture laid
71
+ * before it. The app does not take a Box's own `position: "relative"`; it positions a Box only
72
+ * when one of its children is absolute (seen 2026-10-13: every capsule's words, tiles and 리플이
73
+ * lay under the water, and only controls with a press laid over them showed).
74
+ */
75
+ export function lift(el: Els): RenderNode {
76
+ const { Box } = el
77
+ return <Box position="absolute" top={0} left={0} />
78
+ }
79
+
80
+ /** A picture, `width` × `height` px, in a Box of its own that never shrinks. */
81
+ export function pic(el: Els, key: string, source: string, alt: string, width: number, height: number): RenderNode {
82
+ if (!('Svg' in el)) return ''
83
+ const { Box, Svg } = el
84
+ return (
85
+ <Box key={'pic-' + key} flexShrink={0} alignItems="center">
86
+ <Svg source={source} alt={alt} width={width} height={height} />
87
+ </Box>
88
+ )
89
+ }
90
+
91
+ /**
92
+ * 리플이 on a capsule: its face drawn for the deep (the dark palette: light ink, lavender tone), a
93
+ * plain image in a Box of its own that never shrinks. `percent` fills a gauge face's ring.
94
+ */
95
+ export function ripple(el: Els, key: string, face: Face, alt: string, size: number, percent?: number, dim = false): RenderNode {
96
+ if (!('Svg' in el)) return ''
97
+ const { Box, Svg } = el
98
+ return (
99
+ <Box key={'ripple-' + key} flexShrink={0}>
100
+ <Svg source={rippleSource(face, size, 'dark', percent, dim)} alt={alt} width={size} height={size} />
101
+ </Box>
102
+ )
103
+ }
104
+
105
+ /**
106
+ * A control that lights under the pointer: a keyed, positioned Box with a soft rounded light as its
107
+ * hover, holding what it shows and, last, its press. `scope` joins a hover group, so pointing at the
108
+ * control also shows what it does elsewhere (the band's tip).
109
+ */
110
+ export function lit(el: Els, key: string, cells: number, children: RenderNode[], paddingX = 0.5, scope?: string): RenderNode {
111
+ const { Box } = el
112
+ return (
113
+ <Box key={'hv-' + key} position="relative" flexDirection="row" alignItems="center" columnGap={1} paddingX={paddingX} paddingY={0} width={cells} flexShrink={0} borderStyle="round" borderColor="rgba(0,0,0,0)" hover={{ backgroundColor: HOVER, borderColor: HOVER_EDGE, ...(scope ? { scope } : {}) }}>
114
+ {children}
115
+ </Box>
116
+ )
117
+ }
118
+
119
+ /**
120
+ * A capsule: a rounded Box edged in violet light, the deep (art.ts `seaArt`) painted behind it from
121
+ * its top left, and everything it holds in one positioned layer above the sea. `rows` is about how
122
+ * tall it draws; the sea is taken taller and wider than that, and the capsule clips it.
123
+ */
124
+ export function capsule(el: Els, frame: Frame, c: { key: string; sea: Omit<Sea, 'at'> & { at?: Sea['at'] }; rows: number; paddingX?: number; paddingY?: number; gap?: number; children: RenderNode[] }): RenderNode {
125
+ const { Box } = el
126
+ const padX = c.paddingX ?? 1.5
127
+ const padY = c.paddingY ?? 0.5
128
+ const width = Math.max(40, frame.columns) * CELL_PX + 48
129
+ // Taken well past the estimate: a capsule taller than its sea would show the page beneath.
130
+ const visible = Math.round(c.rows * ROW_PX)
131
+ const height = visible + 220
132
+ const at = c.sea.at ?? { x: padX * CELL_PX + 12, y: padY * ROW_PX * 0.5 + 13 }
133
+ return (
134
+ <Box key={c.key} position="relative" flexDirection="column" borderStyle="round" borderColor={EDGE} paddingX={0} paddingY={0} overflow="hidden" width="100%" minWidth={0}>
135
+ <Box key={c.key + '-sea'} position="absolute" top={0} left={0}>
136
+ {pic(el, c.key + '-sea', seaArt(width, height, { ...c.sea, at, visible }), 'ClaudeRipple', width, height)}
137
+ </Box>
138
+ <Box key={c.key + '-layer'} position="relative" flexDirection="column" rowGap={c.gap ?? 0} paddingX={padX} paddingY={padY} width="100%" minWidth={0}>
139
+ {lift(el)}
140
+ {c.children}
141
+ </Box>
142
+ </Box>
143
+ )
144
+ }
145
+
146
+ /**
147
+ * A drawn button with its label in it: `primary` the one thing to press, `ghost` a quiet one. Its
148
+ * Box key starts `pill-` or `ghost-`, its press is `key`.
149
+ */
150
+ export function pill(el: Els, key: string, label: string, look: PillLook, onPress: () => void, tone: Tone = 'violet', glyph?: Glyph): RenderNode {
151
+ const { Box } = el
152
+ const art = pillArt(label, look, tone, glyph)
153
+ return (
154
+ <Box key={(look === 'primary' ? 'pill-' : 'ghost-') + key} position="relative" width={art.cells} height={rowsOf(CONTROL_PX)} flexShrink={0} alignItems="center" justifyContent="center">
155
+ {pic(el, key, art.source, label, art.cells * CELL_PX, CONTROL_PX)}
156
+ {press(el, key, art.cells * CELL_PX, onPress)}
157
+ </Box>
158
+ )
159
+ }
160
+
161
+ /** Cells the way out takes. */
162
+ export const CLOSE_CELLS = cellsOf(CLOSE_PX + 4)
163
+
164
+ /** The way out: a soft disc with a ×, `label` its name for a reader that cannot see it. */
165
+ export function closeButton(el: Els, key: string, label: string, onPress: () => void, scope?: string): RenderNode {
166
+ return lit(el, key, CLOSE_CELLS, [pic(el, key, closeArt(), label, CLOSE_PX, CLOSE_PX), press(el, key, CLOSE_CELLS * CELL_PX, onPress)], 0.25, scope)
167
+ }
168
+
169
+ /** Cells a switch and its word take, their hover's air included. */
170
+ export function toggleCells(label: string): number {
171
+ return Math.ceil((SWITCH_W + CELL_PX + chOf(label) * CELL_PX * 1.1) / CELL_PX) + 1
172
+ }
173
+
174
+ /** A switch and its word (On / Off), the whole pressable; lit while on. */
175
+ export function toggle(el: Els, key: string, on: boolean, label: string, onPress: () => void, busy = false, scope?: string): RenderNode {
176
+ const { Text } = el
177
+ const cells = toggleCells(label)
178
+ return lit(el, key, cells, [
179
+ pic(el, key, switchArt(on, busy), label, SWITCH_W, SWITCH_H),
180
+ <Text key={key + '-word'} color={on ? INK : SUB} bold={on}>{label}</Text>,
181
+ press(el, key, cells * CELL_PX, onPress),
182
+ ], 0.5, scope)
183
+ }
184
+
185
+ /**
186
+ * A field's well: a native field (Input) on a plate of the app's own page colour, light or dark as
187
+ * the app is (art.ts `wellArt`), so the host's own text colour reads in it on either theme. The
188
+ * field sits in a positioned layer above the plate.
189
+ */
190
+ export function well(el: Els, key: string, cells: number, field: RenderNode, alt: string): RenderNode {
191
+ const { Box } = el
192
+ const w = cells * CELL_PX
193
+ return (
194
+ <Box key={'well-' + key} position="relative" flexDirection="row" alignItems="center" width={cells} flexShrink={1} minWidth={16} height={rowsOf(WELL_PX)} paddingX={1} overflow="hidden">
195
+ <Box position="absolute" top={0} left={0}>
196
+ {pic(el, 'well-' + key, wellArt(w, WELL_PX), alt, w, WELL_PX)}
197
+ </Box>
198
+ <Box key={'well-field-' + key} position="relative" flexGrow={1} flexShrink={1} minWidth={0}>
199
+ {lift(el)}
200
+ {field}
201
+ </Box>
202
+ </Box>
203
+ )
204
+ }
205
+
206
+ export type SegmentOption<T extends string> = { value: T; label: string }
207
+
208
+ /** Each segment's cells: its label drawn at 12 px with air either side, at least five cells. */
209
+ function segmentCells(labels: readonly string[]): number[] {
210
+ return labels.map(label => Math.max(5, Math.ceil((labelPx(label, 12) + 26) / CELL_PX)))
211
+ }
212
+
213
+ /**
214
+ * A segmented control drawn whole (art.ts), the chosen segment lit, each segment the press of its
215
+ * own option (`key-value`). `colors` lights the chosen one in its own colour (the efforts' depths).
216
+ */
217
+ export function segmented<T extends string>(el: Els, key: string, options: readonly SegmentOption<T>[], value: T | null, onPick: (value: T) => void, tone: Tone = 'violet', colors?: readonly string[]): RenderNode {
218
+ const { Box } = el
219
+ const labels = options.map(o => o.label)
220
+ const cells = segmentCells(labels)
221
+ const total = cells.reduce((n, c) => n + c, 0)
222
+ const chosen = options.findIndex(o => o.value === value)
223
+ const alt = labels.map((label, i) => (i === chosen ? '[' + label + ']' : label)).join(' · ')
224
+ return (
225
+ <Box key={'seg-' + key} position="relative" flexDirection="row" width={total} height={rowsOf(CONTROL_PX)} flexShrink={0}>
226
+ <Box position="absolute" top={0} left={0}>
227
+ {pic(el, 'seg-' + key, segmentedArt(labels, cells, chosen, tone, colors), alt, total * CELL_PX, CONTROL_PX)}
228
+ </Box>
229
+ {options.map((o, i) => (
230
+ <Box key={'sg-' + key + '-' + o.value} position="relative" width={cells[i]} height={rowsOf(CONTROL_PX)} flexShrink={0}>
231
+ {press(el, key + '-' + o.value, cells[i]! * CELL_PX, () => onPick(o.value))}
232
+ </Box>
233
+ ))}
234
+ </Box>
235
+ )
236
+ }
237
+
238
+ /**
239
+ * A stepped slider: `values` as stops on one drawn track, the chosen one lit, each stop the press of
240
+ * an equal part of the track (`key-value`).
241
+ */
242
+ export function slider<T extends string | number>(el: Els, key: string, values: readonly T[], value: T, onPick: (value: T) => void, cellsPerStop = 4): RenderNode {
243
+ const { Box } = el
244
+ const chosen = Math.max(0, values.indexOf(value))
245
+ const total = values.length * cellsPerStop
246
+ return (
247
+ <Box key={'sl-' + key} position="relative" flexDirection="row" width={total} height={rowsOf(CONTROL_PX)} flexShrink={0} alignItems="center">
248
+ <Box position="absolute" top={0} bottom={0} left={0} alignItems="center" justifyContent="center">
249
+ {pic(el, 'sl-' + key, sliderArt(values.length, cellsPerStop, chosen), String(value), total * CELL_PX, 22)}
250
+ </Box>
251
+ {values.map(v => (
252
+ <Box key={'st-' + key + '-' + v} position="relative" width={cellsPerStop} height={rowsOf(CONTROL_PX)} flexShrink={0}>
253
+ {press(el, key + '-' + v, cellsPerStop * CELL_PX, () => onPick(v))}
254
+ </Box>
255
+ ))}
256
+ </Box>
257
+ )
258
+ }
@@ -0,0 +1,109 @@
1
+ // The request log pane: the router's records of model requests, newest first, one entry each with
2
+ // what matters (model, provider, effort, tokens, cache, time, result). Moved here unchanged in
3
+ // content from register.tsx's own drawing.
4
+
5
+ import { t } from '../i18n'
6
+ import type { RenderNode } from 'claude-code'
7
+
8
+ import type { JudgeRun, StatusRequest } from '../../types'
9
+ import { GREET_SIZE, action, actions as actions_, speak } from './parts'
10
+ import type { Els } from './parts'
11
+ import { cacheTone, providerColor, textTone } from './theme'
12
+ import type { Frame, LogActions, LogView } from './view'
13
+ import { cachePercent, clockTime, effortLabel, modelOf, promptTokens, seconds } from './view'
14
+
15
+ function entry(el: Els, frame: Frame, record: StatusRequest, mineOnly: boolean): RenderNode {
16
+ const { Box, Text } = el
17
+ const tone = textTone(frame.appearance)
18
+ const cache = cachePercent(record.usage)
19
+ return (
20
+ <Box key={record.at + '-' + record.target} flexDirection="column">
21
+ <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
22
+ {mineOnly ? null : (
23
+ <Text color={record.mine ? tone.accent : undefined} dimColor={!record.mine}>
24
+ {record.mine ? '●' : '○'}
25
+ </Text>
26
+ )}
27
+ <Text dimColor>{clockTime(record.at)}</Text>
28
+ <Text bold wrap="wrap">
29
+ {modelOf(record)}
30
+ </Text>
31
+ <Text color={providerColor(record.provider)}>{record.provider}</Text>
32
+ {record.resent ? (
33
+ <Text dimColor>{t(frame.lang, 'log.resent', { status: String(record.status) })}</Text>
34
+ ) : (
35
+ <Text color={record.ok ? tone.ok : tone.bad} bold={!record.ok}>
36
+ {record.ok ? '✓' : '✕ ' + String(record.status)}
37
+ </Text>
38
+ )}
39
+ </Box>
40
+ <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
41
+ <Text dimColor>{record.effort ? effortLabel(record.effort, frame.lang) : '-'}</Text>
42
+ {record.usage ? <Text dimColor>{t(frame.lang, 'log.input', { tokens: tokens(promptTokens(record.usage)) })}</Text> : null}
43
+ {cache !== undefined ? <Text color={tone[cacheTone(cache)]}>{t(frame.lang, 'band.cachePercent', { percent: cache })}</Text> : null}
44
+ {record.usage ? <Text dimColor>{t(frame.lang, 'log.output', { tokens: tokens(record.usage.output) })}</Text> : null}
45
+ <Text dimColor>{seconds(record.ms / 1000, frame.lang)}</Text>
46
+ </Box>
47
+ {!record.ok && record.note ? (
48
+ <Text color={record.resent ? undefined : tone.bad} dimColor={record.resent} wrap="wrap">
49
+ {record.note}
50
+ </Text>
51
+ ) : null}
52
+ </Box>
53
+ )
54
+ }
55
+
56
+ const JUDGE_NAME = { haiku: 'settings.haiku', jev: 'settings.jev', model: 'agents.routerModel' } as const
57
+
58
+ /** A verdict of this session's judge: who answered, in how long, what it chose, or why it fell back. */
59
+ function judgeEntry(el: Els, frame: Frame, run: JudgeRun): RenderNode {
60
+ const { Box, Text } = el
61
+ const tone = textTone(frame.appearance)
62
+ const fell = run.by !== run.asked
63
+ const who = fell ? t(frame.lang, 'log.judgeFell', { asked: t(frame.lang, JUDGE_NAME[run.asked]), by: t(frame.lang, JUDGE_NAME[run.by]) }) : t(frame.lang, JUDGE_NAME[run.by])
64
+ const verdict = run.effort ? effortLabel(run.effort, frame.lang) + (run.sure !== undefined ? ' · ' + t(frame.lang, 'log.sure', { percent: Math.round(run.sure * 100) }) : '') : t(frame.lang, 'toasts.noVerdict')
65
+ return (
66
+ <Box key={'judge-' + run.at} flexDirection="column">
67
+ <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
68
+ <Text dimColor>{clockTime(run.at)}</Text>
69
+ <Text bold>{t(frame.lang, run.for === 'worker' ? 'log.judgeWorker' : 'log.judge')}</Text>
70
+ <Text color={fell ? tone.warn : tone.accent}>{who}</Text>
71
+ <Text dimColor>{'→ ' + verdict}</Text>
72
+ <Text dimColor>{seconds(run.ms / 1000, frame.lang)}</Text>
73
+ </Box>
74
+ {run.error ? (
75
+ <Text color={tone.warn} wrap="wrap">
76
+ {run.error}
77
+ </Text>
78
+ ) : null}
79
+ </Box>
80
+ )
81
+ }
82
+
83
+ /** The log pane body: this session's verdicts in time order among its requests. */
84
+ export function renderLog(el: Els, frame: Frame, view: LogView, actions: LogActions): RenderNode {
85
+ const { Box, Text } = el
86
+ const judges = view.mineOnly ? (view.judges ?? []) : []
87
+ const items = [...view.rows.map(record => ({ at: Date.parse(record.at), node: () => entry(el, frame, record, view.mineOnly) })), ...judges.map(run => ({ at: run.at, node: () => judgeEntry(el, frame, run) }))].sort((a, b) => b.at - a.at)
88
+ const scopeName = view.mineOnly ? t(frame.lang, 'log.thisSession') : t(frame.lang, 'log.allSessions')
89
+ const scope = action(el, frame, 'scope', view.mineOnly ? t(frame.lang, 'log.showAll') : t(frame.lang, 'log.onlySession'), actions.toggleScope, { hotkey: 'a' })
90
+ const legend = !view.mineOnly ? <Text key="legend" dimColor wrap="wrap">{t(frame.lang, 'log.legend')}</Text> : ''
91
+ // Empty, the pane has nothing to redraw it, so 리플이 can live out its idle loops here; with
92
+ // requests every poll redraws it and would start its motion over, so the list has no picture.
93
+ if (items.length === 0) return speak(el, frame, { key: 'log', face: 'calm', size: GREET_SIZE, title: scopeName, line: t(frame.lang, 'log.empty'), blocks: [legend, actions_(el, frame, [scope])] })
94
+ return (
95
+ <Box flexDirection="column" gap={1} width="100%">
96
+ <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={2}>
97
+ <Text bold>{scopeName}</Text>
98
+ {scope}
99
+ </Box>
100
+ {legend}
101
+ {items.map(item => item.node())}
102
+ </Box>
103
+ )
104
+ }
105
+
106
+ // The log keeps one decimal at every size (12.3k), as it always has.
107
+ function tokens(n: number): string {
108
+ return n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + 'M' : n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
109
+ }
@@ -0,0 +1,217 @@
1
+ // The pieces every drawing is made of: the element table, 리플이, the way it speaks (its face on
2
+ // the left, what it says in one column beside it), and the controls of the cards and panels.
3
+ //
4
+ // These are the terminal's pieces and 리플이 everywhere; the desktop's drawn surfaces and controls
5
+ // (2026-10-12) are art.ts and kit.tsx, which build on the facts below.
6
+ // Engine and host facts (Claude Code 2.1.293, the desktop app's assets, 2026-10-10):
7
+ // - The desktop draws every answer as a new DOM tree, each Svg a new element. What blinked was the
8
+ // interactive Svg: isInteractive makes it a sandboxed <iframe srcdoc>, which the host rebuilds
9
+ // blank on every redraw. A plain Svg is an <img> of a data URL; it does not blink across
10
+ // redraws, and CSS @keyframes inside the SVG run in it (seen in the owner's desktop app on
11
+ // 2026-10-11 at commit 8d43b97: 리플이 moving in the band and the quick settings with no
12
+ // flicker; Effortless animates its band the same way). So 리플이 is always a plain image, never
13
+ // interactive. A redraw still starts its motion over, so every loop opens on a natural pose
14
+ // (ripple.ts).
15
+ // - A Button's label is text only and its colours are the host's. A drawn control takes its press
16
+ // from a blank native button laid over it, and that blank label has to be sized a little short
17
+ // of the drawing and made of no-break spaces, or the host cuts it to an ellipsis or folds it to
18
+ // nothing (seen 2026-10-10 as "자동 (권장) …"); kit.tsx `blank` does both.
19
+ // - A plain Button under the pointer turns full ink on a ghost fill, dimmed or not, so a state told
20
+ // by dimness alone looks the same on and off right after the click. A chosen option therefore
21
+ // says so in its shape: ● chosen, ○ not (the terminal's marks too), the unchosen also dimmed.
22
+ // - `role="dismiss"` is drawn by the desktop as its native close control at the site's trailing
23
+ // edge (the band's × is one), the label its accessible name; the terminal draws a Button.
24
+ // - An Svg needs alt, width and height (an interactive one without both falls back to ~300×150).
25
+ // Its CSS animations run without isInteractive (the 2026-10-10 reading that they need it was
26
+ // wrong); what isInteractive adds is the frame, and with it the blink.
27
+
28
+ import type { ElementTable, RenderNode } from 'claude-code'
29
+
30
+ import type { Frame } from './view'
31
+ import { isRich } from './view'
32
+ import { GAUGE_FACES, GRID, detailOf, gaugeStep, rippleBody, rippleCss } from './ripple'
33
+ import type { Face } from './ripple'
34
+ import { svgDoc } from './theme'
35
+ import type { Appearance } from './theme'
36
+
37
+ export type Els = ElementTable
38
+
39
+ /** 리플이's size on a card (alerts, quick settings), px: the sheet's card badge. */
40
+ export const FACE_SIZE = 44
41
+ /** Where it greets or waits with nothing else to show (setup, empty panes), px: a bigger presence. */
42
+ export const GREET_SIZE = 56
43
+
44
+ const sources = new Map<string, string>()
45
+
46
+ /**
47
+ * 리플이's SVG document: `face` at `size` px (the mini drawing below 34 px), the gauge in its 5%
48
+ * steps, dimmed or not. One fixed string per state, built once: the host is handed the same
49
+ * source until the state changes.
50
+ */
51
+ export function rippleSource(face: Face, size: number, appearance: Appearance, percent?: number, dim = false): string {
52
+ const step = percent !== undefined && GAUGE_FACES.includes(face) ? gaugeStep(percent) : undefined
53
+ const key = [face, size, appearance, step ?? '', dim ? 'dim' : ''].join('|')
54
+ let source = sources.get(key)
55
+ if (!source) {
56
+ const detail = detailOf(size)
57
+ source = svgDoc(size, size, appearance, rippleBody(face, step, detail), rippleCss(face, step, detail) + (dim ? 'svg>g{opacity:.5}' : ''), '0 0 ' + GRID + ' ' + GRID)
58
+ sources.set(key, source)
59
+ }
60
+ return source
61
+ }
62
+
63
+ /**
64
+ * 리플이 with one of its faces (ripple.ts), `size` px square, alive: a plain image whose CSS motion
65
+ * runs inside it and survives redraws without a blink (never isInteractive, see above). `percent`
66
+ * fills the ring of a gauge face. Desktop and the editor only.
67
+ */
68
+ export function mascot(el: Els, frame: Frame, key: string, face: Face, alt: string, size = FACE_SIZE, percent?: number): RenderNode {
69
+ if (!('Svg' in el)) return ''
70
+ const { Box, Svg } = el
71
+ // In a Box of its own that never shrinks: beside a column of text it keeps its size.
72
+ return (
73
+ <Box key={'ripple-' + key} flexShrink={0}>
74
+ <Svg source={rippleSource(face, size, frame.appearance, percent)} alt={alt} width={size} height={size} />
75
+ </Box>
76
+ )
77
+ }
78
+
79
+ /** True where 리플이 can be drawn: the desktop and the editor, with an Svg in the table. */
80
+ export function canDraw(el: Els, frame: Frame): boolean {
81
+ return isRich(frame) && 'Svg' in el
82
+ }
83
+
84
+ export type Speech = {
85
+ key: string
86
+ face: Face
87
+ /** What it says: one short sentence, bold. */
88
+ title: string
89
+ /** Quiet words on the title's line after it (a step count, a panel's name). */
90
+ aside?: string
91
+ /** Colour of the title on the terminal, where there is no face to carry it. */
92
+ titleColor?: string
93
+ /** One line of why, dim. */
94
+ line?: string
95
+ /** The ring as a gauge (a percent). */
96
+ percent?: number
97
+ /** 리플이's size, px (FACE_SIZE unless it greets or waits: GREET_SIZE). */
98
+ size?: number
99
+ /** What follows, each block a row apart: fields, options, the actions. */
100
+ blocks?: RenderNode[]
101
+ /** The site's close control (desktop: the host's native ×; terminal: in the last block). */
102
+ dismiss?: RenderNode
103
+ }
104
+
105
+ /**
106
+ * 리플이 speaks: on the desktop its face on the left and beside it one column, the title and its
107
+ * line of why held together, then each block a row apart. The terminal draws the same column
108
+ * without the face. Every card and panel uses this, so they share one rhythm.
109
+ */
110
+ export function speak(el: Els, frame: Frame, s: Speech): RenderNode {
111
+ const { Box, Text } = el
112
+ const rich = isRich(frame)
113
+ const head = (
114
+ <Box key="say" flexDirection="column">
115
+ <Box flexDirection="row" flexWrap="wrap" columnGap={2} alignItems="center">
116
+ <Text bold color={rich ? undefined : s.titleColor} wrap="wrap">{s.title}</Text>
117
+ {s.aside ? <Text dimColor>{s.aside}</Text> : null}
118
+ </Box>
119
+ {s.line ? <Text dimColor wrap="wrap">{s.line}</Text> : null}
120
+ </Box>
121
+ )
122
+ const blocks = (s.blocks ?? []).filter(b => b !== '' && b !== null && b !== undefined)
123
+ const column = (
124
+ // The text takes the width the face leaves and wraps in it (a row that wraps sizes its items
125
+ // to their whole line, which ran a body past the card: seen 2026-10-10).
126
+ <Box key="column" flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0} rowGap={rich ? 1 : 0}>
127
+ {head}
128
+ {blocks}
129
+ </Box>
130
+ )
131
+ if (!canDraw(el, frame)) {
132
+ return (
133
+ <Box key={'say-' + s.key} flexDirection="column" width="100%">
134
+ {column}
135
+ {s.dismiss ?? null}
136
+ </Box>
137
+ )
138
+ }
139
+ return (
140
+ <Box key={'say-' + s.key} flexDirection="row" alignItems="flex-start" columnGap={2} width="100%">
141
+ {mascot(el, frame, s.key, s.face, s.title, s.size ?? FACE_SIZE, s.percent)}
142
+ {column}
143
+ {s.dismiss ?? null}
144
+ </Box>
145
+ )
146
+ }
147
+
148
+ // ---- Controls ----------------------------------------------------------------------------------
149
+
150
+ /** A terminal (or mobile) control: `k: Label` with a hotkey, the label alone without one. */
151
+ export function textButton(el: Els, key: string, label: string, onPress: () => void, hotkey?: string, primary?: boolean): RenderNode {
152
+ const { Button } = el
153
+ // A bracketed button hides its hotkey on the terminal, so one with a hotkey stays plain (`s: Save`).
154
+ if (primary && !hotkey) return <Button key={key} variant="primary" label={label} onPress={() => onPress()} />
155
+ return hotkey ? <Button key={key} plain hotkey={hotkey} label={label} onPress={() => onPress()} /> : <Button key={key} plain label={label} onPress={() => onPress()} />
156
+ }
157
+
158
+ /**
159
+ * A button. The one main action of a card or panel is the host's primary button on the desktop;
160
+ * every other action is quiet, dim text until the pointer is on it. The terminal keeps its hotkeys.
161
+ */
162
+ export function action(el: Els, frame: Frame, key: string, label: string, onPress: () => void, opts: { hotkey?: string; primary?: boolean } = {}): RenderNode {
163
+ const { Button } = el
164
+ if (isRich(frame)) {
165
+ return opts.primary ? <Button key={key} variant="primary" label={label} onPress={() => onPress()} /> : <Button key={key} plain dimColor label={label} onPress={() => onPress()} />
166
+ }
167
+ return textButton(el, key, label, onPress, opts.hotkey, opts.primary)
168
+ }
169
+
170
+ /** The site's close control: the desktop's native ×, named `label`; a button on the terminal. */
171
+ export function dismiss(el: Els, frame: Frame, key: string, label: string, onPress: () => void, hotkey?: string): RenderNode {
172
+ const { Button } = el
173
+ if (isRich(frame)) return <Button key={key} plain role="dismiss" label={label} onPress={() => onPress()} />
174
+ return textButton(el, key, label, onPress, hotkey)
175
+ }
176
+
177
+ /** The main action, then the quiet ones, in one row that wraps whole buttons. */
178
+ export function actions(el: Els, frame: Frame, buttons: RenderNode[]): RenderNode {
179
+ const { Box } = el
180
+ const shown = buttons.filter(b => b !== '' && b !== null && b !== undefined)
181
+ if (!shown.length) return ''
182
+ return (
183
+ <Box key="actions" flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={isRich(frame) ? 1 : 2}>
184
+ {shown}
185
+ </Box>
186
+ )
187
+ }
188
+
189
+ /** One option of several: `● Label` chosen, `○ Label` dim otherwise, on every surface. */
190
+ export function option(el: Els, key: string, label: string, chosen: boolean, onPress: () => void, hotkey?: string): RenderNode {
191
+ const { Button } = el
192
+ const text = (chosen ? '● ' : '○ ') + label
193
+ if (hotkey) return <Button key={key} plain hotkey={hotkey} dimColor={!chosen} label={text} onPress={() => onPress()} />
194
+ return <Button key={key} plain dimColor={!chosen} label={text} onPress={() => onPress()} />
195
+ }
196
+
197
+ /** A row of options, one chosen. */
198
+ export function segmented<T extends string>(
199
+ el: Els,
200
+ frame: Frame,
201
+ key: string,
202
+ options: readonly { value: T; label: string; hotkey?: string }[],
203
+ value: T,
204
+ onPick: (value: T) => void,
205
+ ): RenderNode {
206
+ const { Box } = el
207
+ return (
208
+ <Box flexDirection="row" flexWrap="wrap" columnGap={isRich(frame) ? 1 : 2}>
209
+ {options.map(o => option(el, key + '-' + o.value, o.label, o.value === value, () => onPick(o.value), isRich(frame) ? undefined : o.hotkey))}
210
+ </Box>
211
+ )
212
+ }
213
+
214
+ /** A two-state switch: one button that says its state (`● On` / `○ Off`) and flips it. */
215
+ export function toggle(el: Els, frame: Frame, key: string, label: string, on: boolean, onPress: () => void, hotkey?: string): RenderNode {
216
+ return option(el, key, label, on, onPress, isRich(frame) ? undefined : hotkey)
217
+ }