clauderipple 0.9.3 → 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.
- package/CHANGELOG.md +28 -0
- package/dist/app/package.json +1 -1
- package/dist/mod/hooks/i18n.ts +36 -0
- package/dist/mod/hooks/register.tsx +8 -4
- package/dist/mod/hooks/session.ts +2 -2
- package/dist/mod/hooks/ui/alerts.tsx +10 -0
- package/dist/mod/hooks/ui/art.ts +64 -1
- package/dist/mod/hooks/ui/band.tsx +70 -42
- package/dist/mod/hooks/ui/kit.tsx +34 -2
- package/dist/mod/hooks/ui/view.ts +5 -0
- package/dist/mod/tests/register.test.tsx +31 -3
- package/dist/mod/tests/ui.test.tsx +108 -18
- package/dist/mod/types/index.d.ts +1 -1
- package/dist/router/src/admin.js +65 -6
- package/dist/router/src/agents.js +5 -0
- package/dist/router/src/catalog.js +18 -8
- package/dist/router/src/index.js +13 -4
- package/dist/router/src/providers/chatgpt/index.js +8 -0
- package/dist/router/src/providers/retry.js +15 -0
- package/dist/router/src/version.js +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.4 — 2026-10-14
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Reasoning models are no longer cut off after five minutes of thinking.** Node's `fetch` dropped a
|
|
8
|
+
response that sent nothing for 300 seconds. Muse Spark 1.3 at xhigh thinks for minutes in silence
|
|
9
|
+
on a large context, so long steps failed with 529 at 303–305 seconds and started over. The router
|
|
10
|
+
now waits up to 15 minutes. OpenCode's own CLI had no such limit, which is why the same model
|
|
11
|
+
worked there.
|
|
12
|
+
- **OpenCode Go and Zen models get their real context window.** They fell back to the global
|
|
13
|
+
258,400, so Claude Code compacted DeepSeek V4.1 Flash, GLM-5.3 and Muse Spark 1.3 at a quarter of
|
|
14
|
+
their 1M windows. The router now reads the window from the same models.dev catalogue OpenCode's
|
|
15
|
+
CLI uses, and fills models already saved without one.
|
|
16
|
+
- **A ChatGPT account whose limit was reset or topped up is usable again right away.** It stayed
|
|
17
|
+
"cooling" until the old reset time, so the app could not switch to GPT models.
|
|
18
|
+
- **Adding an OpenAI-compatible provider such as B.ai works.** The connection check asked for one
|
|
19
|
+
token, which some vendors refuse ("max_tokens must be greater than 2", #57).
|
|
20
|
+
- **The plan limit shows on the band**, and every control's tip shows on hover even when the band
|
|
21
|
+
is full.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **The band:** Auto is a word that lights when on, instead of a switch. Compact and Clear (with a
|
|
26
|
+
confirm) sit beside settings. The limit draws as a small gauge and its percent.
|
|
27
|
+
- **Workers read and edit with the dedicated tools.** Generated worker agents are told to read files
|
|
28
|
+
with Read and change them with Edit, not with `sed`/`cat` through Bash.
|
|
29
|
+
- `/ripple debug` also shows the model, the limit and the host's rate limits.
|
|
30
|
+
|
|
3
31
|
## 0.9.3 — 2026-10-14
|
|
4
32
|
|
|
5
33
|
### Fixed
|
package/dist/app/package.json
CHANGED
package/dist/mod/hooks/i18n.ts
CHANGED
|
@@ -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: {
|
package/dist/mod/hooks/ui/art.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -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
|
|
7
|
-
//
|
|
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,
|
|
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,
|
|
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
|
|
288
|
-
*
|
|
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) => (
|
|
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') + (!
|
|
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
|
-
...(
|
|
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') + (
|
|
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:
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
473
|
-
|
|
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
|
-
|
|
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={
|
|
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="
|
|
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
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
643
|
-
// keep-warm), the rules, the
|
|
644
|
-
// the
|
|
645
|
-
|
|
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)
|
|
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 =
|
|
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.
|
|
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>
|