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 +41 -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/presets.js +3 -0
- package/dist/router/src/providers/chatgpt/index.js +49 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +247 -25
- package/dist/router/src/providers/retry.js +15 -0
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/version.js +1 -1
- package/docs/ARCHITECTURE.md +56 -4
- package/package.json +3 -2
|
@@ -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>
|
|
@@ -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
|
|
@@ -184,6 +192,30 @@ export function toggleCells(label: string): number {
|
|
|
184
192
|
return Math.ceil((SWITCH_W + CELL_PX + chOf(label) * CELL_PX * 1.1) / CELL_PX) + 1
|
|
185
193
|
}
|
|
186
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
|
+
|
|
187
219
|
/** A switch and its word (On / Off), the whole pressable; lit while on. */
|
|
188
220
|
export function toggle(el: Els, key: string, on: boolean, label: string, onPress: () => void, busy = false, scope?: string): RenderNode {
|
|
189
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
|
|
@@ -1753,14 +1753,16 @@ test('every band control changes the next drawing, and a toggle says its state o
|
|
|
1753
1753
|
http(on)
|
|
1754
1754
|
await start($)
|
|
1755
1755
|
await b.clock.advance(3000)
|
|
1756
|
-
// The terminal says it in the label (● / ○); the desktop in the control's drawing: a
|
|
1757
|
-
//
|
|
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.
|
|
1758
1758
|
const desktopState = (n: unknown) => {
|
|
1759
1759
|
const drawing = JSON.stringify(n ?? '')
|
|
1760
1760
|
return /<svg[^>]* class=\\"on\\"/.test(drawing) ? 'on' : /<svg[^>]* class=\\"off\\"/.test(drawing) ? 'off' : 'other'
|
|
1761
1761
|
}
|
|
1762
1762
|
const state = async (ui: Awaited<ReturnType<typeof band>>, key: string, surface: string) =>
|
|
1763
|
-
surface === 'desktop'
|
|
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 }))
|
|
1764
1766
|
for (const surface of ['terminal', 'desktop'] as const) {
|
|
1765
1767
|
const ui = await band($, surface)
|
|
1766
1768
|
const drawn = async () => JSON.stringify(await ui.drawn()).replace(/"handle":\d+/g, '')
|
|
@@ -1791,3 +1793,29 @@ test('every band control changes the next drawing, and a toggle says its state o
|
|
|
1791
1793
|
await ui.unmount()
|
|
1792
1794
|
}
|
|
1793
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
|
+
})
|