SLOPSHOPPER

overdrive-tabs

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

newbandguardprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · overdrive-tabs
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ┏━━ ░▒▓█ ⏣ O V E R D R I V E █▓▒░ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ 1: code 2: tickets 3: claude 4: overdrive 5: serpent click or ctrl+x tab → 1-5 ┃ ┃ ━━━━━━━ ┃ ┃ branch not a git repo ┃ ┃ changes 2 changed ┃ ┃ review unknown ┃ ┃ commit no commits ┃ ┗⸌∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿(◣▾◢)⟫≋≋≋✶━┛ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
┏━━ ░▒▓█ ⏣ O V E R D R I V E █▓▒░ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ 1: code 2: tickets 3: claude 4: overdrive 5: serpent click or ctrl+x tab → 1-5 ┃ ┃ ━━━━━━━ ┃ ┃ branch not a git repo ┃ ┃ changes 2 changed ┃ ┃ review unknown ┃ ┃ commit no commits ┃ ┗⸌∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿(◣▾◢)⟫≋≋≋✶━┛
README

<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 &amp;&amp; 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 -&gt; OVERDRIVE-TOOLS -&gt; TRANSCRIPT ROW. The mod wraps the engine&#x27;s own drawing. The stored row and the model&#x27;s context do not change." src="assets/readme/flow-v2-dark.svg" width="100%">

<img alt="~/.claude -&gt; overdrive snapshot -&gt; 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] [&quot;msg&quot;]: Regenerate the lock and commit if anything changed. overdrive bump patch|minor|major &quot;msg&quot;: New version, new log, commit and tag. overdrive history · changelog [N]: Versions with their messages and log lines. overdrive trace [&lt;session&gt;|--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&#x27;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%">

Source 4 files
hooks/register.tsx 451 lines
1import { 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}
451
hooks/collect.ts 129 lines
1export 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}
129
hooks/tabs.ts 299 lines
1import 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]
299
types/index.d.ts 59 lines
1// 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