clauderipple 0.9.3 → 0.9.5

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.5 — 2026-10-14
4
+
5
+ ### Fixed
6
+
7
+ - **Routed reasoning models keep their train of thought between tool calls.** The router dropped
8
+ every translated model's previous reasoning, so on each tool call Muse, GPT and DeepSeek reasoned
9
+ the whole task again from scratch. Workers took minutes per step, kept re-deriving the same plan,
10
+ and sometimes stopped with no answer. The model's own reasoning now travels in Claude Code's
11
+ transcript and goes back to it unchanged, the way OpenCode's and Codex's own clients do it. On four
12
+ real fixes graded by their hidden tests, Muse Spark 1.3 at xhigh finished in 90–584 seconds, where
13
+ 0.9.4 took up to 13 minutes and twice ran past 30 minutes without finishing. Switching a session
14
+ to a Claude model still works: the carried reasoning is left out of requests to Anthropic.
15
+
16
+ ## 0.9.4 — 2026-10-14
17
+
18
+ ### Fixed
19
+
20
+ - **Reasoning models are no longer cut off after five minutes of thinking.** Node's `fetch` dropped a
21
+ response that sent nothing for 300 seconds. Muse Spark 1.3 at xhigh thinks for minutes in silence
22
+ on a large context, so long steps failed with 529 at 303–305 seconds and started over. The router
23
+ now waits up to 15 minutes. OpenCode's own CLI had no such limit, which is why the same model
24
+ worked there.
25
+ - **OpenCode Go and Zen models get their real context window.** They fell back to the global
26
+ 258,400, so Claude Code compacted DeepSeek V4.1 Flash, GLM-5.3 and Muse Spark 1.3 at a quarter of
27
+ their 1M windows. The router now reads the window from the same models.dev catalogue OpenCode's
28
+ CLI uses, and fills models already saved without one.
29
+ - **A ChatGPT account whose limit was reset or topped up is usable again right away.** It stayed
30
+ "cooling" until the old reset time, so the app could not switch to GPT models.
31
+ - **Adding an OpenAI-compatible provider such as B.ai works.** The connection check asked for one
32
+ token, which some vendors refuse ("max_tokens must be greater than 2", #57).
33
+ - **The plan limit shows on the band**, and every control's tip shows on hover even when the band
34
+ is full.
35
+
36
+ ### Changed
37
+
38
+ - **The band:** Auto is a word that lights when on, instead of a switch. Compact and Clear (with a
39
+ confirm) sit beside settings. The limit draws as a small gauge and its percent.
40
+ - **Workers read and edit with the dedicated tools.** Generated worker agents are told to read files
41
+ with Read and change them with Edit, not with `sed`/`cat` through Bash.
42
+ - `/ripple debug` also shows the model, the limit and the host's rate limits.
43
+
3
44
  ## 0.9.3 — 2026-10-14
4
45
 
5
46
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "type": "commonjs"
5
5
  }
@@ -63,6 +63,9 @@ const EN = {
63
63
  'band.warm': 'Warm',
64
64
  'band.handoff': 'Handoff',
65
65
  'band.compact': 'Compact',
66
+ 'band.clear': 'Clear',
67
+ 'band.compactAlt': 'Compact — summarise the conversation to free context',
68
+ 'band.clearAlt': 'New start (/clear) — clear the conversation and start fresh',
66
69
  'band.log': 'Log',
67
70
  'band.agents': 'Agents',
68
71
  'band.settings': 'Settings',
@@ -152,6 +155,9 @@ const EN = {
152
155
  'alerts.demoDown': 'Jev out of credits (demo)',
153
156
  'alerts.copied': 'The resume note was copied; this chat was kept.',
154
157
  'alerts.compactFailed': 'Compaction did not finish.',
158
+ 'alerts.clearTitle': 'Start this conversation over?',
159
+ 'alerts.clearBody': 'The conversation\'s context is emptied; a fresh chat starts with nothing.',
160
+ 'alerts.clearGo': 'Start over',
155
161
  // Settings.
156
162
  'settings.effortBlurb': 'How Auto picks the effort for each prompt you send.',
157
163
  'settings.workers': 'Workers',
@@ -513,6 +519,9 @@ const KO: Record<Key, string> = {
513
519
  'band.warm': '캐시 유지',
514
520
  'band.handoff': '인수인계',
515
521
  'band.compact': '압축',
522
+ 'band.clear': '새로 시작',
523
+ 'band.compactAlt': '대화 압축 — 지금까지 대화를 요약해 컨텍스트를 줄입니다',
524
+ 'band.clearAlt': '새로 시작 (/clear) — 대화 맥락을 비우고 새로 시작합니다',
516
525
  'band.log': '로그',
517
526
  'band.agents': '에이전트',
518
527
  'band.settings': '설정',
@@ -602,6 +611,9 @@ const KO: Record<Key, string> = {
602
611
  'alerts.demoDown': 'Jev 크레딧 소진 (데모)',
603
612
  'alerts.copied': '재개용 요약 메모를 복사했습니다. 현재 대화는 보존됩니다.',
604
613
  'alerts.compactFailed': '대화 압축을 마치지 못했습니다.',
614
+ 'alerts.clearTitle': '대화를 새로 시작할까요?',
615
+ 'alerts.clearBody': '지금 대화 맥락이 비워집니다. 새 대화가 아무것도 없는 상태에서 시작됩니다.',
616
+ 'alerts.clearGo': '새로 시작',
605
617
  // Settings.
606
618
  'settings.effortBlurb': '전송하는 프롬프트마다 추론 강도를 자동으로 선택합니다.',
607
619
  'settings.workers': '워커',
@@ -961,6 +973,9 @@ const ZH: Record<Key, string> = {
961
973
  'band.warm': '保温',
962
974
  'band.handoff': '交接',
963
975
  'band.compact': '压缩',
976
+ 'band.clear': '新开',
977
+ 'band.compactAlt': '压缩对话 — 总结对话以释放上下文',
978
+ 'band.clearAlt': '重新开始 (/clear) — 清空对话上下文,重新开始',
964
979
  'band.log': '日志',
965
980
  'band.agents': '代理',
966
981
  'band.settings': '设置',
@@ -1050,6 +1065,9 @@ const ZH: Record<Key, string> = {
1050
1065
  'alerts.demoDown': 'Jev 额度用尽(演示)',
1051
1066
  'alerts.copied': '已复制续接说明,此对话已保留。',
1052
1067
  'alerts.compactFailed': '压缩未完成。',
1068
+ 'alerts.clearTitle': '重新开始此对话?',
1069
+ 'alerts.clearBody': '当前对话的上下文将被清空;新对话从空白开始。',
1070
+ 'alerts.clearGo': '重新开始',
1053
1071
  // Settings.
1054
1072
  'settings.effortBlurb': '自动模式如何为你发送的每条提示词选择推理强度。',
1055
1073
  'settings.workers': 'Worker',
@@ -1409,6 +1427,9 @@ const JA: Record<Key, string> = {
1409
1427
  'band.warm': '保持',
1410
1428
  'band.handoff': '引き継ぎ',
1411
1429
  'band.compact': '圧縮',
1430
+ 'band.clear': '新しく開始',
1431
+ 'band.compactAlt': '圧縮 — これまでの会話を要約してコンテキストを減らします',
1432
+ 'band.clearAlt': '新しく開始 (/clear) — 会話の文脈を空にして新しく始めます',
1412
1433
  'band.log': 'ログ',
1413
1434
  'band.agents': 'エージェント',
1414
1435
  'band.settings': '設定',
@@ -1498,6 +1519,9 @@ const JA: Record<Key, string> = {
1498
1519
  'alerts.demoDown': 'Jev のクレジット切れ(デモ)',
1499
1520
  'alerts.copied': '再開メモをコピーしました。この会話はそのままです。',
1500
1521
  'alerts.compactFailed': '圧縮が完了しませんでした。',
1522
+ 'alerts.clearTitle': 'この会話を新しく始めますか?',
1523
+ 'alerts.clearBody': '今の会話のコンテキストは空になります。新しい会話は何もない状態から始まります。',
1524
+ 'alerts.clearGo': '新しく開始',
1501
1525
  // Settings.
1502
1526
  'settings.effortBlurb': '送信するプロンプトごとに、自動が推論強度を選ぶ方法。',
1503
1527
  'settings.workers': 'ワーカー',
@@ -1857,6 +1881,9 @@ const ES: Record<Key, string> = {
1857
1881
  'band.warm': 'Mantener',
1858
1882
  'band.handoff': 'Traspaso',
1859
1883
  'band.compact': 'Compactar',
1884
+ 'band.clear': 'Reiniciar',
1885
+ 'band.compactAlt': 'Compactar — resume la conversación para liberar contexto',
1886
+ 'band.clearAlt': 'Empezar de nuevo (/clear) — vacía la conversación y empieza desde cero',
1860
1887
  'band.log': 'Registro',
1861
1888
  'band.agents': 'Agentes',
1862
1889
  'band.settings': 'Ajustes',
@@ -1946,6 +1973,9 @@ const ES: Record<Key, string> = {
1946
1973
  'alerts.demoDown': 'Jev sin créditos (demo)',
1947
1974
  'alerts.copied': 'Se copió la nota para continuar; esta conversación se conservó.',
1948
1975
  'alerts.compactFailed': 'La compactación no terminó.',
1976
+ 'alerts.clearTitle': '¿Empezar esta conversación de nuevo?',
1977
+ 'alerts.clearBody': 'El contexto de esta conversación se vacía; una conversación nueva empieza de cero.',
1978
+ 'alerts.clearGo': 'Empezar de nuevo',
1949
1979
  // Settings.
1950
1980
  'settings.effortBlurb': 'Cómo Auto elige el esfuerzo de cada prompt que envías.',
1951
1981
  'settings.workers': 'Workers',
@@ -2305,6 +2335,9 @@ const PT: Record<Key, string> = {
2305
2335
  'band.warm': 'Manter',
2306
2336
  'band.handoff': 'Repasse',
2307
2337
  'band.compact': 'Compactar',
2338
+ 'band.clear': 'Recomeçar',
2339
+ 'band.compactAlt': 'Compactar — resume a conversa para liberar contexto',
2340
+ 'band.clearAlt': 'Começar de novo (/clear) — limpa a conversa e começa do zero',
2308
2341
  'band.log': 'Log',
2309
2342
  'band.agents': 'Agentes',
2310
2343
  'band.settings': 'Ajustes',
@@ -2394,6 +2427,9 @@ const PT: Record<Key, string> = {
2394
2427
  'alerts.demoDown': 'Jev sem créditos (demo)',
2395
2428
  'alerts.copied': 'A nota para continuar foi copiada; esta conversa foi mantida.',
2396
2429
  'alerts.compactFailed': 'A compactação não terminou.',
2430
+ 'alerts.clearTitle': 'Começar esta conversa de novo?',
2431
+ 'alerts.clearBody': 'O contexto desta conversa é esvaziado; uma conversa nova começa do zero.',
2432
+ 'alerts.clearGo': 'Começar de novo',
2397
2433
  // Settings.
2398
2434
  'settings.effortBlurb': 'Como o Auto escolhe o esforço de cada prompt que você envia.',
2399
2435
  'settings.workers': 'Workers',
@@ -931,6 +931,9 @@ async function openCompact($: EngineInterface, surface: Frame['surface']): Promi
931
931
  await update($, compactNote, () => '')
932
932
  await update($, panel, () => 'compact')
933
933
  }
934
+ async function openClear($: EngineInterface): Promise<void> {
935
+ await update($, panel, () => 'clear')
936
+ }
934
937
  /** The router's figure for this login's plan windows; nothing when the router cannot say. */
935
938
  async function routerClaudeLimits($: EngineInterface): Promise<SessionRateLimit[]> {
936
939
  try {
@@ -1090,6 +1093,7 @@ async function alertView($: EngineInterface, now: number): Promise<AlertView | n
1090
1093
  kept: d === 'kept' || keptAlert(await read($, kept), m, now),
1091
1094
  swamp: d === 'swamp' || (s.swampAlert && !await read($, busy) && swampAlert(swamped(c, s.swampAt), await read($, swampHidden))) })
1092
1095
  if (which === 'compact') return { kind: 'compactNote', draft: await read($, compactNote) }
1096
+ if (which === 'clear') return { kind: 'clear' }
1093
1097
  if (which === 'handoff') return { kind: 'handoffChoice', handoffKind: await read($, handoffChoiceKind), after: await read($, handoffChoiceAfter) }
1094
1098
  if (which === 'judgeDown') return { kind: 'judgeDown', judge: s.judge, reason: d === 'down' ? t(language, 'alerts.demoDown') : downReason!, network: d !== 'down' && await read($, judgeDownNetwork) }
1095
1099
  if (which === 'hot') return { kind: 'hot', limitKind: h?.kind ?? 'five_hour', percent: d === 'hot' ? 86 : h!.percent, resetsAt: h?.resetsAt, save: (await read($, saveUntil) ?? 0) > now }
@@ -1127,7 +1131,7 @@ async function dismiss($: EngineInterface, view: AlertView): Promise<void> {
1127
1131
  else await update($, panel, () => null)
1128
1132
  }
1129
1133
  function bandActions($: EngineInterface, surface: Frame['surface']): BandActions {
1130
- return { toggleAuto: () => toggleAuto($), toggleWarm: () => toggleWarm($), compact: () => openCompact($, surface), handoff: () => openHandoff($), toggleLog: () => toggleLog($), toggleAgents: () => toggleAgents($), openSettings: () => startPanel($, 'settings'),
1134
+ return { toggleAuto: () => toggleAuto($), toggleWarm: () => toggleWarm($), compact: () => openCompact($, surface), clear: () => openClear($), handoff: () => openHandoff($), toggleLog: () => toggleLog($), toggleAgents: () => toggleAgents($), openSettings: () => startPanel($, 'settings'),
1131
1135
  toggleEffortMenu: () => update($, effortMenuOpen, value => !value), pickEffort: effort => manualPick($, effort) }
1132
1136
  }
1133
1137
  function settingsActions($: EngineInterface): SettingsActions {
@@ -1147,7 +1151,7 @@ function setupActions($: EngineInterface): SetupActions {
1147
1151
  back: () => update($, setupStep, step => Math.max(1, step - 1) as 1 | 2 | 3 | 4), next: () => update($, setupStep, step => Math.min(4, step + 1) as 1 | 2 | 3 | 4), finish: () => finishSetup($), skip: () => finishSetup($, true) }
1148
1152
  }
1149
1153
  function alertActions($: EngineInterface, view: AlertView): AlertActions {
1150
- return { compact: note => runCompact($, note), handoff: () => openHandoff($), dismiss: () => dismiss($, view), toggleSave: () => toggleSave($), openSettings: () => startPanel($, 'settings'),
1154
+ return { compact: note => runCompact($, note), clear: async () => { await update($, panel, () => null); await $.command.run({ command: 'clear', args: '' }) }, handoff: () => openHandoff($), dismiss: () => dismiss($, view), toggleSave: () => toggleSave($), openSettings: () => startPanel($, 'settings'),
1151
1155
  noteInput: text => update($, compactNote, () => text), setHandoffKind: kind => update($, handoffChoiceKind, () => kind), setHandoffAfter: after => update($, handoffChoiceAfter, () => after), go: () => queueHandoff($), copy: () => copyHandoff($), openNew: () => moveToNewSession($) }
1152
1156
  }
1153
1157
  function toolLine(e: ToolCallInput): string {
@@ -1457,7 +1461,7 @@ export const register: Register = on => {
1457
1461
  const language = await read($, lang)
1458
1462
  const [sub = 'settings', arg] = e.args.trim().toLowerCase().split(/\s+/)
1459
1463
  if (sub === 'stats') return { text: formatStats(await read($, stats), language) }
1460
- if (sub === 'debug') return { text: debugText(language, { judge: (await currentSettings($)).judge, judgeModel: (await currentSettings($)).judgeModel, pick: await read($, pick), paused: await read($, paused), cache: await read($, cache), warm: await read($, warm), lastSpawn: await read($, lastSpawn), lastRequest: await read($, lastRequest) }) }
1464
+ if (sub === 'debug') return { text: debugText(language, { judge: (await currentSettings($)).judge, judgeModel: (await currentSettings($)).judgeModel, pick: await read($, pick), paused: await read($, paused), cache: await read($, cache), warm: await read($, warm), lastSpawn: await read($, lastSpawn), lastRequest: await read($, lastRequest), model: await $.session.model(), limit: await read($, limit), hostRateLimits: (await $.session.usage()).rateLimits }) }
1461
1465
  if (sub === 'settings' || sub === 'setup') await startPanel($, sub)
1462
1466
  else if (sub === 'auto') await toggleAuto($)
1463
1467
  else if (sub === 'warm') await toggleWarm($)
@@ -1502,7 +1506,7 @@ export const register: Register = on => {
1502
1506
  const frame = await frameOf($, e)
1503
1507
  const el = $.ui.resolve(e)
1504
1508
  if (which === 'settings') return renderSettings(el, frame, await settingsView($), settingsActions($)) as RenderElement
1505
- if (which === 'compact' || which === 'handoff') {
1509
+ if (which === 'compact' || which === 'clear' || which === 'handoff') {
1506
1510
  const choice = await alertView($, frame.now)
1507
1511
  if (choice) return renderAlert(el, frame, choice, alertActions($, choice)) as RenderElement
1508
1512
  }
@@ -87,9 +87,9 @@ export function swampAlert(percent: number | null, hidden: number | null): boole
87
87
 
88
88
  /** Rendering consumes eligibility, keeping dismissal and idle decisions outside drawing code. */
89
89
  export function bandPriority(state: {
90
- panel?: 'settings' | 'compact' | 'handoff' | 'setup' | null
90
+ panel?: 'settings' | 'compact' | 'clear' | 'handoff' | 'setup' | null
91
91
  judgeDown?: boolean; hot?: boolean; result?: boolean; cold?: boolean; swamp?: boolean; kept?: boolean
92
- }): 'settings' | 'compact' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'kept' | 'dashboard' {
92
+ }): 'settings' | 'compact' | 'clear' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'kept' | 'dashboard' {
93
93
  if (state.panel) return state.panel
94
94
  for (const band of ['judgeDown', 'hot', 'result', 'cold', 'swamp', 'kept'] as const) if (state[band]) return band
95
95
  return 'dashboard'
@@ -114,6 +114,15 @@ function cardOf(frame: Frame, alert: AlertView, a: AlertActions): Card {
114
114
  main: { ...compact, onPress: () => a.compact(alert.draft) },
115
115
  close: cancel,
116
116
  }
117
+ case 'clear':
118
+ return {
119
+ key: 'clear',
120
+ face: 'asking',
121
+ title: t(L, 'alerts.clearTitle'),
122
+ body: t(L, 'alerts.clearBody'),
123
+ main: { key: 'clear-go', label: t(L, 'alerts.clearGo'), hotkey: 'g', onPress: a.clear },
124
+ close: cancel,
125
+ }
117
126
  case 'handoffChoice':
118
127
  return {
119
128
  key: 'handoff',
@@ -222,6 +231,7 @@ function seaOf(card: Card, alert: AlertView): Omit<Sea, 'at'> {
222
231
  case 'judge':
223
232
  return { tone: 'red', weather: 'static' }
224
233
  case 'note':
234
+ case 'clear':
225
235
  case 'handoff':
226
236
  return { tone: 'violet', weather: 'sparkles', ripples: 'calm' }
227
237
  default: {
@@ -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.
@@ -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
  /**