clauderipple 0.9.2 → 0.9.4

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.
@@ -278,6 +278,9 @@ export function plateArt(width: number, height: number): string {
278
278
 
279
279
  // ---- Controls -----------------------------------------------------------------------------------
280
280
 
281
+ /** A drawn control's height, px (kit.tsx holds it too for the wells). */
282
+ export const CONTROL_PX = 26
283
+
281
284
  export const SWITCH_W = 36
282
285
  export const SWITCH_H = 22
283
286
 
@@ -299,6 +302,18 @@ function lit(on: boolean): string {
299
302
  return ' class="' + (on ? 'on' : 'off') + '"'
300
303
  }
301
304
 
305
+ /**
306
+ * One cell of the drawn light laid over a control under the pointer (kit.tsx `extraHover`): a thin
307
+ * white wash, its edges fading, on the water's dark.
308
+ */
309
+ export function extraHoverArt(widthPx: number): string {
310
+ return svg(widthPx, CONTROL_PX,
311
+ '<defs><linearGradient id="f" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".1"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>' +
312
+ '<linearGradient id="v" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".12"/><stop offset=".5" stop-color="#fff" stop-opacity=".02"/><stop offset="1" stop-color="#fff" stop-opacity=".12"/></linearGradient></defs>' +
313
+ '<rect x="0" y="2" width="' + r(widthPx) + '" height="' + r(CONTROL_PX - 4) + '" fill="url(#f)"/>' +
314
+ '<rect x="2" y="1" width="' + r(Math.max(0, widthPx - 4)) + '" height="' + r(CONTROL_PX - 2) + '" fill="url(#v)"/>')
315
+ }
316
+
302
317
  /** The look of a drawn button: the one thing to press, or a quiet one. */
303
318
  export type PillLook = 'primary' | 'ghost'
304
319
 
@@ -346,6 +361,37 @@ export function pillArt(label: string, look: PillLook, tone: Tone = 'violet', gl
346
361
  return { source: svg(w, h, body), cells }
347
362
  }
348
363
 
364
+ /**
365
+ * A word that is itself a control, 26 px high: on, a lit violet pill with the word in white (the
366
+ * whole capsule's one violet check, on hover a pulse breathing around it); off, a quiet ghost with
367
+ * the word in soft ink. The deciding pulse rings the lit pill.
368
+ */
369
+ export function wordArt(label: string, on: boolean, busy = false): { source: string; cells: number } {
370
+ const t = TONE.violet
371
+ const size = 12
372
+ const pad = 11
373
+ const cells = Math.ceil((labelPx(label, size) + pad * 2) / CELL_PX)
374
+ const w = cells * CELL_PX
375
+ const h = 26
376
+ let body: string
377
+ let css = ''
378
+ if (on) {
379
+ body =
380
+ '<defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="' + t.lo + '"/><stop offset="1" stop-color="' + t.hi + '"/></linearGradient>' +
381
+ '<filter id="f" x="-30%" y="-60%" width="160%" height="220%"><feGaussianBlur stdDeviation="1.8"/></filter></defs>' +
382
+ (busy ? '<rect class="pu" x="1" y="1" width="' + r(w - 2) + '" height="' + (h - 2) + '" rx="' + (h / 2 - 1) + '" fill="none" stroke="' + t.hi + '" stroke-width="1.4" filter="url(#f)"/>' : '') +
383
+ '<rect x="1" y="1" width="' + r(w - 2) + '" height="' + (h - 2) + '" rx="' + (h / 2 - 1) + '" fill="url(#g)"/>' +
384
+ '<rect x="2" y="1.5" width="' + r(w - 4) + '" height="' + (h / 2 - 2) + '" rx="' + (h / 4) + '" fill="#fff" fill-opacity=".16"/>' +
385
+ '<text x="' + r(w / 2) + '" y="' + r(h / 2 + size * 0.36) + '" text-anchor="middle" font-size="' + size + '" font-weight="650" fill="' + t.on + '">' + esc(label) + '</text>'
386
+ css += busy ? '.pu{transform-box:fill-box;transform-origin:center;animation:pu 1.3s ease-in-out infinite}@keyframes pu{0%,100%{opacity:.55;transform:scale(1)}50%{opacity:.15;transform:scale(1.06)}}' : ''
387
+ } else {
388
+ body =
389
+ '<rect x="1" y="1" width="' + r(w - 2) + '" height="' + (h - 2) + '" rx="' + (h / 2 - 1) + '" fill="#fff" fill-opacity=".05" stroke="' + HAIR + '" stroke-opacity=".2"/>' +
390
+ '<text x="' + r(w / 2) + '" y="' + r(h / 2 + size * 0.36) + '" text-anchor="middle" font-size="' + size + '" font-weight="500" fill="' + SOFT + '" opacity=".55">' + esc(label) + '</text>'
391
+ }
392
+ return { source: svg(w, h, body, css, lit(on)), cells }
393
+ }
394
+
349
395
  /**
350
396
  * A segmented control, its segments `cells` wide each and 26 px high: a dark track, the chosen
351
397
  * segment a lit pill in `tone`, the rest named in soft ink. `chosen` −1 lights none.
@@ -431,8 +477,8 @@ export function wellArt(width: number, height: number): string {
431
477
  export const CLOSE_PX = 22
432
478
 
433
479
  /** The way out of a capsule, 22 px: a soft disc with a ×. */
434
- export function closeArt(): string {
435
- return svg(CLOSE_PX, CLOSE_PX, '<circle cx="11" cy="11" r="10" fill="#fff" fill-opacity=".07" stroke="' + HAIR + '" stroke-opacity=".2"/><path d="M7.6 7.6l6.8 6.8M14.4 7.6l-6.8 6.8" stroke="' + LABEL + '" stroke-width="1.6" stroke-linecap="round"/>')
480
+ export function closeArt(lit = false): string {
481
+ return svg(CLOSE_PX, CLOSE_PX, '<circle cx="11" cy="11" r="10" fill="#fff" fill-opacity="' + (lit ? '.16' : '.07') + '" stroke="' + HAIR + '" stroke-opacity="' + (lit ? '.45' : '.2') + '"/><path d="M7.6 7.6l6.8 6.8M14.4 7.6l-6.8 6.8" stroke="' + LABEL + '" stroke-width="1.6" stroke-linecap="round"/>')
436
482
  }
437
483
 
438
484
  // ---- The band's instruments ---------------------------------------------------------------------
@@ -496,10 +542,23 @@ export function clockArt(share: number, tone: Tone | 'soft'): string {
496
542
  return svg(s, s, '<circle cx="' + c + '" cy="' + c + '" r="' + rad + '" fill="none" stroke="' + fill + '" stroke-opacity=".75" stroke-width="1.3"/>' + pie)
497
543
  }
498
544
 
545
+ /**
546
+ * The plan limit's window as a small gauge, 16 px, in the idiom of the ring and the clock: a dial
547
+ * with a needle, the two windows a needle at a different turn (the five-hour one up, the weekly one
548
+ * leaning right). The window's name and its reset time are in the fact's own tip; a band at 100
549
+ * columns once cut this fact's name to "g…" (2026-10-14), and the glyph buys the room back.
550
+ */
551
+ export function gaugeArt(kind: string): string {
552
+ const stroke = SOFT
553
+ const arc = '<path d="M1.8 11.4A6.2 6.2 0 0 1 14.2 11.4" fill="none" stroke="' + stroke + '" stroke-opacity=".7" stroke-width="1.3" stroke-linecap="round"/>'
554
+ const needle = kind === 'seven_day' ? 'M8 11.4L11.4 7.6' : 'M8 11.4L8 6.5'
555
+ return svg(16, 16, arc + '<path d="' + needle + '" fill="none" stroke="' + stroke + '" stroke-width="1.4" stroke-linecap="round"/><circle cx="8" cy="11.4" r="1.2" fill="' + stroke + '"/>')
556
+ }
557
+
499
558
  export const ICON_PX = 16
500
559
 
501
560
  /** Line icons of the band's controls, 16 px. */
502
- export type Icon = 'flame' | 'gear' | 'agents' | 'log'
561
+ export type Icon = 'flame' | 'gear' | 'agents' | 'log' | 'compact' | 'clear'
503
562
 
504
563
  const FLAME = 'M8.1 14.3c2.8 0 4.5-1.9 4.5-4.4 0-2.8-2.4-4.2-2.8-7.2-1.7 1.1-2.6 2.8-2.5 4.3-1-.4-1.5-1.4-1.6-2.3-1.4 1.3-2 2.9-2 5.2 0 2.5 1.6 4.4 4.4 4.4z'
505
564
 
@@ -508,6 +567,10 @@ const ICON: Record<Icon, string> = {
508
567
  gear: '<circle cx="8" cy="8" r="2.3"/><path d="M8 1.7v1.8M8 12.5v1.8M1.7 8h1.8M12.5 8h1.8M3.5 3.5l1.3 1.3M11.2 11.2l1.3 1.3M3.5 12.5l1.3-1.3M11.2 4.8l1.3-1.3"/><circle cx="8" cy="8" r="4.8"/>',
509
568
  agents: '<circle cx="4.8" cy="5" r="2.1"/><circle cx="11.4" cy="5" r="2.1"/><path d="M1.6 13.4c.4-2.4 1.7-3.6 3.2-3.6s2.8 1.2 3.2 3.6M8.2 13.4c.4-2.4 1.7-3.6 3.2-3.6s2.8 1.2 3.2 3.6"/>',
510
569
  log: '<path d="M2.4 3.8h11.2M2.4 8h11.2M2.4 12.2h6.8"/>',
570
+ // Two arrows coming in from the corners to the middle: the chat drawn down into itself.
571
+ compact: '<path d="M3.2 3.2L7.1 7.1M7.1 7.1H4.9M7.1 7.1V4.9M12.8 12.8L8.9 8.9M8.9 8.9h2.2M8.9 8.9v2.2"/>',
572
+ // An eraser laid on its edge, the sleeve band bold, a short line being swept away below.
573
+ clear: '<path d="M1.33 8.69L5.8 4.94L9.01 8.77L4.55 12.52Z" fill="#fff" fill-opacity=".4" stroke="none"/><path d="M9.45 1.88L12.67 5.71L4.55 12.52L1.33 8.69Z"/><path d="M9.01 8.77L5.8 4.94"/><path d="M3.45 14.3h9.1"/>',
511
574
  }
512
575
 
513
576
  /**
@@ -3,8 +3,9 @@
3
3
  // Desktop: one capsule of the deep (see "Desktop" below, art.ts and kit.tsx): 리플이 glowing at its
4
4
  // left, acting out what the band is doing (thinking while Auto picks, busy while a request is out,
5
5
  // drowsy as the cache runs out, asleep once it has, otherwise its idle life), the effort as five
6
- // bars rising shallow to deep, the context ring, the cache clock in whole minutes, the plan limit and
7
- // the mode when on, the last route, the judge's reason; then the drawn controls and the way out.
6
+ // bars rising shallow to deep, the context ring, the cache clock in whole minutes, the plan limit
7
+ // (its window a gauge, its name and reset in the tip) and the mode when on, the last route, the
8
+ // judge's reason; then the drawn controls and the way out.
8
9
  // What each fact means shows in the reason's place on hover. The terminal draws the same facts as
9
10
  // one quiet row of text, without 리플이.
10
11
  //
@@ -32,9 +33,9 @@ import type { Lang } from '../i18n'
32
33
  import type { RenderChildren, RenderNode } from 'claude-code'
33
34
 
34
35
  import type { Effort } from '../../types'
35
- import { AGENTS_W, CELL_PX, DEPTH, ICON_PX, INK, METER_H, METER_W, MUTE, ROW_PX, SUB, TONE, agentsArt, clockArt, iconArt, meterArt, pillArt, plateArt, ringArt, ruleArt, switchArt, textPx } from './art'
36
+ import { AGENTS_W, CELL_PX, DEPTH, ICON_PX, INK, METER_H, METER_W, MUTE, ROW_PX, SUB, TONE, agentsArt, clockArt, gaugeArt, iconArt, meterArt, pillArt, plateArt, ringArt, ruleArt, textPx } from './art'
36
37
  import type { Sea, Tone } from './art'
37
- import { CLOSE_CELLS, capsule, closeButton, lift, lit, pic, pill, press, ripple, rowsOf, segmented, toggle, toggleCells } from './kit'
38
+ import { CLOSE_CELLS, capsule, closeButton, lift, lit, pic, pill, press, ripple, rowsOf, segmented, word, wordCells } from './kit'
38
39
  import { textButton } from './parts'
39
40
  import type { Els } from './parts'
40
41
  import type { Face } from './ripple'
@@ -284,31 +285,31 @@ function routeFact(frame: Frame, view: DashboardView, room: number, onPress: ()
284
285
  // ---- Controls ----------------------------------------------------------------------------------
285
286
 
286
287
  /**
287
- * A control: its label, the terminal's hotkey, and whether it is on (a toggle that is on, an open
288
- * pane, the advised Handoff). The terminal says it in the label (● / ○), as its plain Buttons take
289
- * no colour.
288
+ * A control: its label, the hotkey, and whether it is on (a toggle that is on, an open pane, the
289
+ * advised Handoff). The terminal says it in the label (● / ○), as its plain Buttons take no colour.
290
290
  */
291
291
  type Control = { key: string; label: string; hotkey: string; onPress: () => void; on: boolean; count?: string }
292
292
 
293
293
  function controls(frame: Frame, view: DashboardView, actions: BandActions): Control[] {
294
- const rich = isRich(frame)
295
294
  const L = frame.lang
296
- const mark = (on: boolean) => (rich ? '' : on ? ' ●' : ' ○')
295
+ const mark = (on: boolean) => (on ? ' ●' : ' ○')
297
296
  const out: Control[] = []
298
297
  // Handoff comes and goes; first in the row, it moves nothing when it does.
299
298
  if (view.handoff !== 'off') {
300
299
  const advised = view.handoff === 'advised'
301
- out.push({ key: 'handoff', label: t(L, 'band.handoff') + (!rich && advised ? ' ◆' : ''), hotkey: 'h', onPress: actions.handoff, on: advised })
300
+ out.push({ key: 'handoff', label: t(L, 'band.handoff') + (!isRich(frame) && advised ? ' ◆' : ''), hotkey: 'h', onPress: actions.handoff, on: advised })
302
301
  }
303
302
  out.push(
304
303
  { key: 'auto', label: t(L, 'band.auto') + mark(view.auto), hotkey: 'a', onPress: actions.toggleAuto, on: view.auto },
305
304
  { key: 'warm', label: t(L, 'band.warm') + mark(view.warm.on), hotkey: 'w', onPress: actions.toggleWarm, on: view.warm.on },
306
305
  // The desktop opens the log from the route itself (its value is that button), so it has no Log of its own.
307
- ...(rich ? [] : [{ key: 'log', label: t(L, 'band.log') + mark(view.logOpen), hotkey: 'l', onPress: actions.toggleLog, on: view.logOpen }]),
306
+ ...(!isRich(frame) ? [{ key: 'log', label: t(L, 'band.log') + mark(view.logOpen), hotkey: 'l', onPress: actions.toggleLog, on: view.logOpen }] : []),
308
307
  // The count has a slot of its own (desktop) or is always a number (terminal): no width change.
309
- { key: 'agents', label: t(L, 'band.agents') + (rich ? '' : ' ' + (view.agents.live || '·')) + mark(view.agentsOpen), hotkey: 'g', onPress: actions.toggleAgents, on: view.agentsOpen, ...(rich ? { count: view.agents.live > 0 ? String(view.agents.live) : '' } : {}) },
308
+ { key: 'agents', label: t(L, 'band.agents') + (isRich(frame) ? '' : ' ' + (view.agents.live || '·')) + mark(view.agentsOpen), hotkey: 'g', onPress: actions.toggleAgents, on: view.agentsOpen, ...(isRich(frame) ? { count: view.agents.live > 0 ? String(view.agents.live) : '' } : {}) },
309
+ // The desktop's Clear asks first (a card of its own, "clear" in bandPriority); Compact opens its note bar.
310
+ ...(isRich(frame) ? [{ key: 'compact', label: t(L, 'band.compact'), hotkey: 'c', onPress: actions.compact, on: false }, { key: 'clear', label: t(L, 'band.clear'), hotkey: 'x', onPress: actions.clear, on: false }] : []),
310
311
  // A word on the desktop: the host's 14 px font draws ⚙ as a small ring that reads as "o".
311
- { key: 'settings', label: rich ? t(L, 'band.settings') : '⚙', hotkey: 's', onPress: actions.openSettings, on: false },
312
+ { key: 'settings', label: isRich(frame) ? t(L, 'band.settings') : '⚙', hotkey: 's', onPress: actions.openSettings, on: false },
312
313
  )
313
314
  return out
314
315
  }
@@ -358,16 +359,19 @@ export const BAND_RIPPLE = 24
358
359
  export const CREST_CELLS = 4
359
360
  /** The row's height, px: a pill's, whether or not one shows, so Handoff never moves the band. */
360
361
  const ROW_H = 26
362
+ // Fact and control widths alone decide the row, never a value, so a changing figure never moves its
363
+ // neighbours. The air between facts and between controls is trimmed below what reads as a whole cell
364
+ // (the limit's word became a gauge for the same reason): at 100 columns a route name of 13
365
+ // characters ("glm-5.3-flash") must draw whole beside everything else, and a loose gap ate the
366
+ // route's room and cut it (2026-10-14).
361
367
  /** The air between facts, in cells (the host takes fractions of a cell). */
362
- const DRAWN_GAP = 1.25
368
+ const DRAWN_GAP = 0.2
363
369
  /** A rule between the band's groups, in cells. */
364
370
  const RULE_CELLS = 1
365
371
  /** The room the route is counted at (a model name and its hit); it takes more when the row has it. */
366
- const ROUTE_ROOM = 10
372
+ const ROUTE_ROOM = 14
367
373
  /** The controls whose words go first when the band is narrow, in that order; Auto's goes last. */
368
- const WORDLESS = ['settings', 'agents', 'warm'] as const
369
- /** Cells Auto's switch takes without its word. */
370
- const SWITCH_ONLY = Math.ceil(36 / CELL_PX) + 1
374
+ const WORDLESS = ['clear', 'compact', 'settings', 'agents', 'warm'] as const
371
375
  /** The capsule's own air, in cells across and rows down. */
372
376
  const PAD_X = 1
373
377
  const PAD_Y = 0.5
@@ -467,12 +471,14 @@ function limitDrawn(el: Els, frame: Frame, view: DashboardView): DrawnFact | nul
467
471
  const limit = view.limit
468
472
  if (!limit || !view.extras.includes('limits')) return null
469
473
  const { Text } = el
470
- const names = [limitName('five_hour', frame.lang), limitName('seven_day', frame.lang)]
471
474
  const save = t(frame.lang, 'band.save')
472
- // Save's word is counted whether it shows or not: turning save mode on moves nothing.
473
- const cells = Math.ceil((Math.max(...names.map(textPx)) + CELL_PX + textPx('100%') + CELL_PX + textPx(save)) / CELL_PX)
475
+ // The window is a gauge (art.ts), not its word: at 100 columns the name "주간"/"Weekly" was cut
476
+ // to "g…" beside the route, and the window's name and reset stay in the tip (2026-10-14). Save's
477
+ // word takes room only while save mode is on: reserved always, it cost the limit its place on a
478
+ // band near 95 columns (2026-10-14).
479
+ const cells = Math.ceil((16 + CELL_PX + textPx('100%') + (limit.save ? CELL_PX + textPx(save) : 0)) / CELL_PX)
474
480
  return drawnFact(el, 'limit', cells, [
475
- <Text key="limit-name" color={SUB}>{limitName(limit.kind, frame.lang)}</Text>,
481
+ pic(el, 'limit', gaugeArt(limit.kind), t(frame.lang, 'band.limit'), 16, 16),
476
482
  <Text key="limit-value" color={figure(limitTone(limit.percent))}>{Math.round(limit.percent) + '%'}</Text>,
477
483
  limit.save ? <Text key="limit-save" color={TONE.violet.text}>{save}</Text> : '',
478
484
  ], limitFact(frame, view)?.card?.join(' · '))
@@ -495,7 +501,7 @@ function routeName(model: string): string {
495
501
  }
496
502
 
497
503
  /** The last route: where it went (the provider's dot), the model, its cache hit; the way into the log. */
498
- function routeDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActions): RenderNode {
504
+ function routeDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActions, room: number): RenderNode {
499
505
  const { Box, Text } = el
500
506
  if (view.hide.includes('route')) return ''
501
507
  const r = view.route
@@ -510,7 +516,7 @@ function routeDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandAct
510
516
  const tone = r.cachePct !== undefined ? cacheTone(r.cachePct) : 'ok'
511
517
  const tip = routeFact(frame, view, 200, actions.toggleLog)?.card?.join(' · ')
512
518
  return (
513
- <Box key="fact-route" position="relative" flexDirection="row" alignItems="center" columnGap={1} flexShrink={1} minWidth={ROUTE_ROOM} overflow="hidden" {...(tip ? { hover: { scope: tipScope('route') } } : {})}>
519
+ <Box key="fact-route" position="relative" flexDirection="row" alignItems="center" columnGap={0.5} flexShrink={1} minWidth={Math.min(ROUTE_ROOM, room)} overflow="hidden" {...(tip ? { hover: { scope: tipScope('route') } } : {})}>
514
520
  <Box flexShrink={0}>
515
521
  {r.ok ? <Text color={r.resent ? MUTE : providerColor(r.provider)}>{r.resent ? '↻' : '•'}</Text> : <Text color={TONE.red.text}>✕</Text>}
516
522
  </Box>
@@ -533,14 +539,24 @@ function routeDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandAct
533
539
  * What the row leaves, blank, and each fact's or control's tip laid over it on hover. The judge's
534
540
  * reason is no longer written here (cut to a word or two it said nothing, 2026-10-13); it is in the
535
541
  * effort's tip.
542
+ *
543
+ * The layer is placed absolutely over the facts (columns `left` to the facts' right edge, which is
544
+ * just before the controls) rather than drawn in what the row leaves free: at the band's real width
545
+ * (2026-10-14: 100 columns with every fact and control drawn) nothing is left free, and a tip drawn
546
+ * in a zero-width box never showed. Covering the facts while a tip shows is expected. The tips rest
547
+ * `display: "none"` (so they take no pointer) and carry no `key`: a keyed Box drawn `display: "none"`
548
+ * is refused as a hover scope (the pointer could never be over it), so each tip reveals through its
549
+ * own `hover.scope`, which the fact's or control's hover lights.
536
550
  */
537
- function whyDrawn(el: Els, frame: Frame, tips: { key: string; text: string }[]): RenderNode {
551
+ function whyDrawn(el: Els, frame: Frame, tips: { key: string; text: string }[], at: number): RenderNode {
538
552
  const { Box, Text } = el
553
+ // Placed Box offsets are whole cells; the layer starts at the first cell past 리플이 and the effort.
554
+ const left = Math.ceil(at)
539
555
  const width = Math.max(40, frame.columns) * CELL_PX
540
556
  return (
541
- <Box key="band-why" position="relative" flexDirection="row" flexGrow={1} flexShrink={1} width={0} minWidth={0} overflow="hidden">
557
+ <Box key="band-why" position="absolute" top={0} bottom={0} left={left} right={0} minWidth={0} overflow="hidden">
542
558
  {tips.map(tip => (
543
- <Box key={'tip-' + tip.key} position="absolute" top={0} bottom={0} left={0} right={0} display="none" hover={{ scope: tipScope(tip.key), display: 'flex' }} overflow="hidden">
559
+ <Box position="absolute" top={0} bottom={0} left={0} right={0} display="none" hover={{ scope: tipScope(tip.key), display: 'flex' }} overflow="hidden">
544
560
  <Box position="absolute" top={0} left={0}>
545
561
  {pic(el, 'tip-plate-' + tip.key, plateArt(width, ROW_PX), tip.text, width, ROW_PX)}
546
562
  </Box>
@@ -590,17 +606,21 @@ function controlsDrawn(el: Els, frame: Frame, view: DashboardView, actions: Band
590
606
  const look = advised ? 'primary' : 'ghost'
591
607
  out.push({ key: 'handoff', cells: pillArt(label, look, 'violet').cells + 1, node: pill(el, 'handoff', label, look, actions.handoff, 'violet'), tip: t(L, 'alerts.handoffTitle') })
592
608
  }
609
+ // Auto's word is the control (art.ts `wordArt`): lit violet while on, a quiet ghost while off,
610
+ // pulsing while the judge decides. No switch: the state is in the word's own light (2026-10-14).
593
611
  const autoLabel = t(L, 'band.auto')
594
612
  const autoTip = t(L, 'band.effortAutoAlt', { state: state(view.auto) }) + ' · ' + t(L, 'settings.autoLine')
595
- out.push(words.includes('auto')
596
- ? { key: 'auto', cells: toggleCells(autoLabel), node: toggle(el, 'auto', view.auto, autoLabel, actions.toggleAuto, view.effort.mode === 'deciding', tipScope('auto')), tip: autoTip }
597
- : { key: 'auto', cells: SWITCH_ONLY, node: lit(el, 'auto', SWITCH_ONLY, [pic(el, 'auto', switchArt(view.auto, view.effort.mode === 'deciding'), autoLabel, 36, 22), press(el, 'auto', SWITCH_ONLY * CELL_PX, actions.toggleAuto)], 0.25, tipScope('auto')), tip: autoTip })
613
+ out.push({ key: 'auto', cells: wordCells(autoLabel), node: word(el, 'auto', view.auto, autoLabel, actions.toggleAuto, view.effort.mode === 'deciding', tipScope('auto')), tip: autoTip })
598
614
  const warmLabel = t(L, 'band.warm')
599
615
  out.push(iconControl(el, 'warm', iconArt('flame', view.warm.on, 'amber'), ICON_PX, warmLabel, t(L, 'band.warmAlt', { state: state(view.warm.on) }) + ' · ' + t(L, view.warm.here ? 'band.warmHere' : 'settings.warmLine'), actions.toggleWarm, words.includes('warm') ? warmLabel : undefined, view.warm.on ? TONE.amber.text : SUB))
600
616
  const agentsLabel = t(L, 'band.agents')
601
617
  const live = view.agents.live
602
618
  // The live count sits on the icon's shoulder, in one picture of one width: no width change.
603
619
  out.push(iconControl(el, 'agents', agentsArt(live, view.agentsOpen || live > 0), AGENTS_W, live > 0 ? agentsLabel + ' ' + live : agentsLabel, t(L, 'band.agentsAlt', { count: live }), actions.toggleAgents, words.includes('agents') ? agentsLabel : undefined, view.agentsOpen ? INK : SUB))
620
+ const compactLabel = t(L, 'band.compact')
621
+ out.push(iconControl(el, 'compact', iconArt('compact', false), ICON_PX, compactLabel, t(L, 'band.compactAlt'), actions.compact, words.includes('compact') ? compactLabel : undefined))
622
+ const clearLabel = t(L, 'band.clear')
623
+ out.push(iconControl(el, 'clear', iconArt('clear', false), ICON_PX, clearLabel, t(L, 'band.clearAlt'), actions.clear, words.includes('clear') ? clearLabel : undefined))
604
624
  const settingsLabel = t(L, 'band.settings')
605
625
  out.push(iconControl(el, 'settings', iconArt('gear', false), ICON_PX, settingsLabel, t(L, 'band.settingsAlt'), actions.openSettings, words.includes('settings') ? settingsLabel : undefined))
606
626
  return out
@@ -611,7 +631,7 @@ function effortMenuDrawn(el: Els, frame: Frame, view: DashboardView, actions: Ba
611
631
  const current = view.effort.mode === 'manual' || view.effort.mode === 'off' ? view.effort.level : null
612
632
  return (
613
633
  <Box key="effort-menu" flexDirection="row" alignItems="center" flexWrap="wrap" columnGap={2} rowGap={1} paddingTop={1} paddingLeft={CREST_CELLS}>
614
- {toggle(el, 'pick-auto', view.auto, t(frame.lang, 'band.auto'), actions.toggleAuto)}
634
+ {word(el, 'pick-auto', view.auto, t(frame.lang, 'band.auto'), actions.toggleAuto)}
615
635
  {segmented(el, 'pick', EFFORTS.map(effort => ({ value: effort, label: effortName(effort, frame.lang) })), current, effort => actions.pickEffort(effort), 'violet', DEPTH)}
616
636
  </Box>
617
637
  )
@@ -628,10 +648,17 @@ function bandDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActi
628
648
  const close = actions.hide ? CLOSE_CELLS : 0
629
649
  const effort = effortDrawn(el, frame, view, actions)
630
650
  const optional = [modeDrawn(el, frame, view), contextDrawn(el, frame, view), cacheDrawn(el, frame, view), limitDrawn(el, frame, view)].filter((f): f is DrawnFact => f !== null)
631
- let words: readonly string[] = ['auto', ...WORDLESS]
651
+ // The Auto word is its control: it never loses its shape, so no word of it is dropped, and the
652
+ // spare words (the icons' beside it) go first when the row narrows.
653
+ let words: readonly string[] = [...WORDLESS]
632
654
  // Counted with Handoff in its place whether it shows or not, and the route at a fixed room
633
655
  // (a long model name is cut, a short one leaves air): what the band holds never depends on a value.
634
- const counted = (w: readonly string[]) => controlsDrawn(el, frame, { ...view, handoff: 'advised' }, actions, w).reduce((n, c) => n + c.cells + 0.5, 0)
656
+ // The air between controls is the row's own columnGap (a fraction of a cell, as the facts' air),
657
+ // and the hide button a half-separator of its own.
658
+ const counted = (w: readonly string[]) => {
659
+ const list = controlsDrawn(el, frame, { ...view, handoff: 'advised' }, actions, w)
660
+ return list.reduce((n, c) => n + c.cells, 0) + 0.15 * (list.length - 1) + (actions.hide ? 0.15 : 0)
661
+ }
635
662
  let controlCells = counted(words)
636
663
  let route = view.hide.includes('route') ? 0 : ROUTE_ROOM
637
664
  let ruled = true
@@ -639,16 +666,17 @@ function bandDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActi
639
666
  const rules = (facts: DrawnFact[]) => (ruled ? (facts.length ? RULE_CELLS + DRAWN_GAP : 0) + (route ? RULE_CELLS + DRAWN_GAP : 0) : 0)
640
667
  const need = (facts: DrawnFact[]) => CREST_CELLS + DRAWN_GAP + effort.cells + DRAWN_GAP + rules(facts) + span(facts) + (route ? route + DRAWN_GAP : 0) + controlCells + close + 1
641
668
  let facts = optional
642
- // Narrow, things go in this order until the row fits: the controls' words (settings, agents,
643
- // keep-warm), the rules, the standing facts (the plan limit, the mode, context), Auto's word, then
644
- // the route and last the cache. A changing figure outlasts a standing state.
645
- for (const drop of [...WORDLESS, 'rules', 'limit', 'mode', 'context', 'auto', 'route', 'cache']) {
669
+ // Narrow, things go in this order until the row fits: the icons' words (clear, compact,
670
+ // settings, agents, keep-warm), the rules, then the route draws in to its least, then the mode
671
+ // and the context chip, last the cache. The limit and the new icons stay (2026-10-14: the band
672
+ // sits at its ~100-column maximum and must hold everything there, Handoff with the rest).
673
+ for (const drop of [...WORDLESS, 'rules', 'route', 'mode', 'context', 'cache']) {
646
674
  if (need(facts) <= cols) break
647
- if ((WORDLESS as readonly string[]).includes(drop) || drop === 'auto') {
675
+ if ((WORDLESS as readonly string[]).includes(drop)) {
648
676
  words = words.filter(w => w !== drop)
649
677
  controlCells = counted(words)
650
678
  } else if (drop === 'rules') ruled = false
651
- else if (drop === 'route') route = 0
679
+ else if (drop === 'route') route = RICH_ROUTE_MIN
652
680
  else facts = facts.filter(f => f.key !== drop)
653
681
  }
654
682
  const controls = controlsDrawn(el, frame, view, actions, words)
@@ -662,7 +690,7 @@ function bandDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActi
662
690
  {ripple(el, 'band-' + mood.face + (mood.dim ? '-dim' : ''), mood.face, 'ClaudeRipple', BAND_RIPPLE, undefined, mood.dim)}
663
691
  </Box>
664
692
  )
665
- const routeNode = route > 0 ? routeDrawn(el, frame, view, actions) : ''
693
+ const routeNode = route > 0 ? routeDrawn(el, frame, view, actions, route) : ''
666
694
  const row = (
667
695
  <Box key="band-row" flexDirection="row" alignItems="center" width="100%" minWidth={0} height={rowsOf(ROW_H)}>
668
696
  <Box key="band-facts" flexDirection="row" alignItems="center" columnGap={DRAWN_GAP} flexGrow={1} flexShrink={1} width={0} minWidth={0}>
@@ -672,9 +700,9 @@ function bandDrawn(el: Els, frame: Frame, view: DashboardView, actions: BandActi
672
700
  {facts.map(f => f.node)}
673
701
  {ruled && routeNode ? rule(el, 'route') : null}
674
702
  {routeNode}
675
- {whyDrawn(el, frame, tips)}
703
+ {whyDrawn(el, frame, tips, CREST_CELLS + DRAWN_GAP + effort.cells + DRAWN_GAP)}
676
704
  </Box>
677
- <Box key="band-controls" flexDirection="row" alignItems="center" columnGap={0.5} flexShrink={0} marginLeft={1}>
705
+ <Box key="band-controls" flexDirection="row" alignItems="center" columnGap={0.15} flexShrink={0} marginLeft={0.5}>
678
706
  {controls.map(c => c.node)}
679
707
  {actions.hide ? closeButton(el, 'hide', t(frame.lang, 'band.hideBand'), () => actions.hide!(), tipScope('hide')) : null}
680
708
  </Box>
@@ -22,7 +22,7 @@ import { rippleSource } from './parts'
22
22
  import type { Els } from './parts'
23
23
  import type { Face } from './ripple'
24
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'
25
+ import { CELL_PX, CLOSE_PX, INK, ROW_PX, SUB, SWITCH_H, SWITCH_W, WELL_PX, closeArt, extraHoverArt, labelPx, pillArt, seaArt, segmentedArt, sliderArt, switchArt, wellArt, wordArt } from './art'
26
26
  import type { Glyph, PillLook, Sea, Tone } from './art'
27
27
  import { chOf } from './theme'
28
28
 
@@ -35,7 +35,7 @@ const EDGE = 'rgba(128,124,116,0.55)'
35
35
  /** A control under the pointer: a soft light on the water. */
36
36
  const HOVER = 'rgba(255,255,255,0.09)'
37
37
  const HOVER_EDGE = 'rgba(255,255,255,0.16)'
38
- /** A drawn control's height, px: pills, segments. */
38
+ /** A drawn control's height, px: pills, segments (art.ts holds the same figure). */
39
39
  const CONTROL_PX = 26
40
40
 
41
41
  /** Cells a run of px takes, rounded up. */
@@ -116,6 +116,14 @@ export function lit(el: Els, key: string, cells: number, children: RenderNode[],
116
116
  )
117
117
  }
118
118
 
119
+ /** The drawn light over `cells`, in absolute Boxes of whole cells each. */
120
+ function extraHover(el: Els, cells: number): RenderNode[] {
121
+ const { Box } = el
122
+ const parts: RenderNode[] = []
123
+ for (let i = 0; i < cells; i++) parts.push(<Box key={'extra-' + i} position="absolute" top={0} bottom={0} left={i} width={1}>{pic(el, 'extra-' + i, extraHoverArt(CELL_PX), '', CELL_PX, CONTROL_PX)}</Box>)
124
+ return parts
125
+ }
126
+
119
127
  /**
120
128
  * A capsule: a rounded Box edged in violet light, the deep (art.ts `seaArt`) painted behind it from
121
129
  * its top left, and everything it holds in one positioned layer above the sea. `rows` is about how
@@ -161,9 +169,22 @@ export function pill(el: Els, key: string, label: string, look: PillLook, onPres
161
169
  /** Cells the way out takes. */
162
170
  export const CLOSE_CELLS = cellsOf(CLOSE_PX + 4)
163
171
 
164
- /** The way out: a soft disc with a ×, `label` its name for a reader that cannot see it. */
172
+ /**
173
+ * The way out: a soft disc with a ×, `label` its name for a reader that cannot see it. The disc is
174
+ * its own light under the pointer: `lit`'s rounded box around it read as a second button
175
+ * (2026-10-14).
176
+ */
165
177
  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)
178
+ const { Box } = el
179
+ return (
180
+ <Box key={'hv-' + key} position="relative" flexDirection="row" alignItems="center" justifyContent="center" width={CLOSE_CELLS} flexShrink={0} {...(scope ? { hover: { scope } } : {})}>
181
+ {pic(el, key, closeArt(), label, CLOSE_PX, CLOSE_PX)}
182
+ <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center" display="none" hover={{ display: 'flex' }}>
183
+ {pic(el, key + '-lit', closeArt(true), label, CLOSE_PX, CLOSE_PX)}
184
+ </Box>
185
+ {press(el, key, CLOSE_CELLS * CELL_PX, onPress)}
186
+ </Box>
187
+ )
167
188
  }
168
189
 
169
190
  /** Cells a switch and its word take, their hover's air included. */
@@ -171,6 +192,30 @@ export function toggleCells(label: string): number {
171
192
  return Math.ceil((SWITCH_W + CELL_PX + chOf(label) * CELL_PX * 1.1) / CELL_PX) + 1
172
193
  }
173
194
 
195
+ /** Cells the Auto word takes as its own control: its picture's width (the pill's own air drawn in). */
196
+ export function wordCells(label: string): number {
197
+ return wordArt(label, true).cells
198
+ }
199
+
200
+ /**
201
+ * The Auto word as the control itself (art.ts `wordArt`): lit violet while Auto is on, a quiet
202
+ * ghost while off, pulsing while the judge decides; the whole pressable.
203
+ */
204
+ export function word(el: Els, key: string, on: boolean, label: string, onPress: () => void, busy = false, scope?: string): RenderNode {
205
+ const { Box } = el
206
+ const art = wordArt(label, on, busy)
207
+ return (
208
+ <Box key={(on ? 'word-on-' : 'ghost-') + key} position="relative" width={art.cells} height={rowsOf(CONTROL_PX)} flexShrink={0} alignItems="center" justifyContent="center">
209
+ {pic(el, key, art.source, label, art.cells * CELL_PX, CONTROL_PX)}
210
+ <Box position="absolute" top={0} bottom={0} left={0} right={0} display="none" hover={{ scope }}>
211
+ {lift(el)}
212
+ {extraHover(el, art.cells)}
213
+ </Box>
214
+ {press(el, key, art.cells * CELL_PX, onPress)}
215
+ </Box>
216
+ )
217
+ }
218
+
174
219
  /** A switch and its word (On / Off), the whole pressable; lit while on. */
175
220
  export function toggle(el: Els, key: string, on: boolean, label: string, onPress: () => void, busy = false, scope?: string): RenderNode {
176
221
  const { Text } = el
@@ -95,6 +95,7 @@ export type BandActions = {
95
95
  toggleAuto: () => void
96
96
  toggleWarm: () => void
97
97
  compact: () => void
98
+ clear: () => void
98
99
  handoff: () => void
99
100
  toggleLog: () => void
100
101
  toggleAgents: () => void
@@ -125,11 +126,15 @@ export type AlertView =
125
126
  | { kind: 'result'; stage: ResultStage; handoffKind?: HandoffKind; detail?: string }
126
127
  /** Compact with an optional note of what to keep. */
127
128
  | { kind: 'compactNote'; draft: string }
129
+ /** The band's Clear asks before the conversation is started over. */
130
+ | { kind: 'clear' }
128
131
  /** Quick or full handoff, and what happens after. */
129
132
  | { kind: 'handoffChoice'; handoffKind: HandoffKind; after: Settings['handoffAfter'] }
130
133
 
131
134
  export type AlertActions = {
132
135
  compact: (note?: string) => void
136
+ /** Runs the conversation's clear, from the Clear confirm card. */
137
+ clear: () => void
133
138
  /** Opens the handoff choice bar. */
134
139
  handoff: () => void
135
140
  dismiss: () => void
@@ -93,8 +93,8 @@ export function scoreModels(raw: unknown): ModelScore[] {
93
93
  * Allow-lists name worker agents, and Claude's own models as `general-purpose:<model>`; provider
94
94
  * names from settings saved before 2026-10-10 still match.
95
95
  */
96
- export function allowed(candidate: WorkerCandidate, allow: readonly string[]): boolean {
97
- return isAllowed(candidateKey(candidate.target), candidate.provider, allow)
96
+ export function allowed(candidate: WorkerCandidate, allow: readonly string[], known: readonly string[] = []): boolean {
97
+ return isAllowed(candidateKey(candidate.target), candidate.provider, allow, known)
98
98
  }
99
99
  /** Claude's own models as candidates: general-purpose on each. */
100
100
  export function claudeCandidates(): WorkerCandidate[] {
@@ -106,16 +106,16 @@ const BILLINGS: readonly Billing[] = ['free', 'payg', 'subscription']
106
106
  * provider can run now, with ladders, billing and cheapest measured cost, then Claude's own
107
107
  * models; only the ticked ones (`allow`, empty for every model).
108
108
  */
109
- export function ladderOptionsFrom(raw: unknown, until: number, allow: readonly string[] = []): LadderOptions {
109
+ export function ladderOptionsFrom(raw: unknown, until: number, allow: readonly string[] = [], allowKnown: readonly string[] = []): LadderOptions {
110
110
  const root = record(raw)
111
111
  const efforts = (value: unknown): Effort[] => list(value).filter((e): e is Effort => EFFORTS.includes(e as Effort))
112
112
  const usable = new Set(list(root.providers).flatMap(p => { const v = record(p); return typeof v.name === 'string' && v.usable !== false ? [v.name] : [] }))
113
- const known = list(root.providers).length > 0
113
+ const catalogue = list(root.providers).length > 0
114
114
  const scores = scoreModels(root.scores)
115
115
  const choices: LadderChoice[] = list(root.agents).flatMap(value => {
116
116
  const a = record(value)
117
117
  if (typeof a.name !== 'string' || !a.name || typeof a.model !== 'string' || typeof a.provider !== 'string') return []
118
- if (known && !usable.has(a.provider)) return []
118
+ if (catalogue && !usable.has(a.provider)) return []
119
119
  const costs = (scores.find(s => s.model === a.model && s.provider === a.provider)?.variants ?? []).flatMap(v => v.costPerTask === null ? [] : [v.costPerTask])
120
120
  const billing = BILLINGS.includes(a.billing as Billing) ? a.billing as Billing : 'payg'
121
121
  return [{ target: { agent: a.name }, model: a.model, provider: a.provider, billing, efforts: efforts(a.efforts), ...(costs.length ? { cost: Math.min(...costs) } : {}) }]
@@ -125,7 +125,7 @@ export function ladderOptionsFrom(raw: unknown, until: number, allow: readonly s
125
125
  if (typeof p.name !== 'string' || typeof p.usable !== 'boolean') return []
126
126
  return [[p.name, { usable: p.usable, ...(typeof p.reason === 'string' ? { reason: p.reason } : {}), ...(typeof p.exhaustedUntil === 'number' ? { exhaustedUntil: p.exhaustedUntil } : {}) }]]
127
127
  }))
128
- return { until, choices: tickedChoices([...choices, ...claudeChoices(efforts(root.claudeEfforts))], allow), providers }
128
+ return { until, choices: tickedChoices([...choices, ...claudeChoices(efforts(root.claudeEfforts))], allow, allowKnown), providers }
129
129
  }
130
130
  export function candidateUsable(candidate: WorkerCandidate, settings: Settings, chatgpt: boolean, providers: NonNullable<LadderOptions['providers']> = {}): boolean {
131
131
  const live = providers[candidate.provider]
@@ -152,7 +152,7 @@ export const SESSION_MODEL: TierTarget = { agent: 'general-purpose' }
152
152
  * The ladder: the verdict's rung, else one rung up, else the session model, never down. Whatever
153
153
  * ran instead says why (the fallback toast).
154
154
  */
155
- export function ladderPick(verdict: WorkerVerdict, rule: Rule, settings: Settings, candidates: readonly WorkerCandidate[], chatgpt: boolean, lang: Lang, allow: readonly string[] = [], providers: NonNullable<LadderOptions['providers']> = {}): Resolution {
155
+ export function ladderPick(verdict: WorkerVerdict, rule: Rule, settings: Settings, candidates: readonly WorkerCandidate[], chatgpt: boolean, lang: Lang, allow: readonly string[] = [], providers: NonNullable<LadderOptions['providers']> = {}, known: readonly string[] = []): Resolution {
156
156
  const wanted = rungOf(verdict)
157
157
  const skipped: NonNullable<Resolution['skipped']> = []
158
158
  const reasons: string[] = []
@@ -163,7 +163,7 @@ export function ladderPick(verdict: WorkerVerdict, rule: Rule, settings: Setting
163
163
  for (const [index, step] of steps.entries()) {
164
164
  const target = step.target
165
165
  const candidate = candidateOf(target, candidates)
166
- const ticked = target.agent === 'general-purpose' && !target.model || candidate && allowed(candidate, allow)
166
+ const ticked = target.agent === 'general-purpose' && !target.model || candidate && allowed(candidate, allow, known)
167
167
  if (candidate && candidateUsable(candidate, settings, chatgpt, providers) && ticked) {
168
168
  return { target: { ...target }, effort: step.effort, rung, position: index + 1, skipped, ...(reasons.length ? { fallbackReason: reasons.join('; ') } : {}) }
169
169
  }
@@ -1184,6 +1184,16 @@ for (const surface of ['terminal', 'desktop'] as const) test('quick controls sav
1184
1184
  await ui.unmount()
1185
1185
  })
1186
1186
 
1187
+ test('ripple-bar says where the line is when the layout is minimal', async ($, on) => {
1188
+ const b = boundaries(on, { layout: 'minimal' })
1189
+ http(on)
1190
+ await start($)
1191
+ const run = () => $.command.run({ command: 'ripple-bar', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: '' })
1192
+ expect((await run()).text).toContain('hidden')
1193
+ expect(b.saved.get('bandHidden')).toBe(true)
1194
+ expect((await run()).text).toContain('Minimal')
1195
+ })
1196
+
1187
1197
  for (const surface of ['terminal', 'desktop'] as const) test('quick settings select a saved mode, never edit rungs, on ' + surface, async ($, on) => {
1188
1198
  const b = boundaries(on, { modes: BURN })
1189
1199
  http(on)
@@ -1743,14 +1753,16 @@ test('every band control changes the next drawing, and a toggle says its state o
1743
1753
  http(on)
1744
1754
  await start($)
1745
1755
  await b.clock.advance(3000)
1746
- // The terminal says it in the label (● / ○); the desktop in the control's drawing: a switch's lit
1747
- // track, a lit icon, each picture marked `on` or `off` at its root.
1756
+ // The terminal says it in the label (● / ○); the desktop in the control's drawing: a lit icon,
1757
+ // a lit word, each picture marked `on` or `off` at its root.
1748
1758
  const desktopState = (n: unknown) => {
1749
1759
  const drawing = JSON.stringify(n ?? '')
1750
1760
  return /<svg[^>]* class=\\"on\\"/.test(drawing) ? 'on' : /<svg[^>]* class=\\"off\\"/.test(drawing) ? 'off' : 'other'
1751
1761
  }
1752
1762
  const state = async (ui: Awaited<ReturnType<typeof band>>, key: string, surface: string) =>
1753
- surface === 'desktop' ? desktopState(await ui.find({ type: 'Box', key: 'hv-' + key })) : (n => /●/.test(String(n?.props.label)) ? 'on' : /○/.test(String(n?.props.label)) ? 'off' : 'other')(await ui.find({ type: 'Button', key }))
1763
+ surface === 'desktop'
1764
+ ? desktopState(await ui.find({ type: 'Box', key: key === 'auto' ? 'word-on-auto' : 'hv-' + key }) ?? await ui.find({ type: 'Box', key: 'ghost-' + key }))
1765
+ : (n => /●/.test(String(n?.props.label)) ? 'on' : /○/.test(String(n?.props.label)) ? 'off' : 'other')(await ui.find({ type: 'Button', key }))
1754
1766
  for (const surface of ['terminal', 'desktop'] as const) {
1755
1767
  const ui = await band($, surface)
1756
1768
  const drawn = async () => JSON.stringify(await ui.drawn()).replace(/"handle":\d+/g, '')
@@ -1781,3 +1793,29 @@ test('every band control changes the next drawing, and a toggle says its state o
1781
1793
  await ui.unmount()
1782
1794
  }
1783
1795
  })
1796
+
1797
+ // 2026-10-14: the band's Clear icon asks before it runs /clear; Cancel leaves the chat alone.
1798
+ test('the band Clear asks first, and confirming runs the clear once', async ($, on) => {
1799
+ const b = boundaries(on)
1800
+ http(on)
1801
+ const commands: string[] = []
1802
+ on('command.run', { command: 'clear' }, () => { commands.push('clear'); return {} })
1803
+ await start($)
1804
+ const ui = await band($, 'desktop')
1805
+ expect(await ui.find({ key: 'clear' })).toBeDefined()
1806
+ await ui.press({ key: 'clear' })
1807
+ expect(commands).toEqual([])
1808
+ expect(await ui.find({ type: 'Text', text: 'Start this conversation over?' })).toBeDefined()
1809
+ expect(await ui.find({ type: 'Text', text: /context is emptied/ })).toBeDefined()
1810
+ // Cancel keeps the conversation and closes the card.
1811
+ await ui.press({ key: 'dismiss' })
1812
+ expect(commands).toEqual([])
1813
+ expect(await ui.find({ type: 'Text', text: 'Start this conversation over?' })).toBeUndefined()
1814
+ // The main action runs the clear once.
1815
+ await ui.press({ key: 'clear' })
1816
+ await ui.press({ key: 'clear-go' })
1817
+ await b.clock.advance(3000)
1818
+ expect(commands).toEqual(['clear'])
1819
+ expect(await ui.find({ type: 'Text', text: 'Start this conversation over?' })).toBeUndefined()
1820
+ await ui.unmount()
1821
+ })