SLOPSHOPPER

band

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…

panebandcommandtoaststatusprocess
v0.8.0MITupdated 2026-10-26julian-passebecq/claude-mod/mods/band
A shopper browsing a rack in a slop shop
README

band — « Jauge »

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).

La Jauge « clarity » (default, 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
  • Running state: ● 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).
  • Running rows: the main thread, each Workflow (bar of phases done/total, %, 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).
  • Gauges: bars (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).
  • « Ailleurs sur ce PC »: the PC-wide counts, zeros left out (rien d’autre when all are zero, CI ? when the ci cache is stale).
  • Narrow: the folded line drops week, then 5 h, then the bars, uses 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).
  • Settings in 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.

La ligne (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 status first (field 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.
  • Zeros grey, non-zero green. Nothing runs: one green ✓ 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.
  • Context, 5 h and semaine carry a mini bar: ▰▱ (5 cells, jauge.bar_cells) in the terminal, a thin Svg bar on the desktop.
  • Traffic light on the bar and the % (the label words stay grey), thresholds in jauge.levels:
FigureGreenOrangeRed
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.

  • A field is never cut (Julian, 22 Oct, narrow chat column: « ▶ 7 c… .1… .3… » was unreadable). Each field (▶ 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):
  • it wraps before 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;
  • only then whole fields of 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 %
  • Past the hand-off threshold, on row 1: a red chip « relais conseillé » (labels.relay_hint) next to a RESUME button; narrower a red ● (labels.relay_mark, no words) before RESUME; narrowest RESUME alone.

Export pour une autre IA (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):

  • Export conv: ONE Markdown brief of this conversation, masked, saved under ~/.claude/export-eta/briefs/<date>-<titre>.md and copied to the clipboard; a toast gives the path and « copié ». No model call, no PR.
  • Export + enfants +N: only when the desktop app links sub-conversations to this one (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.

En cours (cette conversation)

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
  • One row per running Workflow: name, a mini bar of phases done/total (else agents done/total), the current phase, running agents and their model, elapsed time, and fin ~HH:MM when this PC has at least min_samples past completed runs of the same Workflow (median, lib/core/estimate.mjs).
  • One indented row of its running agents (agent_lines rows, then +N).
  • One row per Agent-tool agent outside Workflows: ◦ background, • foreground (the current turn's subagents).
  • Bullet and bar colour: green while it writes, orange after 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.
  • A row wider than the band cuts the names first (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.
  • At most 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.

Preview

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.

Tests

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.

Source 9 files
hooks/register.tsx 1400 lines
1// 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 lines
1// 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}
657
hooks/gauge.ts 132 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
132
hooks/jauge.ts 421 lines
1// 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}
421
hooks/sections.ts 164 lines
1// 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}
164
hooks/encours.ts 257 lines
1// 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('')
257
hooks/clarity.ts 473 lines
1// 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}
473
hooks/brief.ts 111 lines
1// 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}
111
types/index.d.ts 186 lines
1// 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