Jauge : bandeau au-dessus du prompt, en lecture seule, sur UNE ligne ajustée à la largeur (libellés courts puis champs retirés dans l'ordre de…

Read-only band above the prompt, and /jauge for the detail pane. UI in French. Settings: band.config.json (your own JSON via the mod's userConfig config, deep-merged).
jauge.layout: "clarity")Julian, 25 Oct: « not clear, I don't see what's running on the conv, too much info not collapsed, no line jumped, not enough indentation, not enough colors, progression bar % token eta ». Folded by default to ONE line: what runs in this conversation first, coloured by state, then the gauges; the buttons sit on that line when they fit (else under « Mods » once unfolded). ▶ / ▼ (a button) folds and unfolds; the state is kept in the mod's $.store sections (expanded).
▶ ● 1 workflow · 3 agents en cours · ctx ▰▰▰▱▱ 57 % · 5 h ▰▰▰▱▱ 64 % · sem ▰▰▰▰▱ 74 % [Usage] [Règles] …
▶ ○ rien en cours · ctx ▰▱▱▱▱ 19 % · 5 h ▱▱▱▱▱ 0 % · sem ▰▰▱▱▱ 41 %
Unfolded: each section after a blank line, a header (accent colour, bold) and indented tree rows (├ └ │):
▼ ● 1 workflow · 3 agents en cours · ctx ▰▰▰▱▱ 57 % · 5 h ▰▰▰▱▱ 64 %
En cours · cette conversation
├ ● fil principal
├ ◇ demo-release-wave ▰▰▰▰▰▰▱▱▱▱ 60 % · 3/5 · ▸ Verify · 47 min · fin ~16:48
│ ├ • coder lane-alpha-identity · opus · 20 min · 1,2 M tok
│ └ • tester lane-beta · sonnet · 9 min · rien écrit depuis 18 min
└ ◦ auditor scan · sonnet · 3 min
Jauges
├ ctx ▰▰▰▰▰▰▱▱▱▱ 57 % · 228 k / 400 k · relais 250 k · compact ~16 min
├ 5 h ▰▰▰▰▰▰▱▱▱▱ 64 %
├ sem ▰▰▰▰▰▰▰▱▱▱ 74 % · projeté 108 %
└ débit 4,4 k tok/s
Ailleurs sur ce PC
└ 2 conv · 1 wf · 3 ag · 1 CI
● green while it writes, amber when something is silent for en_cours.warn_min, red when one failed or is silent for bad_min; ○ rien en cours grey. Claude travaille when only the main thread works (the engine's isWorking).done/total, phase, elapsed, tokens when the reader gives them, fin ~HH:MM from past runs) with its agents one per row (max_agents, then +N agents), then Agent-tool agents (◦ background, • foreground). No « agent d'arrière-plan » words. Tokens per agent show only when scanner/encours.mjs passes a tokens field (lib/core/running.mjs does not yet).bar_cells, 10; summary_bar_cells, 5 on the folded line; a thin Svg on the desktop) coloured green → orange → red by jauge.levels (see the table below).rien d’autre when all are zero, CI ? when the ci cache is stale).1 wf · 3 ag, and keeps RESUME (the red « relais conseillé » words only when they fit); tree rows cut names first, then drop phase, model, ETA. Tested from 40 to 120 columns (tests/clarity.test.mjs).band.config.json jauge.clarity: sections, summary, gauges, summary_bar_cells, bar_cells, indent, blank_lines, max_agents, max_running, glyphs, labels. jauge.layout: "line" brings back the older line described below.jauge.layout: "line")One line when it fits the band's width, else two rows, then the five buttons (Usage · Règles · Réf · Cockpit · Exporter):
▶ 1 conv · 0 wf · 0 ag · 0 CI │ ◆ 2 │ ctx ▰▰▱▱▱ 19 % │ 5 h ▱▱▱▱▱ 0 % │ sem ▰▰▰▰▱ 74 % │ 4.4k tok/s
✓ au repos │ ctx ▰▱▱▱▱ 19 % │ 5 h ▱▱▱▱▱ 0 % │ sem ▰▰▱▱▱ 41 % │ 380 tok/s
activity, Julian 21 Oct: « how do I know no work is in process? »), for the whole PC, counted by lib/core/busy.mjs in the scanner:conv: conversations whose main thread has a turn in progress (last user/assistant entry not a final answer, written within jauge.activity.busy_s, 120 s); this conversation also counts while the engine says it is working (isWorking).wf / ag: Workflows and agents (Workflow agents, Agent-tool agents foreground or background) that wrote within sections.running_s; this conversation's own « en cours » data raises them and colours them (amber stalled, red failed).CI: running or queued pipelines in the ci mod's cache (jauge.activity.ci_cache, ~/.claude/ci-pipelines-cache.json, GitHub Actions + GitLab), red when a run failed within ci_stale_min. A cache older than ci_stale_min (15) minutes, or missing, shows a grey CI ?, never 0 CI. ci_cache: "" turns CI off.✓ au repos instead (with CI ? when the CI cannot be read). Never dropped when narrow.◆ N: conversations waiting on you (Veille alerts marked julian), amber, hidden at 0.▰▱ (5 cells, jauge.bar_cells) in the terminal, a thin Svg bar on the desktop.jauge.levels:| Figure | Green | Orange | Red |
|---|---|---|---|
| ctx | < 50 % | 50 % to the relay | ≥ the relay (context.handoff_tokens, 250 K, as a % of the effective window) |
| 5 h, semaine | < 60 % | 60–85 %, or projected 90–100 % at reset | ≥ 85 %, or projected > 100 % at reset |
The projection is the usage mod's samples' pace (lib/core/usage.mjs runOut) carried to the window's reset, worked out in scanner/scan.mjs (claude.fiveProj / weekProj); without samples only the % counts.
▶ 7 conv, 1 wf, 3 ag, CI ?, ctx ▰▰▰▱▱ 63 %, 5 h ▱▱▱▱▱ 2 %, sem ▰▰▰▰▱ 77 %, 2.1k tok/s) is drawn as one block that never shrinks. Too wide for one row (full labels, then short ones):jauge.wrap_at (context): row 1 the status counts, row 2 ctx · 5 h · sem · tok/s, each row with full labels, else short ones;jauge.drop_order are left out one by one: tps_all, five, act_ci, act_ag, act_wf (conv, ctx and sem stay). If the engine's width is off (a desktop font wider than its cells), the row moves whole fields to the next row; it never truncates one.50 colonnes, past the relay:
▶ 7 conv · 1 wf · 3 ag · CI ? ● [ RESUME ]
ctx ▰▰▰▱▱ 63 % │ 5 h ▱▱▱▱▱ 2 % │ sem ▰▰▰▰▱ 77 %
labels.relay_hint) next to a RESUME button; narrower a red ● (labels.relay_mark, no words) before RESUME; narrowest RESUME alone.export_brief)Julian, 26 Oct: « 1 button to export all the messages of the conversation and the actions Claude took, the GitHub progress, git, a live ETA I can paste to another AI so it knows where we are; for a main conversation, a button that exports all its children too ». Two buttons on the Jauge line (on the line when they fit, else a row of their own under it):
~/.claude/export-eta/briefs/<date>-<titre>.md and copied to the clipboard; a toast gives the path and « copié ». No model call, no PR.spawnedFrom; N counts every descendant): the same brief with one numbered section per sub-conversation (1, 1.1, 1.1.1…), file <date>-<titre>-avec-enfants.md.Labels, order and the +{n} suffix: export_brief.buttons in band.config.json; toasts: export_brief.labels. The press runs export-eta's exporter/brief.mjs (path export_brief.script, relative to band's folder) with $.process.run, so it works while Claude works (a slash command would wait for the turn to end). The child count is read every export_brief.refresh_s (60 s) with brief.mjs info. When the script is missing the buttons are hidden (when_missing: "grey" shows them greyed, a press says how to add the mod). What the brief holds and its settings: export-eta's README.
Only while something runs in this conversation, rows appear under the line and above the buttons (none when idle, so the Jauge stays one line):
◇ cockpit-preview-g2 ▰▰▰▱▱ 3/5 · ▸ Verify · 2 agents opus · 47 min · fin ~01:58
• coder p43-rag-identity · opus · 47 min • coder p44-migration · opus · 47 min
◦ agent d'arrière-plan · auditor · sonnet · 3 min
fin ~HH:MM when this PC has at least min_samples past completed runs of the same Workflow (median, lib/core/estimate.mjs).agent_lines rows, then +N).◦ background, • foreground (the current turn's subagents).warn_min (15) minutes without a write, red after bad_min (30) or failed (kept failed_keep_min). Type words are grey. Same bullets as the cockpit.datapass-facto…), then the phase name, then leaves out fin ~HH:MM, then the agents and models (fin comes back when it then fits). The bullet, bar, count, elapsed time and state are kept; tested at 60 and 90 columns.jauge.en_cours_rows rows (default 5, 0 = off), then « +N de plus · Cockpit › ».Source: scanner/encours.mjs, a thin wrapper over lib/core/running.mjs (this mod's synced copy of the repo's lib/core, same file rules as factory's workflow_scan.py): the conversation's subagents/workflows/<runId>/journal.jsonl, agent-<id>.meta.json, the Workflow script's meta, and the run record written when a run ends. Only files whose size or mtime changed are re-read, transcripts from the last offset. It runs every refresh_s (≥ 5 s) while something runs; when idle the band only stats the conversation's folders and starts it when they change or after idle_poll_s. Settings: jauge.en_cours_rows and jauge.en_cours in band.config.json.
node mods/band/scripts/preview.mjs --cols 40,50,60,80 prints the clarity Jauge, each fixture folded then unfolded (--layout line for the older line); node mods/band/scripts/preview.mjs --layout line --cols 40,50,60,80 (or bare widths: 60 90) prints the Jauge as the terminal draws it (ANSI colours; --plain without) for an idle (✓ au repos), a busy-with-stale-CI (CI ?), a running and a busy-past-the-relay conversation (7 conv · 1 wf · 3 ag, ctx 63 %) at each width given (default 80), with each row's width. Invented fixtures.
node --test mods/band/tests/*.test.mjs (clarity.test.mjs: the clarity rows; brief.test.mjs: the export buttons) (invented fixtures in a temp folder) and claude plugin test mods/band.
hooks/register.tsx 1400 lines1// band mod ("Jauge"): Julian's always-visible live gauge, read-only.
2// Above the prompt: this conversation's context against auto-compact and the RESUME hand-off threshold, the burn rate
3// at the scopes the settings list, and the week's rate-limit use. /jauge opens the pane: per-scope table, hottest
4// sessions, projects, 30-minute sparklines with compaction markers, and each conversation's compactions.
5// Line 1 also carries « À toi : N » and « attend Julian : N » once each, counted by lib/core/veille.mjs in the scanner.
6// Under the band, a Mods button bar (launcher in band.config.json, preset hubs: Jauge, Cockpit, Veille, À toi,
7// Registres, Référence): a press runs the other mod's slash command, which opens that mod's own pane ($.ui.open draws
8// only the caller's panes, so a mod cannot open another's directly); a second press runs /<command> close. Jauge is
9// band's own pane, opened directly. A mod whose command is not in the session is greyed out and says how to load it.
10// Sources: $.session.usage() / session.measure for this conversation (the status line's figures), and
11// scanner/scan.mjs, an incremental tail of ~/.claude/projects/**/*.jsonl run every refresh_s. Nothing here blocks or
12// rewrites a tool call or a prompt.
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { EngineFigures } from '../types'
17import {
18 SCOPES,
19 bandParts,
20 bandText,
21 compactLabel,
22 contextInfo,
23 deepMerge,
24 expandHome,
25 fmtPct,
26 fmtTok,
27 fmtUsd,
28 ageText,
29 handoffAdvice,
30 handoffButtons,
31 handoffReason,
32 hhmm,
33 allLauncherItems,
34 launcherButtons,
35 launcherItems,
36 othersOpen,
37 launcherPress,
38 launcherKey,
39 launcherTyped,
40 markerLine,
41 recentHandoffs,
42 resumeCandidates,
43 sparkline,
44 stampKeys,
45 stampText,
46 toggleOpen,
47} from './logic'
48import type { BandPart, Config, Ctx, HandoffButton, LauncherButton, View } from './logic'
49import { barSvg, gaugeModel, gaugeSvg, gaugeText } from './gauge'
50import { cells, fitButtons, fitLine, jaugeCfg, layoutJauge, relayCells, relayText } from './jauge'
51import type { ActFacts, Field, Fitted, JaugeCfg, JaugeFacts, Relay } from './jauge'
52import type { Gauge } from './gauge'
53import {
54 sectionsCfg,
55 sectionList,
56 initialUi,
57 toggleSection,
58 isOpen,
59 headLine,
60 convSummary,
61 convLines,
62 counters,
63} from './sections'
64import type { BandUi, SectionsCfg } from './sections'
65import { ecCfg, enCoursView, levelOf } from './encours'
66import type { EcData, Seg } from './encours'
67import { clarityCfg, clarityView, segsText } from './clarity'
68import type { ClarityIn, CSeg } from './clarity'
69import { briefArgv, briefButtons, briefCfg, briefToast, fillB, infoOf, scriptPath } from './brief'
70import type { BriefButton } from './brief'
71
72// The gauge's input from this conversation's context and the band's rates (style "gauge", the default).
73const gaugeOf = (c: Config, ctx: Ctx | undefined, v: View | null, week: number | undefined): Gauge => {
74 const lb = c.labels
75 const rates: string[] = []
76 for (const id of ((c as any).gauge?.scopes as string[] | undefined) ?? c.band.scopes) {
77 const row = v?.scopes.find(r => r.id === id)
78 if (!row) continue
79 const r = row.rates.find(x => x.w === c.band_window_s) ?? row.rates[0]
80 if (r) rates.push(`${lb[id] ?? id} ${fmtTok(r.tps)} tok/s`)
81 }
82 const idle = ctx?.idleEtaMin !== undefined && (ctx.idleMin ?? 0) >= c.idle_compact.show_after_idle_min ? ctx.idleEtaMin : undefined
83 return gaugeModel(
84 {
85 tokens: ctx?.tokens,
86 window: ctx?.window ?? c.context.window_fallback,
87 compactAt: ctx?.threshold,
88 handoffAt: c.context.handoff_tokens,
89 etaMin: ctx?.etaMin ?? idle,
90 level: ctx?.level,
91 rates,
92 ...(week !== undefined ? { week } : {}),
93 },
94 (c as any).gauge?.labels ?? {},
95 )
96}
97
98// The two-line Jauge's figures: counters from the scanner (lib/core/veille.mjs), context from the engine, Claude's
99// 5 h / week from the engine's rate limits (else the usage mod's samples), Codex's from its codex.json, PC activity,
100// tok/s of the scopes in jauge.tps_scopes, CPU / GPU from the scanner.
101/**
102 * The activity status (lib/core/busy.mjs counts from the scanner): this conversation counts as busy also when the
103 * engine says it is working; its own Workflows and agents (scanner/encours.mjs) raise wf / ag and give their colour
104 * (amber stalled, red failed). undefined while the scanner has not answered yet.
105 */
106const actFacts = (c: Config, v: View | null, ec: EcData | null, working: boolean): ActFacts | undefined => {
107 const a = v?.activity
108 if (!a) return undefined
109 const ecc = ecCfg((c as any).jauge)
110 const now = v?.now ?? Date.now()
111 const wfs = ec?.workflows ?? []
112 const lone = ec?.agents ?? []
113 const wfAgents = wfs.flatMap(w => w.agents.filter(x => x.status === 'running' || x.status === 'failed'))
114 const worst = (xs: { status: string; last?: number; stalled?: boolean }[]) => {
115 const ls = xs.map(x => levelOf(x, now, ecc))
116 return ls.includes('bad') ? ('bad' as const) : ls.includes('warn') ? ('warn' as const) : undefined
117 }
118 return {
119 convs: a.convs + (working && !a.thisBusy ? 1 : 0),
120 workflows: Math.max(a.workflows, wfs.length),
121 agents: Math.max(a.agents, wfAgents.filter(x => x.status === 'running').length + lone.filter(x => x.status === 'running').length),
122 wfTone: worst(wfs),
123 agTone: worst([...wfAgents, ...lone]),
124 ...(a.ci ? { ci: { running: a.ci.running, failed: a.ci.failed, stale: a.ci.stale || !a.ci.available } } : {}),
125 }
126}
127
128const jaugeFacts = (c: Config, jc: JaugeCfg, ctx: Ctx | undefined, v: View | null, en: EngineFigures | null, act?: ActFacts): JaugeFacts => {
129 const lim = (k: string) => en?.limits?.find(l => l.kind === k && Number.isFinite(l.percentUsed))?.percentUsed
130 const tps: Record<string, number | undefined> = {}
131 for (const [id, scope] of Object.entries(jc.tps_scopes)) {
132 const row = v?.scopes.find(r => r.id === scope)
133 const r = row ? (row.rates.find(x => x.w === c.band_window_s) ?? row.rates[0]) : undefined
134 tps[id] = r?.tps
135 }
136 const pct = ctx?.tokens !== undefined && ctx.window ? (ctx.tokens * 100) / ctx.window : undefined
137 return {
138 waiting: v?.waiting?.julian,
139 todo: v?.waiting?.aToi,
140 ctxPct: pct,
141 relayAt: c.context.handoff_tokens,
142 ...(ctx?.window ? { relayPct: (c.context.handoff_tokens * 100) / ctx.window } : {}),
143 five: lim('five_hour') ?? v?.claude?.five,
144 week: lim('seven_day') ?? en?.week ?? v?.claude?.week,
145 fiveProj: v?.claude?.fiveProj,
146 weekProj: v?.claude?.weekProj,
147 codexWeek: v?.codex?.week,
148 codexFive: v?.codex?.five,
149 agents: v?.pc?.agents,
150 convs: v?.pc?.convs,
151 tps,
152 cpu: v?.machine?.cpu,
153 gpu: v?.machine?.gpu,
154 ...(act ? { act } : {}),
155 }
156}
157
158const PANE = { id: 'jauge' }
159const viewAtom = atom({ plugin: 'band', key: 'view' } as const, null)
160const engineAtom = atom({ plugin: 'band', key: 'engine' } as const, null)
161const errorAtom = atom({ plugin: 'band', key: 'error' } as const, '')
162const launcherAtom = atom({ plugin: 'band', key: 'launcher' } as const, { commands: [], open: [] })
163const handoffAtom = atom({ plugin: 'band', key: 'handoff' } as const, { ran: [], show: false })
164const uiAtom = atom({ plugin: 'band', key: 'ui' } as const, { expanded: false, open: [] })
165const STORE_KEY = 'sections'
166const stampsAtom = atom({ plugin: 'band', key: 'stamps' } as const, {})
167const STAMPS_KEY = 'stamps'
168const encoursAtom = atom({ plugin: 'band', key: 'encours' } as const, null)
169const briefAtom = atom({ plugin: 'band', key: 'brief' } as const, { ready: false, children: 0, busy: '', at: 0 })
170
171const loadStamps = async ($: EngineInterface) => {
172 let stored: unknown
173 try {
174 stored = await $.store.get(STAMPS_KEY)
175 } catch {}
176 if (stored && typeof stored === 'object') {
177 const ok: Record<string, number> = {}
178 for (const [k, t] of Object.entries(stored as Record<string, unknown>)) if (typeof t === 'number' && Number.isFinite(t)) ok[k] = t
179 await update($, stampsAtom, s => ({ ...ok, ...s }))
180 }
181}
182
183// A stamped button (Exporter) ran, pressed or typed: remember when, for « dernier export HH:MM ».
184const noteStamp = async ($: EngineInterface, keys: string[]) => {
185 if (!keys.length) return
186 const now = await $.clock.now()
187 const next = await update($, stampsAtom, s => {
188 const o = { ...s }
189 for (const k of keys) o[k] = now
190 return o
191 })
192 try {
193 await $.store.set(STAMPS_KEY, next)
194 } catch {}
195}
196
197const secCfg = (c: Config): SectionsCfg => sectionsCfg((c as any).sections)
198
199// The folding at session start: $.store's (remember "store"), else the config's defaults. A reload keeps $.state's.
200const loadUi = async ($: EngineInterface, c: Config) => {
201 const sc = secCfg(c)
202 let stored: unknown
203 if (sc.remember === 'store') {
204 try {
205 stored = await $.store.get(STORE_KEY)
206 } catch {}
207 }
208 const ui = initialUi(sc, stored)
209 await update($, uiAtom, () => ui)
210}
211
212const toggleUi = async ($: EngineInterface, id: string) => {
213 const c = await loadConfig($)
214 const next = await update($, uiAtom, u => toggleSection(u as BandUi, id))
215 if (secCfg(c).remember === 'store') {
216 try {
217 await $.store.set(STORE_KEY, next)
218 } catch {}
219 }
220}
221
222type Options = { config?: string; band?: string; status_line?: string }
223
224const DOCS = {
225 statusline: 'https://code.claude.com/docs/en/statusline',
226 mods: 'https://code.claude.com/docs/en/plugins/mods/overview',
227}
228
229const home = async ($: EngineInterface) => {
230 let v: string | undefined
231 try {
232 v = await $.env.get('USERPROFILE')
233 } catch {}
234 if (!v) {
235 try {
236 v = await $.env.get('HOME')
237 } catch {}
238 }
239 return v ?? ''
240}
241
242let options: Options = {}
243let cfg: Config | undefined
244let cfgProblem = ''
245let scanning = false
246let thresholdAt = 0
247let threshold: number | undefined
248let timer: { cancel: () => void } | undefined
249let ecTimer: { cancel: () => void } | undefined
250let ecBusy = false
251let ecLast = { at: 0, sig: '', live: false, dir: '' }
252
253const loadConfig = async ($: EngineInterface): Promise<Config> => {
254 if (cfg) return cfg
255 const root = $.plugin.root.replace(/\\/g, '/')
256 let base = {} as Config
257 try {
258 base = JSON.parse((await $.fs.read(`${root}/band.config.json`, { as: 'text' })) as string)
259 } catch (err) {
260 cfgProblem = `band.config.json: ${String(err).slice(0, 120)}`
261 }
262 if (options.config?.trim()) {
263 const p = expandHome(options.config.trim(), await home($))
264 try {
265 base = deepMerge(base, JSON.parse((await $.fs.read(p, { as: 'text' })) as string))
266 } catch (err) {
267 cfgProblem = `${p}: ${String(err).slice(0, 120)}`
268 }
269 }
270 cfg = base
271 return base
272}
273
274let sessionFacts: { model?: string; effort?: string; at: number } = { at: 0 }
275
276const figures = async ($: EngineInterface, context?: { tokens?: number; window: number; percent?: number }, limits?: { kind: string; percentUsed: number; resetsAt?: string }[]) => {
277 const now = await $.clock.now()
278 let cost: number | undefined
279 if (!context || !limits) {
280 try {
281 const u = await $.session.usage()
282 context = u.context
283 limits = u.rateLimits
284 cost = (u as any).cost?.usd
285 } catch {}
286 } else {
287 const prev = await read($, engineAtom)
288 cost = prev?.cost
289 }
290 // model and effort: cheap, local, once a minute
291 if (now - sessionFacts.at > 60000) {
292 sessionFacts = { at: now }
293 try {
294 sessionFacts.model = await $.session.model()
295 } catch {}
296 try {
297 sessionFacts.effort = (await $.env.get('CLAUDE_EFFORT')) || undefined
298 } catch {}
299 }
300 // The auto-compact threshold is only in the breakdown; "summary" counts locally and sends nothing. Every 5 min.
301 if (now - thresholdAt > 300000) {
302 thresholdAt = now
303 try {
304 const u = await $.session.usage({ breakdown: 'summary' })
305 threshold = u.context.breakdown?.isAutoCompactEnabled === false ? undefined : u.context.breakdown?.autoCompactThreshold
306 } catch {}
307 }
308 const week = limits?.find(l => l.kind === 'seven_day')?.percentUsed
309 const f: EngineFigures = {
310 ...(context?.tokens !== undefined ? { tokens: context.tokens } : {}),
311 ...(context?.window ? { window: context.window } : {}),
312 ...(context?.percent !== undefined ? { percent: context.percent } : {}),
313 ...(threshold !== undefined ? { autoCompactThreshold: threshold } : {}),
314 ...(week !== undefined ? { week } : {}),
315 ...(limits ? { limits: limits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, ...(l.resetsAt ? { resetsAt: l.resetsAt } : {}) })) } : {}),
316 ...(cost !== undefined && Number.isFinite(cost) ? { cost } : {}),
317 ...(sessionFacts.model ? { model: sessionFacts.model } : {}),
318 ...(sessionFacts.effort ? { effort: sessionFacts.effort } : {}),
319 at: now,
320 }
321 await update($, engineAtom, () => f)
322}
323
324const ctxOf = (c: Config, e: EngineFigures | null, v: View | null, now: number): Ctx | undefined => {
325 if (!e && !v?.conv) return undefined
326 return contextInfo(
327 {
328 tokens: e?.tokens ?? v?.conv?.ctx,
329 window: e?.window,
330 percent: e?.percent,
331 autoCompactThreshold: e?.autoCompactThreshold,
332 growthTpm: v?.conv?.growthTpm,
333 last: v?.conv?.last,
334 now,
335 },
336 c.context,
337 c.idle_compact,
338 )
339}
340
341const statusLine = async ($: EngineInterface) => {
342 const c = await loadConfig($)
343 if (options.status_line !== 'on') return
344 const v = await read($, viewAtom)
345 const e = await read($, engineAtom)
346 const ctx = ctxOf(c, e, v, await $.clock.now())
347 $.ui.status(bandText(bandParts(v ?? undefined, ctx, e?.week, c.status_line, c)) || undefined)
348}
349
350// The slash commands of this session, to grey out the mods that are not loaded. Local: no model call.
351const seeCommands = async ($: EngineInterface) => {
352 try {
353 const list = await $.command.list()
354 const commands = list.map(c => ({ name: c.name, ...(c.plugin ? { plugin: c.plugin } : {}) }))
355 await update($, launcherAtom, l => ({ ...l, commands }))
356 } catch {}
357}
358
359const buttonsOf = async ($: EngineInterface, c: Config) => {
360 const l = await read($, launcherAtom)
361 return launcherButtons(c.launcher, l.commands, l.open, c.labels)
362}
363
364// The unfolded buttons (more_items: Jauge), same rules as the hubs.
365const moreButtonsOf = async ($: EngineInterface, c: Config) => {
366 const l = await read($, launcherAtom)
367 const more = c.launcher?.more_items
368 return Array.isArray(more) && more.length ? launcherButtons({ ...c.launcher, preset: 'more', items: more }, l.commands, l.open, c.labels) : []
369}
370
371// launcher.single: one hub open at a time. Opening `key` closes every other hub known open (not those of the same
372// command, which the opening run replaces anyway): /<command> <close_args>, or band's own Jauge pane directly.
373// fromHook: called inside a command.run hook, where the engine refuses $.command.run (it would wait on the turn the
374// hook holds), so the closing runs go to a timer ($.clock.after 0).
375const closeOthers = async ($: EngineInterface, c: Config, key: string, command: string, fromHook = false) => {
376 if (!c.launcher?.single) return
377 const l = await read($, launcherAtom)
378 const others = othersOpen(allLauncherItems(c.launcher), l.open, key).filter(o => o.command !== command)
379 if (!others.length) return
380 const shut = others.map(launcherKey)
381 await update($, launcherAtom, x => ({ ...x, open: x.open.filter(k => !shut.includes(k)) }))
382 const closeArgs = c.launcher?.close_args || 'close'
383 const runs: { command: string; args: string }[] = []
384 for (const o of others) {
385 if (o.mod === 'band' && o.command === 'jauge') {
386 await $.ui.close({ id: PANE.id })
387 continue
388 }
389 if (!runs.some(r => r.command === o.command)) runs.push({ command: o.command, args: closeArgs })
390 }
391 const go = () => {
392 for (const r of runs) $.command.run(r).catch((err: unknown) => $.ui.toast(`/${r.command} ${r.args} : ${String(err).slice(0, 160)}`))
393 }
394 if (!runs.length) return
395 if (fromHook) $.clock.after(0, go)
396 else go()
397}
398
399// A press: open the mod (/<command>) or close it (/<command> close). $.command.run queues the command until the
400// session is idle, so it is not awaited here; while Claude works a toast says it is queued.
401const press = async ($: EngineInterface, b: LauncherButton, isWorking: boolean) => {
402 const c = await loadConfig($)
403 const a = launcherPress(b, c.launcher?.close_args || 'close')
404 if ('toast' in a) {
405 $.ui.toast(a.toast)
406 return
407 }
408 if (a.open) await closeOthers($, c, launcherKey(b), b.command)
409 await update($, launcherAtom, l => ({ ...l, open: toggleOpen(l.open, launcherKey(b), a.open) }))
410 // Jauge is band's own pane: $.command.run skips the caller's own hooks, so open or close it here.
411 if (b.mod === 'band' && b.command === 'jauge') {
412 if (a.open) {
413 await $.ui.open({ id: PANE.id, title: c.labels.pane_title ?? 'Jauge' })
414 void refresh($)
415 } else await $.ui.close({ id: PANE.id })
416 return
417 }
418 if (isWorking) $.ui.toast(`${b.label} : ${c.labels.launcher_queued ?? 'queued'}`)
419 // $.command.run skips band's own command.run observer, so a stamped press is noted here.
420 if (b.stamp) await noteStamp($, [launcherKey(b)])
421 $.command.run(a.run).catch(async (err: unknown) => {
422 await update($, launcherAtom, l => ({ ...l, open: toggleOpen(l.open, launcherKey(b), !a.open) }))
423 $.ui.toast(`/${b.command} : ${String(err).slice(0, 160)}`)
424 })
425}
426
427// ---------- hand-off helper ----------
428// Read-only: it stats RESUME and reads it on a press; « Préparer RESUME » runs the handoff command only on a press.
429
430const seeResume = async ($: EngineInterface, c: Config) => {
431 const hc = c.handoff
432 if (!hc || hc.enabled === false) return
433 let cwd = ''
434 try {
435 cwd = await $.session.cwd()
436 } catch {}
437 const places = resumeCandidates(cwd, hc.files ?? [], c.worktree_markers)
438 let resume: { path: string; mtime?: number } | undefined = places[0] ? { path: places[0] } : undefined
439 for (const p of places) {
440 try {
441 const st = await $.fs.stat(p)
442 if (st.kind === 'file') {
443 resume = { path: p, mtime: st.mtimeMs }
444 break
445 }
446 } catch {}
447 }
448 let startedAt: number | undefined
449 try {
450 startedAt = (await $.session.usage()).startedAt
451 } catch {}
452 await update($, handoffAtom, s => ({ ...s, resume, ...(startedAt !== undefined ? { startedAt } : {}) }))
453}
454
455const noteRan = async ($: EngineInterface) => {
456 const now = await $.clock.now()
457 await update($, handoffAtom, s => ({ ...s, ran: [...s.ran, now].slice(-10) }))
458}
459
460const showResume = async ($: EngineInterface, c: Config) => {
461 await seeResume($, c)
462 const s = await read($, handoffAtom)
463 let text: string | undefined
464 if (s.resume?.mtime !== undefined) {
465 try {
466 const all = (await $.fs.read(s.resume.path, { as: 'text' })) as string
467 const cap = c.handoff?.preview_chars ?? 12000
468 text = all.length > cap ? `${all.slice(0, cap)}\n\n…` : all
469 } catch {}
470 } else $.ui.toast(`RESUME ${c.labels.handoff_absent ?? 'absent'} : ${s.resume?.path ?? '?'}`)
471 await update($, handoffAtom, x => ({ ...x, show: true, ...(text !== undefined ? { text } : {}) }))
472 await $.ui.open({ id: PANE.id, title: c.labels.pane_title ?? 'Jauge' })
473}
474
475const pressHandoff = async ($: EngineInterface, b: HandoffButton, isWorking: boolean) => {
476 const c = await loadConfig($)
477 const hc = c.handoff
478 if (!hc) return
479 if (!b.enabled) {
480 $.ui.toast(b.hint ?? b.label)
481 return
482 }
483 if (b.key === 'handoff-open') {
484 await showResume($, c)
485 return
486 }
487 if (isWorking) $.ui.toast(`${b.label} : ${c.labels.handoff_queued ?? 'queued'}`)
488 // $.command.run skips the calling plugin's own hooks, so band's command.run observer will not see this run: note it here.
489 await noteRan($)
490 $.command
491 .run({ command: hc.command, ...(hc.args ? { args: hc.args } : {}) })
492 .catch((err: unknown) => $.ui.toast(`/${hc.command} : ${String(err).slice(0, 160)}`))
493}
494
495// « en cours » rows (jauge.en_cours): scanner/encours.mjs reads only the files of THIS conversation that changed. While
496// nothing runs it is not even started: the conversation's folders are stat-ed every refresh_s and the scanner runs
497// only when one of them changed (a new agent or Workflow) or idle_poll_s went by.
498const refreshEnCours = async ($: EngineInterface, force = false) => {
499 if (ecBusy) return
500 const c = await loadConfig($)
501 const j = ((c as any).jauge ?? {}) as any
502 const ec = j.en_cours ?? {}
503 if (ecCfg(j).rows <= 0 || ec.enabled === false) return
504 ecBusy = true
505 try {
506 const now = await $.clock.now()
507 let sig = ''
508 if (ecLast.dir) {
509 for (const p of [ecLast.dir, `${ecLast.dir}/subagents`, `${ecLast.dir}/subagents/workflows`]) {
510 try {
511 sig += `${(await $.fs.stat(p)).mtimeMs};`
512 } catch {
513 sig += '-;'
514 }
515 }
516 }
517 const idle = Math.max(5, Number(ec.idle_poll_s) || 60) * 1000
518 if (!force && !ecLast.live && ecLast.at && sig === ecLast.sig && now - ecLast.at < idle) return
519 const h = await home($)
520 let session = ''
521 try {
522 session = await $.session.id()
523 } catch {}
524 const req = { projects_root: expandHome(c.projects_root, h), cache_file: expandHome(ec.cache_file || '~/.claude/band/encours-cache.json', h), session, now, cfg: c }
525 const root = $.plugin.root.replace(/\\/g, '/')
526 const r = await $.process.run([c.node, `${root}/scanner/encours.mjs`], { stdin: JSON.stringify(req), timeoutMs: 60000 })
527 let d: any
528 try {
529 d = JSON.parse(r.stdout)
530 } catch {}
531 const data: EcData = { now, workflows: Array.isArray(d?.workflows) ? d.workflows : [], agents: Array.isArray(d?.agents) ? d.agents : [] }
532 const dir = typeof d?.dir === 'string' ? d.dir.replace(/\\/g, '/') : ''
533 ecLast = { at: now, sig: dir === ecLast.dir ? sig : '', live: Boolean(data.workflows!.length || data.agents!.length), dir }
534 await update($, encoursAtom, () => data as any)
535 } catch {
536 } finally {
537 ecBusy = false
538 }
539}
540
541// ---------- brief for another AI (export_brief) ----------
542// The child count comes from export-eta's exporter/brief.mjs `info` (the app's session files, cached by mtime), read
543// every export_brief.refresh_s; a press runs `run` directly with $.process.run so it works while Claude works.
544const briefScript = ($: EngineInterface, c: Config) => scriptPath($.plugin.root, briefCfg((c as any).export_brief).script)
545
546const refreshBrief = async ($: EngineInterface, force = false) => {
547 const c = await loadConfig($)
548 const bc = briefCfg((c as any).export_brief)
549 if (!bc.enabled) return
550 const now = await $.clock.now()
551 const s = await read($, briefAtom)
552 if (!force && s.at && now - s.at < bc.refresh_s * 1000) return
553 await update($, briefAtom, x => ({ ...x, at: now }))
554 const script = briefScript($, c)
555 let ready = false
556 try {
557 ready = (await $.fs.stat(script)).kind === 'file'
558 } catch {}
559 let children = 0
560 if (ready) {
561 try {
562 const session = await $.session.id()
563 const r = await $.process.run([c.node, script, 'info', '--session', session], { timeoutMs: 120000 })
564 children = infoOf(r.stdout).children
565 } catch {}
566 }
567 await update($, briefAtom, x => ({ ...x, ready, children }))
568}
569
570const pressBrief = async ($: EngineInterface, b: BriefButton) => {
571 const c = await loadConfig($)
572 const bc = briefCfg((c as any).export_brief)
573 const s = await read($, briefAtom)
574 const script = briefScript($, c)
575 if (!s.ready) {
576 $.ui.toast(fillB(bc.labels.missing, { script }))
577 return
578 }
579 if (s.busy) return
580 await update($, briefAtom, x => ({ ...x, busy: b.id }))
581 $.ui.toast(b.id === 'tree' ? fillB(bc.labels.started_tree, { n: s.children }) : bc.labels.started)
582 try {
583 let session = ''
584 let cwd = ''
585 try {
586 session = await $.session.id()
587 cwd = await $.session.cwd()
588 } catch {}
589 const r = await $.process.run(briefArgv(c.node, script, b.id, session, cwd), { timeoutMs: bc.timeout_s * 1000 })
590 $.ui.toast(briefToast(bc, r))
591 } catch (err) {
592 $.ui.toast(fillB(bc.labels.failed, { error: String(err).slice(0, 200) }))
593 } finally {
594 await update($, briefAtom, x => ({ ...x, busy: '' }))
595 }
596}
597
598const fileHref = (p: string) => `file:///${encodeURI(p.replace(/\\/g, '/').replace(/^\/+/, ''))}`
599
600const refresh = async ($: EngineInterface) => {
601 if (scanning) return
602 scanning = true
603 try {
604 const c = await loadConfig($)
605 const h = await home($)
606 const root = $.plugin.root.replace(/\\/g, '/')
607 let session = ''
608 let cwd = ''
609 try {
610 session = await $.session.id()
611 cwd = await $.session.cwd()
612 } catch {}
613 const req = {
614 projects_root: expandHome(c.projects_root, h),
615 cache_file: expandHome(c.cache_file, h),
616 session,
617 cwd,
618 now: await $.clock.now(),
619 cfg: c,
620 }
621 const r = await $.process.run([c.node, `${root}/scanner/scan.mjs`], { stdin: JSON.stringify(req), timeoutMs: 180000 })
622 let parsed: any
623 try {
624 parsed = JSON.parse(r.stdout)
625 } catch {
626 parsed = { error: (r.stderr || r.stdout || `exit ${r.exitCode}`).slice(0, 300) }
627 }
628 if (parsed?.error) await update($, errorAtom, () => String(parsed.error).split('\n')[0])
629 else {
630 await update($, viewAtom, () => parsed as View)
631 await update($, errorAtom, () => '')
632 }
633 await figures($)
634 await statusLine($)
635 await seeCommands($)
636 await seeResume($, c)
637 await refreshEnCours($)
638 await refreshBrief($)
639 } catch (err) {
640 await update($, errorAtom, () => `scanner: ${String(err).slice(0, 200)}`)
641 } finally {
642 scanning = false
643 }
644}
645
646export const register: Register = (on, raw) => {
647 options = (raw ?? {}) as Options
648 cfg = undefined
649 timer?.cancel()
650 timer = undefined
651 ecTimer?.cancel()
652 ecTimer = undefined
653 ecLast = { at: 0, sig: '', live: false, dir: '' }
654
655 on('session.start', async ($, e, next) => {
656 const started = await next(e)
657 await $.command.register({
658 name: 'jauge',
659 description: 'Jauge: contexte de cette conversation, débit en tok/s par périmètre, sessions les plus actives, compactages (args: [refresh])',
660 argumentHint: '[refresh]',
661 })
662 const c = await loadConfig($)
663 await loadUi($, c)
664 await loadStamps($)
665 timer?.cancel()
666 timer = $.clock.every(Math.max(5, c.refresh_s) * 1000, () => void refresh($))
667 ecTimer?.cancel()
668 ecTimer = $.clock.every(Math.max(5, Number((c as any).jauge?.en_cours?.refresh_s) || 5) * 1000, () => void refreshEnCours($))
669 void seeCommands($)
670 void refresh($)
671 void refreshEnCours($, true)
672 void refreshBrief($, true)
673 return started
674 })
675
676 // Observe only: a launcher mod's command typed by the person keeps the button's open state in step. Passes through.
677 on('command.run', async ($, e, next) => {
678 const r = await next(e)
679 try {
680 const c = await loadConfig($)
681 if (c.handoff && c.handoff.enabled !== false && e.command === c.handoff.command) await noteRan($)
682 await noteStamp($, stampKeys(allLauncherItems(c.launcher), e.command))
683 const t = launcherTyped(allLauncherItems(c.launcher), e.command, e.args ?? '', c.launcher?.close_args || 'close')
684 if (t) {
685 for (const k of t.open) await closeOthers($, c, k, e.command, true)
686 await update($, launcherAtom, l => ({ ...l, open: [...l.open.filter(k => !t.close.includes(k)), ...t.open.filter(k => !l.open.includes(k))] }))
687 }
688 } catch {}
689 return r
690 }).catch(($, e, next) => next(e))
691
692 // The Jauge pane closed by its own ✕: the Jauge button follows. Passes through.
693 on('ui.close', { id: PANE.id }, async ($, e, next) => {
694 await update($, launcherAtom, l => ({ ...l, open: l.open.filter(k => k !== 'band') }))
695 return next(e)
696 }).catch(($, e, next) => next(e))
697
698 on('session.measure', async ($, e, next) => {
699 const r = await next(e)
700 await figures($, e.context, e.rateLimits as { kind: string; percentUsed: number }[])
701 await statusLine($)
702 return r
703 }).catch(($, e, next) => next(e))
704
705 on('command.run', { command: 'jauge' }, async ($, e) => {
706 const c = await loadConfig($)
707 if ((e.args ?? '').trim() === (c.launcher?.close_args || 'close')) {
708 await $.ui.close({ id: PANE.id })
709 return { text: 'jauge: fermée' }
710 }
711 await refresh($)
712 await $.ui.open({ id: PANE.id, title: c.labels.pane_title ?? 'Jauge' })
713 const v = await read($, viewAtom)
714 const en = await read($, engineAtom)
715 const err = await read($, errorAtom)
716 const ctx = ctxOf(c, en, v, await $.clock.now())
717 const text = bandText(bandParts(v ?? undefined, ctx, en?.week, { items: c.band.items, scopes: [...SCOPES] }, c))
718 let relay = ''
719 if (c.handoff && c.handoff.enabled !== false) {
720 const hs = await read($, handoffAtom)
721 const advice = handoffAdvice(ctx, c.handoff)
722 relay = ` · passation : ${advice.show ? `${c.labels.handoff_advice ?? 'conseillée'} (${handoffReason(advice, ctx, c.handoff, c.labels)})` : 'pas encore'} · RESUME ${ageText(hs.resume?.mtime, await $.clock.now(), c.labels)}`
723 }
724 return { text: `jauge: ${text || 'pas encore de mesure'}${relay}${err ? ` · erreur: ${err}` : ''}${cfgProblem ? ` · ${cfgProblem}` : ''}` }
725 })
726
727 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
728 if (options.band === 'off' || e.props.hasSurvey) return next(e)
729 const c = await loadConfig($)
730 const v = await read($, viewAtom)
731 const en = await read($, engineAtom)
732 const ctx = ctxOf(c, en, v, v?.now ?? Date.now())
733 const parts = bandParts(v ?? undefined, ctx, en?.week, c.band, c)
734 const buttons = await buttonsOf($, c)
735 const advice = c.handoff?.band === false ? { show: false, reasons: [] } : handoffAdvice(ctx, c.handoff)
736 // What the mods after this one draw (veille's line, machine's entry) stays, below the gauge and the Mods bar.
737 const theirs = await next(e)
738 if (!parts.length && !buttons.length && !advice.show) return theirs
739 const els = $.ui.resolve(e)
740 const { Box, Text, Button } = els
741 const Svg = e.surface === 'terminal' ? undefined : (els as any).Svg
742 const color = (p: BandPart) => (p.tone ? c.colors[p.tone] : undefined)
743 const isWorking = e.props.isWorking
744 const style = (c as any).gauge?.style ?? 'two_lines'
745 if (style === 'two_lines') {
746 // The Jauge (Julian, 17 Oct): ONE line of figures, the five buttons (on that line when both fit), a relay hint
747 // and its RESUME button only past the hand-off threshold. No ▾, no second line, nothing else unless configured.
748 const jc = jaugeCfg((c as any).jauge)
749 const jx = ((c as any).jauge ?? {}) as { buttons_inline?: boolean; others_above_prompt?: string; layout?: string }
750 const cols = Math.max(20, Number((e.props as any).bodyColumns) || jc.columns_fallback)
751 const ecData = (await read($, encoursAtom)) as EcData | null
752 const facts = jaugeFacts(c, jc, ctx, v ?? null, en, actFacts(c, v ?? null, ecData, Boolean(isWorking)))
753 const relay: Relay | undefined = advice.show && c.handoff ? { hint: jc.labels.relay_hint ?? 'relais conseillé', button: jc.labels.relay_button ?? 'RESUME', mark: jc.labels.relay_mark ?? '●' } : undefined
754 const stamps = (await read($, stampsAtom)) as Record<string, number>
755 const stampOf = (b: LauncherButton) => stampText(b, stamps[launcherKey(b)])
756 const stampW = buttons.reduce((s, b) => s + (stampOf(b) ? cells(stampOf(b)) + 1 : 0), 0)
757 const fitted = fitButtons(buttons.map(b => ({ label: b.label, ...(b.short ? { short: b.short } : {}), open: b.open })), jc, cols, 0)
758 // « Export conv » / « Export + enfants » (export_brief): on the line when they fit, else a row of their own
759 const bs = await read($, briefAtom)
760 const briefBs = briefButtons(briefCfg((c as any).export_brief), bs)
761 const briefW = briefBs.reduce((w, b) => w + cells(b.label) + jc.button_pad + 1, 0)
762 const briefEls = briefBs.map(b => (
763 <Button key={b.key} {...(b.enabled ? {} : { dimColor: true })} onPress={() => pressBrief($, b)}>
764 {b.label}
765 </Button>
766 ))
767 // A field is never cut (Julian, 22 Oct): one row, else two (status counts, then ctx · 5 h · sem · tok/s), then
768 // whole fields of drop_order left out; the buttons join row 1 only when everything fits on it.
769 const lay = layoutJauge(jc.line1, facts, jc, cols, relay)
770 const inline =
771 jx.buttons_inline !== false && lay.rows.length === 1 && cells(lay.rows[0].text) + relayCells(relay, lay.relay, jc) + cells(jc.sep) + fitted.width + stampW + briefW <= cols
772 const l2 = jc.line2.length ? fitLine(jc.line2, facts, jc, cols) : undefined
773 // Traffic light on the mini bar and the % (ok green, warn orange, bad red); label words grey (Julian, 21 Oct).
774 const toneOf = (t?: Field['tone']) => (t === 'ok' || t === 'warn' || t === 'bad' ? { color: c.colors[t] ?? t } : t === 'dim' ? { dimColor: true } : {})
775 const svgColors = { ok: '#3fb950', warn: '#d29922', bad: '#f85149', track: '#8b949e55', ...((c as any).gauge?.colors ?? {}) } as Record<string, string>
776 const miniSvg = (pct: number, tone: string | undefined, key: string, alt: string) => (
777 <Svg key={key} source={barSvg(pct, svgColors[tone ?? 'ok'] ?? svgColors.ok, 36, svgColors.track)} alt={alt} width={36} height={6} />
778 )
779 // Each field is one Box that never shrinks (its separator in front), so a too-narrow row moves whole fields to
780 // the next row and never draws « 7 c… » (the old per-Text truncate-end squeezed every field at once).
781 const fieldEls = (row: Fitted, ri: number) =>
782 row.line.flatMap((g, gi) =>
783 g.map((f, fi) => {
784 const k = `j${ri}-${f.id}`
785 const pre = fi ? row.dot : gi ? row.sep : ''
786 const parts: any[] = pre ? [<Text key={`${k}-s`} bold dimColor>{pre}</Text>] : []
787 if (f.value === undefined) parts.push(<Text key={`${k}-t`} bold {...toneOf(f.tone)}>{f.text}</Text>)
788 else {
789 if (f.label) parts.push(<Text key={`${k}-l`} bold dimColor>{f.label}</Text>)
790 if (f.bar !== undefined && f.pct !== undefined) {
791 parts.push(Svg ? miniSvg(f.pct, f.tone, `${k}-b`, `${f.label ?? ''}${f.value}`.replace(/\s+/g, ' ').trim()) : <Text key={`${k}-b`} bold {...toneOf(f.tone)}>{f.bar}</Text>)
792 }
793 parts.push(<Text key={`${k}-v`} bold {...toneOf(f.tone)}>{f.value}</Text>)
794 }
795 return (
796 <Box key={k} flexDirection="row" flexShrink={0} flexWrap="nowrap">
797 {parts}
798 </Box>
799 )
800 }),
801 )
802 let relayEls: any[] = []
803 if (relay && c.handoff && lay.relay !== 'none') {
804 const lc = await read($, launcherAtom)
805 const hs = await read($, handoffAtom)
806 const prep = handoffButtons(c.handoff, lc.commands, hs.resume, c.labels)[0]
807 // red: the words as one chip, narrower a red mark, narrowest RESUME alone
808 const mark = relayText(relay, lay.relay)
809 relayEls = [
810 ...(mark ? [<Text key="band-relay-hint" bold color={c.colors.bad}>{mark}</Text>] : []),
811 <Button key={prep.key} variant="primary" {...(prep.enabled ? {} : { dimColor: true })} onPress={() => pressHandoff($, prep, isWorking)}>
812 {relay.button}
813 </Button>,
814 ].map((el, i) => (
815 <Box key={`band-relay-${i}`} flexShrink={0}>
816 {el}
817 </Box>
818 ))
819 }
820 const rowEls = (tail: any[]) =>
821 lay.rows.map((row, ri) => (
822 <Box key={`jauge-r${ri}`} flexDirection="row" flexWrap="wrap" columnGap={1}>
823 <Box key="jauge-figures" flexDirection="row" flexWrap="wrap" flexShrink={1}>
824 {fieldEls(row, ri)}
825 </Box>
826 {ri === 0 ? tail : null}
827 </Box>
828 ))
829 const buttonEls = buttons.flatMap((b, i) => {
830 const st = stampOf(b)
831 const btn = (
832 <Button
833 key={`mod-${launcherKey(b)}`}
834 {...(b.loaded ? {} : { dimColor: true })}
835 {...(b.open ? { variant: 'primary' as const } : {})}
836 onPress={() => press($, b, isWorking)}
837 >
838 {fitted.labels[i]}
839 </Button>
840 )
841 return st ? [btn, <Text key={`stamp-${launcherKey(b)}`} dimColor wrap="truncate-end">{st}</Text>] : [btn]
842 })
843 const keepOthers = jx.others_above_prompt === 'show'
844 if (jx.layout !== 'line') {
845 // The clarity Jauge (default since 25 Oct): folded, ONE line « ▶ ● 3 agents en cours · ctx · 5 h · sem »
846 // coloured by state; ▶/▼ unfolds sections (blank line, header, ├ └ tree rows). Pure rows from clarity.ts.
847 const cc = clarityCfg((jx as any).clarity)
848 const ecc = ecCfg(jx)
849 const ui = (await read($, uiAtom)) as BandUi
850 const cin: ClarityIn = {
851 now: v?.now ?? Date.now(),
852 working: Boolean(isWorking),
853 running: ecData,
854 ...(facts.ctxPct !== undefined ? { ctxPct: facts.ctxPct } : {}),
855 ...(ctx?.tokens !== undefined ? { tokens: ctx.tokens } : {}),
856 ...(ctx?.window ? { window: ctx.window } : {}),
857 relayAt: facts.relayAt,
858 ...(facts.relayPct !== undefined ? { relayPct: facts.relayPct } : {}),
859 ...(ctx?.etaMin !== undefined ? { etaMin: ctx.etaMin } : {}),
860 ...(facts.five !== undefined ? { five: facts.five } : {}),
861 ...(facts.fiveProj !== undefined ? { fiveProj: facts.fiveProj } : {}),
862 ...(facts.week !== undefined ? { week: facts.week } : {}),
863 ...(facts.weekProj !== undefined ? { weekProj: facts.weekProj } : {}),
864 ...(facts.tps.tps_all !== undefined ? { tps: facts.tps.tps_all } : {}),
865 ...(facts.waiting !== undefined ? { waiting: facts.waiting } : {}),
866 ...(facts.act ? { pc: facts.act } : {}),
867 }
868 const cv = clarityView(cin, cc, jc.levels, cols, ui.expanded, relay ? { hint: relay.hint, button: relay.button, pad: jc.button_pad } : undefined, { warn_min: ecc.warn_min, bad_min: ecc.bad_min })
869 const tone = (t?: CSeg['tone']) =>
870 t === 'ok' || t === 'warn' || t === 'bad' ? { color: c.colors[t] ?? t } : t === 'accent' ? { color: (c.colors as any).accent ?? c.colors.this } : t === 'dim' ? { dimColor: true } : {}
871 const barW = (s: CSeg) => Math.max(12, (s.cells ?? cells(s.text)) * 7)
872 const segEls = (segs: CSeg[], key: string) =>
873 segs.map((s, i) =>
874 Svg && s.bar !== undefined ? (
875 <Svg key={`${key}-${i}`} source={barSvg(s.bar, svgColors[s.tone ?? 'ok'] ?? svgColors.ok, barW(s), svgColors.track)} alt={`${Math.round(s.bar)} %`} width={barW(s)} height={6} />
876 ) : (
877 <Text key={`${key}-${i}`} {...(s.bold ? { bold: true } : {})} {...tone(s.tone)} wrap="truncate-end">
878 {s.text}
879 </Text>
880 ),
881 )
882 const [head, ...rest] = cv.rows
883 const summaryW = cells(segsText(head.segs)) + cv.reserve
884 const inlineButtons = jx.buttons_inline !== false && summaryW + cells(jc.sep) + fitted.width + stampW <= cols
885 const inlineBrief = briefEls.length > 0 && summaryW + (inlineButtons ? cells(jc.sep) + fitted.width + stampW : 0) + 2 + briefW <= cols
886 let relayC: any[] = []
887 if (relay && c.handoff && cv.relay !== 'none') {
888 const lc = await read($, launcherAtom)
889 const hs = await read($, handoffAtom)
890 const prep = handoffButtons(c.handoff, lc.commands, hs.resume, c.labels)[0]
891 relayC = [
892 ...(cv.relay === 'chip' ? [<Text key="clar-relay-hint" bold color={c.colors.bad}>{relay.hint}</Text>] : []),
893 <Button key={prep.key} variant="primary" {...(prep.enabled ? {} : { dimColor: true })} onPress={() => pressHandoff($, prep, isWorking)}>
894 {relay.button}
895 </Button>,
896 ]
897 }
898 const summary = (
899 <Box key="clar-summary" flexDirection="row" flexWrap="nowrap">
900 <Button key="clar-toggle" onPress={() => toggleUi($, 'band')}>
901 {head.segs[0].text.trim()}
902 </Button>
903 <Text key="clar-gap"> </Text>
904 {segEls(head.segs.slice(1), 'clar-s')}
905 {relayC.length ? (
906 <Box key="clar-relay" flexDirection="row" flexShrink={0} marginLeft={1} columnGap={1}>
907 {relayC}
908 </Box>
909 ) : null}
910 {inlineButtons ? (
911 <Box key="clar-buttons-inline" flexDirection="row" flexShrink={0} marginLeft={2} columnGap={1}>
912 {buttonEls}
913 </Box>
914 ) : null}
915 {inlineBrief ? (
916 <Box key="clar-brief-inline" flexDirection="row" flexShrink={0} marginLeft={2} columnGap={1}>
917 {briefEls}
918 </Box>
919 ) : null}
920 </Box>
921 )
922 const briefRow =
923 briefEls.length && !inlineBrief ? (
924 <Box key="clar-brief-row" flexDirection="row" flexWrap="wrap" columnGap={1}>
925 {briefEls}
926 </Box>
927 ) : null
928 const body: any[] = []
929 for (const r of rest) {
930 // with the buttons on the summary line the Mods section (and its blank line) is left out
931 if (r.section === 'mods' && inlineButtons) continue
932 if (r.kind === 'blank') body.push(<Text key={`clar-${r.key}`}> </Text>)
933 else if (r.kind === 'header' && r.section === 'mods') {
934 body.push(
935 <Box key={`clar-${r.key}`} flexDirection="column">
936 {segEls(r.segs, `clar-${r.key}`)}
937 <Box key="clar-mods-row" flexDirection="row" flexWrap="wrap" marginLeft={cc.indent} columnGap={1}>
938 <Text key="clar-mods-guide" dimColor>
939 {cc.glyphs.last.trimEnd()}
940 </Text>
941 {buttonEls}
942 </Box>
943 </Box>,
944 )
945 } else
946 body.push(
947 <Box key={`clar-${r.key}`} flexDirection="row" flexWrap="nowrap">
948 {segEls(r.segs, `clar-${r.key}`)}
949 </Box>,
950 )
951 }
952 return (
953 <Box flexDirection="column">
954 {summary}
955 {briefRow}
956 {body}
957 {keepOthers ? (theirs ?? null) : null}
958 </Box>
959 )
960 }
961 const ecv = enCoursView(ecData, ecCfg(jx), cols, v?.now)
962 const segColor = (s: Seg) => (s.tone ? { color: c.colors[s.tone] ?? s.tone } : s.dim ? { dimColor: true } : {})
963 const segEl = (s: Seg, key: string) =>
964 Svg && s.bar !== undefined ? miniSvg(s.bar, s.tone, key, `${Math.round(s.bar)} %`) : (
965 <Text key={key} {...segColor(s)} wrap="truncate-end">
966 {s.text}
967 </Text>
968 )
969 const cockpitBtn = buttons.find(b => b.command === 'cockpit')
970 const ecEls = ecv.rows.length
971 ? [
972 ...ecv.rows.map(r => (
973 <Box key={r.key} flexDirection="row" flexWrap="nowrap" {...(r.indent ? { marginLeft: r.indent } : {})}>
974 {r.segs.map((s, i) => segEl(s, `${r.key}-${i}`))}
975 </Box>
976 )),
977 ...(ecv.more
978 ? [
979 <Box key="ec-more" flexDirection="row" flexWrap="nowrap" gap={1}>
980 <Text dimColor wrap="truncate-end">{`${(ecCfg(jx).labels.more ?? '+{n}').replace('{n}', String(ecv.more))}${cockpitBtn ? ' ·' : ''}`}</Text>
981 {cockpitBtn ? (
982 <Button key="ec-cockpit" {...(cockpitBtn.loaded ? {} : { dimColor: true })} onPress={() => press($, cockpitBtn, isWorking)}>
983 {ecCfg(jx).labels.cockpit}
984 </Button>
985 ) : null}
986 </Box>,
987 ]
988 : []),
989 ]
990 : []
991 if (inline) {
992 return (
993 <Box flexDirection="column">
994 {rowEls([...relayEls, ...buttonEls, ...briefEls])}
995 {l2?.text ? <Text key="jauge-l2-t" dimColor wrap="truncate-end">{l2.text}</Text> : null}
996 {ecEls}
997 {keepOthers ? (theirs ?? null) : null}
998 </Box>
999 )
1000 }
1001 return (
1002 <Box flexDirection="column">
1003 {rowEls(relayEls)}
1004 {l2?.text ? <Text key="jauge-l2-t" dimColor wrap="truncate-end">{l2.text}</Text> : null}
1005 {ecEls}
1006 <Box key="jauge-buttons" flexDirection="row" flexWrap="nowrap" gap={1}>
1007 {buttonEls}
1008 {briefEls}
1009 </Box>
1010 {keepOthers ? (theirs ?? null) : null}
1011 </Box>
1012 )
1013 }
1014 if (style === 'compact') {
1015 const sc = secCfg(c)
1016 const L = sc.labels
1017 const ui = (await read($, uiAtom)) as BandUi
1018 const g = gaugeOf(c, ctx, v ?? null, undefined)
1019 const gw = (c as any).gauge?.width ?? 120
1020 const scope = (((c as any).gauge?.scopes as string[] | undefined) ?? ['conv'])[0]
1021 const srow = v?.scopes.find(r => r.id === scope)
1022 const rate = srow ? (srow.rates.find(x => x.w === c.band_window_s) ?? srow.rates[0]) : undefined
1023 const compactions = v?.compactions.find(r => r.isThis)?.items.length
1024 const head = headLine({ ctxPct: g.pct, etaMin: ctx?.etaMin, tps: rate?.tps, compactions, limits: en?.limits }, L)
1025 const tone = c.colors[g.level]
1026 const marks = g.marks.map(m => m.label).join(' · ')
1027 const line1 = (
1028 <Box key="band-line" flexDirection="row" gap={1} alignItems="center" flexWrap="nowrap">
1029 {Svg && g.hasReading ? (
1030 <Svg key="band-gauge" source={gaugeSvg(g, (c as any).gauge?.colors ?? {}, gw, (c as any).gauge?.mark_labels ?? 'nearest')} alt={`${g.title} · ${marks}`} />
1031 ) : (
1032 <Text key="band-gauge-t" color={tone}>{gaugeText(g, (c as any).gauge?.text_width ?? 12)}</Text>
1033 )}
1034 <Text key="band-head" color={tone} wrap="truncate-end">{head}</Text>
1035 {counters(v?.waiting, L).map(k => (
1036 <Text key={k.key} bold color={c.colors.bad ?? '#f85149'}>{k.text}</Text>
1037 ))}
1038 <Button key="band-toggle" onPress={() => toggleUi($, 'band')}>
1039 {ui.expanded ? '▾' : '▸'}
1040 </Button>
1041 </Box>
1042 )
1043 const facts = {
1044 model: en?.model,
1045 effort: en?.effort,
1046 agents: v?.conv?.agents,
1047 pr: v?.conv?.pr,
1048 tokens: ctx?.tokens,
1049 handoffAt: c.context.handoff_tokens,
1050 }
1051 const header = (id: string, title: string, summary: string) => (
1052 <Box key={`sec-h-${id}`} flexDirection="row" gap={1} alignItems="center" flexWrap="nowrap">
1053 <Button key={`sec-${id}`} onPress={() => toggleUi($, id)}>
1054 {`${isOpen(ui, id) ? '▾' : '▸'} ${title}`}
1055 </Button>
1056 {isOpen(ui, id) ? null : <Text dimColor wrap="truncate-end">{summary}</Text>}
1057 </Box>
1058 )
1059 const sections = sectionList(sc).map(id => {
1060 if (id === 'mods') return null
1061 if (!ui.expanded) return null
1062 if (id === 'conv') {
1063 return (
1064 <Box key="sec-conv" flexDirection="column">
1065 {header('conv', L.conv, convSummary(facts, L))}
1066 {isOpen(ui, 'conv') ? (
1067 <Box flexDirection="column" marginLeft={2}>
1068 {convLines(facts, L).map((t, i) => (
1069 <Text key={`cl${i}`} dimColor={i > 0}>{t}</Text>
1070 ))}
1071 </Box>
1072 ) : null}
1073 </Box>
1074 )
1075 }
1076 return null
1077 })
1078 let relayC = null
1079 if (advice.show && c.handoff) {
1080 const hs = await read($, handoffAtom)
1081 const lc = await read($, launcherAtom)
1082 const hb = handoffButtons(c.handoff, lc.commands, hs.resume, c.labels)
1083 relayC = (
1084 <Box key="band-relay" flexDirection="row" flexWrap="wrap" gap={1}>
1085 <Text color={c.colors.warn}>{`${c.labels.handoff_advice ?? 'Passation conseillée'} : ${handoffReason(advice, ctx, c.handoff, c.labels)}`}</Text>
1086 {hb.map(b => (
1087 <Button key={b.key} {...(b.enabled ? {} : { dimColor: true })} onPress={() => pressHandoff($, b, isWorking)}>
1088 {b.label}
1089 </Button>
1090 ))}
1091 <Text dimColor>{`RESUME ${ageText(hs.resume?.mtime, v?.now ?? Date.now(), c.labels)}`}</Text>
1092 </Box>
1093 )
1094 }
1095 const allButtons = [...buttons, ...(await moreButtonsOf($, c))]
1096 const modsRow =
1097 sectionList(sc).includes('mods') && allButtons.length ? (
1098 <Box key="band-mods" flexDirection="row" flexWrap="wrap" gap={1}>
1099 <Text dimColor>{`${c.labels.launcher ?? 'Mods'} :`}</Text>
1100 {allButtons.map(b => (
1101 <Button
1102 key={`mod-${launcherKey(b)}`}
1103 {...(b.loaded ? {} : { dimColor: true })}
1104 {...(b.open ? { variant: 'primary' as const } : {})}
1105 onPress={() => press($, b, isWorking)}
1106 >
1107 {b.open ? `${b.label} ✕` : b.loaded ? b.label : `${b.label} · ${b.hint ?? ''}`}
1108 </Button>
1109 ))}
1110 </Box>
1111 ) : null
1112 const order = sectionList(sc)
1113 const body = order.map((id, i) => (id === 'mods' ? modsRow : sections[i]))
1114 return (
1115 <Box flexDirection="column">
1116 {line1}
1117 {relayC}
1118 {body}
1119 {theirs ?? null}
1120 </Box>
1121 )
1122 }
1123 const gaugeStyle = style === 'gauge'
1124 const g = gaugeStyle ? gaugeOf(c, ctx, v ?? null, en?.week) : undefined
1125 const gaugeEl = g ? (
1126 <Box flexDirection="column">
1127 <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
1128 <Text color={c.colors[g.level]}>{g.title}</Text>
1129 {Svg ? null : <Text color={c.colors[g.level]}>{gaugeText(g, (c as any).gauge?.text_width ?? 30)}</Text>}
1130 </Box>
1131 {Svg && g.hasReading ? <Svg source={gaugeSvg(g, (c as any).gauge?.colors ?? {}, (c as any).gauge?.width ?? 320)} alt={`${g.title}${g.marks.map(m => ' · ' + m.label).join('')}`} /> : null}
1132 {g.line ? <Text dimColor>{g.line}</Text> : null}
1133 </Box>
1134 ) : null
1135 let relay = null
1136 if (advice.show && c.handoff) {
1137 const hs = await read($, handoffAtom)
1138 const lc = await read($, launcherAtom)
1139 const hb = handoffButtons(c.handoff, lc.commands, hs.resume, c.labels)
1140 relay = (
1141 <Box flexDirection="row" flexWrap="wrap" gap={1}>
1142 <Text color={c.colors.warn}>{`${c.labels.handoff_advice ?? 'Passation conseillée'} : ${handoffReason(advice, ctx, c.handoff, c.labels)}`}</Text>
1143 {hb.map(b => (
1144 <Button key={b.key} {...(b.enabled ? {} : { dimColor: true })} onPress={() => pressHandoff($, b, isWorking)}>
1145 {b.label}
1146 </Button>
1147 ))}
1148 <Text dimColor>{`RESUME ${ageText(hs.resume?.mtime, v?.now ?? Date.now(), c.labels)}`}</Text>
1149 </Box>
1150 )
1151 }
1152 return (
1153 <Box flexDirection="column">
1154 {gaugeEl}
1155 {!gaugeStyle && parts.length ? (
1156 <Box flexDirection="row" flexWrap="wrap">
1157 {parts.map((p, i) => (
1158 <Text key={`b${i}`} {...(color(p) ? { color: color(p) } : { dimColor: true })}>
1159 {`${i ? ' · ' : ''}${p.text}`}
1160 </Text>
1161 ))}
1162 </Box>
1163 ) : null}
1164 {relay}
1165 {buttons.length ? (
1166 <Box flexDirection="row" flexWrap="wrap" gap={1}>
1167 <Text dimColor>{`${c.labels.launcher ?? 'Mods'} :`}</Text>
1168 {buttons.map(b => (
1169 <Button
1170 key={`mod-${launcherKey(b)}`}
1171 {...(b.loaded ? {} : { dimColor: true })}
1172 {...(b.open ? { variant: 'primary' as const } : {})}
1173 onPress={() => press($, b, isWorking)}
1174 >
1175 {b.open ? `${b.label} ✕` : b.loaded ? b.label : `${b.label} · ${b.hint ?? ''}`}
1176 </Button>
1177 ))}
1178 </Box>
1179 ) : null}
1180 {theirs ?? null}
1181 </Box>
1182 )
1183 }).catch(($, e, next) => next(e))
1184
1185 on('ui.render', { component: 'Pane', requestId: 'jauge' }, async ($, e) => {
1186 const { Box, Text, Button, Link, Markdown } = $.ui.resolve(e)
1187 const c = await loadConfig($)
1188 const v = await read($, viewAtom)
1189 const en = await read($, engineAtom)
1190 const err = await read($, errorAtom)
1191 const now = v?.now ?? Date.now()
1192 const ctx = ctxOf(c, en, v, now)
1193 const lb = c.labels
1194 const tone = (t?: string) => (t && c.colors[t] ? { color: c.colors[t] } : {})
1195 const pad = (s: string, n: number) => (s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length))
1196 const lpad = (s: string, n: number) => (s.length >= n ? s : ' '.repeat(n - s.length) + s)
1197 const nv = <Text color={c.colors.warn}>non vérifié</Text>
1198
1199 const head = (
1200 <Box flexDirection="row" gap={1} flexWrap="wrap">hooks/logic.ts 657 lines1// band mod ("Jauge"): the pure math. Rates over sliding windows, scope grouping (worktree -> repo), context and
2// compaction figures, sparklines and the French band text. No I/O here: the scanner (scanner/scan.mjs) and the hooks
3// module (register.tsx) both import it, and node --test runs it directly (Node 22.18+ strips the types).
4
5import type { Compaction, CompactRow, CountMode, Ev, Rate, ScopeId, ScopeRow, SessionMeta, TopRow, Usage, View } from '../types'
6
7export type { Compaction, CompactRow, CountMode, Ev, Rate, ScopeId, ScopeRow, SessionMeta, TopRow, Usage, View }
8
9/** This conversation's agents in the kept window: total seen, running (wrote within runningS), the running Workflow. */
10export const agentsOf = (events: Ev[], session: string | undefined, now: number, runningS: number) => {
11 if (!session) return undefined
12 const last = new Map<string, { t: number; w?: string }>()
13 for (const e of events) {
14 if (e.s !== session || !e.a) continue
15 const p = last.get(e.a)
16 if (!p || e.t > p.t) last.set(e.a, { t: e.t, ...(e.w ? { w: e.w } : {}) })
17 }
18 const running = [...last.values()].filter(x => now - x.t <= runningS * 1000)
19 const wf = running.filter(x => x.w).sort((a, b) => b.t - a.t)[0]?.w
20 return { running: running.length, total: last.size, ...(wf ? { workflow: wf } : {}) }
21}
22
23/** The whole PC: agents running (subagents and Workflow agents that wrote within runningS) and conversations active
24 * (any of their files wrote within activeS), from the kept events. */
25export const pcActivity = (events: Ev[], now: number, runningS: number, activeS: number) => {
26 const agents = new Set<string>()
27 const convs = new Set<string>()
28 for (const e of events) {
29 if (e.t > now) continue
30 if (e.a && now - e.t <= runningS * 1000) agents.add(`${e.s}/${e.a}`)
31 if (now - e.t <= activeS * 1000) convs.add(e.s)
32 }
33 return { agents: agents.size, convs: convs.size }
34}
35
36
37export type Rates = Record<string, { in: number; out: number; cache_read: number; cache_write_5m: number }>
38
39export type Config = {
40 projects_root: string
41 cache_file: string
42 node: string
43 refresh_s: number
44 windows_s: number[]
45 band_window_s: number
46 spark_min: number
47 bucket_s: number
48 top_n: number
49 count: CountMode
50 compact_history_hours: number
51 first_read_mb: number
52 shared_lib: string
53 worktree_markers: string[]
54 band: { items: string[]; scopes: string[] }
55 status_line: { items: string[]; scopes: string[] }
56 launcher?: Launcher
57 handoff?: HandoffCfg
58 context: {
59 window_fallback: number
60 autocompact_pct_fallback: number
61 warn_tokens: number
62 handoff_tokens: number
63 handoff_near_tokens: number
64 growth_window_s: number
65 }
66 idle_compact: { min_tokens: number; idle_min: number; show_after_idle_min: number }
67 colors: Record<string, string>
68 labels: Record<string, string>
69 rates: Rates
70}
71
72export const SCOPES = ['conv', 'conv_all', 'agents', 'workflows', 'project', 'all'] as const
73
74// ---------- numbers ----------
75
76export const tokensOf = (u: Usage, mode: CountMode) => u.inp + u.cw + u.out + (mode === 'all' ? u.cr : 0)
77
78const rateFor = (rates: Rates, model: string) => {
79 if (rates[model]) return rates[model]
80 const m = model.toLowerCase()
81 for (const fam of ['fable', 'opus', 'sonnet', 'haiku']) if (m.includes(fam) && rates[fam]) return rates[fam]
82 return rates.default ?? { in: 0, out: 0, cache_read: 0, cache_write_5m: 0 }
83}
84
85/** List-price dollars of one response (cache writes priced at the 5-minute rate). */
86/** A price table, or a function pricing one response (the shared mods/usage price table). */
87export type Price = Rates | ((e: Ev) => number)
88
89export const costOf = (e: Ev, rates: Price) => {
90 if (typeof rates === 'function') return rates(e)
91 const r = rateFor(rates, e.m)
92 return (e.u.inp * r.in + e.u.cw * r.cache_write_5m + e.u.cr * r.cache_read + e.u.out * r.out) / 1e6
93}
94
95/** French number: 120, 2,1 k, 41 k, 1,2 M. */
96export const fmtTok = (n: number) => {
97 const a = Math.abs(n)
98 const one = (x: number) => (x < 10 ? x.toFixed(1).replace('.', ',').replace(/,0$/, '') : String(Math.round(x)))
99 if (a < 1000) return String(Math.round(n))
100 if (a < 1e6) return `${one(n / 1000)} k`
101 return `${one(n / 1e6)} M`
102}
103export const fmtUsd = (n: number) => `${n < 10 ? n.toFixed(2) : Math.round(n).toString()} $`.replace('.', ',')
104export const fmtPct = (n: number) => `${Math.round(n)} %`
105export const hhmm = (t: number) => {
106 const d = new Date(t)
107 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
108}
109
110// ---------- scopes ----------
111
112const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').replace(/^([a-z]):/, (_, d: string) => `${d.toUpperCase()}:`)
113
114/** Repo key of a working directory: a worktree folder (".claude/worktrees/x", ...) counts as its repo. */
115export const repoKey = (cwd: string | undefined, markers: string[]) => {
116 if (!cwd) return '?'
117 let p = norm(cwd)
118 for (const m of markers) {
119 const i = p.toLowerCase().indexOf(m.toLowerCase())
120 if (i > 0) p = p.slice(0, i)
121 }
122 return p
123}
124export const repoName = (key: string) => key.split('/').filter(Boolean).pop() ?? key
125
126export type ScopeCtx = { session?: string; repo?: string; repoOf: (s: string) => string | undefined }
127
128export const inScope = (scope: ScopeId, e: Ev, c: ScopeCtx) => {
129 switch (scope) {
130 case 'conv':
131 return e.s === c.session && !e.a
132 case 'conv_all':
133 return e.s === c.session
134 case 'agents':
135 return e.s === c.session && !!e.a
136 case 'workflows':
137 return e.s === c.session && !!e.w
138 case 'project':
139 return !!c.repo && c.repoOf(e.s) === c.repo
140 case 'all':
141 return true
142 }
143}
144
145
146/** Sliding-window rate: the events with t in (now - w, now], over w seconds. */
147export const rateOf = (events: Ev[], now: number, wS: number, mode: CountMode, rates: Price): Rate => {
148 const from = now - wS * 1000
149 let tokens = 0
150 let usd = 0
151 let calls = 0
152 for (const e of events) {
153 if (e.t <= from || e.t > now) continue
154 tokens += tokensOf(e.u, mode)
155 usd += costOf(e, rates)
156 calls++
157 }
158 const tps = tokens / wS
159 return { w: wS, tokens, tps, tpm: tps * 60, tph: tps * 3600, usdPerH: (usd / wS) * 3600, calls }
160}
161
162/** Tokens per bucket over the last `minutes`, oldest first. */
163export const spark = (events: Ev[], now: number, minutes: number, bucketS: number, mode: CountMode) => {
164 const n = Math.max(1, Math.round((minutes * 60) / bucketS))
165 const out = new Array<number>(n).fill(0)
166 const start = now - n * bucketS * 1000
167 for (const e of events) {
168 if (e.t <= start || e.t > now) continue
169 const i = Math.min(n - 1, Math.floor((e.t - start) / (bucketS * 1000)))
170 out[i] += tokensOf(e.u, mode)
171 }
172 return out
173}
174export const bucketOf = (t: number, now: number, minutes: number, bucketS: number) => {
175 const n = Math.max(1, Math.round((minutes * 60) / bucketS))
176 const start = now - n * bucketS * 1000
177 if (t <= start || t > now) return -1
178 return Math.min(n - 1, Math.floor((t - start) / (bucketS * 1000)))
179}
180const BARS = '▁▂▃▄▅▆▇█'
181export const sparkline = (values: number[]) => {
182 const max = Math.max(0, ...values)
183 return values.map(v => (v <= 0 ? ' ' : BARS[Math.min(7, Math.floor((v / max) * 7.999))])).join('')
184}
185export const markerLine = (n: number, marks: number[], ch = '▲') => {
186 const a = new Array<string>(n).fill(' ')
187 for (const i of marks) if (i >= 0 && i < n) a[i] = ch
188 return a.join('')
189}
190
191// ---------- the view the scanner hands to the mod ----------
192
193
194const titleOf = (m: SessionMeta | undefined, s: string) => m?.title || s.slice(0, 8)
195
196/** Context growth of the main thread, tokens per minute, over the last `wS` seconds; none across a compaction. */
197export const growthTpm = (samples: [number, number][], now: number, wS: number) => {
198 const from = now - wS * 1000
199 const xs = samples.filter(([t]) => t > from && t <= now)
200 if (xs.length < 2) return undefined
201 let k = xs.length - 1
202 while (k > 0 && xs[k - 1][1] <= xs[k][1]) k--
203 const a = xs[k]
204 const b = xs[xs.length - 1]
205 if (b[0] <= a[0] || b[1] <= a[1]) return undefined
206 return ((b[1] - a[1]) / (b[0] - a[0])) * 60000
207}
208
209export const buildView = (
210 events: Ev[],
211 sessions: Record<string, SessionMeta>,
212 cfg: Pick<Config, 'windows_s' | 'spark_min' | 'bucket_s' | 'count' | 'rates' | 'top_n' | 'compact_history_hours' | 'worktree_markers'> & {
213 context?: { growth_window_s: number }
214 sections?: { running_s?: number }
215 jauge?: { active_s?: number }
216 },
217 opts: { now: number; session?: string; cwd?: string; files?: number; ms?: number; price?: (e: Ev) => number; lib?: string },
218): View => {
219 const { now, session } = opts
220 const price: Price = opts.price ?? cfg.rates
221 const repoOf = (s: string) => sessions[s]?.repo ?? (sessions[s]?.cwd ? repoKey(sessions[s].cwd, cfg.worktree_markers) : undefined)
222 const repo = opts.cwd ? repoKey(opts.cwd, cfg.worktree_markers) : session ? repoOf(session) : undefined
223 const ctx: ScopeCtx = { session, repo, repoOf }
224 const ids = SCOPES.filter(id => (session ? true : id === 'project' ? !!repo : id === 'all'))
225 const longest = Math.max(...cfg.windows_s)
226 const scopes = ids.map(id => {
227 const evs = events.filter(e => inScope(id, e, ctx))
228 return {
229 id,
230 rates: cfg.windows_s.map(w => rateOf(evs, now, w, cfg.count, price)),
231 spark: spark(evs, now, cfg.spark_min, cfg.bucket_s, cfg.count),
232 }
233 })
234
235 // hottest sessions over the longest window
236 const bySession = new Map<string, Ev[]>()
237 for (const e of events) {
238 if (e.t <= now - longest * 1000 || e.t > now) continue
239 const l = bySession.get(e.s) ?? []
240 l.push(e)
241 bySession.set(e.s, l)
242 }
243 const since = now - cfg.compact_history_hours * 3600000
244 const compOf = (s: string) => (sessions[s]?.compactions ?? []).filter(c => c.t > since && !c.a)
245 const top: TopRow[] = [...bySession.entries()]
246 .map(([s, evs]) => {
247 const r = rateOf(evs, now, longest, cfg.count, price)
248 const m = sessions[s]
249 const cs = compOf(s)
250 return {
251 s,
252 title: titleOf(m, s),
253 repo: repoName(repoOf(s) ?? '?'),
254 tps: r.tps,
255 usdPerH: r.usdPerH,
256 ctx: m?.ctx,
257 compactions: cs.length,
258 lastCompact: cs.at(-1)?.t,
259 last: m?.last ?? 0,
260 isThis: s === session,
261 }
262 })
263 .sort((a, b) => b.tps - a.tps)
264 .slice(0, cfg.top_n)
265
266 const byRepo = new Map<string, { evs: Ev[]; sessions: Set<string> }>()
267 for (const [s, evs] of bySession) {
268 const k = repoOf(s) ?? '?'
269 const g = byRepo.get(k) ?? { evs: [], sessions: new Set() }
270 g.evs.push(...evs)
271 g.sessions.add(s)
272 byRepo.set(k, g)
273 }
274 const projects = [...byRepo.entries()]
275 .map(([k, g]) => {
276 const r = rateOf(g.evs, now, longest, cfg.count, price)
277 return { repo: repoName(k), tps: r.tps, usdPerH: r.usdPerH, sessions: g.sessions.size }
278 })
279 .sort((a, b) => b.tps - a.tps)
280
281 const compactions: CompactRow[] = Object.values(sessions)
282 .map(m => ({ s: m.s, title: titleOf(m, m.s), repo: repoName(repoOf(m.s) ?? '?'), isThis: m.s === session, items: compOf(m.s) }))
283 .filter(r => r.items.length > 0)
284 .sort((a, b) => Number(b.isThis) - Number(a.isThis) || (b.items.at(-1)?.t ?? 0) - (a.items.at(-1)?.t ?? 0))
285
286 const marksFor = (pick: (s: string) => boolean) =>
287 Object.values(sessions)
288 .filter(m => pick(m.s))
289 .flatMap(m => m.compactions.map(c => bucketOf(c.t, now, cfg.spark_min, cfg.bucket_s)))
290 .filter(i => i >= 0)
291 const me = session ? sessions[session] : undefined
292 return {
293 now,
294 windows: cfg.windows_s,
295 spark_min: cfg.spark_min,
296 bucket_s: cfg.bucket_s,
297 count: cfg.count,
298 repo: repo ? repoName(repo) : undefined,
299 scopes,
300 top,
301 projects,
302 compactions,
303 marks: {
304 conv: marksFor(s => s === session),
305 project: marksFor(s => !!repo && repoOf(s) === repo),
306 all: marksFor(() => true),
307 },
308 conv: me
309 ? {
310 ctx: me.ctx,
311 ctxT: me.ctxT,
312 last: me.last,
313 title: me.title,
314 growthTpm: growthTpm(me.samples, now, cfg.context?.growth_window_s ?? 600),
315 agents: agentsOf(events, session, now, cfg.sections?.running_s ?? 120),
316 ...(me.pr ? { pr: me.pr } : {}),
317 }
318 : undefined,
319 pc: pcActivity(events, now, cfg.sections?.running_s ?? 120, cfg.jauge?.active_s ?? 300),
320 files: opts.files ?? 0,
321 ms: opts.ms ?? 0,
322 lib: opts.lib ?? 'band',
323 }
324}
325
326// ---------- this conversation's context ----------
327
328export type Ctx = {
329 tokens?: number
330 window: number
331 usedPct?: number
332 threshold: number
333 thresholdPct: number
334 thresholdSource: 'engine' | 'config'
335 toCompact?: number
336 toCompactPct?: number
337 etaMin?: number
338 relay: 'ok' | 'near' | 'over'
339 level: 'ok' | 'warn' | 'bad'
340 idleEtaMin?: number
341 idleMin?: number
342}
343
344export const contextInfo = (
345 a: { tokens?: number; window?: number; percent?: number; autoCompactThreshold?: number; growthTpm?: number; last?: number; now: number },
346 c: Config['context'],
347 idle: Config['idle_compact'],
348): Ctx => {
349 const window = a.window || c.window_fallback
350 const fromEngine = typeof a.autoCompactThreshold === 'number' && a.autoCompactThreshold > 0
351 const threshold = fromEngine ? (a.autoCompactThreshold as number) : Math.round((window * c.autocompact_pct_fallback) / 100)
352 const t = a.tokens
353 const usedPct = a.percent ?? (t !== undefined ? (t * 100) / window : undefined)
354 const toCompact = t !== undefined ? Math.max(0, threshold - t) : undefined
355 const etaMin = toCompact !== undefined && a.growthTpm && a.growthTpm > 0 ? toCompact / a.growthTpm : undefined
356 const relay = t === undefined ? 'ok' : t >= c.handoff_tokens ? 'over' : t >= c.handoff_near_tokens ? 'near' : 'ok'
357 const level = t === undefined ? 'ok' : t >= c.handoff_tokens ? 'bad' : t >= c.warn_tokens ? 'warn' : 'ok'
358 const idleMin = a.last ? Math.max(0, (a.now - a.last) / 60000) : undefined
359 const idleEtaMin = t !== undefined && t >= idle.min_tokens && idleMin !== undefined ? Math.max(0, idle.idle_min - idleMin) : undefined
360 return {
361 tokens: t,
362 window,
363 usedPct,
364 threshold,
365 thresholdPct: (threshold * 100) / window,
366 thresholdSource: fromEngine ? 'engine' : 'config',
367 toCompact,
368 toCompactPct: toCompact !== undefined ? (toCompact * 100) / window : undefined,
369 etaMin,
370 relay,
371 level,
372 idleEtaMin,
373 idleMin,
374 }
375}
376
377// ---------- band text ----------
378
379export type BandPart = { text: string; tone?: 'ok' | 'warn' | 'bad' | 'dim' }
380
381const L = (labels: Record<string, string>, k: string) => labels[k] ?? k
382
383export const bandParts = (
384 view: View | undefined,
385 ctx: Ctx | undefined,
386 week: number | undefined,
387 spec: { items: string[]; scopes: string[] },
388 cfg: Pick<Config, 'labels' | 'band_window_s' | 'idle_compact'>,
389): BandPart[] => {
390 const lb = cfg.labels
391 const out: BandPart[] = []
392 for (const item of spec.items) {
393 if (item === 'ctx' && ctx?.usedPct !== undefined) {
394 out.push({ text: `${L(lb, 'ctx')} ${fmtPct(ctx.usedPct)} (${fmtTok(ctx.tokens ?? 0)})`, tone: ctx.level })
395 } else if (item === 'compact' && ctx?.toCompactPct !== undefined) {
396 const eta = ctx.etaMin !== undefined ? ` · ${L(lb, 'compact_in')} ~${Math.max(1, Math.round(ctx.etaMin))} min` : ''
397 out.push({ text: `${L(lb, 'compact_at')} ${fmtPct(ctx.thresholdPct)} · ${L(lb, 'left')} ${fmtPct(ctx.toCompactPct)}${eta}`, tone: ctx.toCompactPct < 5 ? 'bad' : ctx.toCompactPct < 15 ? 'warn' : undefined })
398 } else if (item === 'relay' && ctx && ctx.relay !== 'ok') {
399 out.push({ text: L(lb, ctx.relay === 'over' ? 'relay_over' : 'relay_near'), tone: ctx.relay === 'over' ? 'bad' : 'warn' })
400 } else if (item === 'idle' && ctx?.idleEtaMin !== undefined && (ctx.idleMin ?? 0) >= cfg.idle_compact.show_after_idle_min) {
401 out.push({ text: `${L(lb, 'idle_compact_in')} ~${Math.round(ctx.idleEtaMin)} min`, tone: 'warn' })
402 } else if (item === 'rates' && view) {
403 for (const id of spec.scopes) {
404 const row = view.scopes.find(r => r.id === id)
405 if (!row) continue
406 const r = row.rates.find(x => x.w === cfg.band_window_s) ?? row.rates[0]
407 out.push({ text: `${L(lb, id)} ${fmtTok(r.tps)} tok/s` })
408 }
409 } else if (item === 'week' && week !== undefined) {
410 out.push({ text: `${L(lb, 'week')} ${fmtPct(week)}`, tone: week >= 90 ? 'bad' : week >= 75 ? 'warn' : undefined })
411 }
412 }
413 return out
414}
415export const bandText = (parts: BandPart[]) => parts.map(p => p.text).join(' · ')
416
417// ---------- config ----------
418
419const isObj = (x: unknown): x is Record<string, unknown> => !!x && typeof x === 'object' && !Array.isArray(x)
420export const deepMerge = <T>(base: T, over: unknown): T => {
421 if (!isObj(base) || !isObj(over)) return (over === undefined ? base : (over as T))
422 const out: Record<string, unknown> = { ...base }
423 for (const [k, v] of Object.entries(over)) {
424 if (k.startsWith('_')) continue
425 out[k] = isObj(v) && isObj(out[k]) ? deepMerge(out[k], v) : v
426 }
427 return out as T
428}
429export const expandHome = (p: string, home: string) => (p.startsWith('~') ? home.replace(/[\\/]+$/, '') + p.slice(1) : p)
430
431export const compactLabel = (c: Compaction, labels: Record<string, string>, idleMin: number) => {
432 if (c.trigger === 'manual') return L(labels, 'trigger_manual')
433 if (c.trigger === 'auto' && (c.gapMin ?? 0) >= idleMin * 0.8) return L(labels, 'trigger_idle')
434 if (c.trigger === 'auto') return L(labels, 'trigger_auto')
435 return c.trigger === '?' ? L(labels, 'trigger_unknown') : c.trigger
436}
437
438// ---------- launcher (the Mods button bar) ----------
439
440/** args: what the press passes to the command (« fil » opens /veille fil); key: the open-state id when two buttons share a mod. */
441export type LauncherItem = {
442 label: string
443 short?: string
444 mod: string
445 command: string
446 args?: string
447 key?: string
448 /** an action, not a pane: every press runs the command and the button never shows ✕ (Usage, Exporter) */
449 action?: boolean
450 /** loaded whichever plugin registers the command, or none (a built-in such as /usage) */
451 any_plugin?: boolean
452 /** what runs instead when the command is not in this session (Usage: built-in /usage, else the usage mod's /conso) */
453 fallback?: { mod: string; command: string; args?: string }
454 /** a small dim state after the button once pressed or typed, « dernier export {hhmm} », kept in $.store */
455 stamp?: string
456}
457/** preset five (default): items, the five Jauge buttons; hubs: hub_items, the older hub row; all: all_items, the 15 older buttons. */
458export type Launcher = {
459 enabled?: boolean
460 close_args?: string
461 preset?: string
462 /** one hub open at a time */
463 single?: boolean
464 items?: LauncherItem[]
465 hub_items?: LauncherItem[]
466 /** shown when the band is unfolded (▾): Jauge, band's own detail pane */
467 more_items?: LauncherItem[]
468 all_items?: LauncherItem[]
469}
470
471/** The items of the chosen preset: all -> all_items, hubs -> hub_items (when listed), anything else -> items. */
472export const launcherItems = (l: Launcher | undefined): LauncherItem[] =>
473 !l
474 ? []
475 : l.preset === 'all' && Array.isArray(l.all_items) && l.all_items.length
476 ? l.all_items
477 : l.preset === 'hubs' && Array.isArray(l.hub_items) && l.hub_items.length
478 ? l.hub_items
479 : (l.items ?? [])
480/** The preset's items and the unfolded ones (more_items), for the open-state tracking. */
481export const allLauncherItems = (l: Launcher | undefined): LauncherItem[] => [...launcherItems(l), ...(l && Array.isArray(l.more_items) ? l.more_items : [])]
482
483/** single: the open buttons to close when `key` opens (every other open one), as items. */
484export const othersOpen = (items: readonly LauncherItem[], open: readonly string[], key: string) =>
485 items.filter((i, n) => i && launcherKey(i) !== key && open.includes(launcherKey(i)) && items.findIndex(j => launcherKey(j) === launcherKey(i)) === n)
486
487/** One slash command as $.command.list() gives it (name, and the plugin that registered it when known). */
488export type CommandSeen = { name: string; plugin?: string }
489/** via: the fallback command that runs in place of the item's own (not in this session). */
490export type LauncherButton = LauncherItem & { loaded: boolean; open: boolean; hint?: string; via?: string }
491export type LauncherAction = { run: { command: string; args?: string }; open: boolean } | { toast: string }
492
493/** A mod counts as loaded when its command is in this session and, where the engine names the plugin, it is that mod. */
494export const isLoaded = (item: Pick<LauncherItem, 'mod' | 'command' | 'any_plugin'>, commands: readonly CommandSeen[]) =>
495 commands.some(c => c.name === item.command && (item.any_plugin || !c.plugin || c.plugin === item.mod || c.plugin.startsWith(`${item.mod}@`)))
496
497/** The buttons to draw, in config order; empty when the launcher is off or lists nothing. */
498export const launcherButtons = (
499 l: Launcher | undefined,
500 commands: readonly CommandSeen[],
501 open: readonly string[],
502 labels: Record<string, string>,
503): LauncherButton[] => {
504 if (!l || l.enabled === false) return []
505 return launcherItems(l)
506 .filter(i => i && i.label && i.mod && i.command)
507 .map((i): LauncherButton => {
508 const loaded = isLoaded(i, commands)
509 const fb = i.fallback
510 if (!loaded && fb && fb.command && fb.mod && isLoaded(fb, commands)) {
511 const { args: _own, ...rest } = i
512 return { ...rest, mod: fb.mod, command: fb.command, ...(fb.args ? { args: fb.args } : {}), loaded: true, open: !i.action && open.includes(launcherKey(i)), via: `/${fb.command}` }
513 }
514 return {
515 ...i,
516 loaded,
517 open: loaded && !i.action && open.includes(launcherKey(i)),
518 ...(loaded ? {} : { hint: (labels.launcher_missing ?? 'not loaded: add mods/{mod} to CLAUDE_CODE_PLUGIN_DIRS').replace('{mod}', i.mod) }),
519 }
520 })
521}
522
523/** What a press does: a mod not loaded explains itself; else open (/<command>) or, when open, close (/<command> <close_args>). */
524export const launcherPress = (b: LauncherButton, closeArgs = 'close'): LauncherAction => {
525 if (!b.loaded) return { toast: b.hint ?? b.mod }
526 if (b.action) return { run: { command: b.command, ...(b.args ? { args: b.args } : {}) }, open: false }
527 return b.open ? { run: { command: b.command, args: closeArgs }, open: false } : { run: { command: b.command, ...(b.args ? { args: b.args } : {}) }, open: true }
528}
529
530/** The open-state id of a button: its key, else its mod. */
531export const launcherKey = (i: LauncherItem) => i.key || i.mod
532
533/** What a typed /<command> <args> does to the buttons: closing closes every button of that command; else the button
534 * whose args match opens (the one without args when none matches), and the other buttons of that command close. */
535export const launcherTyped = (items: readonly LauncherItem[], command: string, args: string, closeArgs = 'close') => {
536 const mine = items.filter(i => i && !i.action && i.command === command)
537 if (!mine.length) return null
538 const a = (args ?? '').trim()
539 if (a === closeArgs) return { open: [] as string[], close: mine.map(launcherKey) }
540 const hit = mine.find(i => (i.args ?? '') && a.split(/\s+/)[0] === i.args) ?? mine.find(i => !i.args)
541 return { open: hit ? [launcherKey(hit)] : [], close: mine.filter(i => i !== hit).map(launcherKey) }
542}
543
544/** The stamped buttons a run of /<command> concerns (its own command or its fallback's), by open-state key. */
545export const stampKeys = (items: readonly LauncherItem[], command: string) =>
546 items.filter(i => i && i.stamp && (i.command === command || i.fallback?.command === command)).map(launcherKey)
547
548/** « dernier export 14:32 » from the item's stamp template and the time it last ran; '' when it never ran. */
549export const stampText = (item: Pick<LauncherItem, 'stamp'>, t: number | undefined) => (item.stamp && t ? item.stamp.replace('{hhmm}', hhmm(t)) : '')
550
551/** The open list after a press on `mod`. */
552export const toggleOpen = (open: readonly string[], mod: string, isOpen: boolean) =>
553 isOpen ? (open.includes(mod) ? [...open] : [...open, mod]) : open.filter(m => m !== mod)
554
555// ---------- hand-off helper (« Passation conseillée ») ----------
556
557export type HandoffCfg = {
558 enabled?: boolean
559 /** the orange line under the band; false: only the pane's Passation block */
560 band?: boolean
561 /** context tokens of this conversation from which the line shows (the RESUME rule: 250 K) */
562 threshold_tokens: number
563 /** also show it when the forecast puts auto-compact within this many minutes */
564 eta_min: number
565 /** the slash command « Préparer RESUME » runs, without the slash, and its arguments */
566 command: string
567 args?: string
568 /** RESUME files looked for, relative to the session's folder, then to its repo root; the first found wins */
569 files: string[]
570 history_n: number
571 preview_chars: number
572}
573
574export type HandoffAdvice = { show: boolean; reasons: ('tokens' | 'eta')[] }
575
576/** The line shows when the context reached threshold_tokens or the auto-compact forecast is within eta_min. */
577export const handoffAdvice = (ctx: Ctx | undefined, h: HandoffCfg | undefined): HandoffAdvice => {
578 if (!ctx || !h || h.enabled === false) return { show: false, reasons: [] }
579 const reasons: HandoffAdvice['reasons'] = []
580 if (ctx.tokens !== undefined && ctx.tokens >= h.threshold_tokens) reasons.push('tokens')
581 if (ctx.etaMin !== undefined && h.eta_min > 0 && ctx.etaMin <= h.eta_min) reasons.push('eta')
582 return { show: reasons.length > 0, reasons }
583}
584
585const fill = (s: string, vars: Record<string, string>) => s.replace(/\{(\w+)\}/g, (m, k: string) => vars[k] ?? m)
586
587export const handoffReason = (a: HandoffAdvice, ctx: Ctx | undefined, h: HandoffCfg, labels: Record<string, string>) =>
588 a.reasons
589 .map(r =>
590 r === 'tokens'
591 ? fill(L(labels, 'handoff_reason_tokens'), { tokens: fmtTok(ctx?.tokens ?? 0), threshold: fmtTok(h.threshold_tokens) })
592 : fill(L(labels, 'handoff_reason_eta'), { min: String(Math.max(1, Math.round(ctx?.etaMin ?? 0))) }),
593 )
594 .join(' · ')
595
596/** "mis à jour il y a 3 h", or the absent label when there is no file. */
597export const ageText = (mtime: number | undefined, now: number, labels: Record<string, string>) => {
598 if (mtime === undefined) return L(labels, 'handoff_absent')
599 const min = Math.max(0, (now - mtime) / 60000)
600 const ago = min < 1 ? "à l'instant" : min < 60 ? `il y a ${Math.round(min)} min` : min < 48 * 60 ? `il y a ${Math.round(min / 60)} h` : `il y a ${Math.round(min / 1440)} j`
601 return `${L(labels, 'handoff_updated')} ${ago}`
602}
603
604/** Where to look for RESUME: each file under the session's folder, then under its repo root (worktree markers cut). */
605export const resumeCandidates = (cwd: string | undefined, files: string[], markers: string[]) => {
606 if (!cwd) return []
607 const here = norm(cwd)
608 const repo = repoKey(cwd, markers)
609 const dirs = repo && repo !== '?' && repo !== here ? [here, repo] : [here]
610 return dirs.flatMap(d => files.map(f => `${d}/${f.replace(/\\/g, '/').replace(/^\/+/, '')}`))
611}
612
613export type HandoffButton = { key: 'handoff-prepare' | 'handoff-open'; label: string; enabled: boolean; hint?: string }
614
615/** The two buttons. « Préparer » is greyed when the command is not in this session; « Ouvrir » always answers. */
616export const handoffButtons = (h: HandoffCfg, commands: readonly CommandSeen[], resume: { path?: string; mtime?: number } | undefined, labels: Record<string, string>): HandoffButton[] => {
617 const has = commands.some(c => c.name === h.command)
618 return [
619 {
620 key: 'handoff-prepare',
621 label: L(labels, 'handoff_prepare'),
622 enabled: has,
623 ...(has ? {} : { hint: fill(L(labels, 'handoff_missing'), { command: h.command }) }),
624 },
625 {
626 key: 'handoff-open',
627 label: L(labels, 'handoff_open'),
628 enabled: true,
629 ...(resume?.path && resume.mtime !== undefined ? {} : { hint: L(labels, 'handoff_absent') }),
630 },
631 ]
632}
633
634export type HandoffEvent = { t: number; kind: 'compact' | 'handoff' | 'resume'; text: string }
635
636/** The last `n` hand-offs (/handoff runs, RESUME writes during this conversation) and compactions, newest first. */
637export const recentHandoffs = (
638 compactions: Compaction[],
639 ran: number[],
640 resume: { mtime?: number } | undefined,
641 since: number | undefined,
642 n: number,
643 labels: Record<string, string>,
644 idleMin: number,
645): HandoffEvent[] => {
646 const out: HandoffEvent[] = [
647 ...compactions.filter(c => !c.a).map(c => ({
648 t: c.t,
649 kind: 'compact' as const,
650 text: `${L(labels, 'handoff_compact')} ${compactLabel(c, labels, idleMin)}${c.pre ? ` ${fmtTok(c.pre)}→${fmtTok(c.post ?? 0)}` : ''}`,
651 })),
652 ...ran.map(t => ({ t, kind: 'handoff' as const, text: L(labels, 'handoff_ran') })),
653 ]
654 if (resume?.mtime !== undefined && (since === undefined || resume.mtime >= since)) out.push({ t: resume.mtime, kind: 'resume', text: L(labels, 'handoff_written') })
655 return out.sort((a, b) => b.t - a.t).slice(0, Math.max(0, n))
656}
657hooks/gauge.ts 132 lines1// The context gauge, pure and self-contained (no imports): a thin rounded bar of this conversation's context with a
2// mark at the hand-off threshold (RESUME, 250 K) and one at auto-compact, green -> orange -> red, and one short line
3// under it (« compactage dans ~16 min · 53 tok/s · semaine 13 % »). Drawn by band above the prompt and by cockpit's
4// Jauge strip (cockpit copies this file into its lib/, scripts/sync-libs.mjs). The desktop draws `svg`; the terminal,
5// which has no Svg, draws `text`.
6
7export type GaugeIn = {
8 tokens?: number
9 window: number
10 /** auto-compact threshold in tokens */
11 compactAt?: number
12 /** hand-off threshold in tokens (RESUME rule) */
13 handoffAt?: number
14 /** minutes to auto-compact at the current growth */
15 etaMin?: number
16 level?: 'ok' | 'warn' | 'bad'
17 /** short rate texts already formatted, e.g. ['53 tok/s'] */
18 rates?: string[]
19 week?: number
20}
21
22export type GaugeLabels = { ctx?: string; compact_in?: string; week?: string; handoff_mark?: string; compact_mark?: string; no_reading?: string }
23export type GaugeColors = { ok?: string; warn?: string; bad?: string; track?: string; mark?: string; text?: string }
24
25export type GaugeMark = { pct: number; label: string; kind: 'handoff' | 'compact' }
26export type Gauge = { pct?: number; level: 'ok' | 'warn' | 'bad'; title: string; marks: GaugeMark[]; line: string; hasReading: boolean }
27
28const DEF_LABELS: Required<GaugeLabels> = { ctx: 'ctx', compact_in: 'compactage dans', week: 'semaine', handoff_mark: 'relais', compact_mark: 'compact', no_reading: 'pas encore de mesure' }
29const DEF_COLORS: Required<GaugeColors> = { ok: '#3fb950', warn: '#d29922', bad: '#f85149', track: '#8b949e55', mark: '#8b949e', text: '#8b949e' }
30
31export const kTok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1).replace('.', ',')} M` : n >= 1e3 ? `${Math.round(n / 1e3)} k` : String(Math.round(n)))
32const clamp = (x: number) => Math.max(0, Math.min(100, x))
33
34export function gaugeModel(g: GaugeIn, labels: GaugeLabels = {}): Gauge {
35 const L = { ...DEF_LABELS, ...labels }
36 const w = g.window > 0 ? g.window : 0
37 const pct = g.tokens !== undefined && w ? clamp((g.tokens * 100) / w) : undefined
38 const marks: GaugeMark[] = []
39 if (g.handoffAt && w) marks.push({ pct: clamp((g.handoffAt * 100) / w), label: `${L.handoff_mark} ${kTok(g.handoffAt)}`, kind: 'handoff' })
40 if (g.compactAt && w) marks.push({ pct: clamp((g.compactAt * 100) / w), label: `${L.compact_mark} ${Math.round((g.compactAt * 100) / w)} %`, kind: 'compact' })
41 const level = g.level ?? (g.tokens === undefined ? 'ok' : g.handoffAt && g.tokens >= g.handoffAt ? 'bad' : g.compactAt && g.tokens >= g.compactAt * 0.8 ? 'warn' : 'ok')
42 const title = pct === undefined ? `${L.ctx} : ${L.no_reading}` : `${L.ctx} ${Math.round(pct)} % (${kTok(g.tokens ?? 0)})`
43 const parts: string[] = []
44 if (g.etaMin !== undefined && Number.isFinite(g.etaMin)) parts.push(`${L.compact_in} ~${Math.max(1, Math.round(g.etaMin))} min`)
45 for (const r of g.rates ?? []) if (r) parts.push(r)
46 if (g.week !== undefined) parts.push(`${L.week} ${Math.round(g.week)} %`)
47 return { pct, level, title, marks, line: parts.join(' · '), hasReading: pct !== undefined }
48}
49
50const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
51
52export type PlacedMark = GaugeMark & { x: number; anchor: 'start' | 'middle' | 'end'; show: boolean }
53
54/** The mark the context reaches next (the first threshold above the fill), else the last one passed. */
55export function nearestMark(m: Gauge): GaugeMark | undefined {
56 const sorted = [...m.marks].sort((a, b) => a.pct - b.pct)
57 return sorted.find((k) => k.pct > (m.pct ?? 0)) ?? sorted[sorted.length - 1]
58}
59
60/**
61 * Where each mark's label goes on a bar `width` px wide, and which labels show: never two that overlap. The nearest
62 * threshold ahead wins, then the others by distance to the fill; a label whose box (length x charPx, anchored start,
63 * middle or end so it stays inside the bar) comes within `gap` px of an accepted one is hidden.
64 * mode 'nearest' shows only the nearest one, 'none' none (ticks only).
65 */
66export function placeMarks(m: Gauge, width: number, mode: 'all' | 'nearest' | 'none' = 'all', charPx = 5.2, gap = 6): PlacedMark[] {
67 const near = nearestMark(m)
68 const isNear = (k: GaugeMark) => Boolean(near && k.kind === near.kind && k.pct === near.pct)
69 const at = m.pct ?? 0
70 const placed: PlacedMark[] = m.marks.map((k) => {
71 const x = Math.round((width * k.pct) / 100)
72 const w = k.label.length * charPx
73 const anchor: PlacedMark['anchor'] = x + w / 2 > width ? 'end' : x - w / 2 < 0 ? 'start' : 'middle'
74 return { ...k, x, anchor, show: false }
75 })
76 if (mode === 'none') return placed
77 const span = (p: PlacedMark): [number, number] => {
78 const w = p.label.length * charPx
79 return p.anchor === 'start' ? [p.x, p.x + w] : p.anchor === 'end' ? [p.x - w, p.x] : [p.x - w / 2, p.x + w / 2]
80 }
81 const order = [...placed].sort((a, b) => Number(isNear(b)) - Number(isNear(a)) || Math.abs(a.pct - at) - Math.abs(b.pct - at))
82 const taken: [number, number][] = []
83 for (const p of order) {
84 if (mode === 'nearest' && !isNear(p)) continue
85 const [a, b] = span(p)
86 if (taken.some(([c, d]) => a < d + gap && c < b + gap)) continue
87 taken.push([a, b])
88 p.show = true
89 }
90 return placed
91}
92
93/**
94 * A thin rounded bar with tick marks, as an SVG document (Svg `source`). width in CSS px. labels: 'all' (default;
95 * overlapping ones dropped by placeMarks), 'nearest' (the next threshold only) or 'none' (ticks only, a slimmer bar).
96 */
97export function gaugeSvg(m: Gauge, colors: GaugeColors = {}, width = 320, labels: 'all' | 'nearest' | 'none' = 'all'): string {
98 const C = { ...DEF_COLORS, ...colors }
99 const marks = placeMarks(m, width, labels)
100 const h = marks.some((k) => k.show) ? 22 : 10
101 const y = 2
102 const bh = 6
103 const fill = m.pct === undefined ? 0 : Math.max(bh, (width * m.pct) / 100)
104 const ticks = marks.map((k) => {
105 const dash = k.kind === 'handoff' ? ' stroke-dasharray="2 2"' : ''
106 return `<line x1="${k.x}" y1="0" x2="${k.x}" y2="${y + bh + 2}" stroke="${C.mark}" stroke-width="1.5"${dash}/>` +
107 (k.show ? `<text x="${k.x}" y="${h - 2}" font-size="9" font-family="sans-serif" fill="${C.text}" text-anchor="${k.anchor}">${esc(k.label)}</text>` : '')
108 }).join('')
109 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">` +
110 `<rect x="0" y="${y}" width="${width}" height="${bh}" rx="3" fill="${C.track}"/>` +
111 (fill ? `<rect x="0" y="${y}" width="${fill.toFixed(1)}" height="${bh}" rx="3" fill="${C[m.level]}"/>` : '') +
112 ticks + '</svg>'
113}
114
115/** The same bar in text for the terminal: █ filled, ░ empty, ┃ at each mark. */
116export function gaugeText(m: Gauge, width = 30): string {
117 const w = Math.max(8, width)
118 const full = m.pct === undefined ? 0 : Math.round((w * m.pct) / 100)
119 const cells = Array.from({ length: w }, (_, i) => (i < full ? '█' : '░'))
120 for (const k of m.marks) cells[Math.min(w - 1, Math.round((w * k.pct) / 100))] = '┃'
121 return cells.join('')
122}
123
124/** A thin rounded bar for a percentage (usage cards): same look as the gauge, no marks. */
125export function barSvg(pct: number, color: string, width = 320, track = DEF_COLORS.track): string {
126 const p = clamp(pct)
127 const fill = p > 0 ? Math.max(4, (width * p) / 100) : 0
128 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="6" viewBox="0 0 ${width} 6">` +
129 `<rect x="0" y="0" width="${width}" height="6" rx="3" fill="${track}"/>` +
130 (fill ? `<rect x="0" y="0" width="${fill.toFixed(1)}" height="6" rx="3" fill="${color}"/>` : '') + '</svg>'
131}
132hooks/jauge.ts 421 lines1// band mod: the two-line Jauge (gauge.style "two_lines", the default), pure (no $, no I/O; node --test runs it).
2// line 1: ▶ 1 conv · 0 wf · 0 ag · 0 CI │ ◆ 2 │ ctx ▰▰▱▱▱ 41 % │ 5 h ▱▱▱▱▱ 0 % · sem ▰▰▰▰▱ 74 % │ 4.4k tok/s (◆ N hidden at 0)
3// the activity status (actFields) reads « ✓ au repos » in green when nothing runs
4// line 2, grey: 5 h 24 % · sem 14 % · Codex 30 % │ 3 agents · 5 conv actives │ 434 tok/s tout · 120 ce projet · 50 cette conv │ CPU 12 % · GPU 3 %
5// buttons: Cockpit · Veille · À toi · Registres · Référence · ▾
6// Context, 5 h and week carry a ▰▱ mini bar and a traffic light (jauge.levels); the waiting count is amber.
7// A field is atomic: it is never cut. layoutJauge fits line1 to the band's width (bodyColumns): one row with the full
8// labels, else the short ones, else it WRAPS at jauge.wrap_at (row 1 the status counts, row 2 ctx · 5 h · sem · tok/s),
9// and only then leaves out whole fields of drop_order (tok/s, 5 h, CI, ag, wf). The relay shows as a red « relais
10// conseillé » chip next to RESUME, or RESUME alone when even that does not fit (Julian, 22 Oct: « I don't understand that »).
11// Which fields, their order, labels, short labels, wrap_at and the drop order all come from band.config.json `jauge`.
12
13/**
14 * One figure. text is what the terminal shows (and what fitting counts). A figure with a level (context, 5 h, week)
15 * is also split: label (grey words), bar (▰▱ cells; the desktop draws a thin Svg of pct instead) and value, the bar
16 * and the value in the level's colour (ok green, warn orange, bad red).
17 */
18export type Field = { id: string; text: string; tone?: 'ok' | 'bad' | 'warn' | 'dim'; label?: string; bar?: string; pct?: number; value?: string }
19
20/** Traffic-light thresholds (band.config.json jauge.levels). */
21export type Levels = {
22 /** context: orange from warn_pct, red from the hand-off threshold (context.handoff_tokens of the effective window) */
23 ctx: { warn_pct: number }
24 /** 5 h and week: orange from warn_pct or a projection at reset from proj_warn_pct; red from bad_pct or a projection over proj_bad_pct */
25 quota: { warn_pct: number; bad_pct: number; proj_warn_pct: number; proj_bad_pct: number }
26}
27
28export const DEF_LEVELS: Levels = {
29 ctx: { warn_pct: 50 },
30 quota: { warn_pct: 60, bad_pct: 85, proj_warn_pct: 90, proj_bad_pct: 100 },
31}
32
33export type Level = 'ok' | 'warn' | 'bad'
34
35/** Context level: red at or past the relay (as a % of the window), orange from warn_pct, else green. */
36export const ctxLevel = (pct: number, relayPct: number | undefined, lv: Levels = DEF_LEVELS): Level =>
37 relayPct !== undefined && pct >= relayPct ? 'bad' : pct >= lv.ctx.warn_pct ? 'warn' : 'ok'
38
39/** 5 h / week level from the % used and, when known, the % projected at the window's reset. */
40export const quotaLevel = (pct: number, proj: number | undefined, lv: Levels = DEF_LEVELS): Level => {
41 const q = lv.quota
42 if (pct >= q.bad_pct || (proj !== undefined && proj > q.proj_bad_pct)) return 'bad'
43 if (pct >= q.warn_pct || (proj !== undefined && proj >= q.proj_warn_pct)) return 'warn'
44 return 'ok'
45}
46
47/** A mini bar of `n` cells: ▰ filled, ▱ empty. */
48export const miniBar = (pct: number, n = 5, on = '▰', off = '▱') => {
49 const k = Math.max(0, Math.min(n, Math.round((n * Math.max(0, Math.min(100, pct))) / 100)))
50 return on.repeat(k) + off.repeat(n - k)
51}
52/** A line is groups of fields: fields of a group are joined by `dot`, groups by `sep`. */
53export type Line = Field[][]
54
55export type JaugeCfg = {
56 line1: string[]
57 line2: string[]
58 /** fields left out first when a line is too wide, in order (act_ci / act_ag / act_wf are the activity's parts) */
59 drop_order: string[]
60 /** where line1 wraps when one row is too narrow: the fields from this id on go to row 2 */
61 wrap_at: string
62 sep: string
63 dot: string
64 sep_short: string
65 dot_short: string
66 /** a conversation counts as active when it wrote within this many seconds */
67 active_s: number
68 /** which band scope each tok/s field reads */
69 tps_scopes: Record<string, string>
70 /** width used when the engine gives no bodyColumns */
71 columns_fallback: number
72 /** cells a button takes beyond its label (brackets, padding), to fit the button row */
73 button_pad: number
74 labels: Record<string, string>
75 labels_short: Record<string, string>
76 levels: Levels
77 /** cells of a mini bar ({bar} in a label) */
78 bar_cells: number
79}
80
81export const DEF_JAUGE_LABELS: Record<string, string> = {
82 waiting: '◆ {n}',
83 waiting_none: '',
84 todo: '{n} à toi',
85 context: 'ctx {bar} {pct} %',
86 context_none: 'ctx ?',
87 five: '5 h {bar} {n} %',
88 week: 'sem {bar} {n} %',
89 codex_week: 'Codex {n} %',
90 codex_five: 'Codex 5 h {n} %',
91 agents: '{n} agents',
92 convs: '{n} conv actives',
93 tps_all: '{n} tok/s',
94 tps_project: '{n} ce projet',
95 tps_conv: '{n} cette conv',
96 cpu: 'CPU {n} %',
97 gpu: 'GPU {n} %',
98 more: '▾',
99 less: '▴',
100 act_conv: '▶ {n} conv',
101 act_wf: '{n} wf',
102 act_ag: '{n} ag',
103 act_ci: '{n} CI',
104 act_ci_stale: 'CI ?',
105 act_idle: '✓ au repos',
106}
107
108export const DEF_JAUGE_SHORT: Record<string, string> = {
109 waiting: '◆ {n}',
110 waiting_none: '',
111 todo: '{n} à toi',
112 context: 'ctx {bar} {pct}%',
113 context_none: 'ctx ?',
114 five: '5h {bar} {n}%',
115 week: 'sem {bar} {n}%',
116 codex_week: 'Cx {n}%',
117 codex_five: 'Cx 5h {n}%',
118 agents: '{n} ag',
119 convs: '{n} conv',
120 tps_all: '{n} t/s',
121 tps_project: '{n} proj',
122 tps_conv: '{n} ici',
123 cpu: 'CPU {n}%',
124 gpu: 'GPU {n}%',
125 act_conv: '▶ {n} conv',
126 act_wf: '{n} wf',
127 act_ag: '{n} ag',
128 act_ci: '{n} CI',
129 act_ci_stale: 'CI ?',
130 act_idle: '✓ au repos',
131}
132
133export const DEF_JAUGE: JaugeCfg = {
134 line1: ['activity', '|', 'waiting', 'todo', '|', 'context'],
135 line2: ['five', 'week', 'codex_week', '|', 'agents', 'convs', '|', 'tps_all', 'tps_project', 'tps_conv', '|', 'cpu', 'gpu'],
136 drop_order: ['gpu', 'tps_project', 'convs', 'cpu', 'codex_week', 'tps_all', 'five', 'act_ci', 'act_ag', 'act_wf', 'agents', 'todo'],
137 wrap_at: 'context',
138 sep: ' │ ',
139 dot: ' · ',
140 sep_short: ' │ ',
141 dot_short: ' · ',
142 active_s: 300,
143 tps_scopes: { tps_all: 'all', tps_project: 'project', tps_conv: 'conv' },
144 columns_fallback: 120,
145 button_pad: 4,
146 labels: DEF_JAUGE_LABELS,
147 labels_short: DEF_JAUGE_SHORT,
148 levels: DEF_LEVELS,
149 bar_cells: 5,
150}
151
152export const jaugeCfg = (raw?: Partial<JaugeCfg>): JaugeCfg => ({
153 ...DEF_JAUGE,
154 ...(raw ?? {}),
155 tps_scopes: { ...DEF_JAUGE.tps_scopes, ...((raw && raw.tps_scopes) || {}) },
156 labels: { ...DEF_JAUGE_LABELS, ...((raw && raw.labels) || {}) },
157 labels_short: { ...DEF_JAUGE_SHORT, ...((raw && raw.labels_short) || {}) },
158 levels: {
159 ctx: { ...DEF_LEVELS.ctx, ...((raw as any)?.levels?.ctx || {}) },
160 quota: { ...DEF_LEVELS.quota, ...((raw as any)?.levels?.quota || {}) },
161 },
162 bar_cells: Number((raw as any)?.bar_cells) > 0 ? Math.floor(Number((raw as any).bar_cells)) : 5,
163})
164
165/** Terminal cells of a string: one per code point (the band's texts have no wide glyphs). */
166export const cells = (s: string) => [...s].length
167
168const fill = (tpl: string, vars: Record<string, string | number>) => tpl.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m))
169
170const kTok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1).replace('.', ',')} M` : n >= 1e4 ? `${Math.round(n / 1e3)} k` : n >= 1e3 ? `${(n / 1e3).toFixed(1).replace('.', ',')} k` : String(Math.round(n)))
171
172/** tok/s, short: 434, 4.4k, 12k, 1.2M. */
173export const kShort = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(Math.round(n)))
174
175const num = (x: unknown): number | undefined => (typeof x === 'number' && Number.isFinite(x) ? x : undefined)
176
177/** The figures both lines read. Missing ones leave their field out (except the two counters and the context). */
178export type JaugeFacts = {
179 waiting?: number
180 todo?: number
181 ctxPct?: number
182 relayAt: number
183 /** the relay (context.handoff_tokens) as a % of the effective window: the context turns red there */
184 relayPct?: number
185 five?: number
186 week?: number
187 /** % projected at the 5 h / week reset at the current pace (lib/core/usage.mjs runOut), when known */
188 fiveProj?: number
189 weekProj?: number
190 codexWeek?: number
191 codexFive?: number
192 agents?: number
193 convs?: number
194 tps: Record<string, number | undefined>
195 cpu?: number
196 gpu?: number
197 /** what runs (lib/core/busy.mjs counts, see actFields); undefined leaves the « activity » field out */
198 act?: ActFacts
199}
200
201/**
202 * The activity status at the start of the line: busy main threads, Workflows, agents, CI. tone of wf / ag: ok, warn
203 * (one stalled), bad (one failed). ci undefined = off; stale = the ci cache is too old (or missing) to say 0.
204 */
205export type ActFacts = {
206 convs: number
207 workflows: number
208 agents: number
209 wfTone?: Level
210 agTone?: Level
211 ci?: { running: number; failed: number; stale: boolean }
212}
213
214/**
215 * « ▶ 1 conv · 0 wf · 0 ag · 0 CI » (zeros grey, non-zero green, amber stalled, red failed), or when nothing runs one
216 * green « ✓ au repos » (plus a grey « CI ? » when the CI cannot be read). Fields act_conv, act_wf, act_ag, act_ci.
217 */
218export const actFields = (a: ActFacts | undefined, L: Record<string, string>): Field[] => {
219 if (!a) return []
220 const n = (x: number) => Math.max(0, Math.round(Number(x) || 0))
221 const ci = a.ci
222 const ciRun = ci && !ci.stale ? n(ci.running) : 0
223 const ciField: Field | undefined = !ci
224 ? undefined
225 : ci.stale
226 ? { id: 'act_ci', text: L.act_ci_stale ?? 'CI ?', tone: 'dim' }
227 : { id: 'act_ci', text: fill(L.act_ci, { n: ciRun }), tone: ci.failed ? 'bad' : ciRun ? 'ok' : 'dim' }
228 if (!n(a.convs) && !n(a.workflows) && !n(a.agents) && !ciRun) {
229 return [{ id: 'act_idle', text: L.act_idle ?? '✓ au repos', tone: 'ok' }, ...(ciField && (ci!.stale || ci!.failed) ? [ciField] : [])]
230 }
231 const one = (id: string, v: number, tone?: Level): Field => ({ id, text: fill(L[id], { n: n(v) }), tone: n(v) ? (tone ?? 'ok') : 'dim' })
232 return [one('act_conv', a.convs), one('act_wf', a.workflows, a.wfTone), one('act_ag', a.agents, a.agTone), ...(ciField ? [ciField] : [])]
233}
234
235/** Every field's text with the labels given (full or short); undefined when its figure is missing. */
236export const fieldText = (id: string, f: JaugeFacts, L: Record<string, string>, c: Pick<JaugeCfg, 'levels' | 'bar_cells'> = DEF_JAUGE): Field | undefined => {
237 const pct = (x?: number) => (x === undefined ? undefined : Math.round(x))
238 // label words grey, then the bar and the value in the level's colour; a bar only when the template has {bar}
239 const levelled = (tpl: string, vars: Record<string, string | number>, p: number, tone: Level): Field => {
240 const bar = miniBar(p, c.bar_cells)
241 const at = tpl.indexOf('{bar}')
242 const cut = at >= 0 ? at : tpl.search(/\{(pct|n)\}/)
243 const label = cut > 0 ? fill(tpl.slice(0, cut), vars) : ''
244 const rest = cut >= 0 ? tpl.slice(cut) : tpl
245 const value = fill(rest.replace('{bar}', ''), vars)
246 return { id, text: label + fill(rest, { ...vars, bar }), tone, label, value, ...(at >= 0 ? { bar, pct: Math.max(0, Math.min(100, p)) } : {}) }
247 }
248 switch (id) {
249 case 'waiting': {
250 // hidden at 0 (waiting_none empty), else a short amber « ◆ N »
251 const n = Math.max(0, Math.round(num(f.waiting) ?? 0))
252 const text = n ? fill(L.waiting, { n }) : fill(L.waiting_none ?? '', { n })
253 return text ? { id, text, tone: n ? ('warn' as const) : ('dim' as const) } : undefined
254 }
255 case 'todo': {
256 const n = Math.max(0, Math.round(num(f.todo) ?? 0))
257 return { id, text: fill(L.todo, { n }) }
258 }
259 case 'context': {
260 if (f.ctxPct === undefined) return { id, text: fill(L.context_none, { relay: kTok(f.relayAt) }), tone: 'dim' }
261 return levelled(L.context, { pct: Math.round(f.ctxPct), relay: kTok(f.relayAt) }, f.ctxPct, ctxLevel(f.ctxPct, f.relayPct, c.levels))
262 }
263 case 'five':
264 case 'week': {
265 const raw = num(id === 'five' ? f.five : f.week)
266 if (raw === undefined) return undefined
267 const proj = num(id === 'five' ? f.fiveProj : f.weekProj)
268 return levelled(L[id], { n: Math.round(raw) }, raw, quotaLevel(raw, proj, c.levels))
269 }
270 case 'cpu':
271 case 'gpu': {
272 const v = pct(num(id === 'cpu' ? f.cpu : f.gpu))
273 return v === undefined ? undefined : { id, text: fill(L[id], { n: v }) }
274 }
275 case 'codex_week':
276 case 'codex_five': {
277 const v = pct(num(id === 'codex_week' ? f.codexWeek : f.codexFive))
278 return v === undefined ? undefined : { id, text: fill(L[id], { n: v }) }
279 }
280 case 'agents':
281 case 'convs': {
282 const v = num(id === 'agents' ? f.agents : f.convs)
283 return v === undefined ? undefined : { id, text: fill(L[id], { n: Math.round(v) }) }
284 }
285 default: {
286 if (id.startsWith('tps_')) {
287 const v = num(f.tps[id])
288 return v === undefined ? undefined : { id, text: fill(L[id] ?? `{n} ${id}`, { n: kShort(v) }) }
289 }
290 return undefined
291 }
292 }
293}
294
295/** The line's groups from its ids ('|' starts a new group); empty groups are dropped. */
296export const buildLine = (ids: string[], f: JaugeFacts, L: Record<string, string>, drop: ReadonlySet<string> = new Set(), c: Pick<JaugeCfg, 'levels' | 'bar_cells'> = DEF_JAUGE): Line => {
297 const out: Line = [[]]
298 for (const id of ids) {
299 if (id === '|') {
300 out.push([])
301 continue
302 }
303 if (drop.has(id)) continue
304 if (id === 'activity') {
305 out[out.length - 1].push(...actFields(f.act, L).filter(x => !drop.has(x.id)))
306 continue
307 }
308 const x = fieldText(id, f, L, c)
309 if (x) out[out.length - 1].push(x)
310 }
311 return out.filter(g => g.length)
312}
313
314export const lineText = (l: Line, sep: string, dot: string) => l.map(g => g.map(x => x.text).join(dot)).join(sep)
315
316/** Whether a line's ids hold this field (act_* are parts of « activity »). */
317const hasField = (ids: string[], id: string) => ids.includes(id) || (id.startsWith('act_') && ids.includes('activity'))
318
319export type Fitted = { line: Line; text: string; short: boolean; dropped: string[]; sep: string; dot: string }
320
321/**
322 * The widest version of a line that fits `columns` cells: full labels, else short labels, else short labels with the
323 * fields of drop_order left out one by one. When nothing fits, the narrowest try (the Text truncates its end).
324 */
325export const fitLine = (ids: string[], f: JaugeFacts, c: JaugeCfg, columns: number): Fitted => {
326 const full = buildLine(ids, f, c.labels, new Set(), c)
327 const ft = lineText(full, c.sep, c.dot)
328 if (cells(ft) <= columns) return { line: full, text: ft, short: false, dropped: [], sep: c.sep, dot: c.dot }
329 const S = { ...c.labels, ...c.labels_short }
330 const drop = new Set<string>()
331 let best: Fitted | undefined
332 for (const id of ['', ...c.drop_order]) {
333 if (id) {
334 if (!hasField(ids, id)) continue
335 drop.add(id)
336 }
337 const l = buildLine(ids, f, S, drop, c)
338 const t = lineText(l, c.sep_short, c.dot_short)
339 best = { line: l, text: t, short: true, dropped: [...drop], sep: c.sep_short, dot: c.dot_short }
340 if (cells(t) <= columns) return best
341 }
342 return best!
343}
344
345/**
346 * The relay (past the hand-off threshold): a red « relais conseillé » chip and the RESUME button; narrower, a red mark
347 * (labels.relay_mark, a glyph, no words) and the button; narrowest, the button alone.
348 */
349export type Relay = { hint: string; button: string; mark?: string }
350export type RelayMode = 'chip' | 'mark' | 'button' | 'none'
351
352export type JaugeLayout = {
353 /** one or two rows of figures; each fits `columns` (row 1 with the relay) whenever dropping can make it fit */
354 rows: Fitted[]
355 /** chip: the words and the button; mark: the red mark and the button; button: RESUME alone; none: no relay */
356 relay: RelayMode
357 /** every field left out */
358 dropped: string[]
359}
360
361/** Cells the relay takes on row 1: a gap before each part, a button is its label plus button_pad. */
362export const relayCells = (r: Relay | undefined, mode: RelayMode, c: Pick<JaugeCfg, 'button_pad'>) =>
363 !r || mode === 'none' ? 0 : (mode === 'chip' ? cells(r.hint) + 1 : mode === 'mark' && r.mark ? cells(r.mark) + 1 : 0) + cells(r.button) + c.button_pad + 1
364
365/** The red words in front of RESUME for a relay mode: the chip's hint, the mark, or none. */
366export const relayText = (r: Relay | undefined, mode: RelayMode) => (!r ? '' : mode === 'chip' ? r.hint : mode === 'mark' ? (r.mark ?? '') : '')
367
368/**
369 * line1 laid out in `columns` cells, never cutting a field: one row (full labels, else short; the relay as a chip),
370 * else two rows split at wrap_at (each full, else short; the relay on row 1 as a chip, else a mark, else RESUME alone), then the
371 * fields of drop_order left out one by one until both rows fit. When nothing fits, the narrowest try: the renderer
372 * then moves whole fields to the next row, it never truncates one.
373 */
374export const layoutJauge = (ids: string[], f: JaugeFacts, c: JaugeCfg, columns: number, relay?: Relay): JaugeLayout => {
375 const modes: RelayMode[] = relay ? ['chip', ...(relay.mark ? (['mark'] as const) : []), 'button'] : ['none']
376 const S = { ...c.labels, ...c.labels_short }
377 const short = (xs: string[], drop: Set<string>): Fitted => {
378 const l = buildLine(xs, f, S, drop, c)
379 return { line: l, text: lineText(l, c.sep_short, c.dot_short), short: true, dropped: [...drop], sep: c.sep_short, dot: c.dot_short }
380 }
381 const fit = (xs: string[], cols: number, drop: Set<string>): Fitted | undefined => {
382 const l = buildLine(xs, f, c.labels, drop, c)
383 const t = lineText(l, c.sep, c.dot)
384 if (cells(t) <= cols) return { line: l, text: t, short: false, dropped: [...drop], sep: c.sep, dot: c.dot }
385 const s = short(xs, drop)
386 return cells(s.text) <= cols ? s : undefined
387 }
388 // one row, nothing dropped: wrapping comes before leaving anything out
389 const r1 = fit(ids, columns - relayCells(relay, modes[0], c), new Set())
390 if (r1) return { rows: [r1], relay: modes[0], dropped: [] }
391 const at = ids.indexOf(c.wrap_at)
392 const cut = at > 0 ? at : Math.max(1, ids.indexOf('|'))
393 const top = ids.slice(0, cut)
394 const bottom = ids.slice(cut)
395 const drop = new Set<string>()
396 for (const id of ['', ...c.drop_order]) {
397 if (id) {
398 if (!hasField(ids, id)) continue
399 drop.add(id)
400 }
401 const b = fit(bottom, columns, drop)
402 if (!b) continue
403 for (const m of modes) {
404 const t = fit(top, columns - relayCells(relay, m, c), drop)
405 if (t) return { rows: b.text ? [t, b] : [t], relay: m, dropped: [...drop] }
406 }
407 }
408 const b = short(bottom, drop)
409 return { rows: b.text ? [short(top, drop), b] : [short(top, drop)], relay: modes[modes.length - 1], dropped: [...drop] }
410}
411
412/** Button row: full labels when they fit, else each item's `short`, else as is (the row may still be too wide). */
413export const fitButtons = (labels: { label: string; short?: string; open?: boolean }[], c: JaugeCfg, columns: number, extra = 1) => {
414 const width = (xs: string[]) => xs.reduce((s, x) => s + cells(x) + c.button_pad, 0) + xs.length + extra + c.button_pad
415 const mark = (s: string, open?: boolean) => (open ? `${s} ✕` : s)
416 const full = labels.map(b => mark(b.label, b.open))
417 if (width(full) <= columns) return { labels: full, short: false, width: width(full) }
418 const short = labels.map(b => mark(b.short || b.label, b.open))
419 return { labels: short, short: true, width: width(short) }
420}
421hooks/sections.ts 164 lines1// band mod: the compact band's pure parts (no $, no I/O; node --test mods/band/tests/logic.test.mjs).
2// Line 1, always shown: a slim gauge, « ctx 8 % · compact ~16 min · 434 tok/s · 1 compactage(s) · 5 h 24 % · sem 14 % »,
3// then « À toi : N » and « attend Julian : N » once each (counted by lib/core/veille.mjs in the scanner), a ▸ toggle.
4// ▸ unfolds « Cette conversation » (a one-line summary while closed); the Mods row (the 6 hubs) stays. The 5 h / week
5// details live in Cockpit (Quota card) and the alerts in Veille: no Usage or Veille section here any more.
6// Which sections exist and their order come from band.config.json `sections`; the open/closed state is remembered
7// through $.store (sections.remember "store") or for the session only ("session").
8
9export const SECTION_IDS = ['conv', 'mods'] as const
10export type SectionId = (typeof SECTION_IDS)[number]
11
12export type SectionsCfg = {
13 order: string[]
14 enabled: string[]
15 /** sections open when nothing is remembered */
16 open: string[]
17 /** the sections list unfolded under line 1 when nothing is remembered */
18 expanded: boolean
19 /** store: kept across sessions ($.store); session: this session only */
20 remember: 'store' | 'session'
21 /** an agent counts as running when it wrote within this many seconds */
22 running_s: number
23 labels: Record<string, string>
24}
25
26export type BandUi = { expanded: boolean; open: string[] }
27
28export const DEF_SECTION_LABELS: Record<string, string> = {
29 conv: 'Cette conversation',
30 ctx: 'ctx',
31 compact: 'compact',
32 five: '5 h',
33 week: 'sem',
34 agents: 'agents',
35 workflow: 'wf',
36 pr: 'PR',
37 no_pr: 'pas de PR',
38 model_unknown: 'modèle ?',
39 compactions: 'compactage(s)',
40 julian_counter: 'attend Julian : {n}',
41 expand: 'déplier',
42 collapse: 'replier',
43 relay_left: 'relais dans',
44 todo: 'À toi : {n}',
45}
46
47export const DEF_SECTIONS: SectionsCfg = {
48 order: ['conv', 'mods'],
49 enabled: ['conv', 'mods'],
50 open: [],
51 expanded: false,
52 remember: 'store',
53 running_s: 120,
54 labels: DEF_SECTION_LABELS,
55}
56
57export const sectionsCfg = (raw?: Partial<SectionsCfg>): SectionsCfg => ({
58 ...DEF_SECTIONS,
59 ...(raw ?? {}),
60 labels: { ...DEF_SECTION_LABELS, ...((raw && raw.labels) || {}) },
61})
62
63/** The sections drawn, in order: `order` filtered by `enabled`, unknown ids dropped. */
64export const sectionList = (c: SectionsCfg): SectionId[] =>
65 c.order.filter((s): s is SectionId => (SECTION_IDS as readonly string[]).includes(s) && c.enabled.includes(s))
66
67/** The state at session start: what $.store remembered (when remember is "store"), else the config's defaults. */
68export const initialUi = (c: SectionsCfg, stored?: unknown): BandUi => {
69 const s = stored as Partial<BandUi> | undefined
70 if (c.remember === 'store' && s && typeof s === 'object') {
71 return { expanded: Boolean(s.expanded), open: Array.isArray(s.open) ? s.open.filter(x => typeof x === 'string') : [] }
72 }
73 return { expanded: c.expanded, open: [...c.open] }
74}
75
76export const toggleSection = (ui: BandUi, id: string): BandUi =>
77 id === 'band' ? { ...ui, expanded: !ui.expanded } : { ...ui, open: ui.open.includes(id) ? ui.open.filter(x => x !== id) : [...ui.open, id] }
78
79export const isOpen = (ui: BandUi, id: string) => ui.open.includes(id)
80
81// ---------- texts ----------
82
83/** claude-opus-5-5 -> opus 5.5; claude-sonnet-5-5[1m] -> sonnet 5.5 */
84export const modelShort = (m?: string) => {
85 if (!m) return ''
86 const x = /(fable|opus|sonnet|haiku)[-_ ]?(\d+)(?:[-.](\d+))?/i.exec(m)
87 return x ? `${x[1].toLowerCase()} ${x[2]}${x[3] ? '.' + x[3] : ''}` : m.replace(/^claude-/, '')
88}
89
90const pct = (n: number) => `${Math.round(n)} %`
91
92export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
93
94const limitOf = (limits: Limit[] | undefined, kind: string) => (limits ?? []).find(l => l.kind === kind && Number.isFinite(l.percentUsed))
95
96/** Line 1 after the gauge: ctx 8 % · compact ~16 min · 434 tok/s · 1 compactage(s) · 5 h 24 % · sem 14 %.
97 * Missing figures are left out; compactions only when this conversation had one (compact_history_hours). */
98export const headLine = (
99 x: { ctxPct?: number; etaMin?: number; tps?: number; compactions?: number; limits?: Limit[] },
100 labels: Record<string, string> = DEF_SECTION_LABELS,
101) => {
102 const L = { ...DEF_SECTION_LABELS, ...labels }
103 const parts: string[] = []
104 parts.push(x.ctxPct === undefined ? `${L.ctx} ?` : `${L.ctx} ${pct(x.ctxPct)}`)
105 if (x.etaMin !== undefined && Number.isFinite(x.etaMin)) parts.push(`${L.compact} ~${Math.max(1, Math.round(x.etaMin))} min`)
106 if (x.tps !== undefined && Number.isFinite(x.tps)) parts.push(`${Math.round(x.tps)} tok/s`)
107 if (x.compactions && x.compactions > 0) parts.push(`${Math.round(x.compactions)} ${L.compactions}`)
108 const five = limitOf(x.limits, 'five_hour')
109 if (five) parts.push(`${L.five} ${pct(five.percentUsed)}`)
110 const week = limitOf(x.limits, 'seven_day')
111 if (week) parts.push(`${L.week} ${pct(week.percentUsed)}`)
112 return parts.join(' · ')
113}
114
115export type ConvFacts = {
116 model?: string
117 effort?: string
118 agents?: { running: number; total: number; workflow?: string }
119 pr?: { number?: number; url?: string }
120 tokens?: number
121 handoffAt?: number
122 title?: string
123}
124
125const kTok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1).replace('.', ',')} M` : n >= 1e3 ? `${Math.round(n / 1e3)} k` : String(Math.round(n)))
126
127/** « opus 5.5 · medium · agents 1/3 · wf package-wave · PR #12 » */
128export const convSummary = (f: ConvFacts, labels: Record<string, string> = DEF_SECTION_LABELS) => {
129 const L = { ...DEF_SECTION_LABELS, ...labels }
130 const parts = [[modelShort(f.model) || L.model_unknown, f.effort].filter(Boolean).join(' · ')]
131 if (f.agents && f.agents.total) parts.push(`${L.agents} ${f.agents.running}/${f.agents.total}`)
132 if (f.agents?.workflow) parts.push(`${L.workflow} ${f.agents.workflow}`)
133 if (f.pr?.number !== undefined) parts.push(`${L.pr} #${f.pr.number}`)
134 return parts.join(' · ')
135}
136
137/** The open body of « Cette conversation »: a few short lines. */
138export const convLines = (f: ConvFacts, labels: Record<string, string> = DEF_SECTION_LABELS) => {
139 const L = { ...DEF_SECTION_LABELS, ...labels }
140 const out: string[] = []
141 out.push(`${modelShort(f.model) || L.model_unknown}${f.effort ? ` · effort ${f.effort}` : ''}${f.title ? ` · ${f.title}` : ''}`)
142 if (f.agents) out.push(`${L.agents} ${f.agents.running} en cours / ${f.agents.total}${f.agents.workflow ? ` · workflow ${f.agents.workflow}` : ''}`)
143 out.push(f.pr?.number !== undefined ? `${L.pr} #${f.pr.number}${f.pr.url ? ` ${f.pr.url}` : ''}` : L.no_pr)
144 if (f.tokens !== undefined && f.handoffAt) {
145 const left = f.handoffAt - f.tokens
146 out.push(left > 0 ? `${L.relay_left} ${kTok(left)} (${kTok(f.handoffAt)})` : `relais dépassé (${kTok(f.tokens)} ≥ ${kTok(f.handoffAt)})`)
147 }
148 return out
149}
150
151/** What the scanner counted with lib/core/veille.mjs (readVeille): the ONE count every view shows. */
152export type Waiting = { aToi?: number; julian?: number; available?: boolean; aToiAvailable?: boolean }
153
154/** Line 1's counters, in order « À toi : N » then « attend Julian : N », each only when N > 0. */
155export const counters = (w: Waiting | null | undefined, labels: Record<string, string> = DEF_SECTION_LABELS) => {
156 const L = { ...DEF_SECTION_LABELS, ...labels }
157 const out: { key: string; text: string }[] = []
158 const n = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) && x > 0 ? Math.round(x) : 0)
159 const fill = (s: string, k: number) => (s.includes('{n}') ? s.replace('{n}', String(k)) : `${s} : ${k}`)
160 if (n(w?.aToi)) out.push({ key: 'band-todo', text: fill(L.todo, n(w?.aToi)) })
161 if (n(w?.julian)) out.push({ key: 'band-julian', text: fill(L.julian_counter, n(w?.julian)) })
162 return out
163}
164hooks/encours.ts 257 lines1// band mod: the Jauge's « en cours » rows, pure (no $, no I/O; node --test runs it). Input: what scanner/encours.mjs
2// prints for THIS conversation. Nothing runs -> no row (the Jauge stays one line). Same bullets as the cockpit:
3// ◇ cockpit-preview-g2 ▰▰▰▱▱ 3/5 · ▸ Preview release · 2 agents opus · 47 min · fin ~01:58
4// • coder p43-rag-identity · opus · 47 min • coder p44-migration · opus · 47 min
5// ◦ agent d'arrière-plan · auditor · sonnet · 3 min
6// Bullet and mini bar colour = state (ok writing, warn no write for warn_min, bad no write for bad_min or failed); type
7// words grey. The bar is phases done/total, else agents done/total (the desktop draws a thin Svg of it). A row wider
8// than the band cuts names first (…), then the phase name, then drops fin ~HH:MM, then the agents and models. At most
9// jauge.en_cours_rows rows, the last one then says « +N de plus · Cockpit › ».
10
11export type EcAgent = { id: string; label?: string; type?: string; model?: string; status: string; start?: number; last?: number; stalled?: boolean; shape?: string; phase?: string }
12export type EcWorkflow = { id: string; name: string; phases: string[]; phase?: string; phaseIdx?: number; status: string; start?: number; last?: number; stalled?: boolean; agents: EcAgent[]; eta?: number; overrun?: boolean }
13export type EcData = { now?: number; workflows?: EcWorkflow[]; agents?: EcAgent[] }
14
15/** bar: the segment is a mini bar of that % (text ▰▱ on the terminal, an Svg on the desktop). */
16export type Seg = { text: string; tone?: 'ok' | 'warn' | 'bad'; dim?: boolean; bar?: number }
17export type EcRow = { key: string; indent: number; segs: Seg[] }
18export type EcView = { rows: EcRow[]; more: number }
19
20export type EcCfg = {
21 rows: number
22 /** agent rows under one workflow before « +N » */
23 agent_lines: number
24 /** cells between two agents on one row */
25 gap: number
26 indent: number
27 /** minutes without a write before orange, then red */
28 warn_min: number
29 bad_min: number
30 bar_cells: number
31 labels: Record<string, string>
32}
33
34export const DEF_EC_LABELS: Record<string, string> = {
35 workflow: '◇',
36 phase: '▸',
37 agent: '•',
38 background: '◦',
39 bg_word: "agent d'arrière-plan",
40 fg_word: 'agent',
41 agents: '{n} agents',
42 agents_one: '1 agent',
43 eta: 'fin ~{time}',
44 eta_over: "au-delà de l'estimation",
45 stalled: 'rien écrit depuis {min} min',
46 progress: '{done}/{total}',
47 failed: 'échoué',
48 more_agents: '+{n}',
49 more: '+{n} de plus',
50 cockpit: 'Cockpit ›',
51 sep: ' · ',
52}
53
54export const ecCfg = (j: any): EcCfg => {
55 const e = (j && j.en_cours) || {}
56 const n = Number(j && j.en_cours_rows)
57 return {
58 rows: Number.isFinite(n) && n >= 0 ? Math.floor(n) : 5,
59 agent_lines: Number.isFinite(Number(e.agent_lines)) ? Math.max(1, Number(e.agent_lines)) : 2,
60 gap: Number.isFinite(Number(e.gap)) ? Math.max(1, Number(e.gap)) : 4,
61 indent: Number.isFinite(Number(e.indent)) ? Math.max(0, Number(e.indent)) : 3,
62 warn_min: Number(e.warn_min) > 0 ? Number(e.warn_min) : 15,
63 bad_min: Number(e.bad_min) > 0 ? Number(e.bad_min) : 30,
64 bar_cells: Number(j && j.bar_cells) > 0 ? Math.floor(Number(j.bar_cells)) : 5,
65 labels: { ...DEF_EC_LABELS, ...(e.labels || {}) },
66 }
67}
68
69const fill = (tpl: string, vars: Record<string, string | number>) => tpl.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m))
70const cells = (s: string) => [...s].length
71const segsW = (xs: Seg[]) => xs.reduce((a, s) => a + cells(s.text), 0)
72
73/** « 47 min », « < 1 min », « 1 h 05 ». */
74export const elapsed = (ms: number) => {
75 const m = Math.floor(Math.max(0, ms) / 60000)
76 if (m < 1) return '< 1 min'
77 if (m < 60) return `${m} min`
78 return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}`
79}
80
81const hhmm = (t: number) => {
82 const d = new Date(t)
83 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
84}
85
86/** ok while it writes, warn after warn_min without a write (or the reader's stalled flag), bad after bad_min or failed. */
87export const levelOf = (x: { status: string; last?: number; stalled?: boolean }, now: number, c: Pick<EcCfg, 'warn_min' | 'bad_min'>): NonNullable<Seg['tone']> => {
88 if (x.status === 'failed') return 'bad'
89 if (x.status !== 'running') return 'ok'
90 const quiet = x.last ? (now - x.last) / 60000 : 0
91 if (quiet >= c.bad_min) return 'bad'
92 if (quiet >= c.warn_min || x.stalled) return 'warn'
93 return 'ok'
94}
95
96const miniBar = (pct: number, n: number) => {
97 const k = Math.max(0, Math.min(n, Math.round((n * Math.max(0, Math.min(100, pct))) / 100)))
98 return '▰'.repeat(k) + '▱'.repeat(n - k)
99}
100
101/** Phases done/total (the current phase is not done yet), else agents done/total; undefined when neither is known. */
102export const progressOf = (w: EcWorkflow): { done: number; total: number } | undefined => {
103 if (w.phases.length && (w.phaseIdx ?? -1) >= 0) return { done: w.status === 'completed' ? w.phases.length : w.phaseIdx!, total: w.phases.length }
104 if (w.agents.length) return { done: w.agents.filter(a => a.status === 'done').length, total: w.agents.length }
105 return undefined
106}
107
108/** A row in pieces: shrink (names, by order) are cut with … first, then drop (by order) pieces are left out. */
109type Piece = { segs: Seg[]; shrink?: number; min?: number; drop?: number }
110
111const shorten = (t: string, by: number) => {
112 const cs = [...t]
113 return by <= 0 ? t : cs.slice(0, Math.max(0, cs.length - by - 1)).join('') + '…'
114}
115
116/** Fits the pieces in `columns` cells: cut the shrinkable texts (names first), then leave out the droppable pieces. */
117export function fitPieces(pieces: Piece[], columns: number): Seg[] {
118 const w = () => pieces.reduce((a, p) => a + segsW(p.segs), 0)
119 const shrunk = pieces.filter(x => x.shrink !== undefined).sort((a, b) => a.shrink! - b.shrink!)
120 const orig = new Map(shrunk.map(p => [p, p.segs[p.segs.length - 1].text]))
121 for (const p of shrunk) {
122 const over = w() - columns
123 if (over <= 0) break
124 const k = p.segs.length - 1
125 const t = p.segs[k].text
126 const room = cells(t) - Math.max(2, p.min ?? 6)
127 if (room <= 0) continue
128 p.segs[k] = { ...p.segs[k], text: shorten(t, Math.min(over, room)) }
129 }
130 const gone: { p: Piece; segs: Seg[] }[] = []
131 for (const p of pieces.filter(x => x.drop !== undefined).sort((a, b) => a.drop! - b.drop!)) {
132 if (w() <= columns) break
133 gone.push({ p, segs: p.segs })
134 p.segs = []
135 }
136 // a bigger drop may leave room for a smaller one dropped before it (names stay cut): put it back
137 for (const g of gone.slice(0, -1).reverse()) if (w() + segsW(g.segs) <= columns) g.p.segs = g.segs
138 // then give the cut names back as much as still fits, in the same order
139 if (gone.length) {
140 for (const p of shrunk) {
141 const slack = columns - w()
142 if (slack <= 0) break
143 const k = p.segs.length - 1
144 const full = orig.get(p)!
145 const now = cells(p.segs[k].text)
146 if (now >= cells(full)) continue
147 const len = Math.min(cells(full), now + slack)
148 p.segs[k] = { ...p.segs[k], text: len >= cells(full) ? full : shorten(full, cells(full) - len) }
149 }
150 }
151 return pieces.flatMap(p => p.segs)
152}
153
154/** « opus », « 2 opus + 1 sonnet » for the running agents' model families. */
155export const modelsText = (agents: EcAgent[]) => {
156 const n = new Map<string, number>()
157 for (const a of agents) n.set(a.model || '?', (n.get(a.model || '?') || 0) + 1)
158 if (n.size <= 1) return [...n.keys()][0] ?? ''
159 return [...n.entries()].sort((a, b) => b[1] - a[1]).map(([m, k]) => `${k} ${m}`).join(' + ')
160}
161
162function workflowRow(w: EcWorkflow, now: number, c: EcCfg, columns: number): EcRow {
163 const L = c.labels
164 const S = L.sep
165 const tone = levelOf(w, now, c)
166 const live = w.agents.filter(a => a.status === 'running')
167 const pieces: Piece[] = [{ segs: [{ text: `${L.workflow} `, tone }] }, { segs: [{ text: w.name }], shrink: 1, min: 8 }]
168 const pr = progressOf(w)
169 if (pr) {
170 const pct = pr.total ? (pr.done * 100) / pr.total : 0
171 pieces.push({ segs: [{ text: ' ' }, { text: miniBar(pct, c.bar_cells), tone, bar: pct }, { text: ' ' }, { text: fill(L.progress, pr), tone }] })
172 }
173 if (w.phase) pieces.push({ segs: [{ text: S, dim: true }, { text: `${L.phase} ${w.phase}` }], shrink: 2, min: 8 })
174 if (live.length) {
175 const m = modelsText(live)
176 pieces.push({ segs: [{ text: S, dim: true }, { text: live.length === 1 ? L.agents_one : fill(L.agents, { n: live.length }), dim: true }, ...(m ? [{ text: ` ${m}` }] : [])], drop: 2 })
177 }
178 if (w.start) pieces.push({ segs: [{ text: S, dim: true }, { text: elapsed(now - w.start) }] })
179 if (w.status === 'failed') pieces.push({ segs: [{ text: S, dim: true }, { text: L.failed, tone: 'bad' }] })
180 else if (tone !== 'ok' && w.last) pieces.push({ segs: [{ text: S, dim: true }, { text: fill(L.stalled, { min: Math.floor((now - w.last) / 60000) }), tone }] })
181 else if (w.eta) pieces.push({ segs: [{ text: S, dim: true }, { text: w.overrun ? L.eta_over : fill(L.eta, { time: hhmm(w.eta) }), dim: true }], drop: 1 })
182 return { key: `ec-wf-${w.id}`, indent: 0, segs: fitPieces(pieces, columns) }
183}
184
185// One agent, as it sits in a workflow's agent row: • coder p43-rag-identity · opus · 47 min
186function agentItem(a: EcAgent, now: number, c: EcCfg, width: number): Seg[] {
187 const L = c.labels
188 const pieces: Piece[] = [{ segs: [{ text: `${L.agent} `, tone: levelOf(a, now, c) }] }]
189 if (a.type) pieces.push({ segs: [{ text: `${a.type} `, dim: true }], drop: 2 })
190 pieces.push({ segs: [{ text: a.label || a.id }], shrink: 1, min: 6 })
191 if (a.model) pieces.push({ segs: [{ text: L.sep, dim: true }, { text: a.model }], drop: 1 })
192 if (a.start) pieces.push({ segs: [{ text: L.sep, dim: true }, { text: elapsed(now - a.start) }] })
193 return fitPieces(pieces, width)
194}
195
196/** A workflow's agents packed into at most `lines` rows of `columns` cells; what does not fit becomes « +N ». */
197function agentRows(w: EcWorkflow, now: number, c: EcCfg, columns: number): EcRow[] {
198 const L = c.labels
199 const show = w.agents.filter(a => a.status === 'running' || a.status === 'failed')
200 if (!show.length) return []
201 const width = Math.max(10, columns - c.indent)
202 const items = show.map(a => agentItem(a, now, c, width))
203 const rows: Seg[][] = [[]]
204 let i = 0
205 for (; i < items.length; i++) {
206 const row = rows[rows.length - 1]
207 const need = (row.length ? c.gap : 0) + segsW(items[i])
208 const lastLine = rows.length === c.agent_lines
209 const restAfter = items.length - i - 1
210 const reserve = lastLine && restAfter > 0 ? c.gap + cells(fill(L.more_agents, { n: restAfter })) : 0
211 if (!row.length || segsW(row) + need + reserve <= width) {
212 if (row.length) row.push({ text: ' '.repeat(c.gap) })
213 row.push(...items[i])
214 continue
215 }
216 if (lastLine) break
217 rows.push([...items[i]])
218 }
219 if (i < items.length) rows[rows.length - 1].push({ text: ' '.repeat(c.gap) }, { text: fill(L.more_agents, { n: items.length - i }), dim: true })
220 return rows.map((segs, k) => ({ key: `ec-wa-${w.id}-${k}`, indent: c.indent, segs }))
221}
222
223function loneRow(a: EcAgent, now: number, c: EcCfg, columns: number): EcRow {
224 const L = c.labels
225 const bg = a.shape !== 'foreground'
226 const tone = levelOf(a, now, c)
227 const pieces: Piece[] = [{ segs: [{ text: `${bg ? L.background : L.agent} `, tone }] }, { segs: [{ text: bg ? L.bg_word : L.fg_word, dim: true }], shrink: 3, min: 5 }]
228 const what = [a.type, a.label].filter(Boolean).join(' ')
229 if (what) pieces.push({ segs: [{ text: L.sep, dim: true }, { text: what }], shrink: 1, min: 8 })
230 if (a.model) pieces.push({ segs: [{ text: L.sep, dim: true }, { text: a.model }], drop: 1 })
231 if (a.start) pieces.push({ segs: [{ text: L.sep, dim: true }, { text: elapsed(now - a.start) }] })
232 if (a.status === 'failed') pieces.push({ segs: [{ text: L.sep, dim: true }, { text: L.failed, tone: 'bad' }] })
233 else if (tone !== 'ok' && a.last) pieces.push({ segs: [{ text: L.sep, dim: true }, { text: fill(L.stalled, { min: Math.floor((now - a.last) / 60000) }), tone }] })
234 return { key: `ec-ag-${a.id}`, indent: 0, segs: fitPieces(pieces, columns) }
235}
236
237/**
238 * The rows for this conversation, capped at c.rows (0 = off): when more are needed, rows-1 of them then {more: N
239 * hidden rows} (the caller draws « +N de plus · Cockpit › »). Empty when nothing runs.
240 */
241export function enCoursView(d: EcData | null | undefined, c: EcCfg, columns: number, nowMs?: number): EcView {
242 const now = nowMs ?? d?.now ?? Date.now()
243 if (!d || c.rows <= 0) return { rows: [], more: 0 }
244 const all: EcRow[] = []
245 for (const w of d.workflows ?? []) {
246 all.push(workflowRow(w, now, c, columns))
247 all.push(...agentRows(w, now, c, columns))
248 }
249 for (const a of d.agents ?? []) all.push(loneRow(a, now, c, columns))
250 if (all.length <= c.rows) return { rows: all, more: 0 }
251 const keep = Math.max(0, c.rows - 1)
252 return { rows: all.slice(0, keep), more: all.length - keep }
253}
254
255/** A row as plain text (tests, the /jauge reply). */
256export const rowText = (r: EcRow) => ' '.repeat(r.indent) + r.segs.map(s => s.text).join('')
257hooks/clarity.ts 473 lines1// band mod: the « clarity » Jauge (jauge.layout "clarity", the default since 25 Oct), pure (no $, no I/O; node --test
2// runs it). Julian, 25 Oct: « not clear, I don't see what's running on the conv, too much info not collapsed, no line
3// jumped, not enough indentation, not enough colors, progression bar % token eta ».
4//
5// Folded (default, state kept in $.store `sections.expanded`): ONE line, what runs in THIS conversation first,
6// coloured by state, then the gauges:
7// ▶ ● 1 workflow · 3 agents en cours · ctx ▰▰▰▱▱ 57 % · 5 h ▰▰▰▱▱ 64 % · sem ▰▰▰▰▱ 74 %
8// Unfolded (▼): the same line, then sections separated by a blank line, each with a header and indented tree rows:
9// ▼ ● 1 workflow · 3 agents en cours · ctx ▰▰▰▱▱ 57 % · 5 h ▰▰▰▱▱ 64 %
10//
11// En cours · cette conversation
12// ├ ◇ demo-release-wave ▰▰▰▰▰▰▱▱▱▱ 60 % · 3/5 · ▸ Verify · 47 min · fin ~14:48
13// │ ├ • coder lane-alpha-identity · opus · 20 min · 1,2 M tok
14// │ └ • tester lane-beta · sonnet · 9 min · rien écrit depuis 18 min
15// └ ◦ auditor scan · sonnet · 3 min
16//
17// Jauges
18// ├ ctx ▰▰▰▰▰▰▱▱▱▱ 57 % · 228 k / 400 k · relais 250 k · compact ~16 min
19// ├ 5 h ▰▰▰▰▰▰▱▱▱▱ 64 %
20// ├ sem ▰▰▰▰▰▰▰▱▱▱ 74 % · projeté 108 %
21// └ débit 4,4 k tok/s
22//
23// Ailleurs sur ce PC
24// └ 2 conv · 1 wf · 3 ag · 1 CI
25// Every label, glyph, section, gauge, bar width and indent comes from band.config.json `jauge.clarity`; thresholds from
26// `jauge.levels` (green → orange → red). Zero counts are left out, the « agent d'arrière-plan » words are gone (◦ is
27// a background agent, • a foreground one). A row wider than the band cuts names first, then drops model / ETA.
28
29import { ctxLevel, miniBar, quotaLevel, DEF_LEVELS } from './jauge.ts'
30import type { ActFacts, Level, Levels } from './jauge.ts'
31import { elapsed, fitPieces, levelOf, progressOf } from './encours.ts'
32import type { EcAgent, EcData, EcWorkflow, Seg } from './encours.ts'
33
34export type CTone = 'ok' | 'warn' | 'bad' | 'dim' | 'accent'
35/** One piece of text; bar = this text is a gauge of that % (the desktop draws a thin Svg of `cells` width instead). */
36export type CSeg = { text: string; tone?: CTone; bold?: boolean; bar?: number; cells?: number }
37/** summary: the one folded line; blank: a line break between sections; header: a section title; item: a tree row. */
38export type CRow = { key: string; kind: 'summary' | 'blank' | 'header' | 'item'; segs: CSeg[]; section?: string }
39
40export type ClarityCfg = {
41 /** sections unfolded under the summary, in order: running, gauges, pc, mods (the renderer draws mods' buttons) */
42 sections: string[]
43 /** what the folded line holds after the running state, in order; dropped from the end when too narrow */
44 summary: string[]
45 /** gauge rows of the gauges section */
46 gauges: string[]
47 /** cells of the bars in the summary and in the sections */
48 summary_bar_cells: number
49 bar_cells: number
50 /** spaces before a section's header and rows */
51 indent: number
52 /** a blank line before each section */
53 blank_lines: boolean
54 /** agents listed under one workflow before « +N » */
55 max_agents: number
56 /** rows of the running section before « +N de plus » */
57 max_running: number
58 glyphs: Record<string, string>
59 labels: Record<string, string>
60}
61
62export const DEF_CLARITY_GLYPHS: Record<string, string> = {
63 open: '▼',
64 closed: '▶',
65 branch: '├ ',
66 last: '└ ',
67 pipe: '│ ',
68 space: ' ',
69 running: '●',
70 idle: '○',
71 workflow: '◇',
72 agent: '•',
73 background: '◦',
74 phase: '▸',
75}
76
77export const DEF_CLARITY_LABELS: Record<string, string> = {
78 idle: 'rien en cours',
79 main: 'Claude travaille',
80 workflow_one: '1 workflow',
81 workflows: '{n} workflows',
82 agent_one: '1 agent',
83 workflows_short: '{n} wf',
84 agents_short: '{n} ag',
85 agents: '{n} agents',
86 running_suffix: 'en cours',
87 failed_suffix: '{n} échoué(s)',
88 stalled_suffix: '{n} silencieux',
89 waiting: '◆ {n} t’attend',
90 context: 'ctx',
91 five: '5 h',
92 week: 'sem',
93 tps: 'débit',
94 tps_value: '{n} tok/s',
95 ctx_detail: '{tokens} / {window}',
96 ctx_relay: 'relais {relay}',
97 ctx_compact: 'compact ~{min} min',
98 projected: 'projeté {n} %',
99 sec_running: 'En cours · cette conversation',
100 sec_gauges: 'Jauges',
101 sec_pc: 'Ailleurs sur ce PC',
102 sec_mods: 'Mods',
103 main_row: 'fil principal',
104 none_running: 'rien en cours ici',
105 pc_conv: '{n} conv',
106 pc_wf: '{n} wf',
107 pc_ag: '{n} ag',
108 pc_ci: '{n} CI',
109 pc_ci_failed: '{n} CI en échec',
110 pc_ci_stale: 'CI ?',
111 pc_none: 'rien d’autre',
112 progress: '{done}/{total}',
113 pct: '{n} %',
114 tokens: '{n} tok',
115 eta: 'fin ~{time}',
116 eta_over: "au-delà de l'estimation",
117 stalled: 'rien écrit depuis {min} min',
118 failed: 'échoué',
119 more_agents: '+{n} agents',
120 more: '+{n} de plus · Cockpit',
121 sep: ' · ',
122}
123
124export const DEF_CLARITY: ClarityCfg = {
125 sections: ['running', 'gauges', 'pc', 'mods'],
126 summary: ['waiting', 'context', 'five', 'week'],
127 gauges: ['context', 'five', 'week', 'tps'],
128 summary_bar_cells: 5,
129 bar_cells: 10,
130 indent: 2,
131 blank_lines: true,
132 max_agents: 6,
133 max_running: 12,
134 glyphs: DEF_CLARITY_GLYPHS,
135 labels: DEF_CLARITY_LABELS,
136}
137
138const posInt = (x: unknown, d: number, min = 0) => (Number.isFinite(Number(x)) && Number(x) >= min ? Math.floor(Number(x)) : d)
139const strs = (x: unknown, d: string[]) => (Array.isArray(x) ? x.filter(s => typeof s === 'string') : d)
140
141export const clarityCfg = (raw?: any): ClarityCfg => ({
142 sections: strs(raw?.sections, DEF_CLARITY.sections),
143 summary: strs(raw?.summary, DEF_CLARITY.summary),
144 gauges: strs(raw?.gauges, DEF_CLARITY.gauges),
145 summary_bar_cells: posInt(raw?.summary_bar_cells, DEF_CLARITY.summary_bar_cells, 1),
146 bar_cells: posInt(raw?.bar_cells, DEF_CLARITY.bar_cells, 1),
147 indent: posInt(raw?.indent, DEF_CLARITY.indent),
148 blank_lines: raw?.blank_lines !== false,
149 max_agents: posInt(raw?.max_agents, DEF_CLARITY.max_agents, 1),
150 max_running: posInt(raw?.max_running, DEF_CLARITY.max_running, 1),
151 glyphs: { ...DEF_CLARITY_GLYPHS, ...(raw?.glyphs || {}) },
152 labels: { ...DEF_CLARITY_LABELS, ...(raw?.labels || {}) },
153})
154
155/** The figures the clarity Jauge reads (register.tsx builds it from the engine, the scanner and scanner/encours.mjs). */
156export type ClarityIn = {
157 now: number
158 /** the engine says this conversation's main thread is working */
159 working: boolean
160 /** what runs in THIS conversation (scanner/encours.mjs) */
161 running: EcData | null
162 ctxPct?: number
163 tokens?: number
164 window?: number
165 relayAt: number
166 relayPct?: number
167 etaMin?: number
168 five?: number
169 fiveProj?: number
170 week?: number
171 weekProj?: number
172 tps?: number
173 waiting?: number
174 /** the whole PC (lib/core/busy.mjs), for « Ailleurs sur ce PC » */
175 pc?: ActFacts
176}
177
178export type ClarityView = { rows: CRow[]; tone: Level | 'dim'; running: number }
179
180const fill = (tpl: string, vars: Record<string, string | number>) => tpl.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m))
181const cellsOf = (s: string) => [...s].length
182export const segsText = (xs: CSeg[]) => xs.map(s => s.text).join('')
183export const rowCells = (r: CRow) => cellsOf(segsText(r.segs))
184
185/** 1,2 M · 228 k · 512 (the band's French figures). */
186export const fmtK = (n: number) => {
187 const one = (x: number) => (x < 10 ? x.toFixed(1).replace('.', ',').replace(/,0$/, '') : String(Math.round(x)))
188 if (Math.abs(n) < 1000) return String(Math.round(n))
189 if (Math.abs(n) < 1e6) return `${one(n / 1000)} k`
190 return `${one(n / 1e6)} M`
191}
192
193const hhmm = (t: number) => {
194 const d = new Date(t)
195 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
196}
197
198const worst = (ls: Level[]): Level => (ls.includes('bad') ? 'bad' : ls.includes('warn') ? 'warn' : 'ok')
199const EC_LEVELS = { warn_min: 15, bad_min: 30 }
200
201/** What runs in this conversation: counts and the worst state (ok writing, warn silent, bad failed or long silent). */
202export const runningOf = (d: EcData | null, working: boolean, now: number, lv: { warn_min: number; bad_min: number } = EC_LEVELS) => {
203 const wfs = (d?.workflows ?? []).filter(w => w.status === 'running' || w.status === 'failed')
204 const wfAgents = wfs.flatMap(w => w.agents.filter(a => a.status === 'running' || a.status === 'failed'))
205 const lone = (d?.agents ?? []).filter(a => a.status === 'running' || a.status === 'failed')
206 const all = [...wfs, ...wfAgents, ...lone]
207 const levels = all.map(x => levelOf(x, now, lv))
208 return {
209 workflows: wfs.filter(w => w.status === 'running').length,
210 agents: [...wfAgents, ...lone].filter(a => a.status === 'running').length,
211 failed: levels.filter((l, i) => l === 'bad' && all[i].status === 'failed').length,
212 silent: levels.filter((l, i) => l !== 'ok' && all[i].status === 'running').length,
213 tone: all.length ? worst(levels) : working ? ('ok' as Level) : undefined,
214 working,
215 }
216}
217
218/** « ● 1 workflow · 3 agents en cours » (state colour), « ● Claude travaille », or a grey « ○ rien en cours ». */
219export const runningSegs = (r: ReturnType<typeof runningOf>, c: ClarityCfg, short = false): CSeg[] => {
220 const L = c.labels
221 const G = c.glyphs
222 const parts: string[] = []
223 if (r.workflows) parts.push(short ? fill(L.workflows_short, { n: r.workflows }) : r.workflows === 1 ? L.workflow_one : fill(L.workflows, { n: r.workflows }))
224 if (r.agents) parts.push(short ? fill(L.agents_short, { n: r.agents }) : r.agents === 1 ? L.agent_one : fill(L.agents, { n: r.agents }))
225 if (!parts.length && !r.failed) {
226 if (r.working) return [{ text: `${G.running} `, tone: 'ok', bold: true }, { text: L.main, tone: 'ok', bold: true }]
227 return [{ text: `${G.idle} `, tone: 'dim' }, { text: L.idle, tone: 'dim' }]
228 }
229 const tone = r.tone ?? 'ok'
230 const out: CSeg[] = [{ text: `${G.running} `, tone, bold: true }]
231 if (parts.length) out.push({ text: `${parts.join(L.sep)} ${L.running_suffix}`, tone, bold: true })
232 if (r.failed) out.push({ text: (parts.length ? L.sep : '') + fill(L.failed_suffix, { n: r.failed }), tone: 'bad', bold: true })
233 else if (r.silent && !short) out.push({ text: L.sep + fill(L.stalled_suffix, { n: r.silent }), tone: 'warn', bold: true })
234 return out
235}
236
237type GaugeFig = { id: string; label: string; pct: number; level: Level; detail: string[] }
238
239/** The gauges with a reading, their level from jauge.levels and their detail words (tokens, relay, projection). */
240export const gaugesOf = (f: ClarityIn, c: ClarityCfg, lv: Levels = DEF_LEVELS): GaugeFig[] => {
241 const L = c.labels
242 const out: GaugeFig[] = []
243 for (const id of ['context', 'five', 'week']) {
244 if (id === 'context') {
245 if (f.ctxPct === undefined) continue
246 const detail: string[] = []
247 if (f.tokens !== undefined && f.window) detail.push(fill(L.ctx_detail, { tokens: fmtK(f.tokens), window: fmtK(f.window) }))
248 detail.push(fill(L.ctx_relay, { relay: fmtK(f.relayAt) }))
249 if (f.etaMin !== undefined && Number.isFinite(f.etaMin)) detail.push(fill(L.ctx_compact, { min: Math.max(0, Math.round(f.etaMin)) }))
250 out.push({ id, label: L.context, pct: f.ctxPct, level: ctxLevel(f.ctxPct, f.relayPct, lv), detail })
251 } else {
252 const v = id === 'five' ? f.five : f.week
253 if (v === undefined || !Number.isFinite(v)) continue
254 const proj = id === 'five' ? f.fiveProj : f.weekProj
255 const detail = proj !== undefined && Number.isFinite(proj) && Math.round(proj) > Math.round(v) ? [fill(L.projected, { n: Math.round(proj) })] : []
256 out.push({ id, label: L[id], pct: v, level: quotaLevel(v, proj, lv), detail })
257 }
258 }
259 return out
260}
261
262const gaugeSegs = (g: GaugeFig, c: ClarityCfg, n: number, pad = 0): CSeg[] => {
263 const p = Math.max(0, Math.min(100, g.pct))
264 return [
265 { text: g.label.padEnd(pad) + ' ', tone: 'dim' },
266 { text: miniBar(p, n), tone: g.level, bar: p, cells: n },
267 { text: ' ' + fill(c.labels.pct, { n: Math.round(g.pct) }).padStart(pad ? 5 : 0), tone: g.level, bold: true },
268 ]
269}
270
271/** The folded line's segments after the toggle, fitted to `columns` (later summary fields are dropped first). */
272export const summarySegs = (f: ClarityIn, c: ClarityCfg, lv: Levels, columns: number, ecLv = EC_LEVELS): { segs: CSeg[]; tone: Level | 'dim'; running: number } => {
273 const r = runningOf(f.running, f.working, f.now, ecLv)
274 const L = c.labels
275 const full = runningSegs(r, c)
276 const head = cellsOf(segsText(full)) <= columns ? full : runningSegs(r, c, true)
277 const gs = gaugesOf(f, c, lv)
278 type P = { segs: CSeg[]; drop?: number }
279 const pieces: P[] = [{ segs: head }]
280 let rank = c.summary.length + 1
281 for (const id of c.summary) {
282 rank--
283 if (id === 'waiting') {
284 const n = Math.max(0, Math.round(Number(f.waiting) || 0))
285 if (n) pieces.push({ segs: [{ text: L.sep, tone: 'dim' }, { text: fill(L.waiting, { n }), tone: 'warn', bold: true }], drop: rank + 100 })
286 continue
287 }
288 const g = gs.find(x => x.id === id)
289 if (g) pieces.push({ segs: [{ text: L.sep, tone: 'dim' }, ...gaugeSegs(g, c, c.summary_bar_cells)], drop: rank })
290 }
291 // too wide: whole later fields go first (week, then 5 h, then the context); the running state stays
292 const w = () => pieces.reduce((a, p) => a + cellsOf(segsText(p.segs)), 0)
293 for (const p of [...pieces].filter(x => x.drop !== undefined).sort((a, b) => a.drop! - b.drop!)) {
294 if (w() <= columns) break
295 // first try the field without its bar
296 const bar = p.segs.findIndex(s => s.bar !== undefined)
297 if (bar >= 0 && p.drop! < 100) {
298 p.segs = p.segs.filter((_, i) => i !== bar).map((s, i) => (i === bar ? { ...s, text: s.text.trimStart() } : s))
299 if (w() <= columns) break
300 }
301 p.segs = []
302 }
303 const running = r.workflows + r.agents
304 return { segs: pieces.flatMap(p => p.segs), tone: r.tone ?? 'dim', running }
305}
306
307// a tree row: the guides (├ └ │) in grey, then the item's pieces fitted to what is left of the width
308type Piece = { segs: Seg[]; shrink?: number; min?: number; drop?: number }
309const toC = (s: Seg): CSeg => ({ text: s.text, ...(s.tone ? { tone: s.tone } : s.dim ? { tone: 'dim' as const } : {}), ...(s.bar !== undefined ? { bar: s.bar, cells: cellsOf(s.text) } : {}) })
310
311const wfPieces = (w: EcWorkflow, now: number, c: ClarityCfg, lv: typeof EC_LEVELS, room = 999): Piece[] => {
312 const L = c.labels
313 const S = L.sep
314 const tone = levelOf(w, now, lv)
315 const pieces: Piece[] = [{ segs: [{ text: `${c.glyphs.workflow} `, tone }] }, { segs: [{ text: w.name }], shrink: 1, min: 8 }]
316 const pr = progressOf(w)
317 if (pr) {
318 const pct = pr.total ? (pr.done * 100) / pr.total : 0
319 pieces.push({ segs: [{ text: ' ' }, { text: miniBar(pct, room < 64 ? c.summary_bar_cells : c.bar_cells), tone, bar: pct, cells: room < 64 ? c.summary_bar_cells : c.bar_cells }, { text: ' ' }, { text: fill(L.pct, { n: Math.round(pct) }), tone }] })
320 pieces.push({ segs: [{ text: S, dim: true }, { text: fill(L.progress, pr), dim: true }], drop: 3 })
321 }
322 if (w.phase) pieces.push({ segs: [{ text: S, dim: true }, { text: `${c.glyphs.phase} ${w.phase}` }], shrink: 2, min: 8, drop: 4 })
323 if (w.start) pieces.push({ segs: [{ text: S, dim: true }, { text: elapsed(now - w.start) }] })
324 const tok = (w as any).tokens
325 if (Number.isFinite(tok) && tok > 0) pieces.push({ segs: [{ text: S, dim: true }, { text: fill(L.tokens, { n: fmtK(tok) }), dim: true }], drop: 2 })
326 if (w.status === 'failed') pieces.push({ segs: [{ text: S, dim: true }, { text: L.failed, tone: 'bad' }] })
327 else if (tone !== 'ok' && w.last) pieces.push({ segs: [{ text: S, dim: true }, { text: fill(L.stalled, { min: Math.floor((now - w.last) / 60000) }), tone }], drop: 5 })
328 else if (w.eta) pieces.push({ segs: [{ text: S, dim: true }, { text: w.overrun ? L.eta_over : fill(L.eta, { time: hhmm(w.eta) }), tone: w.overrun ? 'warn' : undefined }], drop: 1 })
329 return pieces
330}
331
332const agentPieces = (a: EcAgent, now: number, c: ClarityCfg, lv: typeof EC_LEVELS, lone: boolean): Piece[] => {
333 const L = c.labels
334 const S = L.sep
335 const tone = levelOf(a, now, lv)
336 const bullet = lone && a.shape !== 'foreground' ? c.glyphs.background : c.glyphs.agent
337 const pieces: Piece[] = [{ segs: [{ text: `${bullet} `, tone }] }]
338 if (a.type) pieces.push({ segs: [{ text: `${a.type} `, dim: true }], drop: 4 })
339 pieces.push({ segs: [{ text: a.label || a.id }], shrink: 1, min: 6 })
340 if (a.model) pieces.push({ segs: [{ text: S, dim: true }, { text: a.model }], drop: 2 })
341 if (a.start) pieces.push({ segs: [{ text: S, dim: true }, { text: elapsed(now - a.start) }] })
342 const tok = (a as any).tokens
343 if (Number.isFinite(tok) && tok > 0) pieces.push({ segs: [{ text: S, dim: true }, { text: fill(L.tokens, { n: fmtK(tok) }), dim: true }], drop: 1 })
344 const pct = (a as any).pct
345 if (Number.isFinite(pct)) pieces.push({ segs: [{ text: ' ' }, { text: miniBar(pct, c.summary_bar_cells), tone, bar: pct }, { text: ' ' + fill(L.pct, { n: Math.round(pct) }), tone }], drop: 3 })
346 if (a.status === 'failed') pieces.push({ segs: [{ text: S, dim: true }, { text: L.failed, tone: 'bad' }] })
347 else if (tone !== 'ok' && a.last) pieces.push({ segs: [{ text: S, dim: true }, { text: fill(L.stalled, { min: Math.floor((now - a.last) / 60000) }), tone }], drop: 5 })
348 return pieces
349}
350
351const guide = (t: string): CSeg => ({ text: t, tone: 'dim' })
352
353/** The sections' rows (without the summary), in config order; `mods` is a header the renderer fills with buttons. */
354export const sectionRows = (f: ClarityIn, c: ClarityCfg, lv: Levels, columns: number, ecLv = EC_LEVELS): CRow[] => {
355 const L = c.labels
356 const G = c.glyphs
357 const pad = ' '.repeat(c.indent)
358 const rows: CRow[] = []
359 const item = (sec: string, key: string, prefix: string, pieces: Piece[]) => {
360 const room = Math.max(12, columns - cellsOf(pad + prefix))
361 const segs = fitPieces(pieces.map(p => ({ ...p, segs: p.segs.map(s => ({ ...s })) })), room).map(toC)
362 rows.push({ key, kind: 'item', section: sec, segs: [guide(pad + prefix), ...segs] })
363 }
364 const head = (sec: string, title: string) => {
365 if (c.blank_lines) rows.push({ key: `blank-${sec}`, kind: 'blank', section: sec, segs: [{ text: '' }] })
366 rows.push({ key: `head-${sec}`, kind: 'header', section: sec, segs: [{ text: pad + title, tone: 'accent', bold: true }] })
367 }
368 for (const sec of c.sections) {
369 if (sec === 'running') {
370 head(sec, L.sec_running)
371 const d = f.running
372 const wfs = (d?.workflows ?? []).filter(w => w.status === 'running' || w.status === 'failed')
373 const lone = (d?.agents ?? []).filter(a => a.status === 'running' || a.status === 'failed')
374 type Top = { kind: 'main' } | { kind: 'wf'; w: EcWorkflow } | { kind: 'ag'; a: EcAgent }
375 const tops: Top[] = [...(f.working ? [{ kind: 'main' } as Top] : []), ...wfs.map(w => ({ kind: 'wf', w }) as Top), ...lone.map(a => ({ kind: 'ag', a }) as Top)]
376 if (!tops.length) {
377 rows.push({ key: 'run-none', kind: 'item', section: sec, segs: [guide(pad + G.last), { text: L.none_running, tone: 'dim' }] })
378 continue
379 }
380 let used = 0
381 let hidden = 0
382 tops.forEach((t, i) => {
383 const last = i === tops.length - 1
384 const g = last ? G.last : G.branch
385 if (used >= c.max_running) {
386 hidden += t.kind === 'wf' ? 1 + t.w.agents.filter(a => a.status === 'running' || a.status === 'failed').length : 1
387 return
388 }
389 used++
390 if (t.kind === 'main') item(sec, 'run-main', g, [{ segs: [{ text: `${G.running} `, tone: 'ok' }, { text: L.main_row }] }])
391 else if (t.kind === 'ag') item(sec, `run-ag-${t.a.id}`, g, agentPieces(t.a, f.now, c, ecLv, true))
392 else {
393 item(sec, `run-wf-${t.w.id}`, g, wfPieces(t.w, f.now, c, ecLv, columns - cellsOf(pad + g)))
394 const kids = t.w.agents.filter(a => a.status === 'running' || a.status === 'failed')
395 const shown = kids.slice(0, kids.length > c.max_agents ? c.max_agents - 1 : c.max_agents)
396 const rest = kids.length - shown.length
397 const under = last ? G.space : G.pipe
398 shown.forEach((a, k) => {
399 if (used >= c.max_running) return void hidden++
400 used++
401 item(sec, `run-wa-${t.w.id}-${a.id}`, under + (k === shown.length - 1 && !rest ? G.last : G.branch), agentPieces(a, f.now, c, ecLv, false))
402 })
403 if (rest) rows.push({ key: `run-wa-${t.w.id}-more`, kind: 'item', section: sec, segs: [guide(pad + under + G.last), { text: fill(L.more_agents, { n: rest }), tone: 'dim' }] })
404 }
405 })
406 if (hidden) rows.push({ key: 'run-more', kind: 'item', section: sec, segs: [guide(pad + G.last), { text: fill(L.more, { n: hidden }), tone: 'dim' }] })
407 } else if (sec === 'gauges') {
408 const gs = gaugesOf(f, c, lv)
409 const tps = c.gauges.includes('tps') && f.tps !== undefined && Number.isFinite(f.tps) ? f.tps : undefined
410 const list = c.gauges.filter(id => id !== 'tps').map(id => gs.find(g => g.id === id)).filter((g): g is GaugeFig => !!g)
411 if (!list.length && tps === undefined) continue
412 head(sec, L.sec_gauges)
413 const w = Math.max(...list.map(g => cellsOf(g.label)), 0)
414 list.forEach((g, i) => {
415 const last = i === list.length - 1 && tps === undefined
416 const pieces: Piece[] = [{ segs: gaugeSegs(g, c, c.bar_cells, w) as Seg[] }]
417 g.detail.forEach((d, k) => pieces.push({ segs: [{ text: L.sep, dim: true }, { text: d, dim: true }], drop: g.detail.length - k }))
418 item(sec, `gauge-${g.id}`, last ? G.last : G.branch, pieces)
419 })
420 if (tps !== undefined) item(sec, 'gauge-tps', G.last, [{ segs: [{ text: `${L.tps} `, dim: true }, { text: fill(L.tps_value, { n: fmtK(tps) }) }] }])
421 } else if (sec === 'pc') {
422 const a = f.pc
423 if (!a) continue
424 head(sec, L.sec_pc)
425 const segs: Seg[] = []
426 const add = (t: string, tone?: Seg['tone'], dim?: boolean) => segs.push(...(segs.length ? [{ text: L.sep, dim: true }] : []), { text: t, ...(tone ? { tone } : {}), ...(dim ? { dim } : {}) })
427 if (a.convs) add(fill(L.pc_conv, { n: a.convs }), 'ok')
428 if (a.workflows) add(fill(L.pc_wf, { n: a.workflows }), a.wfTone ?? 'ok')
429 if (a.agents) add(fill(L.pc_ag, { n: a.agents }), a.agTone ?? 'ok')
430 if (a.ci) {
431 if (a.ci.stale) add(L.pc_ci_stale, undefined, true)
432 else if (a.ci.failed) add(fill(L.pc_ci_failed, { n: a.ci.failed }), 'bad')
433 else if (a.ci.running) add(fill(L.pc_ci, { n: a.ci.running }), 'ok')
434 }
435 if (!segs.length) segs.push({ text: L.pc_none, dim: true })
436 item(sec, 'pc-line', G.last, [{ segs }])
437 } else if (sec === 'mods') {
438 head(sec, L.sec_mods)
439 }
440 }
441 return rows
442}
443
444/**
445 * The whole clarity Jauge: the summary row (after the toggle glyph; `reserve` cells are kept for the relay and the
446 * buttons the renderer may put on it), then, unfolded, the sections. Pure: same rows in the terminal, the desktop and
447 * scripts/preview.mjs.
448 */
449export type RelayFit = { hint: string; button: string; pad: number }
450export const clarityView = (
451 f: ClarityIn,
452 c: ClarityCfg,
453 lv: Levels,
454 columns: number,
455 expanded: boolean,
456 relay?: RelayFit,
457 ecLv = EC_LEVELS,
458): ClarityView & { relay: 'chip' | 'button' | 'none'; reserve: number } => {
459 const toggle = `${expanded ? c.glyphs.open : c.glyphs.closed} `
460 const room = columns - cellsOf(toggle)
461 // the relay: a red chip and RESUME when the running state still fits beside them, else RESUME alone
462 const btn = relay ? cellsOf(relay.button) + relay.pad + 1 : 0
463 const chip = relay ? cellsOf(relay.hint) + 1 + btn : 0
464 const r = runningOf(f.running, f.working, f.now, ecLv)
465 const headW = cellsOf(segsText(runningSegs(r, c, true)))
466 const mode: 'chip' | 'button' | 'none' = !relay ? 'none' : room - chip >= headW ? 'chip' : 'button'
467 const reserve = mode === 'chip' ? chip : mode === 'button' ? btn : 0
468 const s = summarySegs(f, c, lv, Math.max(10, room - reserve), ecLv)
469 const rows: CRow[] = [{ key: 'summary', kind: 'summary', segs: [{ text: toggle, tone: s.tone === 'dim' ? 'dim' : s.tone, bold: true }, ...s.segs] }]
470 if (expanded) rows.push(...sectionRows(f, c, lv, columns, ecLv))
471 return { rows, tone: s.tone, running: s.running, relay: mode, reserve }
472}
473hooks/brief.ts 111 lines1// band mod: the « Export conv » / « Export + enfants » buttons on the Jauge line (export_brief in band.config.json).
2// Pure: which buttons, their labels (order and words from the config), the script's arguments, the toast from its
3// answer. register.tsx runs export-eta's exporter/brief.mjs with $.process.run (not $.command.run, which would wait for
4// Claude's turn to end) and polls `info` for the child count.
5
6export type BriefButtonCfg = { id: 'conv' | 'tree' | string; label: string; count?: string }
7
8export type BriefCfg = {
9 enabled: boolean
10 /** export-eta's script, relative to band's folder (both mods sit side by side in claude-mod/mods) or absolute */
11 script: string
12 buttons: BriefButtonCfg[]
13 /** seconds between child-count polls (`info`) */
14 refresh_s: number
15 timeout_s: number
16 /** hide (default) or grey the buttons when the script is not found */
17 when_missing: 'hide' | 'grey'
18 labels: Record<string, string>
19}
20
21export const DEF_BRIEF: BriefCfg = {
22 enabled: true,
23 script: '../export-eta/exporter/brief.mjs',
24 buttons: [
25 { id: 'conv', label: 'Export conv' },
26 { id: 'tree', label: 'Export + enfants', count: ' +{n}' },
27 ],
28 refresh_s: 60,
29 timeout_s: 300,
30 when_missing: 'hide',
31 labels: {
32 busy: 'export…',
33 started: 'Brief en cours…',
34 started_tree: 'Brief avec {n} sous-conversation(s) en cours…',
35 done: '{line}',
36 failed: 'Brief échoué : {error}',
37 missing: 'mod export-eta introuvable ({script}) : ajouter mods/export-eta à côté de mods/band',
38 },
39}
40
41export function briefCfg(raw: unknown): BriefCfg {
42 const r = (raw && typeof raw === 'object' ? raw : {}) as Partial<BriefCfg>
43 const buttons = Array.isArray(r.buttons) ? r.buttons.filter(b => b && typeof b.label === 'string' && typeof b.id === 'string') : DEF_BRIEF.buttons
44 return {
45 enabled: r.enabled !== false,
46 script: typeof r.script === 'string' && r.script ? r.script : DEF_BRIEF.script,
47 buttons,
48 refresh_s: Math.max(10, Number(r.refresh_s) || DEF_BRIEF.refresh_s),
49 timeout_s: Math.max(30, Number(r.timeout_s) || DEF_BRIEF.timeout_s),
50 when_missing: r.when_missing === 'grey' ? 'grey' : 'hide',
51 labels: { ...DEF_BRIEF.labels, ...(r.labels ?? {}) },
52 }
53}
54
55export const fillB = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (m, k) => (v[k] === undefined ? m : String(v[k])))
56
57/** The script's absolute path: `script` as is when absolute, else joined to band's folder (../ resolved). */
58export function scriptPath(root: string, script: string): string {
59 const s = script.replace(/\\/g, '/')
60 if (/^([A-Za-z]:)?\//.test(s)) return s
61 const parts = root.replace(/\\/g, '/').replace(/\/+$/, '').split('/')
62 for (const p of s.split('/')) {
63 if (p === '..') parts.pop()
64 else if (p && p !== '.') parts.push(p)
65 }
66 return parts.join('/')
67}
68
69export type BriefButton = { id: string; key: string; label: string; enabled: boolean }
70
71/**
72 * The buttons to draw, in config order: « tree » only when the conversation has children (its count filled in), none
73 * when disabled or (when_missing hide) the script is missing (ready false). busy: the id running, drawn with labels.busy.
74 */
75export function briefButtons(c: BriefCfg, s: { children: number; ready: boolean; busy?: string }): BriefButton[] {
76 if (!c.enabled || (!s.ready && c.when_missing === 'hide')) return []
77 return c.buttons
78 .filter(b => b.id !== 'tree' || s.children > 0)
79 .map(b => ({
80 id: b.id,
81 key: `brief-${b.id}`,
82 label: s.busy === b.id ? c.labels.busy : `${b.label}${b.id === 'tree' && b.count ? fillB(b.count, { n: s.children }) : ''}`,
83 enabled: s.ready && !s.busy,
84 }))
85}
86
87/** argv for the script: run this session, --children for « tree ». */
88export const briefArgv = (node: string, script: string, id: string, session: string, cwd = '') => [node, script, 'run', '--session', session, ...(cwd ? ['--cwd', cwd] : []), '--json', ...(id === 'tree' ? ['--children'] : [])]
89
90/** The toast after a run, from the script's JSON line (else its last output line). */
91export function briefToast(c: BriefCfg, r: { exitCode: number; stdout: string; stderr: string }): string {
92 const last = (r.stdout || '').trim().split('\n').pop() || ''
93 try {
94 const j = JSON.parse(last) as { ok?: boolean; line?: string; error?: string }
95 if (j.ok) return fillB(c.labels.done, { line: j.line ?? '' })
96 return fillB(c.labels.failed, { error: j.error ?? j.line ?? '?' })
97 } catch {
98 return fillB(c.labels.failed, { error: (last || r.stderr || `exit ${r.exitCode}`).slice(0, 200) })
99 }
100}
101
102/** The child count from `info`'s JSON, 0 when unreadable. */
103export function infoOf(stdout: string): { found: boolean; children: number } {
104 try {
105 const j = JSON.parse((stdout || '').trim().split('\n').pop() || '{}') as { found?: boolean; children?: number }
106 return { found: Boolean(j.found), children: Number(j.children) || 0 }
107 } catch {
108 return { found: false, children: 0 }
109 }
110}
111types/index.d.ts 186 lines1// Types of the band mod (Jauge): the view scanner/scan.mjs prints (hooks/logic.ts buildView) and the `$.state` values.
2// Self-contained, as a mod contract must be; hooks/logic.ts and hooks/parse.ts take their shapes from here.
3
4/** Tokens of one response: input, cache write (all of it; cw1h its 1-hour part), cache read, output. */
5export type Usage = { inp: number; cw: number; cr: number; out: number; cw1h?: number }
6
7/** One API response: t ms, main session id, agent id (subagent or Workflow agent), workflow id, model, usage. */
8export type Ev = { t: number; s: string; a?: string; w?: string; m: string; id?: string; u: Usage }
9
10export type Compaction = {
11 t: number
12 /** compactMetadata.trigger as written ("auto", "manual", ...); "?" when the entry has none */
13 trigger: string
14 pre?: number
15 post?: number
16 /** minutes between the entry before the boundary and the boundary (inactive time) */
17 gapMin?: number
18 /** subagent / Workflow agent id when the compaction is an agent's own transcript */
19 a?: string
20}
21
22export type SessionMeta = {
23 s: string
24 cwd?: string
25 repo?: string
26 title?: string
27 /** last entry seen, any file of the session (ms) */
28 last: number
29 /** main thread: input side of the last response (input + cache write + cache read) and when */
30 ctx?: number
31 ctxT?: number
32 model?: string
33 compactions: Compaction[]
34 /** main thread [t, ctx] samples within the kept window */
35 samples: [number, number][]
36 /** the last PR/MR the app linked to this conversation (transcript entry type "pr-link") */
37 pr?: { number: number; url?: string }
38 /** main thread's turn (lib/core/busy.mjs feedTurn): last user/assistant entry (ms) and whether a turn is open */
39 turn?: { last: number; open: boolean }
40}
41
42export type CountMode = 'fresh' | 'all'
43
44export type ScopeId = 'conv' | 'conv_all' | 'agents' | 'workflows' | 'project' | 'all'
45
46export type Rate = { w: number; tokens: number; tps: number; tpm: number; tph: number; usdPerH: number; calls: number }
47
48export type ScopeRow = { id: ScopeId; rates: Rate[]; spark: number[] }
49
50export type TopRow = {
51 s: string
52 title: string
53 repo: string
54 tps: number
55 usdPerH: number
56 ctx?: number
57 compactions: number
58 lastCompact?: number
59 last: number
60 isThis: boolean
61}
62
63export type CompactRow = { s: string; title: string; repo: string; isThis: boolean; items: Compaction[] }
64
65export type View = {
66 now: number
67 windows: number[]
68 spark_min: number
69 bucket_s: number
70 count: CountMode
71 repo?: string
72 scopes: ScopeRow[]
73 top: TopRow[]
74 projects: { repo: string; tps: number; usdPerH: number; sessions: number }[]
75 compactions: CompactRow[]
76 marks: { conv: number[]; project: number[]; all: number[] }
77 conv?: {
78 ctx?: number
79 ctxT?: number
80 growthTpm?: number
81 last?: number
82 title?: string
83 /** its agents seen in the kept window: running = wrote within sections.running_s; the running Workflow's id */
84 agents?: { running: number; total: number; workflow?: string }
85 pr?: { number: number; url?: string }
86 }
87 /** the whole PC: agents running (wrote within sections.running_s), conversations active (within jauge.active_s) */
88 pc?: { agents: number; convs: number }
89 /** Codex's quota windows from the usage mod's data folder (lib/core/usage.mjs readQuotas), % used */
90 codex?: { five?: number; week?: number }
91 /** Claude's quota windows from the same folder, used when the engine gives no rate limits */
92 /** fiveProj / weekProj: % projected at the window's reset at the current pace (usage mod's samples), when known */
93 claude?: { five?: number; week?: number; fiveProj?: number; weekProj?: number }
94 /** machine load: CPU % between two scans (os.cpus), GPU % (nvidia-smi or the Windows 3D-engine counter) */
95 machine?: { cpu?: number; gpu?: number }
96 files: number
97 ms: number
98 /** where the per-line usage and the prices came from: "lib/core" (shared, the mod's synced copy) or "band" (fallback) */
99 lib: string
100 /** « À toi : N » and « attend Julian : N », counted once by lib/core/veille.mjs (readVeille) in the scanner */
101 waiting?: { aToi: number; julian: number; available: boolean; aToiAvailable: boolean }
102 /** what runs on the PC (lib/core/busy.mjs, scanner): busy main threads, Workflows, agents, CI from the ci mod's cache */
103 activity?: Activity
104}
105
106export type Activity = {
107 convs: number
108 /** session ids whose main thread has a turn in progress */
109 busy: string[]
110 /** this conversation's main thread is one of them */
111 thisBusy?: boolean
112 workflows: number
113 agents: number
114 lastDone?: { s: string; title: string; t: number } | null
115 /** off when jauge.activity.ci_cache is "" */
116 ci?: { available: boolean; running: number; queued: number; failed: number; age_ms: number | null; stale: boolean }
117}
118
119/** This conversation's figures from the engine ($.session.usage / session.measure). */
120export type EngineFigures = {
121 tokens?: number
122 window?: number
123 percent?: number
124 /** context.breakdown.autoCompactThreshold (summary breakdown, counted locally) */
125 autoCompactThreshold?: number
126 /** rateLimits seven_day percentUsed, when the account reports it */
127 week?: number
128 /** every rate limit the account reports (five_hour, seven_day, ...), with when it resets */
129 limits?: { kind: string; percentUsed: number; resetsAt?: string }[]
130 /** this session's cost from the engine ($.session.usage().cost.usd) */
131 cost?: number
132 /** this session's model ($.session.model()) and effort (CLAUDE_EFFORT, when the engine sets it) */
133 model?: string
134 effort?: string
135 at: number
136}
137
138/** The compact band's folding: the sections list unfolded under line 1, and which sections are open. */
139export type BandUi = { expanded: boolean; open: string[] }
140
141/** The hand-off helper's state in this session. */
142export type HandoffState = {
143 /** the RESUME file found (mtime set), or the first place looked when absent (no mtime) */
144 resume?: { path: string; mtime?: number }
145 /** when the hand-off command ran in this session (ms), from the button or typed */
146 ran: number[]
147 /** this conversation's start ($.session.usage().startedAt) */
148 startedAt?: number
149 /** the RESUME preview is shown in the pane */
150 show: boolean
151 /** its text (cut at preview_chars) */
152 text?: string
153}
154
155/** One agent of the « en cours » rows (a Workflow agent, or an Agent-tool agent outside Workflows). */
156export type EnCoursAgent = { id: string; label?: string; type?: string; model?: string; status: string; start?: number; last?: number; stalled?: boolean; shape?: string; phase?: string }
157/** A running (or just failed) Workflow of this conversation. */
158export type EnCoursWorkflow = { id: string; name: string; phases: string[]; phase?: string; phaseIdx?: number; status: string; start?: number; last?: number; stalled?: boolean; agents: EnCoursAgent[]; eta?: number; overrun?: boolean }
159export type EnCours = { now?: number; workflows?: EnCoursWorkflow[]; agents?: EnCoursAgent[] }
160
161declare module 'claude-code' {
162 interface PluginState {
163 band: {
164 /** what runs in THIS conversation (scanner/encours.mjs): the Jauge's « en cours » rows; null before the first read */
165 encours: EnCours | null
166 /** last scanner result */
167 view: View | null
168 /** this conversation's context and rate-limit figures */
169 engine: EngineFigures | null
170 /** last scanner error line, '' when fine */
171 error: string
172 /** the Mods button bar: slash commands seen in this session ($.command.list()) and the mods it opened */
173 launcher: { commands: { name: string; plugin?: string }[]; open: string[] }
174 /** the hand-off helper: RESUME found (path, mtime), /handoff runs seen in this session, the RESUME preview */
175 handoff: HandoffState
176 /** the compact band's folding (remembered in $.store under "sections" when sections.remember is "store") */
177 ui: BandUi
178 /** when each stamped button (Exporter) last ran, by its open-state key (ms); kept in $.store under "stamps" */
179 stamps: Record<string, number>
180 /** « Export conv » / « Export + enfants » (export_brief): export-eta's script found, this conversation's child
181 * count (descendants, from the app's session files), the button running, when the count was last read (ms) */
182 brief: { ready: boolean; children: number; busy: string; at: number }
183 }
184 }
185}
186