mait-code in the prompt: /capture to the inbox, and a status bar of this session's cards, In Review, the inbox and context use.

A companion framework that extends Claude Code with persistent memory, a customisable identity, and reusable skills. It transforms Claude Code from a stateless coding assistant into a coding companion that remembers your projects, preferences, and patterns across sessions.
Documentation: <https://wiktordepina.github.io/mait-code/>
SessionStart injects companion context, PreCompact and SessionEnd extract observations asynchronouslymc-tool-memory, mc-tool-reminders, mc-tool-board, mc-tool-inbox, mc-tool-web-fetch)mait-code home, or just mait-code on a terminal) with a user-authored start page of widget and shell-command tiles, the kanban board (mait-code board), the settings editor (mait-code settings), the memory review queue (mait-code review), and the read-only memory browser (mait-code memory), observations browser (mait-code observations), knowledge-graph explorer (mait-code graph) and log viewer (mait-code logs)/recall, /remember, /reflect), reminders (/remind, /reminders), the board (/board), capture triage (/triage), web fetch (/web-fetch), and workflow (/commit, /pre-pr-review)One-liner install (recommended):
curl -fsSL https://raw.githubusercontent.com/wiktordepina/mait-code/main/scripts/bootstrap.sh | bash
This installs uv if missing, clones the latest release to ~/.local/share/mait-code/source/, runs uv tool install, then runs mait-code install to wire up symlinks, settings, and data directories. Idempotent — re-running upgrades in place.
Pass flags after bash -s --:
# AWS Bedrock embeddings instead of the local default:
curl -fsSL https://raw.githubusercontent.com/wiktordepina/mait-code/main/scripts/bootstrap.sh | bash -s -- --embedding-provider bedrock
# Pin to a specific release:
curl -fsSL https://raw.githubusercontent.com/wiktordepina/mait-code/main/scripts/bootstrap.sh | bash -s -- --ref v0.69.0
Prefer to inspect before running:
curl -fsSL https://raw.githubusercontent.com/wiktordepina/mait-code/main/scripts/bootstrap.sh -o /tmp/mait-code-bootstrap.sh
less /tmp/mait-code-bootstrap.sh # review
bash /tmp/mait-code-bootstrap.sh
After the install:
# Personalise your companion
$EDITOR ~/.claude/mait-code-data/soul_document.md
$EDITOR ~/.claude/mait-code-data/user_context.md
# Start Claude Code in any project — the companion loads automatically
claude
If you're developing mait-code itself, or want a clone in a specific location:
git clone https://github.com/wiktordepina/mait-code.git
cd mait-code
uv sync
./scripts/install.sh # thin shim around `mait-code install`
uv is installed automatically by the bootstrap; otherwise grab it from <https://docs.astral.sh/uv/>mait-code/
├── src/mait_code/ # Python package
│ ├── hooks/ # Hooks: session_start, observe, auto_format
│ ├── tools/ # CLI tools: memory, reminders, board, inbox, web_fetch
│ ├── bridge/ # Opt-in capture-in / notify-out transport
│ ├── cli/ # The mait-code CLI and its Textual TUIs
│ └── tui/ # Shared TUI layer: house theme, palette, base app
├── config/ # CLAUDE.md and settings.json templates
├── templates/ # Identity templates
├── scripts/ # Install/uninstall scripts
├── skills/ # Skill definitions
├── agents/ # Agent definitions
├── tests/ # Test suite (mirrors src/mait_code/)
└── docs/ # Documentation
Per-surface guides for each TUI — the home hub, board, settings editor, memory browser, review queue, observations browser, graph explorer and log viewer — live alongside these on the documentation site.
./scripts/uninstall.sh
This removes symlinks and hook registrations from ~/.claude/. Your personalised data in ~/.claude/mait-code-data/ is preserved by default (you'll be asked).
hooks/register.tsx 651 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type {
5 AgentRun, BarStyle, BoundCard, Card, EventData, JiraRef, Palette, RateWindow, SessionData, WorkData,
6} from '../types'
7
8// A thin client of the mc-tool-* and mait-code CLIs: every capability and
9// every colour lives in mait-code; this mod only calls them and draws. It rides
10// an early-access API, so it fails closed: a missing CLI, output it can't read
11// or a host call that throws leaves its segment out, never the session broken.
12
13const ROLES = [
14 'primary', 'secondary', 'accent', 'foreground', 'background',
15 'surface', 'panel', 'success', 'warning', 'error',
16] as const
17const STYLES: readonly BarStyle[] = ['blocks', 'slim']
18const NO_WORK: WorkData = { bound: [], inReview: [], inbox: 0 }
19const NO_EVENTS: EventData = { agents: [] }
20/** How often a running agent's elapsed time moves on screen. */
21const TICK_MS = 5_000
22
23const work = atom({ plugin: 'mait-companion', key: 'work' } as const, NO_WORK)
24const session = atom({ plugin: 'mait-companion', key: 'session' } as const, {})
25const palette = atom({ plugin: 'mait-companion', key: 'palette' } as const, null)
26const style = atom({ plugin: 'mait-companion', key: 'style' } as const, 'blocks')
27const events = atom({ plugin: 'mait-companion', key: 'events' } as const, NO_EVENTS)
28const agentsOpen = atom({ plugin: 'mait-companion', key: 'agentsOpen' } as const, false)
29const now = atom({ plugin: 'mait-companion', key: 'now' } as const, 0)
30
31// --- reading ------------------------------------------------------------------
32
33/** Run a command; its trimmed stdout, or undefined on any failure. */
34async function run($: EngineInterface, argv: string[], cwd?: string): Promise<string | undefined> {
35 try {
36 const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 10_000, cwd })
37 return exitCode === 0 ? stdout.trim() : undefined
38 } catch {
39 return undefined
40 }
41}
42
43async function runJson($: EngineInterface, argv: string[]): Promise<unknown> {
44 const raw = await run($, argv)
45 try {
46 return raw === undefined ? undefined : JSON.parse(raw)
47 } catch {
48 return undefined
49 }
50}
51
52function isRecord(v: unknown): v is Record<string, unknown> {
53 return typeof v === 'object' && v !== null && !Array.isArray(v)
54}
55
56async function quiet<T>(call: () => Promise<T>): Promise<T | undefined> {
57 try {
58 return await call()
59 } catch {
60 return undefined
61 }
62}
63
64function cards(v: unknown): Card[] | undefined {
65 if (!Array.isArray(v)) return undefined
66 const out: Card[] = []
67 for (const c of v) {
68 if (!isRecord(c) || typeof c.id !== 'number' || typeof c.title !== 'string') return undefined
69 out.push({ id: c.id, title: c.title })
70 }
71 return out
72}
73
74/** Jira links as the board resolved them; one it can't read is dropped. */
75function jira(v: unknown): JiraRef[] {
76 if (!Array.isArray(v)) return []
77 return v.flatMap(j => {
78 if (!isRecord(j) || typeof j.key !== 'string') return []
79 return [{ key: j.key, href: typeof j.url === 'string' && j.url.startsWith('https://') ? j.url : null }]
80 })
81}
82
83function boundCards(v: unknown): BoundCard[] | undefined {
84 const base = cards(v)
85 if (base === undefined || !Array.isArray(v)) return undefined
86 return base.map((c, i) => ({ ...c, jira: jira((v[i] as Record<string, unknown>).jira) }))
87}
88
89// One aggregate call per refresh. Anything unreadable empties the card
90// segments rather than showing stale ones.
91async function refreshWork($: EngineInterface): Promise<void> {
92 const id = await quiet(() => $.session.id())
93 const out = id === undefined
94 ? undefined
95 : await runJson($, ['mc-tool-board', 'summary', '--json', '--session', id])
96 const bound = isRecord(out) ? boundCards(out.bound) : undefined
97 const inReview = isRecord(out) ? cards(out.in_review) : undefined
98 const inbox = isRecord(out) && typeof out.inbox === 'number' ? out.inbox : undefined
99 await update($, work, () => (bound && inReview && inbox !== undefined ? { bound, inReview, inbox } : NO_WORK))
100}
101
102/**
103 * claude-opus-5-5[1m] -> opus 5.5; anything else as the engine says it. The
104 * window's size is the context segment's to show, beside what fills it.
105 */
106function shortModel(raw: string): string {
107 const m = raw.match(/^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?(\[1m\])?$/i)
108 if (!m) return raw
109 const [, family, major, minor] = m
110 return `${family!.toLowerCase()} ${major}${minor ? `.${minor}` : ''}`
111}
112
113// Where the session is: read after each turn, as /cd, a checkout or /model
114// may have moved it.
115async function refreshWhere($: EngineInterface): Promise<void> {
116 const [root, cwd, model] = await Promise.all([
117 quiet(() => $.session.root()),
118 quiet(() => $.session.cwd()),
119 quiet(() => $.session.model()),
120 ])
121 const branch = cwd === undefined
122 ? undefined
123 : (await run($, ['git', 'symbolic-ref', '--short', '-q', 'HEAD'], cwd))
124 ?? (await run($, ['git', 'rev-parse', '--short', 'HEAD'], cwd))
125 const git = cwd === undefined ? undefined : gitState(await run($, ['git', 'status', '--porcelain=v2', '--branch'], cwd))
126 await update($, session, prev => ({
127 ...prev,
128 project: root?.split('/').filter(Boolean).at(-1),
129 branch: branch || undefined,
130 model: model ? shortModel(model) : undefined,
131 modelId: model || undefined,
132 dirty: git?.dirty,
133 ahead: git?.ahead,
134 behind: git?.behind,
135 }))
136}
137
138/** `git status --porcelain=v2 --branch`: the changed paths, and ahead/behind when there is an upstream. */
139function gitState(out: string | undefined): { dirty: number; ahead?: number; behind?: number } | undefined {
140 if (out === undefined) return undefined
141 const lines = out.split('\n').filter(Boolean)
142 const ab = lines.find(l => l.startsWith('# branch.ab '))?.match(/\+(\d+) -(\d+)/)
143 return {
144 dirty: lines.filter(l => !l.startsWith('#')).length,
145 ...(ab ? { ahead: Number(ab[1]), behind: Number(ab[2]) } : {}),
146 }
147}
148
149type Usage = {
150 context: { tokens?: number; percent?: number; window?: number }
151 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
152}
153
154async function applyUsage($: EngineInterface, u: Usage): Promise<void> {
155 const { tokens, percent, window: size } = u.context
156 // Without a clock reading the windows still draw, just with no reset times.
157 const at = await quiet(() => $.clock.now())
158 const window = (kind: string): RateWindow | undefined => {
159 const r = u.rateLimits.find(l => l.kind === kind)
160 if (!r) return undefined
161 const resets = r.resetsAt === undefined || at === undefined ? NaN : Date.parse(r.resetsAt)
162 return {
163 percent: Math.round(r.percentUsed),
164 ...(Number.isFinite(resets) && at !== undefined ? { resetsInMs: Math.max(0, resets - at) } : {}),
165 }
166 }
167 await update($, session, prev => ({
168 ...prev,
169 ...(tokens !== undefined && percent !== undefined ? { context: { tokens, percent, window: size } } : {}),
170 fiveHour: window('five_hour'),
171 sevenDay: window('seven_day'),
172 }))
173}
174
175// Theme and style are read once per session start, resolved by mait-code
176// itself (env -> settings.toml -> default, unknown themes -> mait-dark).
177async function refreshSettings($: EngineInterface): Promise<void> {
178 const [themed, styled] = await Promise.all([
179 runJson($, ['mait-code', 'settings', 'get', 'theme', '--palette']),
180 runJson($, ['mait-code', 'settings', 'get', 'status-bar-style', '--json']),
181 ])
182 const colours = isRecord(themed) ? themed.palette : undefined
183 const valid = isRecord(colours)
184 && ROLES.every(r => typeof colours[r] === 'string' && /^#[0-9a-f]{6}$/i.test(colours[r] as string))
185 await update($, palette, () => (valid ? (colours as Palette) : null))
186 const name = isRecord(styled) ? styled.value : undefined
187 await update($, style, () => ((STYLES as readonly unknown[]).includes(name) ? (name as BarStyle) : 'blocks'))
188}
189
190async function guarded(task: () => Promise<unknown>): Promise<void> {
191 try {
192 await task()
193 } catch {
194 // A failed refresh leaves the bar as it was; the session carries on.
195 }
196}
197
198/** Open a link in the browser: xdg-open on Linux, open on macOS. */
199async function openLink($: EngineInterface, href: string): Promise<void> {
200 if ((await run($, ['xdg-open', href])) === undefined) await run($, ['open', href])
201}
202
203// --- agents in flight -----------------------------------------------------------
204
205// Kept from spawn to report: agent.spawn adds one, its loop's tool calls name
206// what it is doing, its turn.complete removes it. $.agent.list() then drops any
207// the engine has finished by other means (killed, failed) without a report,
208// whether it still lists it as finished or has dropped it altogether.
209
210const LIVE: ReadonlySet<string> = new Set(['pending', 'running', 'waiting'])
211
212// A module variable, not state: a reload drops the old environment's timers
213// with it, and session.start starts a fresh one if agents are still running.
214let ticker: Timer | undefined
215
216async function tick($: EngineInterface): Promise<void> {
217 const t = await $.clock.now()
218 await update($, now, () => t)
219}
220
221/** Tick `now` while any agent runs, so elapsed times move; stop when none does. */
222async function syncTicker($: EngineInterface): Promise<void> {
223 const running = (await read($, events)).agents.length > 0
224 if (running && ticker === undefined) {
225 // Claimed before the first await: agents spawned together each get here,
226 // and only the first may start a timer.
227 ticker = $.clock.every(TICK_MS, () => { void guarded(() => tick($)) })
228 await tick($)
229 } else if (!running && ticker !== undefined) {
230 ticker.cancel()
231 ticker = undefined
232 }
233}
234
235async function setAgents($: EngineInterface, fn: (agents: readonly AgentRun[]) => readonly AgentRun[]): Promise<void> {
236 await update($, events, prev => ({ ...prev, agents: fn(prev.agents) }))
237 await syncTicker($)
238}
239
240async function reconcileAgents($: EngineInterface): Promise<void> {
241 const listed = await quiet(() => $.agent.list())
242 if (listed === undefined) return
243 const live = new Set(listed.filter(a => LIVE.has(a.status)).map(a => a.id))
244 // A workflow's agents are never listed, so only their report removes them.
245 const keep = (a: AgentRun) => a.workflow === true || live.has(a.id)
246 if (!(await read($, events)).agents.every(keep)) await setAgents($, agents => agents.filter(keep))
247}
248
249// --- hooks --------------------------------------------------------------------
250
251export const register: Register = on => {
252 on('session.start', async ($, e, next) => {
253 // Isolated: a refused registration must not cost the bar its refresh.
254 await guarded(() => $.command.register({
255 name: 'capture',
256 description: 'Capture a thought to the mait-code inbox without a model turn.',
257 }))
258 await guarded(() => Promise.all([
259 refreshSettings($),
260 refreshWork($),
261 refreshWhere($),
262 $.session.usage().then(u => applyUsage($, u)),
263 ]))
264 await guarded(async () => { await reconcileAgents($); await syncTicker($) })
265 return next(e)
266 })
267
268 on('command.run', { command: 'capture' }, async ($, e) => {
269 const text = e.args.trim()
270 if (!text) return { text: 'Usage: /capture <text>' }
271 const out = await run($, ['mc-tool-inbox', 'add', '--', text])
272 await guarded(() => refreshWork($))
273 return { text: out === undefined ? 'Capture failed.' : out }
274 })
275
276 // Cards are bound, moved and completed by skills mid-turn. A subagent's turn
277 // is its report: it leaves row 3, and the main turn refreshes the rest.
278 on('turn.complete', async ($, e, next) => {
279 const result = await next(e)
280 const { agentId } = e
281 if (agentId !== undefined) {
282 await guarded(() => setAgents($, agents => agents.filter(a => a.id !== agentId)))
283 } else {
284 await guarded(() => Promise.all([refreshWork($), refreshWhere($), reconcileAgents($)]))
285 }
286 return result
287 })
288
289 // Teammates idle and wake rather than report once, so they are left out.
290 on('agent.spawn', async ($, e, next) => {
291 const result = await next(e)
292 const agentId = 'agentId' in result ? result.agentId : undefined
293 if (agentId !== undefined && !e.isTeammate) {
294 await guarded(async () => {
295 const startedAt = await $.clock.now()
296 await setAgents($, agents => [
297 ...agents.filter(a => a.id !== agentId),
298 {
299 id: agentId, type: e.subagentType, description: e.description, startedAt,
300 ...(e.workflow ? { workflow: true as const } : {}),
301 },
302 ])
303 })
304 }
305 return result
306 })
307
308 // What each agent is doing: the tool its loop last called. Not awaited, so
309 // the call itself never waits on the bar.
310 on('tool.call', ($, e, next) => {
311 const { agentId, tool } = e
312 if (agentId !== undefined) {
313 void guarded(async () => {
314 const { agents } = await read($, events)
315 if (agents.some(a => a.id === agentId && a.lastTool !== tool)) {
316 await setAgents($, all => all.map(a => (a.id === agentId ? { ...a, lastTool: tool } : a)))
317 }
318 })
319 }
320 return next(e)
321 })
322
323 // Raised whenever the context fill or a rate-limit window moves,
324 // compactions included, so the usage segments need no polling.
325 on('session.measure', async ($, e, next) => {
326 await guarded(() => applyUsage($, e))
327 return next(e)
328 })
329
330 // One blank row, then up to three full-width rows on the theme's panel
331 // colour: the work (cards; Jira, in review, inbox), the session (project,
332 // branch; model, context, rate limits), and the subagents running.
333 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
334 if (e.props.hasSurvey) return next(e)
335 const c = await read($, palette)
336 if (c === null) return next(e)
337 const barStyle = await read($, style)
338 const bg = c.panel
339 const rows = [workSegments(await read($, work), c), sessionSegments(await read($, session), c)]
340 .filter(r => r.length > 0)
341 const { agents } = await read($, events)
342 if (rows.length === 0 && agents.length === 0) return next(e)
343
344 const draw = RENDERERS[barStyle]
345 const { Box, Text, Button } = $.ui.resolve(e)
346 // A linked Jira key is a plain Button that opens the browser itself: a
347 // Link prints its URL beside the text wherever the engine doubts the
348 // terminal does OSC 8 (under a multiplexer, say).
349 const chunk = (ch: Chunk, key: string) => {
350 const href = ch.href
351 if (href) {
352 return (
353 <Box key={key} backgroundColor={ch.bg} paddingX={1}>
354 <Button
355 key={`jira:${ch.text}`}
356 plain
357 label={ch.text}
358 hover={{ underline: true }}
359 onPress={() => { void openLink($, href) }}
360 />
361 </Box>
362 )
363 }
364 return (
365 <Text
366 key={key}
367 backgroundColor={ch.bg}
368 color={ch.fg}
369 bold={ch.bold}
370 dimColor={ch.dim}
371 wrap="truncate-end"
372 >
373 {ch.raw ? ch.text : ` ${ch.text} `}
374 </Text>
375 )
376 }
377 const row = (segments: Segment[], r: number) => {
378 // In blocks, one cell of panel between badges keeps each one whole.
379 const gap: Chunk[] = barStyle === 'blocks' ? [{ text: ' ', bg, fg: c.foreground, raw: true }] : []
380 const side = (which: Segment['side']) =>
381 segments.filter(s => s.side === which).flatMap((s, i) => [...(i > 0 ? gap : []), ...draw(s, c, bg)])
382 return (
383 <Box key={`row${r}`} width={e.props.bodyColumns} backgroundColor={bg} justifyContent="space-between">
384 <Box flexShrink={1}>{side('left').map((ch, i) => chunk(ch, `l${i}`))}</Box>
385 <Box flexShrink={0}>{side('right').map((ch, i) => chunk(ch, `r${i}`))}</Box>
386 </Box>
387 )
388 }
389 // Row 3 is drawn slim whatever the style: it changes too often for blocks.
390 // Collapsed, one line of what the engine's own agent list can't say at a
391 // glance: the mix of types and the longest-running time. Open, each agent's
392 // task, current tool and time, grouped by type (no heading when all share one).
393 const live = async () => {
394 if (agents.length === 0) return []
395 const isOpen = await read($, agentsOpen)
396 const t = await read($, now)
397 const width = e.props.bodyColumns
398 const groups = byType(agents)
399 const oldest = Math.min(...agents.map(a => a.startedAt))
400 const header = (
401 <Box key="agents" width={width} backgroundColor={bg}>
402 <Text key="agents:glyph" backgroundColor={bg} color={c.accent} bold>{' ⋔ '}</Text>
403 <Box key="agents:toggle-box" backgroundColor={bg} flexShrink={0}>
404 <Button
405 key="agents:toggle"
406 plain
407 label={`${isOpen ? '▾' : '▸'} ${agents.length} ${agents.length === 1 ? 'agent' : 'agents'}`}
408 hover={{ underline: true }}
409 onPress={() => { void guarded(() => update($, agentsOpen, v => !v)) }}
410 />
411 </Box>
412 <Text key="agents:types" backgroundColor={bg} color={c.primary} wrap="truncate-end">
413 {` ${groups.map(([type, runs]) => (runs.length > 1 ? `${type} ×${runs.length}` : type)).join(' · ')}`}
414 </Text>
415 <Text key="agents:age" backgroundColor={bg} color={c.foreground} dimColor>
416 {` · ${elapsed(t - oldest)} `}
417 </Text>
418 </Box>
419 )
420 if (!isOpen) return [header]
421 const line = (a: AgentRun, last: boolean) => (
422 <Box key={`agent:${a.id}`} width={width} backgroundColor={bg} justifyContent="space-between">
423 <Box flexShrink={1}>
424 <Text backgroundColor={bg} color={c.foreground} dimColor>{` ${last ? '└' : '├'} `}</Text>
425 <Text backgroundColor={bg} color={c.foreground} wrap="truncate-end">{a.description}</Text>
426 </Box>
427 <Box flexShrink={0}>
428 {a.lastTool === undefined ? null : (
429 <Text backgroundColor={bg} color={c.secondary}>{` ${toolLabel(a.lastTool)} `}</Text>
430 )}
431 <Text backgroundColor={bg} color={c.foreground} dimColor>{` ${elapsed(t - a.startedAt)} `}</Text>
432 </Box>
433 </Box>
434 )
435 return [header, ...groups.flatMap(([type, runs]) => [
436 ...(groups.length > 1
437 ? [(
438 <Box key={`agents:group:${type}`} width={width} backgroundColor={bg}>
439 <Text backgroundColor={bg} color={c.primary} bold>{` ${type}`}</Text>
440 </Box>
441 )]
442 : []),
443 ...runs.map((a, i) => line(a, i === runs.length - 1)),
444 ])]
445 }
446 return (
447 <Box marginTop={1} flexDirection="column" width={e.props.bodyColumns}>
448 {rows.map(row)}
449 {await live()}
450 </Box>
451 )
452 })
453}
454
455// --- segments: what the bar says ---------------------------------------------
456
457type Segment = {
458 side: 'left' | 'right'
459 glyph: string
460 /** Shown before the value in the blocks style; omitted where the value speaks for itself. */
461 label?: string
462 /** Absent on a segment that is all links. */
463 value?: string
464 detail?: string
465 links?: readonly JiraRef[]
466 /** Drawn slim even in the blocks style: ambient facts, not signals. */
467 quiet?: true
468 /** Drawn as these coloured runs of text in every style, never on a block. */
469 ink?: readonly Ink[]
470 colour: string
471}
472
473/** A run of text in one colour; `fg` absent means the foreground. */
474type Ink = { text: string; fg?: string; bold?: true; dim?: true }
475
476function fill(percent: number, c: Palette): string {
477 return percent < 50 ? c.success : percent < 80 ? c.warning : c.error
478}
479
480function workSegments(d: WorkData, c: Palette): Segment[] {
481 const segments: Segment[] = d.bound.map(card => ({
482 side: 'left',
483 glyph: '◆',
484 value: `#${card.id}`,
485 detail: card.title,
486 colour: c.primary,
487 }))
488 // Every bound card's keys in one block, first on the right, apart from titles.
489 const seen = new Set<string>()
490 const links = d.bound.flatMap(card => card.jira).filter(l => !seen.has(l.key) && seen.add(l.key))
491 if (links.length > 0) {
492 // Primary, as the cards whose tickets these are; apart from In Review's blue.
493 segments.push({ side: 'right', glyph: '⌁', label: 'jira', links, colour: c.primary })
494 }
495 const [first] = d.inReview
496 if (first) {
497 segments.push({
498 side: 'right',
499 glyph: '⟳',
500 label: 'in review',
501 value: d.inReview.length === 1 ? `#${first.id}` : String(d.inReview.length),
502 colour: c.secondary,
503 })
504 }
505 if (d.inbox > 0) {
506 segments.push({ side: 'right', glyph: '✉', label: 'inbox', value: String(d.inbox), colour: c.accent })
507 }
508 return segments
509}
510
511// Row 2 reads left to right as "where" then "what it's using".
512function sessionSegments(d: SessionData, c: Palette): Segment[] {
513 const segments: Segment[] = []
514 if (d.project) segments.push({ side: 'left', glyph: '▣', value: d.project, quiet: true, colour: c.primary })
515 if (d.branch) {
516 segments.push({ side: 'left', glyph: '⎇', value: `${d.branch}${gitMarks(d)}`, quiet: true, colour: c.secondary })
517 }
518 if (d.model) segments.push(modelSegment(d.model, d.modelId, c))
519 // Tokens over the window's size as the label, so the size reads as the context's.
520 if (d.context) {
521 const { tokens, percent, window: size } = d.context
522 const used = size ? `${compact(tokens)}/${compact(size)}` : compact(tokens)
523 segments.push({ side: 'right', glyph: used, label: used, value: `${gauge(percent)} ${percent}%`, colour: fill(percent, c) })
524 }
525 // Both windows in one segment, in the order the label names them, coloured by the fuller.
526 const windows = ([['5h', d.fiveHour], ['7d', d.sevenDay]] as const).filter(
527 (w): w is readonly ['5h' | '7d', RateWindow] => w[1] !== undefined)
528 if (windows.length > 0) {
529 const worst = windows.reduce((a, b) => (b[1].percent > a[1].percent ? b : a))[1]
530 segments.push({
531 side: 'right',
532 glyph: '◷',
533 label: windows.map(([name]) => name).join('·'),
534 value: `${windows.map(([, w]) => w.percent).join('·')}%${resetMark(worst)}`,
535 colour: fill(worst.percent, c),
536 })
537 }
538 return segments
539}
540
541/** ` ±3 ↑1↓2`: changed paths, then commits ahead and behind; nothing when all are zero. */
542function gitMarks(d: SessionData): string {
543 const dirty = d.dirty ? ` ±${d.dirty}` : ''
544 const ab = `${d.ahead ? `↑${d.ahead}` : ''}${d.behind ? `↓${d.behind}` : ''}`
545 return `${dirty}${ab ? ` ${ab}` : ''}`
546}
547
548/** ` ↻41m` on a window at 80% or more, when the engine said when it resets. */
549function resetMark(w: RateWindow): string {
550 if (w.percent < 80 || w.resetsInMs === undefined) return ''
551 const m = Math.ceil(w.resetsInMs / 60_000)
552 return ` ↻${m < 60 ? `${m}m` : m < 48 * 60 ? `${Math.round(m / 60)}h` : `${Math.round(m / 1440)}d`}`
553}
554
555/**
556 * The model in coloured text rather than a block, so the signals around it stand
557 * out: `✦ opus` in accent, the version in foreground. A model id it can't read is
558 * shown as the engine gave it, on a block.
559 */
560function modelSegment(name: string, id: string | undefined, c: Palette): Segment {
561 const m = id?.match(/^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?(\[1m\])?$/i)
562 if (!m) return { side: 'right', glyph: '✦', label: '✦', value: name, colour: c.accent }
563 const [, family, major, minor] = m
564 return {
565 side: 'right',
566 glyph: '✦',
567 colour: c.accent,
568 ink: [
569 { text: '✦', fg: c.accent },
570 { text: family!.toLowerCase(), fg: c.accent, bold: true },
571 { text: `${major}${minor ? `.${minor}` : ''}`, bold: true },
572 ],
573 }
574}
575
576/** Eight cells, filled to the nearest eighth: ▰▰▰▱▱▱▱▱. */
577function gauge(percent: number): string {
578 const filled = Math.min(8, Math.max(0, Math.round(percent / 12.5)))
579 return '▰'.repeat(filled) + '▱'.repeat(8 - filled)
580}
581
582/** Agents grouped by type, in the order each type first started. */
583function byType(agents: readonly AgentRun[]): [string, AgentRun[]][] {
584 const groups = new Map<string, AgentRun[]>()
585 for (const a of agents) groups.set(a.type, [...(groups.get(a.type) ?? []), a])
586 return [...groups]
587}
588
589/** An agent's last tool as the row names it; the hand-back reads as what it is. */
590function toolLabel(tool: string): string {
591 return tool === 'SubagentHandback' ? 'reporting' : tool
592}
593
594/** 8_000 -> 8s, 72_000 -> 1m12s, 3_780_000 -> 1h03m; never negative. */
595function elapsed(ms: number): string {
596 const s = Math.max(0, Math.floor(ms / 1000))
597 if (s < 60) return `${s}s`
598 const m = Math.floor(s / 60)
599 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
600 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
601}
602
603/** 1234 -> 1.2k, 142000 -> 142k, 1000000 -> 1M. */
604function compact(n: number): string {
605 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
606 if (n >= 10_000) return `${Math.round(n / 1000)}k`
607 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
608 return String(n)
609}
610
611// --- renderers: how the bar says it --------------------------------------------
612
613/** `raw` text is drawn as given; any other is padded with a space either side. */
614type Chunk = { text: string; bg?: string; fg: string; bold?: boolean; dim?: boolean; href?: string; raw?: true }
615
616/** A key with no link is drawn as plain text on the same background. */
617function linkChunks(s: Segment, bg: string | undefined, c: Palette): Chunk[] {
618 return (s.links ?? []).map(l => ({ text: l.key, bg, fg: c.foreground, href: l.href ?? undefined }))
619}
620
621// No blocks: a coloured glyph and value, the detail in plain foreground.
622const slim = (s: Segment, c: Palette, bg: string | undefined): Chunk[] => s.ink ? inkChunks(s.ink, c, bg) : [
623 { text: s.value === undefined ? s.glyph : `${s.glyph} ${s.value}`, bg, fg: s.colour, bold: true },
624 ...(s.detail ? [{ text: s.detail, bg, fg: c.foreground }] : []),
625 ...linkChunks(s, bg, c),
626]
627
628/** Ink runs joined by single spaces, one chunk each so each keeps its colour. */
629function inkChunks(ink: readonly Ink[], c: Palette, bg: string | undefined): Chunk[] {
630 return ink.map((r, i) => ({
631 text: `${i === 0 ? ' ' : ''}${r.text} `,
632 bg, fg: r.fg ?? c.foreground, bold: r.bold, dim: r.dim, raw: true,
633 }))
634}
635
636const RENDERERS: Record<BarStyle, (s: Segment, c: Palette, bg: string | undefined) => Chunk[]> = {
637 // A badge: the label on surface joined to its value on the segment's colour,
638 // so each label reads with its own value. With no value the label takes the colour.
639 blocks: (s, c, bg) => s.ink ? inkChunks(s.ink, c, bg) : s.quiet ? slim(s, c, bg) : [
640 ...(s.label
641 ? [s.value === undefined
642 ? { text: s.label, bg: s.colour, fg: c.background, bold: true }
643 : { text: s.label, bg: c.surface, fg: c.foreground }]
644 : []),
645 ...(s.value !== undefined ? [{ text: s.value, bg: s.colour, fg: c.background, bold: true }] : []),
646 ...(s.detail ? [{ text: s.detail, bg: c.surface, fg: c.foreground, bold: true }] : []),
647 ...linkChunks(s, c.surface, c),
648 ],
649 slim,
650}
651types/index.d.ts 97 lines1/** A Jira issue on a card; `href` is null when the bar can't link it. */
2export type JiraRef = { key: string; href: string | null }
3
4/** A board card as the bar shows it. */
5export type Card = { id: number; title: string }
6
7/** A card bound to this session, with its Jira references. */
8export type BoundCard = Card & { jira: readonly JiraRef[] }
9
10/** The colour roles the bar draws with: `mait-code settings get theme --palette`. */
11export type Palette = {
12 primary: string
13 secondary: string
14 accent: string
15 foreground: string
16 background: string
17 surface: string
18 panel: string
19 success: string
20 warning: string
21 error: string
22}
23
24/** Row 1: the work in hand and what's waiting on you. */
25export type WorkData = {
26 /** Cards bound to this Claude Code session. */
27 bound: readonly BoundCard[]
28 /** Cards In Review for the session's project. */
29 inReview: readonly Card[]
30 inbox: number
31}
32
33/** A rate-limit window: how full, and how long until it resets when the engine says. */
34export type RateWindow = { percent: number; resetsInMs?: number }
35
36/** Row 2: where the session is and what it's using. */
37export type SessionData = {
38 /** The project root's folder name. */
39 project?: string
40 /** The branch checked out, or the short commit when detached. */
41 branch?: string
42 model?: string
43 /** The model id as the engine gives it, which the bar splits into family and version. */
44 modelId?: string
45 /** Uncommitted changes in the working tree; 0 or absent when clean. */
46 dirty?: number
47 /** Commits ahead of and behind the upstream; absent without one. */
48 ahead?: number
49 behind?: number
50 /** The live context window, as the engine reports it; absent until known. */
51 context?: { tokens: number; percent: number; window?: number }
52 /** The rate-limit windows; absent off a subscription or before a reading. */
53 fiveHour?: RateWindow
54 sevenDay?: RateWindow
55}
56
57/** A subagent this session started that has not reported back yet. */
58export type AgentRun = {
59 /** The id its loop's events carry as `agentId`. */
60 id: string
61 /** The agent type (`Explore`, `pre-pr-reviewer`, ...). */
62 type: string
63 /** The Agent call's few-word description of the task. */
64 description: string
65 /** Epoch milliseconds, when it started. */
66 startedAt: number
67 /** The tool it last called, once it has called one. */
68 lastTool?: string
69 /** Started by a workflow script, whose agents `$.agent.list()` never names. */
70 workflow?: true
71}
72
73/** Row 3: what is live right now; the row exists only while something is. */
74export type EventData = {
75 agents: readonly AgentRun[]
76}
77
78/** The `status-bar-style` setting. */
79export type BarStyle = 'blocks' | 'slim'
80
81declare module 'claude-code' {
82 interface PluginState {
83 'mait-companion': {
84 work: WorkData
85 session: SessionData
86 events: EventData
87 /** Whether row 3 lists each agent on a line of its own. */
88 agentsOpen: boolean
89 /** Epoch milliseconds, ticked while agents run so their elapsed time moves. */
90 now: number
91 /** `null` until mait-code answers; the bar draws nothing without it. */
92 palette: Palette | null
93 style: BarStyle
94 }
95 }
96}
97