SLOPSHOPPER

integral

One HUD above the prompt: context bar with compact/checkpoint, plan limits, MCP health, plan progress. Replaces context-bar-compact and mcp-doctor.

newbandguardcommandtoastprocess
v0.2.0no licenseupdated 2026-10-07serg-yalosovetsky/integral/integral
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · integral
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /integral ⎿ integral: integral hidden. /integral shows it again ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

integral

Один HUD над промптом Claude Code (serg/tasks#1599). Заменяет context-bar-compact и mcp-doctor.

█████████████───────────────────────░░░ 37% > ↓ !
▀▀▀▀▀▀▀▔▔▔▔▔▔▔▔▔▔▔▔▔   ▀▀▀▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔   ✕ coord reconnect   🔑 figma auth   +2
● Пишу тесты  ━━━━━──────── 2/7
  • Строка 1 — контекст. Полоса по потребителям; 37% нажимается и по кругу называет потребителей от крупнейшего (messages 31%, кусок полосы подсвечен). > — полная легенда (< закрывает), ↓ — /compact (подсвечен у порога автокомпакта), ! — чекпоинт (CHECKPOINT_PROMPT). Пока идёт ход, значки тусклые и не нажимаются.
  • Строка 2 — лимиты + MCP. Полоски 5h и 7d без подписей, цвет — темп (used / доля прошедшего окна: ≤1 зелёный, >1 жёлтый, >2 или ≥80% красный). MCP-проблемы справа с кнопками reconnect/auth, лишнее — +N. Всё в порядке и нет подписки — строки нет.
  • Строка 3 — план. Пока открыт список TodoWrite / TaskCreate+TaskUpdate — он. Иначе эпик трекера меша (MCP tasks): ◆ <title> ━━━━──── 3/11 #1556, прогресс — - [x] из всех пунктов чек-листа в теле; всё отмечено — строки нет. Эпик сессии: /integral plan epic N (явно, авто его не перебивает), иначе последний task_add с parent или task_get эпика. Данные — task_get через MCP при установке, через 5 с после вызовов mcp__tasks__* и раз в 2 мин.

Команда: /integral (скрыть/показать, запоминается), /integral more, /integral mcp, /integral mcp check, /integral plan off|on, /integral plan epic N|off.

Ограничение: у Button в этой версии API нет цвета, поэтому 37% рисуется цветом темы, а цвет уровня проявляется при наведении.

Атрибуция

Логика строки плана (hooks/plan.ts) адаптирована из hoobnn/todo-bar (hooks/board.ts), лицензия MIT:

MIT License

Copyright (c) 2026 hoobnn

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Source 8 files
hooks/register.tsx 549 lines
1// integral (serg/tasks#1599): one HUD above the prompt, replacing context-bar-compact and mcp-doctor.
2//   Row 1, context: the bar by consumer, `37%` (press: names consumers biggest first, lights each
3//     run), then `>` (full legend; `<` closes), `↓` (/compact), `!` (checkpoint prompt).
4//     The icons are dim text while a turn runs: a queued compaction firing after the answer,
5//     unasked, is worse than a button that waits.
6//   Row 2, limits + MCP: the 5h and 7d bars (color = pace, Serg's rule), flush under row 1 thanks
7//     to top-aligned glyphs; MCP problems on the right with reconnect/auth buttons, the rest in +N.
8//   Row 3, plan: only while a task list is open, `● <current task>  ━━━──── 2/7`. Without one,
9//     the session's tracker epic (MCP `tasks`): `◆ <title>  ━━━──── 3/11  #1556`, gone once all is [x].
10//     The epic: /integral plan epic N (off clears), else the last task_add's parent or task_get of an epic.
11//   Each section is built in its own try/catch: one that throws is logged and left out, the rest draw.
12//   /integral hides or shows it all (kept across sessions); /integral more | mcp | mcp check | plan off|on.
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
15
16import type { Board, Epic, Limit, Reading, Report } from '../types'
17import { contextRow, legend, legendHead, legendParts, nextPick, toLimits, toReading } from './context'
18import type { Part } from './context'
19import { TASKS_PREFIX, TASKS_SERVER, epicRow, issueNumber, parseTask, textOf, withInfo } from './epic'
20import type { TaskInfo } from './epic'
21import { statusLayout } from './layout'
22import { MCP_COLOR, MCP_GLYPH, MCP_HEAD, findUrl, isRefusal, parseSearch, parseTools, problemsOf, reconnectName, shortName, summary } from './mcp'
23import type { Problem } from './mcp'
24import { PLAN_TOOLS, addTask, fromTodos, planRow, updateTask } from './plan'
25
26const PLUGIN = 'integral'
27const MIN_WIDTH = 20 // narrower than this, nothing is drawn
28const TICK_MS = 60_000 // limits move on once a minute; MCP is checked every second tick
29const MCP_EVERY = 2
30const DEBOUNCE_MS = 5_000
31const PROBE_QUERY = 'select:mcp__mcpdoctor__none' // matches nothing: only the server lists are wanted
32
33// What the checkpoint action sends. One constant, so a session-checkpoint mod can replace it.
34export const CHECKPOINT_PROMPT = 'Сохрани чекпоинт сессии в memory palace (скилл save-checkpoint).'
35
36// Held by the host, so the HUD survives a hot reload of this file.
37const reading = atom({ plugin: 'integral', key: 'reading' } as const, null as Reading | null)
38const isHidden = atom({ plugin: 'integral', key: 'isHidden' } as const, false)
39const isExpanded = atom({ plugin: 'integral', key: 'isExpanded' } as const, false)
40const limits = atom({ plugin: 'integral', key: 'limits' } as const, [] as Limit[])
41const tick = atom({ plugin: 'integral', key: 'tick' } as const, 0)
42const picked = atom({ plugin: 'integral', key: 'picked' } as const, null as string | null)
43const report = atom({ plugin: 'integral', key: 'report' } as const, null as Report | null)
44const isMcpExpanded = atom({ plugin: 'integral', key: 'isMcpExpanded' } as const, false)
45const startedAt = atom({ plugin: 'integral', key: 'startedAt' } as const, 0)
46const authOpened = atom({ plugin: 'integral', key: 'authOpened' } as const, [] as string[])
47const board = atom({ plugin: 'integral', key: 'board' } as const, null as Board | null)
48const isPlanHidden = atom({ plugin: 'integral', key: 'isPlanHidden' } as const, false)
49const epic = atom({ plugin: 'integral', key: 'epic' } as const, null as Epic | null)
50
51function errText(err: unknown) {
52  return err instanceof Error ? err.message : String(err)
53}
54
55// A failure that must not break the session still leaves a line in the debug log.
56function trace($: EngineInterface, what: string, err: unknown) {
57  $.ui.log(`${PLUGIN}: ${what}: ${errText(err)}`, { to: 'debug' })
58}
59
60// ---------- context ----------
61
62// Asks the engine for /context's breakdown, estimated locally (no token-count calls).
63async function refresh($: EngineInterface) {
64  if (await read($, isHidden)) return
65  const usage = await $.session.usage({ breakdown: 'summary' })
66  await update($, limits, () => toLimits(usage.rateLimits ?? [])) // empty off a subscription
67  const b = usage.context.breakdown
68  if (!b || !(b.rawMaxTokens > 0)) return // no window to measure against: the HUD keeps its last reading
69  await update($, reading, () => toReading(b))
70}
71
72// The two actions. Each is a queued call the engine runs once the session is idle; a refusal is told, not dropped.
73function act($: EngineInterface, key: 'compact' | 'checkpoint') {
74  const run = key === 'compact' ? $.command.run({ command: 'compact' }) : $.prompt.submit({ text: CHECKPOINT_PROMPT })
75  void run.catch(err => {
76    trace($, key, err)
77    $.ui.toast(`integral: ${key} failed, ${errText(err)}`)
78  })
79}
80
81// ---------- MCP ----------
82
83let inflight: Promise<Report | null> | undefined
84
85// One look at the servers; a check already running is shared, not doubled.
86async function check($: EngineInterface): Promise<Report | null> {
87  if (inflight) return inflight
88  inflight = (async () => {
89    try {
90      const [search, tools, now] = await Promise.all([
91        $.tool.call({ tool: 'ToolSearch', query: PROBE_QUERY, max_results: 1 } as never),
92        $.tool.list(),
93        $.clock.now(),
94      ])
95      const s = parseSearch('result' in search ? search.result : undefined)
96      const t = parseTools(tools)
97      const r: Report = { checkedAt: now, connected: t.connected, failed: s.failed, needsAuth: t.needsAuth, pending: s.pending }
98      await update($, report, () => r)
99      return r
100    } catch (err) {
101      trace($, 'mcp check', err) // the row keeps its last report; /integral mcp says the check failed
102      return null
103    } finally {
104      inflight = undefined
105    }
106  })()
107  return inflight
108}
109
110async function reconnect($: EngineInterface, name: string) {
111  let text: string | undefined
112  try {
113    text = (await $.command.run({ command: 'mcp', args: `reconnect ${name}` } as never)).text
114  } catch (err) {
115    trace($, `reconnect ${name}`, err)
116    text = `error: ${errText(err)}`
117  }
118  const r = await check($)
119  const stillFailed = !!r?.failed.some(f => f.name === name)
120  const hint = isRefusal(text) || stillFailed ? ` · набери /mcp reconnect ${name}` : ''
121  $.ui.toast(`MCP ${name}: ${text?.trim() || 'нет ответа'}${hint}`)
122}
123
124async function authenticate($: EngineInterface, seg: string) {
125  let text = ''
126  try {
127    const res = await $.tool.call({ tool: `mcp__${seg}__authenticate` } as never)
128    text = 'deny' in res && res.deny ? res.deny : (res.text ?? JSON.stringify(res.result ?? ''))
129  } catch (err) {
130    trace($, `authenticate ${seg}`, err)
131    $.ui.toast(`MCP ${shortName(seg)}: auth failed, ${errText(err)}`)
132    return
133  }
134  const { url, insecure } = findUrl(text)
135  if (!url) {
136    $.ui.toast(`MCP ${shortName(seg)}: ${insecure ? `не https, не открываю: ${insecure}` : text.slice(0, 300) || 'пустой ответ'}`)
137    return
138  }
139  try {
140    // explorer.exe exits 1 even when it opened the browser: the exit code says nothing here.
141    const r = await $.process.run(['explorer.exe', url])
142    if (r.exitCode !== 0) $.ui.log(`${PLUGIN}: explorer.exe exit ${r.exitCode} for auth ${seg} (normal for explorer)`, { to: 'debug' })
143  } catch (err) {
144    trace($, `open browser for ${seg}`, err)
145    $.ui.toast(`MCP ${shortName(seg)}: браузер не открылся, открой сам: ${url}`)
146    return
147  }
148  await update($, authOpened, l => (l.includes(seg) ? l : [...l, seg]))
149  $.ui.toast(`MCP ${shortName(seg)}: браузер открыт, после логина нажми reconnect`)
150}
151
152function press($: EngineInterface, p: Problem, action: 'reconnect' | 'auth') {
153  const run = action === 'auth' ? authenticate($, p.name) : reconnect($, p.kind === 'auth' ? reconnectName(p.name) : p.name)
154  void run.catch(err => trace($, `${action} ${p.name}`, err))
155}
156
157// ---------- plan ----------
158
159// The main thread's plan tools, once the call has run; a refused or failed call changes nothing.
160async function planFromCall($: EngineInterface, e: { tool: string; agentId?: string }, ran: { deny?: string; isError?: boolean; result?: unknown }) {
161  if (e.agentId !== undefined || !PLAN_TOOLS.has(e.tool)) return // a subagent's list is its own
162  if (ran.deny !== undefined || ran.isError) return
163  const input = e as unknown as Record<string, unknown>
164  const result = (ran.result ?? {}) as Record<string, unknown>
165  if (e.tool === 'TodoWrite') {
166    const todos = Array.isArray(result.newTodos) ? result.newTodos : input.todos
167    await update($, board, () => fromTodos(todos))
168  } else if (e.tool === 'TaskCreate') {
169    const task = result.task as { id?: unknown } | undefined
170    if (typeof task?.id !== 'string') return
171    const made = { id: task.id, subject: String(input.subject ?? ''), activeForm: typeof input.activeForm === 'string' ? input.activeForm : undefined }
172    await update($, board, b => addTask(b, made))
173  } else if (result.success !== false && typeof input.taskId === 'string') {
174    const change = {
175      taskId: input.taskId,
176      status: typeof input.status === 'string' ? input.status : undefined,
177      subject: typeof input.subject === 'string' ? input.subject : undefined,
178      activeForm: typeof input.activeForm === 'string' ? input.activeForm : undefined,
179    }
180    await update($, board, b => updateTask(b, change))
181  }
182}
183
184// ---------- epic (tracker) ----------
185
186let epicInflight: Promise<void> | undefined
187let epicScheduled = false
188
189// The epic's data from task_get. A failure is logged and the row keeps the last data it had.
190async function refreshEpic($: EngineInterface): Promise<void> {
191  if (epicInflight) return epicInflight
192  epicInflight = (async () => {
193    const cur = await read($, epic)
194    if (!cur) return
195    try {
196      const res = await $.mcp.call(TASKS_SERVER, 'task_get', { number: cur.n, comments: 0 })
197      const text = textOf(res.content)
198      const info = res.isError ? null : parseTask(text)
199      if (!info) {
200        $.ui.log(`${PLUGIN}: epic #${cur.n}: task_get ${res.isError ? 'error' : 'unparsed'}: ${text.slice(0, 200)}`, { to: 'debug' })
201        return
202      }
203      await update($, epic, e => (e && e.n === info.n ? withInfo(e, info) : e)) // switched meanwhile: that one fetches its own
204    } catch (err) {
205      trace($, `epic #${cur.n} task_get`, err) // the row keeps the last data
206    }
207  })().finally(() => {
208    epicInflight = undefined
209  })
210  return epicInflight
211}
212
213// Sets the session's epic; an explicit one (the command) is not replaced by a detected one.
214async function setEpic($: EngineInterface, n: number, isExplicit: boolean, info?: TaskInfo) {
215  let changed = false
216  await update($, epic, cur => {
217    if (cur && cur.isExplicit && !isExplicit) return cur
218    changed = true
219    const base: Epic = cur && cur.n === n ? { ...cur, isExplicit } : { n, isExplicit, title: '', done: 0, total: 0 }
220    return info && info.n === n ? withInfo(base, info) : base
221  })
222  if (changed && !info) await refreshEpic($)
223}
224
225// One refresh 5 s after a burst of tracker calls (a task moved, a checklist item ticked).
226function scheduleEpic($: EngineInterface) {
227  if (epicScheduled) return
228  epicScheduled = true
229  void (async () => {
230    try {
231      await $.clock.sleep(DEBOUNCE_MS)
232    } catch (err) {
233      trace($, 'epic debounce sleep', err) // no wait: the refresh runs now
234    }
235    epicScheduled = false
236    await refreshEpic($)
237  })().catch(err => trace($, 'epic refresh after tasks call', err))
238}
239
240// The main thread's tracker calls: task_add with a parent and task_get of an epic name the epic;
241// any other tracker call refreshes it (debounced).
242async function epicFromCall($: EngineInterface, e: { tool: string; agentId?: string }, ran: { deny?: string; isError?: boolean; text?: string; result?: unknown }) {
243  if (e.agentId !== undefined || !e.tool.startsWith(TASKS_PREFIX)) return
244  if (ran.deny !== undefined || ran.isError) return
245  const input = e as unknown as Record<string, unknown>
246  if (e.tool === `${TASKS_PREFIX}task_add`) {
247    const parent = issueNumber(input.parent)
248    const cur = await read($, epic)
249    if (parent !== null && (!cur || cur.n !== parent)) return setEpic($, parent, false)
250  } else if (e.tool === `${TASKS_PREFIX}task_get`) {
251    const info = parseTask(typeof ran.text === 'string' && ran.text ? ran.text : textOf(ran.result))
252    if (info && info.kind === 'epic') return setEpic($, info.n, false, info)
253  }
254  if (await read($, epic)) scheduleEpic($)
255}
256
257// ---------- the module ----------
258
259const HELP = '/integral: show or hide the HUD · more: full legend · mcp: MCP report · mcp check: recheck · plan off|on: the plan row · plan epic N|off: the tracker epic'
260
261export const register: Register = on => {
262  let timer: Timer | undefined // one timer, even when session.start fires again on a reload
263  let scheduled = false
264
265  on('session.start', async ($, e, next) => {
266    const r = await next(e)
267    const now = await $.clock.now()
268    await update($, startedAt, v => v || now) // a hot reload keeps the session's own start
269    timer?.cancel()
270    try {
271      let n = 0
272      timer = $.clock.every(TICK_MS, () => {
273        void update($, tick, k => k + 1).catch(err => trace($, 'tick', err))
274        if (++n % MCP_EVERY === 0) {
275          void check($)
276          void refreshEpic($)
277        }
278      })
279    } catch (err) {
280      trace($, 'clock.every', err) // no timer: limits move on with each response, MCP is checked at start, after MCP errors and on /integral mcp check
281    }
282    // A name Claude Code already has is refused: the HUD still works, only the command is missing.
283    await $.command.register({ name: 'integral', description: 'HUD: show/hide; more | mcp | mcp check | plan off|on | plan epic N|off' }).catch(err => trace($, 'command.register', err))
284    const hidden = (await $.store.get('isHidden').catch(err => (trace($, 'store.get isHidden', err), undefined))) === true
285    await update($, isHidden, () => hidden)
286    const planHidden = (await $.store.get('isPlanHidden').catch(err => (trace($, 'store.get isPlanHidden', err), undefined))) === true
287    await update($, isPlanHidden, () => planHidden)
288    void refresh($).catch(err => trace($, 'refresh at start', err))
289    void check($) // never holds the session's start; errors are traced inside
290    return r
291  })
292
293  on('turn.complete', async ($, e, next) => {
294    const r = await next(e)
295    if (!e.agentId) await refresh($).catch(err => trace($, 'refresh after turn', err)) // a subagent's turn fills its own window
296    return r
297  })
298
299  on('session.compact', async ($, e, next) => {
300    const r = await next(e)
301    if (!e.agentId && 'messages' in r) void refresh($).catch(err => trace($, 'refresh after compact', err))
302    return r
303  })
304
305  // The engine measures after each response; a limits window that moved a point redraws the row.
306  on('session.measure', async ($, e, next) => {
307    const r = await next(e)
308    await update($, limits, () => toLimits(e.rateLimits ?? [])).catch(err => trace($, 'limits from measure', err))
309    return r
310  })
311
312  on('tool.call', async ($, e, next) => {
313    const res = await next(e)
314    // An MCP call that errored: recheck 5 s later, one recheck per burst.
315    try {
316      const tool = String(e.tool)
317      if (tool.startsWith('mcp__') && !tool.endsWith('__authenticate') && 'isError' in res && res.isError && !scheduled) {
318        scheduled = true
319        void (async () => {
320          try {
321            await $.clock.sleep(DEBOUNCE_MS)
322          } catch (err) {
323            trace($, 'debounce sleep', err) // no wait: the recheck runs now
324          }
325          scheduled = false
326          await check($)
327        })().catch(err => trace($, 'recheck after MCP error', err))
328      }
329    } catch (err) {
330      trace($, 'mcp after tool.call', err)
331    }
332    try {
333      await planFromCall($, e as never, res as never)
334    } catch (err) {
335      trace($, `plan from ${String(e.tool)}`, err) // the plan row keeps its last state
336    }
337    try {
338      await epicFromCall($, e as never, res as never)
339    } catch (err) {
340      trace($, `epic from ${String(e.tool)}`, err) // the epic stays as it was
341    }
342    return res
343  })
344
345  on('command.run', { command: 'integral' }, async ($, e) => {
346    const args = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
347    const [cmd, arg, arg2] = args
348    if (!cmd) {
349      const hidden = await update($, isHidden, h => !h)
350      await $.store.set('isHidden', hidden).catch(err => trace($, 'store.set isHidden', err))
351      if (!hidden) await refresh($).catch(err => trace($, 'refresh after show', err))
352      return { text: hidden ? 'integral hidden. /integral shows it again' : 'integral on' }
353    }
354    if (cmd === 'more') {
355      const open = await update($, isExpanded, x => !x)
356      return { text: open ? 'integral: full legend' : 'integral: compact' }
357    }
358    if (cmd === 'mcp') {
359      const fresh = arg === 'check' || !(await read($, report))
360      const ok = fresh ? await check($) : true
361      const text = summary(await read($, report), await $.clock.now(), await read($, startedAt))
362      return { text: ok ? text : `MCP: check failed (see claude --debug).\n${text}` }
363    }
364    if (cmd === 'plan' && (arg === 'off' || arg === 'on')) {
365      const off = arg === 'off'
366      await update($, isPlanHidden, () => off)
367      await $.store.set('isPlanHidden', off).catch(err => trace($, 'store.set isPlanHidden', err))
368      return { text: off ? 'integral: plan row hidden' : 'integral: plan row on' }
369    }
370    if (cmd === 'plan' && arg === 'epic') {
371      if (arg2 === 'off') {
372        await update($, epic, () => null)
373        return { text: 'integral: epic cleared; task_add with a parent or task_get of an epic sets one again' }
374      }
375      const n = issueNumber(arg2)
376      if (n === null) return { text: 'integral: /integral plan epic <N> | off' }
377      await setEpic($, n, true)
378      const cur = await read($, epic)
379      return { text: cur && cur.total > 0 ? `integral: epic #${n} — ${cur.title} (${cur.done}/${cur.total})` : `integral: epic #${n}, no data yet (see claude --debug if it stays so)` }
380    }
381    return { text: HELP }
382  })
383
384  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
385    const rest = await next(e) // what other mods and Claude Code draw here stays
386    if (e.props.hasSurvey || (await read($, isHidden))) return rest
387    const inner = e.props.bodyColumns
388    if (inner < MIN_WIDTH) return rest
389    const { Box, Button, Text } = $.ui.resolve(e)
390    const isWorking = e.props.isWorking
391    const rows: RenderElement[] = []
392    const line = (parts: Part[]) => parts.map(p => <Text color={p.color} dimColor={p.dim} bold={p.bold}>{p.text}</Text>)
393
394    // Row 1: context.
395    const r = await read($, reading)
396    const open = await read($, isExpanded)
397    try {
398      if (r) {
399        const row = contextRow(r, inner, await read($, picked))
400        const icon = (key: string, label: string, onPress: () => void, hot?: string) =>
401          isWorking ? <Text dimColor>{label}</Text> : <Button key={key} plain dimColor={!hot} label={label} onPress={onPress} />
402        rows.push(
403          <Box flexDirection="row">
404            <Box flexShrink={1}>
405              <Text wrap="truncate-end">{line(row.bar)}</Text>
406            </Box>
407            <Box flexShrink={0} marginLeft={1}>
408              <Button key="pct" plain label={row.label} hover={{ scope: 'integral-pct', color: row.level, bold: true }} onPress={() => update($, picked, cur => nextPick(r, cur))} />
409            </Box>
410            <Box flexShrink={0} marginLeft={1}>
411              {icon('legend', open ? '<' : '>', () => void update($, isExpanded, x => !x).catch(err => trace($, 'legend toggle', err)))}
412            </Box>
413            <Box flexShrink={0} marginLeft={1} flexDirection="row">
414              {row.hot && <Text color={row.hot} bold>●</Text>}
415              {icon('compact', '↓', () => act($, 'compact'), row.hot)}
416            </Box>
417            <Box flexShrink={0} marginLeft={1}>
418              {icon('checkpoint', '!', () => act($, 'checkpoint'))}
419            </Box>
420          </Box>,
421        )
422      }
423    } catch (err) {
424      trace($, 'render context', err)
425    }
426
427    // Row 2: limits + MCP; the full MCP list under it when opened.
428    try {
429      await read($, tick) // redraws on each countdown tick
430      const now = await $.clock.now()
431      let problems: Problem[] = []
432      try {
433        problems = problemsOf(await read($, report), now, await read($, startedAt))
434      } catch (err) {
435        trace($, 'render mcp', err) // the row keeps its limit bars
436      }
437      const opened = await read($, authOpened)
438      const layout = statusLayout(await read($, limits), problems, now, inner, opened)
439      const mcpOpen = await read($, isMcpExpanded)
440      const toggle = () => void update($, isMcpExpanded, x => !x).catch(err => trace($, 'mcp toggle', err))
441      const item = (p: Problem, withDetail: boolean) => (
442        <Box flexDirection="row" marginRight={3}>
443          <Text color={MCP_COLOR[p.kind]}>{`${MCP_GLYPH[p.kind]} `}</Text>
444          <Text>{`${p.label} `}</Text>
445          <Button key={`${p.action}:${p.name}`} plain label={p.action} onPress={() => press($, p, p.action)} />
446          {p.kind === 'auth' && opened.includes(p.name) && (
447            <Box marginLeft={1}>
448              <Button key={`reconnect:${p.name}`} plain dimColor label="reconnect" onPress={() => press($, p, 'reconnect')} />
449            </Box>
450          )}
451          {withDetail && p.detail && <Text dimColor wrap="truncate-end">{`  ${p.detail}`}</Text>}
452        </Box>
453      )
454      if (layout) {
455        rows.push(
456          <Box flexDirection="row">
457            {layout.bars && (
458              <Box flexShrink={0}>
459                <Text>{line(layout.bars)}</Text>
460              </Box>
461            )}
462            {problems.length > 0 && (
463              <Box flexDirection="row" flexShrink={0} marginLeft={layout.bars ? 3 : 0}>
464                {layout.head && <Text bold>{MCP_HEAD}</Text>}
465                {(mcpOpen ? [] : layout.shown).map(p => item(p, false))}
466                {mcpOpen ? <Button key="mcp-less" plain dimColor label="less" onPress={toggle} /> : layout.more > 0 && <Button key="mcp-more" plain dimColor label={`+${layout.more}`} onPress={toggle} />}
467              </Box>
468            )}
469          </Box>,
470        )
471        if (mcpOpen && problems.length > 0) for (const p of problems) rows.push(item(p, true))
472      }
473    } catch (err) {
474      trace($, 'render limits', err)
475    }
476
477    // The full legend, under the limits so they stay flush with the bar.
478    try {
479      if (r && open) {
480        rows.push(<Text dimColor>{legendHead(r)}</Text>)
481        for (const l of legend(r, inner)) rows.push(<Text wrap="truncate-end">{line(legendParts(r, l))}</Text>)
482      }
483    } catch (err) {
484      trace($, 'render legend', err)
485    }
486
487    // Row 3: plan. An open task list first, else the epic.
488    let hasPlan = false
489    try {
490      const b = await read($, board)
491      if (b && !(await read($, isPlanHidden))) {
492        hasPlan = true
493        const p = planRow(b, inner)
494        rows.push(
495          <Box flexDirection="row">
496            <Box flexShrink={0}>
497              <Text color={p.isRunning ? 'success' : undefined} dimColor={!p.isRunning}>{`${p.mark} `}</Text>
498            </Box>
499            <Box flexGrow={1} flexShrink={1}>
500              <Text wrap="truncate-end" dimColor={!p.isRunning}>{p.text}</Text>
501            </Box>
502            <Box flexShrink={0} marginLeft={2}>
503              <Text>{line(p.bar)}</Text>
504            </Box>
505            <Box flexShrink={0} marginLeft={1}>
506              <Text>{p.count}</Text>
507            </Box>
508          </Box>,
509        )
510      }
511    } catch (err) {
512      trace($, 'render plan', err)
513    }
514    try {
515      const ep = await read($, epic)
516      const p = !hasPlan && ep && !(await read($, isPlanHidden)) ? epicRow(ep, inner) : null
517      if (p) {
518        rows.push(
519          <Box flexDirection="row">
520            <Text>{p.mark}</Text>
521            <Text>{p.title}</Text>
522            <Box flexShrink={0} marginLeft={2}>
523              <Text>{line(p.bar)}</Text>
524            </Box>
525            <Box flexShrink={0} marginLeft={1}>
526              <Text>{p.count}</Text>
527            </Box>
528            {p.ref && (
529              <Box flexShrink={0} marginLeft={2}>
530                <Text dimColor>{p.ref}</Text>
531              </Box>
532            )}
533          </Box>,
534        )
535      }
536    } catch (err) {
537      trace($, 'render epic', err)
538    }
539
540    if (rows.length === 0) return rest
541    return (
542      <Box flexDirection="column">
543        {rows}
544        {rest}
545      </Box>
546    )
547  })
548}
549
hooks/context.ts 167 lines
1// Context section, pure parts (carried over from context-bar-compact): what fills the window.
2import type { Limit, Reading, Slice } from '../types'
3
4export const SPLIT = '   '
5// Theme keys, so both the dark and the light theme pick a readable shade. /context's own names
6// give several rows the same grey, so each used row gets its own key, in /context's order.
7const PALETTE = ['suggestion', 'remember', 'permission', 'success', 'warning', 'planMode', 'ide', 'merged', 'autoAccept']
8const MESSAGES = 'claude' // the row that grows, in the accent color
9const FREE = '#808080' // a mid grey thin line reads as empty on dark and light themes alike
10const BUFFER = '#808080'
11export const GLYPH = { used: '█', free: '─', buffer: '░' } as const
12const WARN_AT = 0.7 // the compact action lights up from this share of the compaction point
13// Cells the three icon buttons take after the percentage: ` >`, ` ●↓` (marker reserved), ` !`.
14export const ICONS_WIDTH = 2 + 3 + 2
15
16export type Part = { text: string; color?: string; dim?: boolean; bold?: boolean }
17
18export type ContextRow = {
19  bar: Part[] // the bar's runs, the picked one lit and the rest dim
20  label: string // what the percentage button says: `37%` or `messages 31%`
21  level: string // the level color (success / warning / error)
22  hot?: string // the compact button's highlight once near the compaction point
23}
24
25export function toReading(b: {
26  categories: { name: string; tokens: number; color: string; kind: string }[]
27  totalTokens: number
28  rawMaxTokens: number
29  percentage: number
30  autoCompactThreshold?: number
31  isAutoCompactEnabled: boolean
32}): Reading {
33  const slices: Slice[] = b.categories
34    .filter(c => c.kind !== 'deferred' && c.tokens > 0)
35    .map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens, color: c.color, kind: c.kind as Slice['kind'] }))
36  const order = { used: 0, free: 1, buffer: 2 }
37  slices.sort((x, y) => order[x.kind] - order[y.kind]) // stable: used rows keep /context's order
38  let next = 0
39  for (const s of slices) {
40    s.color = s.kind === 'free' ? FREE : s.kind === 'buffer' ? BUFFER : s.name === 'messages' ? MESSAGES : PALETTE[next++ % PALETTE.length]!
41  }
42  return {
43    slices,
44    total: b.totalTokens,
45    window: b.rawMaxTokens,
46    percent: b.percentage,
47    compactsAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
48  }
49}
50
51export function toLimits(rateLimits: readonly Limit[]): Limit[] {
52  if (!Array.isArray(rateLimits)) return [] // off a subscription, or a shape this build does not send
53  return rateLimits
54    .filter(w => Number.isFinite(w.percentUsed))
55    .map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt }))
56    .sort((a, b) => rank(a.kind) - rank(b.kind))
57}
58
59const LIMIT_ORDER = ['five_hour', 'seven_day', 'spend_limit']
60function rank(kind: string) {
61  const i = LIMIT_ORDER.indexOf(kind)
62  return i < 0 ? LIMIT_ORDER.length : i
63}
64
65// Green while there is room, yellow near compaction, red at it.
66export function levelColor(r: Reading) {
67  const level = r.compactsAt ? r.total / r.compactsAt : r.total / r.window
68  return level >= 0.9 ? 'error' : level >= 0.7 ? 'warning' : 'success'
69}
70
71// Row 1: the bar on the rest of the width, the percentage (pressable), then the three icons.
72// No `370k/1M` text (Serg, 07.10). The bar takes all the room the current label leaves.
73export function contextRow(r: Reading, inner: number, pick: string | null = null): ContextRow {
74  const level = levelColor(r)
75  const chosen = pick ? r.slices.find(s => s.name === pick) : undefined
76  const total = `${r.percent}%`
77  const label = chosen ? sliceLabel(r, chosen) : total
78  const width = Math.max(4, inner - 1 - label.length - ICONS_WIDTH) // the bar fills the row; it shortens while a long consumer label shows (Serg, 07.10: use the whole width)
79  const bar = cells(r, width).map(c => ({ text: c.text, color: c.color, dim: !!chosen && c.name !== chosen.name }))
80  const ratio = r.compactsAt ? r.total / r.compactsAt : r.total / r.window
81  return { bar, label, level, hot: ratio >= WARN_AT ? level : undefined }
82}
83
84// The widest label the percentage button can show: the bar is sized against it.
85export function labelRoom(r: Reading) {
86  return Math.max(`${r.percent}%`.length, ...usedOf(r).map(s => sliceLabel(r, s).length))
87}
88
89// `messages 31%`: what the percentage button says for one consumer.
90export function sliceLabel(r: Reading, s: Slice) {
91  return `${s.name} ${share(s.tokens, r.window)}`
92}
93
94// The next pick: the used consumers, biggest first, then back to none (the total).
95export function nextPick(r: Reading, current: string | null): string | null {
96  const order = usedOf(r).map(s => s.name)
97  const i = current ? order.indexOf(current) : -1
98  return i + 1 < order.length ? order[i + 1]! : null
99}
100
101export function usedOf(r: Reading) {
102  return r.slices.filter(s => s.kind === 'used').sort((a, b) => b.tokens - a.tokens)
103}
104
105// One legend line's parts.
106export function legendParts(r: Reading, line: Slice[]): Part[] {
107  return line.flatMap((s, i) => [
108    ...(i > 0 ? [{ text: SPLIT }] : []),
109    { text: s.kind === 'used' ? '■ ' : `${GLYPH[s.kind]} `, color: s.color },
110    { text: `${s.name} `, dim: s.kind !== 'used' },
111    { text: tokens(s.tokens), bold: s.kind === 'used' },
112    ...(s.kind === 'used' ? [{ text: ` ${share(s.tokens, r.window)}`, dim: true }] : []),
113  ])
114}
115
116// The headline of the open legend: totals and where compaction runs.
117export function legendHead(r: Reading) {
118  return `${tokens(r.total)} of ${tokens(r.window)}${r.compactsAt ? `, compacts at ${tokens(r.compactsAt)}` : ''}`
119}
120
121// The bar as runs of cells: each slice gets its share of `width`, a used one at least one cell.
122export function cells(r: Reading, width: number) {
123  const sizes = r.slices.map(s => Math.max(s.kind === 'used' ? 1 : 0, Math.round((s.tokens / r.window) * width)))
124  // Rounding leaves the sum a few cells off: free space takes the difference first, then the largest slices.
125  let diff = width - sizes.reduce((a, n) => a + n, 0)
126  const isUsed = (i: number) => r.slices[i]!.kind === 'used'
127  const order = r.slices.map((_, i) => i).sort((a, b) => Number(isUsed(a)) - Number(isUsed(b)) || sizes[b]! - sizes[a]!)
128  for (const i of order) {
129    if (diff === 0) break
130    const size = Math.max(isUsed(i) ? 1 : 0, sizes[i]! + diff)
131    diff -= size - sizes[i]!
132    sizes[i] = size
133  }
134  return r.slices.map((s, i) => ({ name: s.name, color: s.color, kind: s.kind, text: GLYPH[s.kind].repeat(sizes[i]!) })).filter(c => c.text !== '')
135}
136
137// The legend, packed into lines no wider than `width`.
138export function legend(r: Reading, width: number) {
139  const lines: Slice[][] = [[]]
140  let used = 0
141  for (const s of r.slices) {
142    const size = 2 + s.name.length + 1 + tokens(s.tokens).length + (s.kind === 'used' ? 1 + share(s.tokens, r.window).length : 0)
143    const line = lines.at(-1)!
144    if (line.length > 0 && used + SPLIT.length + size > width) {
145      lines.push([s])
146      used = size
147    } else {
148      used += (line.length > 0 ? SPLIT.length : 0) + size
149      line.push(s)
150    }
151  }
152  return lines.filter(l => l.length > 0)
153}
154
155export function tokens(n: number) {
156  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
157  if (n >= 10_000) return `${Math.round(n / 1000)}k`
158  if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
159  return String(n)
160}
161
162export function share(n: number, window: number) {
163  const p = (n / window) * 100
164  if (p > 0 && p < 0.1) return '<0.1%'
165  return `${p >= 10 ? Math.round(p) : +p.toFixed(1)}%`
166}
167
hooks/epic.ts 81 lines
1// Plan row's second source, pure parts: an epic of the mesh tracker (MCP `tasks`, task_get).
2//   task_get answers JSON in a text block: {"n":1556,"title":"…","kind":"epic","body":"…- [ ] #1599 …- [x] #1590 …"}.
3//   Progress = the `- [x]` items over all checklist items of the body.
4import type { Epic } from '../types'
5import type { Part } from './context'
6
7export const TASKS_SERVER = 'tasks'
8export const TASKS_PREFIX = 'mcp__tasks__'
9
10/** `- [x]` and `- [ ]` items of a body: done and total; no checklist is 0/0. */
11export function checklist(body: unknown): { done: number; total: number } {
12  const text = typeof body === 'string' ? body : ''
13  let done = 0
14  let total = 0
15  for (const m of text.matchAll(/^\s*[-*] \[([ xX])\]/gm)) {
16    total++
17    if (m[1] !== ' ') done++
18  }
19  return { done, total }
20}
21
22/** The text of an MCP result: its text blocks joined; a string as is. */
23export function textOf(v: unknown): string {
24  if (typeof v === 'string') return v
25  const blocks = Array.isArray(v) ? v : Array.isArray((v as { content?: unknown })?.content) ? (v as { content: unknown[] }).content : []
26  return blocks.map(b => (typeof (b as { text?: unknown })?.text === 'string' ? (b as { text: string }).text : '')).join('')
27}
28
29export type TaskInfo = { n: number; title: string; kind: string; done: number; total: number }
30
31/** task_get's answer, parsed; null when it is not a task (an error text, broken JSON). */
32export function parseTask(text: string): TaskInfo | null {
33  let o: Record<string, unknown>
34  try {
35    o = JSON.parse(text) as Record<string, unknown>
36  } catch {
37    return null // not JSON: an error message from the server; the caller logs what it asked for
38  }
39  const n = typeof o?.n === 'number' ? o.n : NaN
40  if (!Number.isInteger(n) || n <= 0) return null
41  const title = typeof o.title === 'string' ? o.title.replace(/\s+/g, ' ').trim() : ''
42  return { n, title, kind: typeof o.kind === 'string' ? o.kind : '', ...checklist(o.body) }
43}
44
45/** `#1556`, `1556` or 1556 as an issue number; anything else is null. */
46export function issueNumber(v: unknown): number | null {
47  const n = typeof v === 'number' ? v : typeof v === 'string' && /^#?\d+$/.test(v.trim()) ? Number(v.trim().replace('#', '')) : NaN
48  return Number.isInteger(n) && n > 0 ? n : null
49}
50
51/** The epic with fresh data from task_get, keeping who set it. */
52export function withInfo(epic: Epic, info: TaskInfo): Epic {
53  return { ...epic, title: info.title, done: info.done, total: info.total }
54}
55
56export type EpicRow = { mark: string; title: string; bar: Part[]; count: string; ref: string; cells: number }
57
58const GAP = 2
59
60// Row 3 from the epic: `◆ <title>  ━━━━──── 3/11  #1556`, the title cut so the row fits `inner`.
61// null when there is nothing to show: no checklist, or every item done.
62export function epicRow(epic: Epic, inner: number): EpicRow | null {
63  if (!(epic.total > 0) || epic.done >= epic.total) return null
64  const mark = '◆ '
65  const count = `${epic.done}/${epic.total}`
66  let ref = `#${epic.n}`
67  let width = Math.max(6, Math.min(30, Math.floor(inner * 0.25)))
68  const fixed = () => mark.length + GAP + width + 1 + count.length + (ref ? GAP + ref.length : 0)
69  while (fixed() > inner && width > 3) width--
70  if (fixed() > inner) ref = ''
71  if (fixed() > inner) return null // narrower than the bar and the count
72  const room = inner - fixed()
73  const full = epic.title || ''
74  const title = full.length <= room ? full : room <= 1 ? '' : `${full.slice(0, room - 1)}…`
75  const n = Math.round((epic.done / epic.total) * width)
76  const bar: Part[] = [{ text: '━'.repeat(n), color: 'success' }, { text: '─'.repeat(width - n), dim: true }].filter(p => p.text !== '')
77  // the gap after the title stays even when the title is cut to nothing: the bar never touches the mark
78  const cells = mark.length + title.length + GAP + width + 1 + count.length + (ref ? GAP + ref.length : 0)
79  return { mark, title, bar, count, ref, cells }
80}
81
hooks/layout.ts 32 lines
1// Row 2's layout, pure: the limit bars and the MCP items sharing one row of `inner` cells.
2import type { Limit } from '../types'
3import { SPLIT } from './context'
4import type { Part } from './context'
5import { barsWidth, limitBars, liveLimits } from './limits'
6import { MCP_HEAD, fold, itemWidth } from './mcp'
7import type { Problem } from './mcp'
8
9export type StatusLayout = {
10  bars: Part[] | null // the limit bars; null off a subscription
11  shown: Problem[] // MCP items named in the row
12  more: number // MCP items folded into +N
13  head: boolean // `MCP  ` before the items (no bars before them)
14  cells: number // the cells the row takes, for the width check
15}
16
17// No MCP problems: the bars share the whole row. Problems: the bars take ~45% on the left
18// (each at least MIN_BAR), the items fill the rest and fold into +N. No bars: the items alone.
19export function statusLayout(limits: readonly Limit[], problems: readonly Problem[], now: number, inner: number, opened: readonly string[] = []): StatusLayout | null {
20  const hasMcp = problems.length > 0
21  const n = liveLimits(limits, now).length
22  const bars = limitBars(limits, now, barsWidth(n, inner, hasMcp))
23  if (!bars && !hasMcp) return null
24  const barCells = bars ? bars.reduce((k, p) => k + p.text.length, 0) : 0
25  if (!hasMcp) return { bars, shown: [], more: 0, head: false, cells: barCells }
26  const head = !bars
27  const offset = bars ? barCells + SPLIT.length : 0
28  const { shown, more } = fold(problems, inner - offset, opened, head ? MCP_HEAD.length : 0)
29  const cells = offset + (head ? MCP_HEAD.length : 0) + shown.reduce((k, p) => k + itemWidth(p, opened), 0) + (more > 0 ? 1 + String(more).length : 0)
30  return { bars, shown, more, head, cells }
31}
32
hooks/mcp.ts 135 lines
1// MCP section, pure parts (carried over from mcp-doctor): which servers are broken.
2//   Status comes from ToolSearch (`pending_mcp_servers`, `failed_mcp_servers`, any query, no prompt)
3//   and tool.list (a server needing OAuth exposes `mcp__<srv>__authenticate`).
4import type { Failed, Report } from '../types'
5
6export const GRACE_MS = 60_000 // pending is not a problem this long after session start
7export const MCP_HEAD = 'MCP  ' // the row's head when no limit bars stand before the items
8
9export type Kind = 'failed' | 'auth' | 'pending'
10export type Problem = {
11  kind: Kind
12  name: string // what /mcp reconnect takes (failed, pending) or the tool-name segment (auth)
13  label: string // what the row shows
14  action: 'reconnect' | 'auth'
15  detail?: string
16}
17
18export const MCP_GLYPH: Record<Kind, string> = { failed: '✕', auth: '🔑', pending: '…' }
19export const MCP_COLOR: Record<Kind, string> = { failed: 'error', auth: 'warning', pending: 'warning' }
20
21// ToolSearch's `result`: the two server lists, whatever else it carries.
22export function parseSearch(result: unknown): { pending: string[]; failed: Failed[] } {
23  const r = (result ?? {}) as { pending_mcp_servers?: unknown; failed_mcp_servers?: unknown }
24  const pending = Array.isArray(r.pending_mcp_servers) ? r.pending_mcp_servers.filter((s): s is string => typeof s === 'string') : []
25  const failed: Failed[] = []
26  if (Array.isArray(r.failed_mcp_servers)) {
27    for (const f of r.failed_mcp_servers) {
28      if (f && typeof f === 'object' && typeof (f as Failed).name === 'string') {
29        const x = f as Failed
30        failed.push({ name: x.name, ...(typeof x.errorCode === 'string' ? { errorCode: x.errorCode } : {}), ...(typeof x.error === 'string' ? { error: x.error } : {}) })
31      }
32    }
33  }
34  return { pending, failed }
35}
36
37// tool.list: servers with tools, and those whose `authenticate` tool says they wait for a login.
38export function parseTools(tools: readonly { name: string; mcp: boolean }[]): { connected: string[]; needsAuth: string[] } {
39  const servers = new Set<string>()
40  const auth = new Set<string>()
41  for (const t of tools) {
42    const m = /^mcp__(.+?)__(.+)$/.exec(t.name)
43    if (!t.mcp || !m) continue
44    servers.add(m[1]!)
45    if (m[2] === 'authenticate') auth.add(m[1]!)
46  }
47  return { connected: [...servers].filter(s => !auth.has(s)).sort(), needsAuth: [...auth].sort() }
48}
49
50// The tool-name segment for a configured name: `claude.ai Gmail` -> `claude_ai_Gmail`.
51export function segment(name: string) {
52  return name.replace(/[^A-Za-z0-9_-]/g, '_')
53}
54
55// What the row calls a server: `plugin_design_figma` -> `figma`.
56export function shortName(name: string) {
57  const m = /^plugin_[^_]+_(.+)$/.exec(name)
58  return m ? m[1]! : name
59}
60
61// A tool-name segment back to a name /mcp reconnect may know: `plugin_design_figma` -> `plugin:design:figma`.
62export function reconnectName(seg: string) {
63  const m = /^plugin_([^_]+)_(.+)$/.exec(seg)
64  return m ? `plugin:${m[1]}:${m[2]}` : seg
65}
66
67// Servers bundled by plugins synced from claude.ai (design, engineering, productivity, common-room):
68// OAuth connectors to SaaS Serg does not use, forever "needs authentication". Not his problems to show.
69const IGNORED = /^plugin[_:](design|engineering|productivity|common-room)[_:]/
70
71export function isIgnored(name: string) {
72  return IGNORED.test(name)
73}
74
75export function problemsOf(r: Report | null, now: number, started: number): Problem[] {
76  if (!r) return []
77  r = { ...r, failed: r.failed.filter(f => !isIgnored(f.name)), needsAuth: r.needsAuth.filter(s => !isIgnored(s)), pending: r.pending.filter(p => !isIgnored(p)) }
78  const out: Problem[] = []
79  const failedSeg = new Set(r.failed.map(f => segment(f.name)))
80  for (const f of r.failed) out.push({ kind: 'failed', name: f.name, label: f.name, action: 'reconnect', detail: [f.errorCode, f.error].filter(Boolean).join(': ') || undefined })
81  for (const s of r.needsAuth) if (!failedSeg.has(s)) out.push({ kind: 'auth', name: s, label: shortName(s), action: 'auth' })
82  if (now - started >= GRACE_MS) for (const p of r.pending) out.push({ kind: 'pending', name: p, label: p, action: 'reconnect', detail: 'still connecting' })
83  return out
84}
85
86// Cells one row item takes: glyph (🔑 draws two cells), name, action, an armed reconnect, the gap after it.
87export function itemWidth(p: Problem, opened: readonly string[] = []) {
88  const glyph = p.kind === 'auth' ? 2 : 1
89  const again = p.kind === 'auth' && opened.includes(p.name) ? 1 + 'reconnect'.length : 0
90  return glyph + 1 + p.label.length + 1 + p.action.length + again + 3
91}
92
93// The items fill `columns` after `head` cells: as many problems as fit, the rest fold into +N.
94// The first one always shows, however narrow.
95export function fold(problems: readonly Problem[], columns = 80, opened: readonly string[] = [], head = MCP_HEAD.length): { shown: Problem[]; more: number } {
96  const tail = 1 + String(problems.length).length // the +N button
97  let used = head
98  let n = 0
99  for (const p of problems) {
100    const w = itemWidth(p, opened)
101    const room = n + 1 < problems.length ? columns - tail : columns // the last one needs no +N after it
102    if (n > 0 && used + w > room) break
103    used += w
104    n++
105  }
106  return { shown: problems.slice(0, n), more: problems.length - n }
107}
108
109// The first https:// URL in an authenticate answer, or what was found instead.
110export function findUrl(text: string): { url?: string; insecure?: string } {
111  const m = /\bhttps?:\/\/[^\s"'<>`)\]]+/i.exec(text)
112  if (!m) return {}
113  const url = m[0].replace(/[.,;]+$/, '')
114  return /^https:\/\//i.test(url) ? { url } : { insecure: url }
115}
116
117// A reconnect answer that says it did not run.
118export function isRefusal(text: string | undefined) {
119  if (!text || !text.trim()) return true
120  return /not (available|supported)|unknown command|isn't available|cannot|can't|only in|interactive|failed|error/i.test(text)
121}
122
123export function summary(r: Report | null, now: number, started: number): string {
124  if (!r) return 'MCP: no check yet. /integral mcp check checks now.'
125  const t = new Date(r.checkedAt).toTimeString().slice(0, 8)
126  const inGrace = now - started < GRACE_MS
127  const auth = r.needsAuth
128  const lines = [`MCP: ${r.connected.length} connected, ${r.failed.length} failed, ${auth.length} need auth, ${r.pending.length} pending (checked ${t})`]
129  for (const f of r.failed) lines.push(`  ✕ ${f.name}${f.errorCode ? `  [${f.errorCode}]` : ''}${f.error ? `  ${f.error}` : ''}`)
130  for (const s of auth) lines.push(`  🔑 ${shortName(s)}  (${s}) needs a login: press auth in the row`)
131  if (r.pending.length) lines.push(`  … pending${inGrace ? ' (session start, not counted yet)' : ''}: ${r.pending.join(', ')}`)
132  if (r.connected.length) lines.push(`  ✓ ${r.connected.join(', ')}`)
133  return lines.join('\n')
134}
135
hooks/plan.ts 76 lines
1// Plan section, pure parts: the open task list, built from the calls the model already makes.
2//   TodoWrite sends the whole list each time; TaskCreate and TaskUpdate one task a call.
3//   A list whose items are all completed is no plan any more: null, and the row goes.
4// Adapted from hoobnn/todo-bar hooks/board.ts (MIT, Copyright (c) 2026 hoobnn), cut down to
5// what the one-line plan row needs (no timings, call counts or subagent rows).
6import type { Board, Item, ItemStatus } from '../types'
7import type { Part } from './context'
8
9type Todo = { content?: unknown; status?: unknown; activeForm?: unknown }
10
11const STATUSES: readonly ItemStatus[] = ['pending', 'in_progress', 'completed']
12export const PLAN_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate'])
13
14const str = (v: unknown) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : '')
15const statusOf = (v: unknown): ItemStatus => STATUSES.find(s => s === v) ?? 'pending'
16
17function settle(source: Board['source'], items: Item[]): Board | null {
18  if (items.length === 0 || items.every(i => i.status === 'completed')) return null
19  return { source, items }
20}
21
22/** TodoWrite's list (its `newTodos`, else the input's `todos`) as the board. */
23export function fromTodos(todos: unknown): Board | null {
24  const list = (Array.isArray(todos) ? todos : []) as Todo[]
25  const items = list
26    .map((t, i): Item => {
27      const title = str(t?.content)
28      return { id: String(i), title, active: str(t?.activeForm) || title, status: statusOf(t?.status) }
29    })
30    .filter(i => i.title)
31  return settle('todo', items)
32}
33
34/** A task TaskCreate made, added to the task board (a todo board makes way). */
35export function addTask(prev: Board | null, task: { id: string; subject: string; activeForm?: string }): Board | null {
36  const title = str(task.subject)
37  if (!title) return prev
38  const kept = prev && prev.source === 'task' ? prev.items : []
39  return settle('task', [...kept.filter(i => i.id !== task.id), { id: task.id, title, active: str(task.activeForm) || title, status: 'pending' }])
40}
41
42/** What TaskUpdate changed on a task the board holds; `deleted` drops it. */
43export function updateTask(prev: Board | null, change: { taskId: string; status?: string; subject?: string; activeForm?: string }): Board | null {
44  if (!prev || prev.source !== 'task' || !prev.items.some(i => i.id === change.taskId)) return prev
45  const items =
46    change.status === 'deleted'
47      ? prev.items.filter(i => i.id !== change.taskId)
48      : prev.items.map(i => {
49          if (i.id !== change.taskId) return i
50          const title = str(change.subject) || i.title
51          const status = change.status === undefined ? i.status : statusOf(change.status)
52          return { ...i, title, active: str(change.activeForm) || (str(change.subject) ? title : i.active), status }
53        })
54  return settle('task', items)
55}
56
57/** Where the board stands: done and total, the running item (else the first open one). */
58export function progress(board: Board): { done: number; total: number; current: Item | null; isRunning: boolean } {
59  const done = board.items.filter(i => i.status === 'completed').length
60  const running = board.items.find(i => i.status === 'in_progress') ?? null
61  const current = running ?? board.items.find(i => i.status === 'pending') ?? null
62  return { done, total: board.items.length, current, isRunning: running !== null }
63}
64
65export type PlanRow = { mark: string; text: string; isRunning: boolean; bar: Part[]; count: string }
66
67// Row 3: `● <activeForm>  ━━━━──── 2/7`; the text is truncated by the drawing, the bar is fixed.
68export function planRow(board: Board, inner: number): PlanRow {
69  const { done, total, current, isRunning } = progress(board)
70  const width = Math.max(6, Math.min(30, Math.floor(inner * 0.25)))
71  const n = total === 0 ? 0 : Math.round((done / total) * width)
72  const bar: Part[] = [{ text: '━'.repeat(n), color: 'success' }, { text: '─'.repeat(width - n), dim: true }].filter(p => p.text !== '')
73  const text = current ? (isRunning ? current.active : current.title) : ''
74  return { mark: isRunning ? '●' : '○', text, isRunning, bar, count: `${done}/${total}` }
75}
76
hooks/limits.ts 58 lines
1// Plan limits, pure parts: two bars (5h, 7d), no letters or numbers; the color carries the pace.
2import type { Limit } from '../types'
3import { SPLIT } from './context'
4import type { Part } from './context'
5
6// Top-aligned glyphs, so the bars sit flush under the context bar with no visible gap.
7export const LIMIT = { full: '▀', empty: '▔' } as const
8export const MIN_BAR = 12 // each bar at least this wide when MCP items share the row
9export const BARS_SHARE = 0.45 // the bars' part of the row when MCP items share it
10
11// How long each window runs, to tell how much of it has passed.
12const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 24 * 3_600_000 }
13
14export function resetMs(w: Limit) {
15  const ms = w.resetsAt ? Date.parse(w.resetsAt) : NaN
16  return Number.isFinite(ms) ? ms : 0
17}
18
19// The windows still worth drawing: one past its reset waits for a fresh reading.
20export function liveLimits(list: readonly Limit[], now: number) {
21  return list.filter(w => !(resetMs(w) > 0 && resetMs(w) <= now))
22}
23
24// Serg's rule (07.10): the pace is the share used against the share of the window gone, to hundredths.
25// ≤1 green, >1 yellow, >2 red, and red once 80% is used; the first 2% of a window reads green.
26export function paceColor(w: Limit, now: number): string {
27  const used = w.percentUsed
28  if (used >= 80) return 'error'
29  const span = WINDOW_MS[w.kind]
30  const reset = resetMs(w)
31  if (!span || reset <= 0) return 'success' // no reset time: only the 80% rule applies
32  const gone = 1 - Math.min(Math.max((reset - now) / span, 0), 1)
33  if (gone < 0.02) return 'success' // the first minutes: any use reads as a huge pace
34  const pace = Math.round((used / (gone * 100)) * 100) / 100
35  return pace > 2 ? 'error' : pace > 1 ? 'warning' : 'success'
36}
37
38// How wide the bars run in all: the whole row alone, ~45% of it (each at least MIN_BAR) beside MCP items.
39export function barsWidth(n: number, inner: number, withMcp: boolean) {
40  if (n === 0) return 0
41  if (!withMcp) return inner
42  return Math.min(inner, Math.max(n * MIN_BAR + SPLIT.length * (n - 1), Math.floor(inner * BARS_SHARE)))
43}
44
45// The bars as parts, `total` cells wide at most; null with no live window.
46export function limitBars(list: readonly Limit[], now: number, total: number): Part[] | null {
47  const live = liveLimits(list, now)
48  if (live.length === 0) return null
49  const width = Math.max(1, Math.floor((total - SPLIT.length * (live.length - 1)) / live.length))
50  const parts: Part[] = []
51  live.forEach((w, i) => {
52    const full = Math.min(width, Math.round((Math.min(Math.max(w.percentUsed, 0), 100) / 100) * width))
53    if (i > 0) parts.push({ text: SPLIT })
54    parts.push({ text: LIMIT.full.repeat(full), color: paceColor(w, now) }, { text: LIMIT.empty.repeat(width - full), dim: true })
55  })
56  return parts.filter(p => p.text !== '')
57}
58
types/index.d.ts 61 lines
1// ---- context ----
2export type Slice = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
3
4// One plan window, as $.session.usage() and session.measure report it.
5export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
6
7export type Reading = {
8  slices: Slice[]
9  total: number // tokens in use
10  window: number // the window measured against
11  percent: number
12  compactsAt?: number // where auto-compaction runs, when it is on
13}
14
15// ---- MCP ----
16// A server that failed to connect, as ToolSearch reports it in `failed_mcp_servers`.
17export type Failed = { name: string; errorCode?: string; error?: string }
18
19// One look at the MCP servers.
20export type Report = {
21  checkedAt: number // clock.now() of the check
22  connected: string[] // servers with tools, by their tool-name segment (`mesh-ops`, `claude_ai_Gmail`)
23  failed: Failed[] // by their configured name (`coord`)
24  needsAuth: string[] // tool-name segment of servers that expose only `authenticate` (`plugin_design_figma`)
25  pending: string[] // still connecting, by configured name
26}
27
28// ---- plan ----
29export type ItemStatus = 'pending' | 'in_progress' | 'completed'
30
31// One task of the plan: its title, the words it shows while it runs, where it stands.
32export type Item = { id: string; title: string; active: string; status: ItemStatus }
33
34// The open plan: from TodoWrite (the whole list each call) or TaskCreate/TaskUpdate (one task a call).
35export type Board = { source: 'todo' | 'task'; items: Item[] }
36
37// The session's epic of the mesh tracker (MCP `tasks`): the plan row's second source.
38// `isExplicit`: set by /integral plan epic N, which auto-detection does not override.
39// title/done/total: the last task_get that answered; 0/0 until one has.
40export type Epic = { n: number; isExplicit: boolean; title: string; done: number; total: number }
41
42declare module 'claude-code' {
43  interface PluginState {
44    integral: {
45      reading: Reading | null
46      isHidden: boolean
47      isExpanded: boolean
48      limits: Limit[]
49      tick: number
50      picked: string | null
51      report: Report | null
52      isMcpExpanded: boolean
53      startedAt: number
54      authOpened: string[]
55      board: Board | null
56      isPlanHidden: boolean
57      epic: Epic | null
58    }
59  }
60}
61