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

Один HUD над промптом Claude Code (serg/tasks#1599). Заменяет context-bar-compact и mcp-doctor.
█████████████───────────────────────░░░ 37% > ↓ !
▀▀▀▀▀▀▀▔▔▔▔▔▔▔▔▔▔▔▔▔ ▀▀▀▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ ✕ coord reconnect 🔑 figma auth +2
● Пишу тесты ━━━━━──────── 2/7
37% нажимается и по кругу называет потребителей от крупнейшего (messages 31%, кусок полосы подсвечен). > — полная легенда (< закрывает), ↓ — /compact (подсвечен у порога автокомпакта), ! — чекпоинт (CHECKPOINT_PROMPT). Пока идёт ход, значки тусклые и не нажимаются.+N. Всё в порядке и нет подписки — строки нет.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.hooks/register.tsx 549 lines1// 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}
549hooks/context.ts 167 lines1// 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}
167hooks/epic.ts 81 lines1// 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}
81hooks/layout.ts 32 lines1// 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}
32hooks/mcp.ts 135 lines1// 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}
135hooks/plan.ts 76 lines1// 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}
76hooks/limits.ts 58 lines1// 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}
58types/index.d.ts 61 lines1// ---- 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