Boxed tab band above the prompt: code, tickets, claude stats, overdrive, serpent pet

<img alt="ops-overdrive — Your terminal, but louder. CLI · MODS · STATUSLINE · FLAIR." src="assets/banner/hero-v2-dark.svg" width="100%">
<img alt="Overdrive treats a local Claude Code setup as one versioned tool. It adds a loud terminal look and two Claude Code mods: a tab band above the prompt and colored, foldable tool rows." src="assets/readme/lead-v2-dark.svg" width="100%">
<img alt="11 CLI COMMANDS. 2 CLAUDE CODE MODS. 5 STATUSLINE ROWS. 68 MOD TESTS" src="assets/readme/stats-v2-dark.svg" width="100%">
<img alt="01 WHAT IT DOES. ONE RIG. ONE VERSION. ONE SERPENT." src="assets/readme/divider-what-v2.svg" width="100%">
<img alt="VERSION: The overdrive CLI versions ~/.claude as a local git repo and pins every tool repo by SHA in one lock file. MODS: overdrive-tabs draws a tab band above the prompt. overdrive-tools colors every tool row and folds it. LOOK: An output style, a session banner and a five-row statusline share one neon palette and one art language" src="assets/readme/features-v2-dark.svg" width="100%">
<img alt="One version for the setup: VERSION holds the semver, overdrive.lock pins components by SHA, and every version keeps its own log. Drift on session start: A SessionStart hook checks the pins in under 150 ms and records drift in the background. Colored tool rows: INF cyan for reads, CHG magenta for changes, RUN yellow for shell and agents. Click a tag to fold or open the row. Tab band: Code, tickets, Claude usage, overdrive state and a serpent pet, in a pink box above the prompt. Session log: Every tool appends INFO, WARN and CRITICAL lines to one crash-safe log per Claude session. Trace: overdrive trace reads a finished session: tool calls, wall time and token counts per tool" src="assets/readme/what-v2-dark.svg" width="100%">
<img alt="02 QUICK START. TWO MODS IN FOUR COMMANDS." src="assets/readme/divider-start-v2.svg" width="100%">
<img alt="Terminal: $ git clone https://github.com/VelimirMueller/ops-overdrive && cd ops-overdrive | $ claude plugin marketplace add ./mods | $ claude plugin install overdrive-tools@overdrive-mods | $ claude plugin install overdrive-tabs@overdrive-mods | # then, inside Claude Code: /tools folded" src="assets/readme/start-v2-dark.svg" width="100%">
git clone https://github.com/VelimirMueller/ops-overdrive && cd ops-overdrive
claude plugin marketplace add ./mods
claude plugin install overdrive-tools@overdrive-mods
claude plugin install overdrive-tabs@overdrive-mods
<img alt="output-styles/overdrive.md: Copy to ~/.claude/output-styles/, then select it under Output style in /config. statusline-command.sh: Point statusLine.command in ~/.claude/settings.json at it. Chips for tools you lack stay empty. hooks/overdrive-banner.sh: A SessionStart hook: the pink banner and the frame reminder. tools/overdrive/overdrive: Link into your PATH. It expects ~/.claude to be a local git repo" src="assets/readme/extras-v2-dark.svg" width="100%">
<img alt="claude: Claude Code with function hooks (early access). Built on 2.1.303. bash · git · python3: For the CLI, the hooks and overdrive trace. rig tools: Optional. The tickets tab reads tix and code-context and shows empty rows without them" src="assets/readme/needs-v2-dark.svg" width="100%">
<img alt="03 HOW IT WORKS. DRAWINGS CHANGE. RECORDS DO NOT." src="assets/readme/divider-how-v2.svg" width="100%">
<img alt="TOOL CALL -> OVERDRIVE-TOOLS -> TRANSCRIPT ROW. The mod wraps the engine's own drawing. The stored row and the model's context do not change." src="assets/readme/flow-v2-dark.svg" width="100%">
<img alt="~/.claude -> overdrive snapshot -> overdrive.lock" src="assets/readme/lock-v2-dark.svg" width="100%">
<img alt="04 USAGE. EVERY COMMAND, ONE LINE EACH." src="assets/readme/divider-usage-v2.svg" width="100%">
<img alt="overdrive: Status: version, uncommitted changes, lock drift. Read-only. overdrive snapshot [--if-drift] ["msg"]: Regenerate the lock and commit if anything changed. overdrive bump patch|minor|major "msg": New version, new log, commit and tag. overdrive history · changelog [N]: Versions with their messages and log lines. overdrive trace [<session>|--last]: Tool calls, wall time and tokens of one session. overdrive catalog · limits: Every tool with its glyph and version. Known limits and caps. overdrive session show|path|list|rm: This session's log" src="assets/readme/cmds-v2-dark.svg" width="100%">
<img alt="/tools folded: Default. Every finished tool row starts folded. /tools changes: Fold info rows. Keep changes and runs open. /tools all: Open every row. Bare /tools toggles folded and all. click a tag: Fold or open that one row. Needs the fullscreen layout" src="assets/readme/tools-v2-dark.svg" width="100%">
<img alt="05 STATUS. STABLE ON TOP OF AN EARLY API." src="assets/readme/divider-status-v2.svg" width="100%">
<img alt="overdrive CLI and lock format: stable. Mod tests: claude plugin test, 68 pass. Function hooks API: early access, may change. Bash classifier in overdrive-tools: stub, every shell row is RUN" src="assets/readme/status-v2-dark.svg" width="100%">
<img alt="Terminal: $ claude plugin validate mods/overdrive-tools | $ claude plugin test mods/overdrive-tools | $ claude plugin test mods/overdrive-tabs | $ python3 tools/overdrive/test_trace.py" src="assets/readme/checks-v2-dark.svg" width="100%">
claude plugin validate mods/overdrive-tools
claude plugin test mods/overdrive-tools
claude plugin test mods/overdrive-tabs
python3 tools/overdrive/test_trace.py
<a href="tools/overdrive/FLAIR.md"><picture><source media="(prefers-color-scheme: light)" srcset="assets/readme/links-1-v2-light.svg"><img alt="Art language (tools/overdrive/FLAIR.md)" src="assets/readme/links-1-v2-dark.svg" width="32%"></picture></a> <a href="tools/overdrive/SESSION-LOG.md"><picture><source media="(prefers-color-scheme: light)" srcset="assets/readme/links-2-v2-light.svg"><img alt="Session log (tools/overdrive/SESSION-LOG.md)" src="assets/readme/links-2-v2-dark.svg" width="32%"></picture></a> <a href="mods/overdrive-tools/SPEC.md"><picture><source media="(prefers-color-scheme: light)" srcset="assets/readme/links-3-v2-light.svg"><img alt="Mod specs (mods/overdrive-tools/SPEC.md)" src="assets/readme/links-3-v2-dark.svg" width="32%"></picture></a>
<img alt="VM. studio / ops · public tool · MIT · look per vm-brand playbook · -- EOF -- frames in chat only --" src="assets/readme/footer-v2-dark.svg" width="100%">
hooks/register.tsx 451 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { ClaudeInfo, CodeInfo, OverdriveInfo, TabId, TicketsInfo } from '../types'
5import {
6 ageMinutes,
7 countLevels,
8 parseGitLog,
9 parseGitStatus,
10 parseInProgress,
11 parsePhaseFile,
12 parseReviewStatus,
13 parseSprints,
14} from './collect'
15import {
16 BODY_ROWS,
17 COLLAPSED,
18 DRAGON_ACCENT,
19 HINT,
20 LABEL_COLS,
21 TABS,
22 WALL_LEFT,
23 WALL_RIGHT,
24 claudeRows,
25 codeRows,
26 framed,
27 headerBox,
28 innerCols,
29 nextActive,
30 overdriveRows,
31 serpentMood,
32 serpentRows,
33 showHint,
34 storedToActive,
35 ticketsRows,
36 truncate,
37 underline,
38} from './tabs'
39import type { Row, Tab } from './tabs'
40
41const STORE_KEY = 'overdrive-tabs.active'
42
43const SPRINTS_SQL =
44 "select s.id, s.name, sum(t.status='DONE') done, count(t.id) total " +
45 'from sprints s left join tickets t on t.sprint_id=s.id and t.deleted_at is null ' +
46 'where s.deleted_at is null and s.archived_at is null ' +
47 "and s.status not in ('closed','done') group by s.id order by s.id desc limit 3"
48
49const IN_PROGRESS_SQL =
50 "select ticket_ref ref, title from tickets where status='IN_PROGRESS' and deleted_at is null " +
51 'order by updated_at desc limit 3'
52
53const NEXT_SQL =
54 "select t.ticket_ref ref, t.title from tickets t join sprints s on s.id=t.sprint_id " +
55 "where t.status='TODO' and t.deleted_at is null and s.deleted_at is null and s.archived_at is null " +
56 "and s.status not in ('closed','done') order by s.id desc, t.priority, t.id limit 1"
57
58/** Tool calls after which the code and tickets tabs read their data again. */
59const REFRESHING_TOOL = /^(Bash|Edit|Write|NotebookEdit|mcp__code-context__.+)$/
60
61export const activeAtom = atom({ plugin: 'overdrive-tabs', key: 'active' } as const, 'code')
62export const codeAtom = atom({ plugin: 'overdrive-tabs', key: 'code' } as const, null)
63export const ticketsAtom = atom({ plugin: 'overdrive-tabs', key: 'tickets' } as const, null)
64export const serpentAtom = atom({ plugin: 'overdrive-tabs', key: 'serpent' } as const, { turns: 0, tools: 0 })
65export const claudeAtom = atom({ plugin: 'overdrive-tabs', key: 'claude' } as const, null)
66export const overdriveAtom = atom({ plugin: 'overdrive-tabs', key: 'overdrive' } as const, null)
67
68let nowMs = 0
69
70function toEpochMs(iso?: string): number | null {
71 if (!iso) return null
72 const ms = Date.parse(iso)
73 return Number.isNaN(ms) ? null : ms
74}
75
76async function collectCode($: EngineInterface): Promise<CodeInfo> {
77 const info: CodeInfo = {
78 branch: null,
79 dirty: 0,
80 ahead: 0,
81 behind: 0,
82 review: null,
83 lastCommit: null,
84 }
85
86 try {
87 const git = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'])
88 if (git.exitCode === 0) {
89 const status = parseGitStatus(git.stdout)
90 info.branch = status.branch
91 info.dirty = status.dirty
92 info.ahead = status.ahead
93 info.behind = status.behind
94 }
95 } catch {
96 // not a repo, or git missing: keep nulls/zeros
97 }
98
99 try {
100 const dr = await $.process.run(['dual-review', '--status'])
101 info.review = parseReviewStatus(dr.stdout.split('\n')[0] ?? '')
102 } catch {
103 info.review = null
104 }
105
106 try {
107 const log = await $.process.run(['git', 'log', '-1', '--format=%h%x1f%s%x1f%ct'])
108 const row = log.exitCode === 0 ? parseGitLog(log.stdout.split('\n')[0] ?? '') : null
109 if (row) info.lastCommit = { sha: row.sha, subject: row.subject, ageMin: ageMinutes(row.ct, nowMs) }
110 } catch {
111 info.lastCommit = null
112 }
113
114 return info
115}
116
117/** Newest `tix phase` file whose phase is not `done`; null when there is none. */
118async function activeLinear($: EngineInterface, home: string): Promise<TicketsInfo['linear']> {
119 const dir = `${home}/.claude/.statusline-cache/tix`
120 const ls = await $.process.run(['ls', '-t', dir])
121 if (ls.exitCode !== 0) return null
122 const files = ls.stdout.split('\n').filter(f => /^phase-.+\.json$/.test(f)).slice(0, 5)
123 const phases = await Promise.all(
124 files.map(file => $.fs.read(`${dir}/${file}`).then(parsePhaseFile, () => null)),
125 )
126 const phase = phases.find(p => p !== null && p.phase !== 'done')
127 if (phase == null) return null
128 return { id: phase.id, phase: phase.phase, by: phase.by, ageMin: Math.max(0, Math.floor((nowMs - phase.atMs) / 60000)) }
129}
130
131async function collectTickets($: EngineInterface): Promise<TicketsInfo> {
132 const info: TicketsInfo = { linear: null, sprints: [], inProgress: [], next: null }
133
134 const home = await $.env.get('HOME').catch(() => undefined)
135 if (!home) return info
136
137 try {
138 info.linear = await activeLinear($, home)
139 } catch {
140 info.linear = null
141 }
142
143 try {
144 const db = `${home}/Desktop/Workspace/mcp-servers/data/context.db`
145 const [sprintsRes, inProgressRes, nextRes] = await Promise.all([
146 $.process.run(['sqlite3', '-readonly', '-json', db, SPRINTS_SQL]),
147 $.process.run(['sqlite3', '-readonly', '-json', db, IN_PROGRESS_SQL]),
148 $.process.run(['sqlite3', '-readonly', '-json', db, NEXT_SQL]),
149 ])
150 if (sprintsRes.exitCode === 0) info.sprints = parseSprints(sprintsRes.stdout)
151 if (inProgressRes.exitCode === 0) info.inProgress = parseInProgress(inProgressRes.stdout)
152 if (nextRes.exitCode === 0) info.next = parseInProgress(nextRes.stdout)[0] ?? null
153 } catch {
154 // missing DB or sqlite3: keep empty arrays
155 }
156
157 return info
158}
159
160async function collectClaude($: EngineInterface): Promise<ClaudeInfo> {
161 const info: ClaudeInfo = {
162 contextPct: null,
163 fiveHourPct: null,
164 fiveHourResetsAt: null,
165 sevenDayPct: null,
166 sevenDayResetsAt: null,
167 costUsd: null,
168 }
169
170 try {
171 const usage = await $.session.usage()
172 info.contextPct = usage.context.percent ?? null
173 info.costUsd = usage.cost?.usd ?? null
174 for (const rl of usage.rateLimits) {
175 if (rl.kind === 'five_hour') {
176 info.fiveHourPct = rl.percentUsed
177 info.fiveHourResetsAt = toEpochMs(rl.resetsAt)
178 } else if (rl.kind === 'seven_day') {
179 info.sevenDayPct = rl.percentUsed
180 info.sevenDayResetsAt = toEpochMs(rl.resetsAt)
181 }
182 }
183 } catch {
184 // keep nulls
185 }
186
187 return info
188}
189
190async function collectOverdrive($: EngineInterface): Promise<OverdriveInfo> {
191 const info: OverdriveInfo = {
192 version: null,
193 lastSnapshot: null,
194 uncommitted: null,
195 warn: 0,
196 critical: 0,
197 }
198
199 let home: string | undefined
200 try {
201 home = await $.env.get('HOME')
202 } catch {
203 home = undefined
204 }
205 if (!home) return info
206
207 const claudeDir = `${home}/.claude`
208
209 try {
210 const version = await $.fs.read(`${claudeDir}/VERSION`)
211 info.version = version.trim() || null
212 } catch {
213 info.version = null
214 }
215
216 try {
217 const log = await $.process.run([
218 'git',
219 '-C',
220 claudeDir,
221 'log',
222 '-1',
223 '--format=%h%x1f%s%x1f%ct',
224 ])
225 if (log.exitCode === 0) {
226 const row = parseGitLog(log.stdout.split('\n')[0] ?? '')
227 if (row) {
228 info.lastSnapshot = { sha: row.sha, subject: row.subject, ageMin: ageMinutes(row.ct, nowMs) }
229 }
230 }
231 } catch {
232 info.lastSnapshot = null
233 }
234
235 try {
236 const status = await $.process.run(['git', '-C', claudeDir, 'status', '--porcelain'])
237 if (status.exitCode === 0) {
238 info.uncommitted = status.stdout.split('\n').filter(l => l.trim() !== '').length
239 }
240 } catch {
241 info.uncommitted = null
242 }
243
244 try {
245 const sessionId = await $.session.id()
246 const logPath = `${claudeDir}/.local_ai_setup_logs/sessions/${sessionId}.log`
247 const log = await $.fs.read(logPath)
248 const counts = countLevels(log)
249 info.warn = counts.warn
250 info.critical = counts.critical
251 } catch {
252 // missing/unreadable log: keep 0/0
253 }
254
255 return info
256}
257
258/** Read one tab's data again. The serpent has no collector: its counters move on events. */
259async function refresh($: EngineInterface, tab: TabId): Promise<void> {
260 const tick = await $.clock.now().catch(() => null)
261 if (tick !== null) nowMs = tick
262
263 try {
264 if (tab === 'code') {
265 const info = await collectCode($)
266 await update($, codeAtom, () => info)
267 } else if (tab === 'tickets') {
268 const info = await collectTickets($)
269 await update($, ticketsAtom, () => info)
270 } else if (tab === 'claude') {
271 const info = await collectClaude($)
272 await update($, claudeAtom, () => info)
273 } else if (tab === 'overdrive') {
274 const info = await collectOverdrive($)
275 await update($, overdriveAtom, () => info)
276 }
277 } catch {
278 return
279 }
280}
281
282async function bodyRows($: EngineInterface, current: TabId | null, cols: number): Promise<Row[] | null> {
283 if (current === 'code') {
284 const info = await read($, codeAtom)
285 return info === null ? null : codeRows(info)
286 }
287 if (current === 'tickets') {
288 const info = await read($, ticketsAtom)
289 return info === null ? null : ticketsRows(info)
290 }
291 if (current === 'claude') {
292 const info = await read($, claudeAtom)
293 return info === null ? null : claudeRows(info, nowMs)
294 }
295 if (current === 'overdrive') {
296 const info = await read($, overdriveAtom)
297 return info === null ? null : overdriveRows(info)
298 }
299 if (current === 'serpent') {
300 const [pet, code, claude, overdrive] = await Promise.all([
301 read($, serpentAtom),
302 read($, codeAtom),
303 read($, claudeAtom),
304 read($, overdriveAtom),
305 ])
306 const pcts = [claude?.contextPct, claude?.fiveHourPct, claude?.sevenDayPct].filter(
307 (p): p is number => typeof p === 'number',
308 )
309 const mood = serpentMood({
310 turns: pet.turns,
311 tools: pet.tools,
312 review: code?.review ?? null,
313 hotPct: pcts.length > 0 ? Math.max(...pcts) : null,
314 warn: overdrive?.warn ?? 0,
315 critical: overdrive?.critical ?? 0,
316 dirty: code?.dirty ?? 0,
317 })
318 return serpentRows(pet, mood, cols)
319 }
320 return null
321}
322
323export const register: Register = on => {
324 on('session.start', async ($, e, next) => {
325 const stored = await $.store.get(STORE_KEY)
326 const value = storedToActive(stored)
327 await update($, activeAtom, () => value)
328
329 await Promise.all(TABS.map(tab => refresh($, tab.id)))
330
331 return next(e)
332 })
333
334 on('turn.complete', async ($, e, next) => {
335 await update($, serpentAtom, pet => ({ ...pet, turns: pet.turns + 1 }))
336 await Promise.all([refresh($, 'code'), refresh($, 'tickets'), refresh($, 'overdrive')])
337
338 return next(e)
339 })
340
341 on('tool.call', async ($, e, next) => {
342 const result = await next(e)
343
344 try {
345 await update($, serpentAtom, pet => ({ ...pet, tools: pet.tools + 1 }))
346 if (REFRESHING_TOOL.test(e.tool)) await Promise.all([refresh($, 'code'), refresh($, 'tickets')])
347 } catch {
348 // bookkeeping only: never fail or repeat the tool call over it
349 }
350
351 return result
352 }).catch(($, e, next) =>
353 // Replay-safe by the engine's contract (Caught in the API types): in a
354 // .catch handler, when the hook had already called next, next(e) resolves
355 // to that settled result and nothing beneath runs again, so the tool call
356 // is never repeated. Only a hook that failed before next(e) runs it here.
357 next(e),
358 )
359
360 on('session.measure', async ($, e, next) => {
361 await refresh($, 'claude')
362
363 return next(e)
364 })
365
366 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
367 if (e.props.hasSurvey) {
368 return next(e)
369 }
370
371 const cols = e.props.bodyColumns
372 const inner = innerCols(cols)
373 const current = await read($, activeAtom)
374 const rows = await bodyRows($, current, inner)
375 const { Box, Button, Text } = $.ui.resolve(e)
376
377 const press = (tab: Tab) => async () => {
378 const value = await update($, activeAtom, now => nextActive(now, tab.id))
379
380 await $.store.set(STORE_KEY, value === null ? COLLAPSED : value)
381 await refresh($, tab.id)
382 }
383
384 const mark = underline(current)
385 const version = (await read($, overdriveAtom))?.version ?? null
386 const [top, bottom] = headerBox(cols, version)
387 const walled = framed(cols)
388
389 /** One menu row: inside the box walls when framed, bare otherwise. */
390 const wall = (key: string, content: RenderChildren) =>
391 walled ? (
392 <Box key={key}>
393 <Text color={DRAGON_ACCENT}>{WALL_LEFT}</Text>
394 <Box width={inner}>{content}</Box>
395 <Text color={DRAGON_ACCENT}>{WALL_RIGHT}</Text>
396 </Box>
397 ) : (
398 <Box key={key}>{content}</Box>
399 )
400
401 const tabs = (
402 <Box columnGap={1}>
403 {TABS.map(tab => (
404 <Button
405 key={tab.key}
406 label={tab.label}
407 hotkey={tab.hotkey}
408 plain
409 dimColor={current !== tab.id}
410 onPress={press(tab)}
411 >
412 {current === tab.id ? <Text bold>{tab.label}</Text> : tab.label}
413 </Button>
414 ))}
415 {showHint(inner) && <Text dimColor>{' '.repeat(5) + HINT}</Text>}
416 </Box>
417 )
418
419 const body =
420 current === null
421 ? [wall('body-collapsed', <Text dimColor>collapsed — press a tab to open</Text>)]
422 : rows === null || rows.length === 0
423 ? [wall('body-loading', <Text dimColor>loading…</Text>)]
424 : rows.map((row, index) =>
425 wall(
426 `body-${index}`,
427 <Box>
428 <Text dimColor>{truncate(row.label.padEnd(LABEL_COLS), inner)}</Text>
429 <Text color={row.hot === true ? 'red' : undefined}>
430 {truncate(row.value, inner - LABEL_COLS)}
431 </Text>
432 </Box>,
433 ),
434 )
435 const filler = Array.from({ length: Math.max(0, BODY_ROWS - body.length) }, (_, index) =>
436 wall(`body-fill-${index}`, <Text> </Text>),
437 )
438
439 return (
440 <Box flexDirection="column">
441 {top !== undefined && <Text color={DRAGON_ACCENT}>{top}</Text>}
442 {wall('tabs', tabs)}
443 {wall('mark', <Text>{mark ?? ' '}</Text>)}
444 {body}
445 {filler}
446 {bottom !== undefined && <Text color={DRAGON_ACCENT}>{bottom}</Text>}
447 </Box>
448 )
449 })
450}
451hooks/collect.ts 129 lines1export type GitStatus = {
2 branch: string | null
3 dirty: number
4 ahead: number
5 behind: number
6}
7
8export type SprintRow = { id: number; name: string; done: number; total: number }
9export type InProgressRow = { ref: string; title: string }
10export type GitLogRow = { sha: string; subject: string; ct: number }
11
12export function parseGitStatus(porcelainV2Branch: string): GitStatus {
13 let branch: string | null = null
14 let ahead = 0
15 let behind = 0
16 let dirty = 0
17
18 for (const line of porcelainV2Branch.split('\n')) {
19 if (line.startsWith('#')) {
20 const head = /^# branch\.head (.+)$/.exec(line)
21 if (head) {
22 branch = head[1] === '(detached)' ? null : (head[1] ?? null)
23 }
24 const ab = /^# branch\.ab \+(\d+) -(\d+)/.exec(line)
25 if (ab) {
26 ahead = Number(ab[1] ?? 0)
27 behind = Number(ab[2] ?? 0)
28 }
29 } else if (line.trim() !== '') {
30 dirty += 1
31 }
32 }
33
34 return { branch, dirty, ahead, behind }
35}
36
37export function parseReviewStatus(line: string): string | null {
38 if (!line) return null
39
40 const status = /\[(pass|stale|fail)\]/.exec(line)?.[1]
41 if (!status) return null
42 if (status === 'stale') return 'stale'
43 if (status === 'fail') return 'fail'
44
45 const marks = line.match(/(?:ds|glm|lock)[✓✗]\d*/g) ?? []
46 const reviewers = marks.join(' ')
47 return reviewers ? `${reviewers} pass` : 'pass'
48}
49
50export function ageMinutes(ct: number, nowMs: number): number {
51 return Math.max(0, Math.floor((nowMs / 1000 - ct) / 60))
52}
53
54export function parseSprints(json: string): SprintRow[] {
55 if (!json) return []
56 try {
57 const rows: unknown = JSON.parse(json)
58 if (!Array.isArray(rows)) return []
59 return rows.map(row => {
60 const r = (row ?? {}) as Record<string, unknown>
61 return {
62 id: Number(r.id ?? 0),
63 name: String(r.name ?? ''),
64 done: Number(r.done ?? 0),
65 total: Number(r.total ?? 0),
66 }
67 })
68 } catch {
69 return []
70 }
71}
72
73export function parseInProgress(json: string): InProgressRow[] {
74 if (!json) return []
75 try {
76 const rows: unknown = JSON.parse(json)
77 if (!Array.isArray(rows)) return []
78 return rows.map(row => {
79 const r = (row ?? {}) as Record<string, unknown>
80 return {
81 ref: String(r.ref ?? ''),
82 title: String(r.title ?? ''),
83 }
84 })
85 } catch {
86 return []
87 }
88}
89
90export function parseGitLog(line: string): GitLogRow | null {
91 if (!line) return null
92 const parts = line.split('\x1f')
93 if (parts.length < 3) return null
94 const sha = parts[0] ?? ''
95 if (!sha) return null
96 return {
97 sha,
98 subject: parts[1] ?? '',
99 ct: Number(parts[2] ?? 0),
100 }
101}
102
103export function countLevels(log: string): { warn: number; critical: number } {
104 let warn = 0
105 let critical = 0
106 for (const line of log.split('\n')) {
107 if (/(^|\s)WARN(\s|$)/.test(line)) warn += 1
108 if (/(^|\s)CRITICAL(\s|$)/.test(line)) critical += 1
109 }
110 return { warn, critical }
111}
112
113export type PhaseFile = { id: string; phase: string; by: string | null; atMs: number }
114
115/** One `tix phase` cache file (`phase-<id>.json`); null when unreadable. */
116export function parsePhaseFile(json: string): PhaseFile | null {
117 try {
118 const r = JSON.parse(json) as Record<string, unknown> | null
119 if (r === null || typeof r !== 'object') return null
120 const id = typeof r.identifier === 'string' ? r.identifier : ''
121 const phase = typeof r.phase === 'string' ? r.phase : ''
122 const atMs = typeof r.at === 'string' ? Date.parse(r.at) : Number.NaN
123 if (!id || !phase || Number.isNaN(atMs)) return null
124 return { id, phase, by: typeof r.by === 'string' && r.by ? r.by : null, atMs }
125 } catch {
126 return null
127 }
128}
129hooks/tabs.ts 299 lines1import type { ClaudeInfo, CodeInfo, OverdriveInfo, SerpentInfo, TabId, TicketsInfo } from '../types'
2
3export type Tab = {
4 readonly id: TabId
5 readonly key: string
6 readonly label: string
7 readonly hotkey: string
8}
9
10export const TABS: readonly Tab[] = [
11 { id: 'code', key: 'tab-code', label: 'code', hotkey: '1' },
12 { id: 'tickets', key: 'tab-tickets', label: 'tickets', hotkey: '2' },
13 { id: 'claude', key: 'tab-claude', label: 'claude', hotkey: '3' },
14 { id: 'overdrive', key: 'tab-overdrive', label: 'overdrive', hotkey: '4' },
15 { id: 'serpent', key: 'tab-serpent', label: 'serpent', hotkey: '5' },
16]
17
18export const DEFAULT_ACTIVE: TabId = 'code'
19export const COLLAPSED = 'none'
20
21export const LABEL_COLS = 8
22/** Body rows the box always shows, so it keeps one height on every tab. */
23export const BODY_ROWS = 4
24export const HINT = 'click or ctrl+x tab → 1-5'
25const HINT_GAP = 6
26const HOT_PCT = 85
27
28export const DRAGON_ACCENT = '#EE4FFF'
29const DRAGON_TAIL = '⸌'
30const DRAGON_BODY = '∿'
31const DRAGON_HEAD = '(◣▾◢)⟫≋≋≋✶'
32const DRAGON_MIN = DRAGON_TAIL.length + DRAGON_HEAD.length + 3
33
34/** The fire-serpent separator: tail first, `∿` coiling along the line, head at the
35 * right end — exactly `cols` code points wide, every glyph one cell. */
36export const dragonRule = (cols: number): string => {
37 if (cols < 1) return ''
38 if (cols < DRAGON_MIN) return DRAGON_TAIL + DRAGON_BODY.repeat(cols - DRAGON_TAIL.length)
39 return (
40 DRAGON_TAIL + DRAGON_BODY.repeat(cols - DRAGON_TAIL.length - DRAGON_HEAD.length) + DRAGON_HEAD
41 )
42}
43
44const HEADER_TITLE = '░▒▓█ ⏣ O V E R D R I V E █▓▒░'
45const HEADER_TITLE_SHORT = '⏣ OVERDRIVE'
46const HEADER_MIN = 20
47
48/** The box's side walls: `┃ ` left and ` ┃` right of every row inside it. */
49export const WALL_LEFT = '┃ '
50export const WALL_RIGHT = ' ┃'
51const WALLS = WALL_LEFT.length + WALL_RIGHT.length
52
53/** True when the band is wide enough for the box around the menu. */
54export const framed = (cols: number): boolean => cols >= HEADER_MIN
55
56/** Columns left for the menu inside the box walls (all of them when unframed). */
57export const innerCols = (cols: number): number => (framed(cols) ? cols - WALLS : cols)
58
59/** The fixed two-line header box: title (and version) in the top edge, the fire
60 * serpent as the bottom edge. Every line is exactly `cols` code points; below
61 * HEADER_MIN cols there is no header. */
62export const headerBox = (cols: number, version: string | null): string[] => {
63 if (cols < HEADER_MIN) return []
64 const tail = version !== null ? ` ${version} ━┓` : '━┓'
65 const fits = (title: string, end: string) => 4 + title.length + 1 + end.length + 1 <= cols
66 const [title, end] = fits(HEADER_TITLE, tail)
67 ? [HEADER_TITLE, tail]
68 : fits(HEADER_TITLE_SHORT, tail)
69 ? [HEADER_TITLE_SHORT, tail]
70 : [HEADER_TITLE_SHORT, '━┓']
71 const head = `┏━━ ${title} `
72 const top = head + '━'.repeat(Math.max(0, cols - head.length - end.length)) + end
73 const bottom = `┗${dragonRule(cols - 3)}━┛`
74 return [top, bottom]
75}
76
77export type Row = {
78 readonly label: string
79 readonly value: string
80 readonly hot?: boolean
81}
82
83export const nextActive = (current: TabId | null, pressed: TabId): TabId | null =>
84 current === pressed ? null : pressed
85
86export const truncate = (text: string, cols: number): string => {
87 if (cols <= 0) return ''
88 return text.length <= cols ? text : `${text.slice(0, cols - 1)}…`
89}
90
91export const isTabId = (value: unknown): value is TabId => TABS.some(tab => tab.id === value)
92
93export const storedToActive = (stored: unknown): TabId | null =>
94 stored === COLLAPSED ? null : isTabId(stored) ? stored : DEFAULT_ACTIVE
95
96export const fmtDuration = (ms: number): string => {
97 const minutes = Math.max(0, Math.floor(ms / 60000))
98 const days = Math.floor(minutes / 1440)
99 const hours = Math.floor((minutes % 1440) / 60)
100 const mins = minutes % 60
101 if (days > 0) return `${days}d ${hours}h`
102 if (hours > 0) return `${hours}h ${mins}m`
103 return `${mins}m`
104}
105
106export const fmtAge = (minutes: number): string => {
107 if (minutes < 60) return `${minutes}m`
108 if (minutes < 1440) return `${Math.floor(minutes / 60)}h`
109 return `${Math.floor(minutes / 1440)}d`
110}
111
112/** A plain button draws `1: label` — the prefix as drawn counts in its width. */
113export const tabWidth = (tab: Tab): number => tab.hotkey.length + 2 + tab.label.length
114
115/** Columns before the tab at `index`: the tabs before it plus one gap each. */
116export const tabOffset = (index: number): number =>
117 TABS.slice(0, index).reduce((sum, tab) => sum + tabWidth(tab), index)
118
119export const tabsWidth = (): number =>
120 TABS.reduce((sum, tab) => sum + tabWidth(tab), 0) + TABS.length - 1
121
122export const showHint = (cols: number): boolean => tabsWidth() + HINT_GAP + HINT.length <= cols
123
124/** The `━` row under the active tab; null when collapsed. */
125export const underline = (active: TabId | null): string | null => {
126 if (active === null) return null
127 const index = TABS.findIndex(tab => tab.id === active)
128 const tab = TABS[index]
129 if (tab === undefined) return null
130 return `${' '.repeat(tabOffset(index))}${'━'.repeat(tabWidth(tab))}`
131}
132
133const reviewText = (review: string | null): string => {
134 if (review === null) return 'unknown'
135 if (review === 'stale') return 'stale'
136 if (review === 'fail') return 'failed'
137 if (review === 'pass') return 'passed'
138 const who: string[] = []
139 if (review.includes('ds')) who.push('ds')
140 if (review.includes('glm')) who.push('glm')
141 return who.length > 0 ? `passed (${who.join(' + ')})` : 'lockfile only — not reviewed'
142}
143
144export const codeRows = (info: CodeInfo): Row[] => {
145 const branch = [info.branch ?? 'not a git repo']
146 if (info.ahead > 0) branch.push(`${info.ahead} ahead`)
147 if (info.behind > 0) branch.push(`${info.behind} behind`)
148
149 return [
150 { label: 'branch', value: branch.join(' · ') },
151 { label: 'changes', value: info.dirty === 0 ? 'clean' : `${info.dirty} changed` },
152 { label: 'review', value: reviewText(info.review) },
153 {
154 label: 'commit',
155 value:
156 info.lastCommit === null
157 ? 'no commits'
158 : `${info.lastCommit.sha} · ${fmtAge(info.lastCommit.ageMin)} ago · ${info.lastCommit.subject}`,
159 },
160 ]
161}
162
163export const ticketsRows = (info: TicketsInfo): Row[] => {
164 const linear = info.linear
165 const rows: Row[] = [
166 {
167 label: 'linear',
168 value:
169 linear === null
170 ? 'no active ticket'
171 : `${linear.id} · ${linear.phase}${linear.by !== null ? ` (${linear.by})` : ''} · ${fmtAge(linear.ageMin)} ago`,
172 },
173 ]
174
175 const sprint = info.sprints[0]
176 rows.push({
177 label: 'sprint',
178 value: sprint === undefined ? 'no open sprint' : `${sprint.name} ${sprint.done} of ${sprint.total} done`,
179 })
180
181 const first = info.inProgress[0]
182 if (first === undefined) {
183 rows.push({ label: 'doing', value: 'nothing in progress' })
184 } else {
185 rows.push({ label: 'doing', value: `${first.ref} ${first.title}` })
186 const second = info.inProgress[1]
187 if (second !== undefined) rows.push({ label: '', value: `${second.ref} ${second.title}` })
188 }
189
190 if (rows.length < 4 && info.next !== null) rows.push({ label: 'next', value: `${info.next.ref} ${info.next.title}` })
191
192 return rows
193}
194
195const windowRow = (label: string, pct: number | null, resetsAt: number | null, nowMs: number): Row | null => {
196 if (pct === null) return null
197 const parts = [`${pct}%`]
198 if (resetsAt !== null) parts.push(`resets in ${fmtDuration(resetsAt - nowMs)}`)
199 const hot = pct >= HOT_PCT
200 if (hot) parts.push('hot')
201 return { label, value: parts.join(' · '), hot: hot === true ? true : undefined }
202}
203
204export const claudeRows = (info: ClaudeInfo, nowMs: number): Row[] => {
205 const rows: Row[] = []
206
207 if (info.contextPct !== null) {
208 const hot = info.contextPct >= HOT_PCT
209 rows.push({
210 label: 'context',
211 value: hot ? `${info.contextPct}% used · hot` : `${info.contextPct}% used`,
212 hot: hot === true ? true : undefined,
213 })
214 }
215 const fiveHour = windowRow('5-hour', info.fiveHourPct, info.fiveHourResetsAt, nowMs)
216 if (fiveHour !== null) rows.push(fiveHour)
217 const week = windowRow('week', info.sevenDayPct, info.sevenDayResetsAt, nowMs)
218 if (week !== null) rows.push(week)
219 if (info.costUsd !== null) rows.push({ label: 'cost', value: `$${info.costUsd.toFixed(2)} this session` })
220
221 return rows
222}
223
224export const overdriveRows = (info: OverdriveInfo): Row[] => [
225 { label: 'version', value: info.version ?? 'unknown' },
226 {
227 label: 'snapshot',
228 value:
229 info.lastSnapshot === null
230 ? 'no snapshot'
231 : `${info.lastSnapshot.sha} · ${fmtAge(info.lastSnapshot.ageMin)} ago · ${info.lastSnapshot.subject}`,
232 },
233 {
234 label: 'changes',
235 value:
236 info.uncommitted === null ? 'unknown' : info.uncommitted === 0 ? 'clean' : `${info.uncommitted} uncommitted`,
237 },
238 {
239 label: 'log',
240 value: info.warn === 0 && info.critical === 0 ? 'clean' : `${info.warn} warnings · ${info.critical} critical`,
241 },
242]
243
244/** What the serpent's mood can see. `hotPct` is the highest of context, 5-hour and week. */
245export type MoodInput = {
246 readonly turns: number
247 readonly tools: number
248 readonly review: string | null
249 readonly hotPct: number | null
250 readonly warn: number
251 readonly critical: number
252 readonly dirty: number
253}
254
255/** The serpent's head (exactly 5 one-cell glyphs, like `(◣▾◢)`) and one mood word. */
256export type Mood = { readonly face: string; readonly text: string }
257
258export const MOODS = {
259 fierce: { face: '(◣▾◢)', text: 'fierce' },
260 happy: { face: '(◕▾◕)', text: 'happy' },
261 hungry: { face: '(°▾°)', text: 'hungry — feed it a ticket' },
262 sleepy: { face: '(-▾-)', text: 'sleepy' },
263 panic: { face: '(⊙▾⊙)', text: 'panicking' },
264 angry: { face: '(╬▾╬)', text: 'angry — review failed' },
265} as const satisfies Record<string, Mood>
266
267/** The serpent's mood: always fierce (Velimir, 2026-10-12). MoodInput stays for a later rule. */
268export const serpentMood = (s: MoodInput): Mood => {
269 void s
270 return MOODS.fierce
271}
272
273export const FORTUNES: readonly string[] = [
274 'the serpent approves of small diffs',
275 'a green dual-review is worth two hot fixes',
276 'today the lockfile sleeps. let it.',
277 'ship on tuesday, never on friday',
278 'every TODO is a promise to future you',
279 'the cache lies. bump the version.',
280 'tables were flipped so code could be fixed ┬─┬ノ( º _ºノ)',
281 'read the log before you blame the model',
282]
283
284export const fortune = (turns: number): string => FORTUNES[turns % FORTUNES.length] ?? ''
285
286/** The serpent: tail, one `∿` per turn (capped to `cols`), the mood's head, fire. */
287export const serpentLine = (turns: number, face: string, cols: number): string => {
288 const head = `${face}⟫≋✶`
289 const room = Math.max(0, cols - DRAGON_TAIL.length - head.length)
290 return DRAGON_TAIL + DRAGON_BODY.repeat(Math.min(Math.max(1, turns), room)) + head
291}
292
293export const serpentRows = (info: SerpentInfo, mood: Mood, cols: number): Row[] => [
294 { label: 'serpent', value: serpentLine(info.turns, mood.face, cols - LABEL_COLS) },
295 { label: 'mood', value: mood.text },
296 { label: 'fed', value: `${info.turns} turns · ${info.tools} tools` },
297 { label: 'fortune', value: fortune(info.turns) },
298]
299types/index.d.ts 59 lines1// overdrive-tabs state contract. Owned by the main session — implementers do not change it.
2export type TabId = 'code' | 'tickets' | 'claude' | 'overdrive' | 'serpent'
3
4/** Tab 1 — code: the git checkout the session runs in. */
5export type CodeInfo = {
6 branch: string | null // `git branch --show-current`; null outside a repo
7 dirty: number // changed + untracked files (`git status --porcelain`)
8 ahead: number // commits ahead of upstream, 0 when none
9 behind: number // commits behind upstream, 0 when none
10 review: string | null // dual-review chip, e.g. "ds✓ glm✓ pass", "stale", "fail"; null when unknown
11 lastCommit: { sha: string; subject: string; ageMin: number } | null // git log -1 in the cwd
12}
13
14/** Tab 2 — tickets. Linear phase from the local `tix phase` cache; the rest from the LOCAL code-context DB (never Linear). */
15export type TicketsInfo = {
16 linear: { id: string; phase: string; by: string | null; ageMin: number } | null // newest phase file not in phase "done"
17 sprints: { id: number; name: string; done: number; total: number }[] // code-context sprints with status not closed/done
18 inProgress: { ref: string; title: string }[] // code-context tickets IN_PROGRESS, newest first, max 3
19 next: { ref: string; title: string } | null // first TODO ticket of the first open sprint, by priority
20}
21
22/** Tab 3 — claude stats, from $.session.usage(). */
23export type ClaudeInfo = {
24 contextPct: number | null
25 fiveHourPct: number | null
26 fiveHourResetsAt: number | null // epoch ms
27 sevenDayPct: number | null
28 sevenDayResetsAt: number | null // epoch ms
29 costUsd: number | null
30}
31
32/** Tab 4 — overdrive (~/.claude umbrella repo). */
33export type OverdriveInfo = {
34 version: string | null // ~/.claude/VERSION
35 lastSnapshot: { sha: string; subject: string; ageMin: number } | null // git -C ~/.claude log -1
36 uncommitted: number | null // git -C ~/.claude status --porcelain line count
37 warn: number // this session's overdrive log: WARN lines
38 critical: number // ... CRITICAL lines
39}
40
41/** Tab 5 — serpent, the session pet. Counters live for the session only. */
42export type SerpentInfo = {
43 turns: number // turn.complete count — the serpent's length
44 tools: number // tool calls this session — what it ate
45}
46
47declare module 'claude-code' {
48 interface PluginState {
49 'overdrive-tabs': {
50 active: TabId | null
51 code: CodeInfo | null
52 tickets: TicketsInfo | null
53 claude: ClaudeInfo | null
54 overdrive: OverdriveInfo | null
55 serpent: SerpentInfo
56 }
57 }
58}
59