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,370 @@
1
+ // The bands that take the dashboard's place when something wants a decision: cold cache, heavy
2
+ // context, a hot plan limit, a judge that failed, the compact and handoff bars, and the cards that
3
+ // report a compact or handoff in progress and done.
4
+ //
5
+ // Each one is 리플이 telling you one thing: its face of the moment on the left, a short title that
6
+ // says what, one line that says why, then at most one main action, the others quiet, and a way to
7
+ // let it go. Where the card is about a figure (context, plan limit), its ring is that gauge. The
8
+ // terminal draws it as 리플이's column (parts.tsx `speak`); the desktop as a capsule of the deep
9
+ // lit in the card's own colour (see "Desktop" below).
10
+
11
+ import { t } from '../i18n'
12
+ import type { RenderNode } from 'claude-code'
13
+
14
+ import type { HandoffKind, Settings } from '../../types'
15
+ import { CELL_PX, MUTE, SUB, TONE, pillArt } from './art'
16
+ import type { Sea, Tone } from './art'
17
+ import { capsule, closeButton, pill, ripple, segmented as drawnSegmented, well } from './kit'
18
+ import type { SegmentOption } from './kit'
19
+ import { action, actions, canDraw, dismiss, segmented, speak } from './parts'
20
+ import type { Els } from './parts'
21
+ import { FACE_TONE } from './ripple'
22
+ import type { Face } from './ripple'
23
+ import { chOf, textTone } from './theme'
24
+ import type { AlertActions, AlertView, Frame } from './view'
25
+ import { compactNumber, isRich, limitName, resetsLabel } from './view'
26
+
27
+ type Choice = { key: string; label: string; hotkey: string; onPress: () => void }
28
+
29
+ type Card = {
30
+ key: string
31
+ /** 리플이's face; on the terminal its tone colours the title's mark. */
32
+ face: Face
33
+ /** The ring as a gauge (context or limit percent). */
34
+ percent?: number
35
+ title: string
36
+ body: string
37
+ /** The one thing to press, drawn as the host's primary button. */
38
+ main?: Choice
39
+ /** Other actions, quiet. */
40
+ quiet?: Choice[]
41
+ /** Letting the card go: the host's × on the desktop. */
42
+ close?: Choice
43
+ }
44
+
45
+ const JUDGE_NAME = { haiku: 'settings.haiku', jev: 'settings.jev', model: 'alerts.routerModel' } as const
46
+
47
+ function cardOf(frame: Frame, alert: AlertView, a: AlertActions): Card {
48
+ const L = frame.lang
49
+ const notNow: Choice = { key: 'dismiss', label: t(L, 'alerts.notNow'), hotkey: 'n', onPress: a.dismiss }
50
+ const cancel: Choice = { key: 'dismiss', label: t(L, 'alerts.cancel'), hotkey: 'x', onPress: a.dismiss }
51
+ const compact: Choice = { key: 'compact', label: t(L, 'band.compact'), hotkey: 'c', onPress: () => a.compact() }
52
+ const handoff: Choice = { key: 'handoff', label: t(L, 'band.handoff'), hotkey: 'h', onPress: a.handoff }
53
+ switch (alert.kind) {
54
+ case 'cold':
55
+ return {
56
+ key: 'cold',
57
+ face: 'sleepy',
58
+ title: t(L, 'alerts.coldTitle'),
59
+ body: t(L, 'alerts.coldBody', { tokens: compactNumber(alert.tokens), minutes: alert.idleMinutes }),
60
+ main: compact,
61
+ quiet: [handoff],
62
+ close: notNow,
63
+ }
64
+ case 'swamp':
65
+ return {
66
+ key: 'swamp',
67
+ face: 'uneasy',
68
+ percent: alert.percent,
69
+ title: t(L, 'alerts.swampTitle', { percent: Math.round(alert.percent) }),
70
+ body: t(L, 'alerts.swampBody', { tokens: compactNumber(alert.tokens) }),
71
+ main: compact,
72
+ quiet: [handoff],
73
+ close: notNow,
74
+ }
75
+ case 'hot': {
76
+ const resets = resetsLabel(alert.resetsAt, frame.now, L)
77
+ const save: Choice = { key: 'save', label: alert.save ? t(L, 'alerts.saveOff') : t(L, 'alerts.save'), hotkey: 's', onPress: a.toggleSave }
78
+ return {
79
+ key: 'hot',
80
+ face: alert.percent >= 95 ? 'alarmed' : 'strained',
81
+ percent: alert.percent,
82
+ title: t(L, 'alerts.hotTitle', { window: limitName(alert.limitKind, L), percent: Math.round(alert.percent) }),
83
+ body: (resets ? resets.charAt(0).toUpperCase() + resets.slice(1) + '. ' : '') + (alert.save ? t(L, 'alerts.saveOnBody') : t(L, 'alerts.saveBody')),
84
+ // Save mode on, there is nothing left to do: turning it off is the quiet way back.
85
+ ...(alert.save ? { quiet: [save] } : { main: save }),
86
+ close: notNow,
87
+ }
88
+ }
89
+ case 'judgeDown':
90
+ return {
91
+ key: 'judge',
92
+ face: 'dazed',
93
+ title: t(L, alert.network ? 'alerts.judgeNetworkTitle' : 'alerts.judgeTitle'),
94
+ body: t(L, 'alerts.judgeBody', { judge: t(L, JUDGE_NAME[alert.judge]), reason: alert.reason, fallback: t(L, alert.judge === 'haiku' ? 'alerts.keepEffort' : 'alerts.askHaiku') }),
95
+ main: { key: 'settings', label: t(L, 'alerts.openSettings'), hotkey: 'o', onPress: a.openSettings },
96
+ close: notNow,
97
+ }
98
+ case 'result':
99
+ return resultCard(frame, alert, a)
100
+ case 'compactNote':
101
+ return {
102
+ key: 'note',
103
+ face: 'asking',
104
+ title: t(L, 'alerts.compactTitle'),
105
+ body: t(L, 'alerts.compactBody'),
106
+ main: { ...compact, onPress: () => a.compact(alert.draft) },
107
+ close: cancel,
108
+ }
109
+ case 'handoffChoice':
110
+ return {
111
+ key: 'handoff',
112
+ face: 'asking',
113
+ title: t(L, 'alerts.handoffTitle'),
114
+ body: alert.handoffKind === 'quick' ? t(L, 'alerts.quickBody') : t(L, 'alerts.fullBody'),
115
+ main: { key: 'go', label: t(L, 'alerts.go'), hotkey: 'g', onPress: a.go },
116
+ close: cancel,
117
+ }
118
+ }
119
+ }
120
+
121
+ function resultCard(frame: Frame, alert: Extract<AlertView, { kind: 'result' }>, a: AlertActions): Card {
122
+ const L = frame.lang
123
+ const close: Choice = { key: 'dismiss', label: t(L, 'alerts.dismiss'), hotkey: 'x', onPress: a.dismiss }
124
+ switch (alert.stage) {
125
+ case 'handingOff':
126
+ return { key: 'result', face: 'going', title: t(L, 'alerts.handingOff'), body: alert.detail ?? (alert.handoffKind === 'full' ? t(L, 'alerts.fullProgress') : t(L, 'alerts.quickProgress')) }
127
+ case 'handoffReady':
128
+ return {
129
+ key: 'result',
130
+ face: 'asking',
131
+ title: t(L, 'alerts.handoffReady'),
132
+ body: t(L, 'alerts.handoffReadyBody'),
133
+ main: { key: 'open-new', label: t(L, 'alerts.openNew'), hotkey: 'g', onPress: a.openNew },
134
+ quiet: [{ key: 'copy', label: t(L, 'alerts.copyText'), hotkey: 'y', onPress: a.copy }],
135
+ close: { key: 'dismiss', label: t(L, 'alerts.cancel'), hotkey: 'x', onPress: a.dismiss },
136
+ }
137
+ case 'handoffDone':
138
+ return { key: 'result', face: 'pleased', title: t(L, 'alerts.handoffDone'), body: alert.detail ?? t(L, 'alerts.summaryReady'), quiet: [{ key: 'copy', label: t(L, 'alerts.copyAgain'), hotkey: 'y', onPress: a.copy }], close }
139
+ case 'compacting':
140
+ return { key: 'result', face: 'squeezing', title: t(L, 'alerts.compacting'), body: alert.detail ?? t(L, 'alerts.compactingBody') }
141
+ case 'compacted':
142
+ return { key: 'result', face: 'pleased', title: t(L, 'alerts.compacted'), body: alert.detail ?? t(L, 'alerts.compactedBody'), close }
143
+ case 'failed':
144
+ return { key: 'result', face: 'sorry', title: t(L, 'alerts.failed'), body: alert.detail ?? t(L, 'alerts.unchanged'), close }
145
+ }
146
+ }
147
+
148
+ function extras(el: Els, frame: Frame, alert: AlertView, a: AlertActions): RenderNode[] {
149
+ const { Box, Text } = el
150
+ if (alert.kind === 'compactNote' && 'Input' in el) {
151
+ const Field = el.Input
152
+ return [
153
+ <Field
154
+ key="note"
155
+ label={t(frame.lang, 'alerts.keep')}
156
+ placeholder={t(frame.lang, 'alerts.keepPlaceholder')}
157
+ value={alert.draft}
158
+ submitLabel={t(frame.lang, 'alerts.compactSubmit')}
159
+ autoFocus
160
+ onInput={(text: string) => a.noteInput(text)}
161
+ onSubmit={(text: string) => a.compact(text)}
162
+ />,
163
+ ]
164
+ }
165
+ if (alert.kind === 'handoffChoice') {
166
+ const kinds: { value: HandoffKind; label: string; hotkey: string }[] = [
167
+ { value: 'quick', label: t(frame.lang, 'alerts.quick'), hotkey: 'q' },
168
+ { value: 'full', label: t(frame.lang, 'alerts.full'), hotkey: 'f' },
169
+ ]
170
+ const afters: { value: Settings['handoffAfter']; label: string; hotkey: string }[] = [
171
+ { value: 'continue', label: t(frame.lang, 'alerts.continue'), hotkey: '1' },
172
+ { value: 'confirm', label: t(frame.lang, 'alerts.confirm'), hotkey: '2' },
173
+ { value: 'copy', label: t(frame.lang, 'alerts.copy'), hotkey: '3' },
174
+ ]
175
+ // Two rows of options under one another, their names in a column of their own.
176
+ const line = (key: string, name: string, control: RenderNode) => (
177
+ <Box key={key} flexDirection="row" alignItems="center" columnGap={2}>
178
+ <Box width={8} flexShrink={0}>
179
+ <Text dimColor>{name.trim()}</Text>
180
+ </Box>
181
+ {control}
182
+ </Box>
183
+ )
184
+ return [
185
+ <Box key="choices" flexDirection="column">
186
+ {line('kind', t(frame.lang, 'alerts.kind'), segmented(el, frame, 'kind', kinds, alert.handoffKind, k => a.setHandoffKind(k)))}
187
+ {line('after', t(frame.lang, 'alerts.then'), segmented(el, frame, 'after', afters, alert.after, k => a.setHandoffAfter(k)))}
188
+ <Text dimColor wrap="wrap">{afterHint(frame, alert.after)}</Text>
189
+ </Box>,
190
+ ]
191
+ }
192
+ return []
193
+ }
194
+
195
+ // ---- Desktop: the card as a capsule of the deep ------------------------------------------------
196
+ //
197
+ // The card's colour is its light (ice and falling snow for a cold cache, amber and bubbles for a
198
+ // heavy chat, embers for a hot limit, a red scanline for a broken judge, sparkles while it asks, a
199
+ // current while it works, green and a tick drawn in when done); 리플이 at the start in its full
200
+ // drawing, so its face says what the card is about; the title in the card's colour, the why after it
201
+ // in soft ink, then the quiet actions as ghost buttons, the main one lit, and the way out. One row
202
+ // when it all fits; else the why and the choices go under the title.
203
+
204
+ function seaOf(card: Card, alert: AlertView): Omit<Sea, 'at'> {
205
+ switch (card.key) {
206
+ case 'cold':
207
+ return { tone: 'ice', weather: 'snow', motes: true }
208
+ case 'swamp':
209
+ return { tone: 'amber', weather: 'bubbles', ripples: 'calm' }
210
+ case 'hot':
211
+ return { tone: (card.percent ?? 0) >= 95 ? 'red' : 'ember', weather: 'embers' }
212
+ case 'judge':
213
+ return { tone: 'red', weather: 'static' }
214
+ case 'note':
215
+ case 'handoff':
216
+ return { tone: 'violet', weather: 'sparkles', ripples: 'calm' }
217
+ default: {
218
+ const stage = alert.kind === 'result' ? alert.stage : 'failed'
219
+ if (stage === 'handingOff' || stage === 'compacting') return { tone: 'violet', ripples: 'busy', current: true }
220
+ if (stage === 'failed') return { tone: 'red', weather: 'static' }
221
+ if (stage === 'handoffReady') return { tone: 'violet', weather: 'sparkles', ripples: 'calm' }
222
+ return { tone: 'green', tick: true, weather: 'sparkles' }
223
+ }
224
+ }
225
+ }
226
+
227
+ /** 리플이 on a card, px: the full drawing (ripple.ts), brows and mouth and all. */
228
+ const CARD_FACE = 34
229
+
230
+ function drawnExtras(el: Els, frame: Frame, alert: AlertView, a: AlertActions, tone: Tone): RenderNode[] {
231
+ const { Box, Text } = el
232
+ if (alert.kind === 'compactNote' && 'Input' in el) {
233
+ const Field = el.Input
234
+ const name = t(frame.lang, 'alerts.keep').trim()
235
+ const placeholder = t(frame.lang, 'alerts.keepPlaceholder')
236
+ // The field's own label would take the host's ink; its name is ours, beside its well.
237
+ const cells = Math.max(20, frame.columns - Math.ceil(CARD_FACE / CELL_PX) - Math.ceil(chOf(name)) - 10)
238
+ return [
239
+ <Box key="note-row" flexDirection="row" alignItems="center" columnGap={2} width="100%" minWidth={0}>
240
+ <Text color={SUB}>{name}</Text>
241
+ {well(el, 'note', cells, (
242
+ <Field
243
+ key="note"
244
+ placeholder={placeholder}
245
+ value={alert.draft}
246
+ submitLabel={t(frame.lang, 'alerts.compactSubmit')}
247
+ autoFocus
248
+ onInput={(text: string) => a.noteInput(text)}
249
+ onSubmit={(text: string) => a.compact(text)}
250
+ />
251
+ ), placeholder)}
252
+ </Box>,
253
+ ]
254
+ }
255
+ if (alert.kind !== 'handoffChoice') return []
256
+ const kinds: SegmentOption<HandoffKind>[] = [
257
+ { value: 'quick', label: t(frame.lang, 'alerts.quick') },
258
+ { value: 'full', label: t(frame.lang, 'alerts.full') },
259
+ ]
260
+ const afters: SegmentOption<Settings['handoffAfter']>[] = [
261
+ { value: 'continue', label: t(frame.lang, 'alerts.continue') },
262
+ { value: 'confirm', label: t(frame.lang, 'alerts.confirm') },
263
+ { value: 'copy', label: t(frame.lang, 'alerts.copy') },
264
+ ]
265
+ const names = [t(frame.lang, 'alerts.kind'), t(frame.lang, 'alerts.then')].map(n => n.trim())
266
+ const nameCells = Math.ceil(Math.max(...names.map(chOf))) + 1
267
+ const line = (key: string, name: string, control: RenderNode) => (
268
+ <Box key={key} flexDirection="row" alignItems="center" columnGap={2}>
269
+ <Box width={nameCells} flexShrink={0}>
270
+ <Text color={MUTE}>{name}</Text>
271
+ </Box>
272
+ {control}
273
+ </Box>
274
+ )
275
+ return [
276
+ <Box key="choices" flexDirection="column" rowGap={1}>
277
+ {line('kind', names[0]!, drawnSegmented(el, 'kind', kinds, alert.handoffKind, k => a.setHandoffKind(k), tone))}
278
+ {line('after', names[1]!, drawnSegmented(el, 'after', afters, alert.after, k => a.setHandoffAfter(k), tone))}
279
+ <Box paddingLeft={nameCells + 2}>
280
+ <Text color={MUTE} wrap="wrap">{afterHint(frame, alert.after)}</Text>
281
+ </Box>
282
+ </Box>,
283
+ ]
284
+ }
285
+
286
+ /** What the chosen "then" does, in a line: the names alone were read the wrong way (2026-10-13). */
287
+ function afterHint(frame: Frame, after: Settings['handoffAfter']): string {
288
+ return t(frame.lang, after === 'continue' ? 'alerts.continueHint' : after === 'confirm' ? 'alerts.confirmHint' : 'alerts.copyHint')
289
+ }
290
+
291
+ function drawnAlert(el: Els, frame: Frame, alert: AlertView, a: AlertActions): RenderNode {
292
+ const { Box, Text } = el
293
+ const card = cardOf(frame, alert, a)
294
+ const sea = seaOf(card, alert)
295
+ const tone = sea.tone
296
+ const quiet = card.quiet ?? []
297
+ // The note's field has its own submit, which is the main action there.
298
+ const fieldSubmits = alert.kind === 'compactNote' && 'Input' in el
299
+ const main = card.main && !fieldSubmits ? card.main : undefined
300
+ const more = drawnExtras(el, frame, alert, a, tone)
301
+ // The actions' cells, as drawn (each pill its own whole cells, a cell between, the way out last).
302
+ const actionCells = quiet.reduce((n, c) => n + pillArt(c.label, 'ghost', tone).cells + 1, 0) + (main ? pillArt(main.label, 'primary', tone).cells + 1 : 0) + (card.close ? 4 : 0)
303
+ const faceCells = Math.ceil(CARD_FACE / CELL_PX) + 2
304
+ const room = frame.columns - 4
305
+ const oneRow = !more.length && faceCells + chOf(card.title) * 1.12 + 2 + chOf(card.body) + 2 + actionCells <= room
306
+ // Too narrow to keep the actions beside the words: they go under them.
307
+ const actionsBelow = !oneRow && room - faceCells - actionCells < 30
308
+ const buttons = (
309
+ <Box key="actions" flexDirection="row" alignItems="center" columnGap={1} flexShrink={0} flexWrap="wrap" rowGap={1}>
310
+ {quiet.map(c => pill(el, c.key, c.label, 'ghost', c.onPress, tone))}
311
+ {main ? pill(el, main.key, main.label, 'primary', main.onPress, tone) : null}
312
+ {card.close ? closeButton(el, card.close.key, card.close.label, card.close.onPress) : null}
313
+ </Box>
314
+ )
315
+ const face = ripple(el, card.key, card.face, card.title, CARD_FACE, card.percent)
316
+ const title = <Text key="title" bold color={TONE[tone].text} wrap="wrap">{card.title}</Text>
317
+ const body = <Text key="body" color={SUB} wrap="wrap">{card.body}</Text>
318
+ const top = oneRow ? (
319
+ <Box key="top" flexDirection="row" alignItems="center" columnGap={2} width="100%" minWidth={0}>
320
+ {face}
321
+ <Box key="title-slot" flexShrink={0}>{title}</Box>
322
+ <Box key="say" flexDirection="row" flexGrow={1} flexShrink={1} width={0} minWidth={0}>
323
+ {body}
324
+ </Box>
325
+ {buttons}
326
+ </Box>
327
+ ) : (
328
+ <Box key="top" flexDirection="row" alignItems="flex-start" columnGap={2} width="100%" minWidth={0}>
329
+ {face}
330
+ <Box key="say" flexDirection="column" rowGap={1} flexGrow={1} flexShrink={1} minWidth={0}>
331
+ {title}
332
+ {body}
333
+ {more}
334
+ {actionsBelow ? buttons : null}
335
+ </Box>
336
+ {actionsBelow ? null : buttons}
337
+ </Box>
338
+ )
339
+ return capsule(el, frame, {
340
+ key: 'alert-' + card.key,
341
+ sea: { ...sea, reach: 130, at: { x: 1.5 * CELL_PX + CARD_FACE / 2, y: 0.5 * 19 * 0.5 + CARD_FACE / 2 } },
342
+ rows: oneRow ? 2 : 4 + (more.length ? 3.2 : 0) + (actionsBelow ? 2 : 0) + (frame.columns < 60 ? 3 : 0),
343
+ children: [top],
344
+ })
345
+ }
346
+
347
+ /** One alert band, chosen by register.tsx from the priority list in the spec (§5 Bands). */
348
+ export function renderAlert(el: Els, frame: Frame, alert: AlertView, a: AlertActions): RenderNode {
349
+ if (canDraw(el, frame)) return drawnAlert(el, frame, alert, a)
350
+ const card = cardOf(frame, alert, a)
351
+ const rich = isRich(frame)
352
+ const tone = FACE_TONE[card.face]
353
+ const text = textTone(frame.appearance)
354
+ const press = (c: Choice, primary = false) => action(el, frame, c.key, c.label, c.onPress, { hotkey: c.hotkey, primary })
355
+ const close = card.close ? dismiss(el, frame, card.close.key, card.close.label, card.close.onPress, card.close.hotkey) : ''
356
+ const buttons = [card.main ? press(card.main, true) : '', ...(card.quiet ?? []).map(c => press(c)), rich ? '' : close]
357
+ // The terminal has no face: a text mark says it instead (work going on, done, failed or broken,
358
+ // a warning, a question).
359
+ const mark = card.face === 'squeezing' || card.face === 'going' ? '…' : tone === 'ok' ? '✓' : tone === 'bad' ? '✕' : tone === 'warn' ? '▲' : '◉'
360
+ return speak(el, frame, {
361
+ key: 'alert-' + card.key,
362
+ face: card.face,
363
+ title: rich ? card.title : mark + ' ' + card.title,
364
+ titleColor: tone === 'accent' ? text.accent : text[tone],
365
+ line: card.body,
366
+ ...(card.percent !== undefined ? { percent: card.percent } : {}),
367
+ blocks: [...extras(el, frame, alert, a), actions(el, frame, buttons)],
368
+ ...(rich && close ? { dismiss: close } : {}),
369
+ })
370
+ }