SLOPSHOPPER

cockpit

A one-line band above the prompt and a roomy Cockpit panel (prompt-cache countdown, context, plan limits, cost, next tasks, shortcuts), plus guards: no Claude…

newpanebandguardcommandtoast
v0.5.0MITupdated 2026-10-10cGradying/claude-code-cockpit/cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
│ ┃ Cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ cockpit ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Prompt cache ⏺ Update(src/auth.ts) │ ┃ ● 60m left of 1 hour ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Refreshes after every answer. ⎿ 3 pass, 1 fail │ ┃ │ ┃ Context ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 97.4k tokens · 49% of 200k │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ k: compact │ ┃ › /next │ ┃ Plan limits ⎿ cockpit: next › nothing queued (/work/app/.claude/NEXT.md) │ ┃ 5-hour limit 31% │ ┃ │ ┃ │ ┃ This session │ ┃ $0.42 │ ┃ ● 9router running │ ┃ │ ┃ Next up │ ┃ Nothing queued. Press What's next? and │ ┃ Claude will write the next three. │ ┃ ● 60m ctx 97.4k Next nothing queued g: git d: details ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● 60m ctx 97.4k Next nothing queued g: git d: details ⟨Claude Code's own drawing⟩
Pane · Cockpit
cockpit Prompt cache ● 60m left of 1 hour Refreshes after every answer. Context 97.4k tokens · 49% of 200k k: compact Plan limits 5-hour limit 31% This session $0.42 ● 9router running Next up Nothing queued. Press What's next? and Claude will write the next three. n: plan Mods Agents & progress Agents & progress-Open subagents, cost, flow progress File tree File tree-Open sidebar with git status Replay Replay-Open step through the last edits Skin Skin-Noir Skin-Off transcript restyle Reflect Reflect-On / off catch corrections (uses Haiku) Keep-warm Keep-warm-Arm Keep-warm-Stop spends tokens every ~50 min Tasks live in .claude/NEXT.md. /next adds one, /next done checks one off.
Pane · Git
git refresh Reading the repository… Buttons ask Claude to do the change, so you see every command. This panel only reads.
README

claude-code-cockpit

A one-line band above the Claude Code prompt, a roomy Cockpit panel beside the conversation, and seven community mods, installed as one plugin. The band shows only what you glance at. The panel has everything else: prompt-cache countdown, context, plan limits with their reset times, session cost, your next tasks and project shortcuts. Everything that spends tokens or redraws your screen starts off.

 ◔ 42m   ctx 72.5k   Next  Write docs/specs/PLAN-2.md for the phase 2 slices        Continue   Details

The band is drawn with Claude's own text and buttons, so it takes the app's fonts and theme, truncates the task by itself, and cannot flash or resize. Details (or /cockpit) opens the panel as a tab in the right-hand side panel.

<img src="docs/images/band.png" alt="The band above the prompt in the Claude desktop app: working, context 704.8k at 70 percent, the next task, and a Details button" width="720">

<img src="docs/images/panel.png" alt="The Cockpit panel in the side panel: prompt cache with a green bar, context at 70 percent, plan limits with reset times, session cost, next up and shortcuts" width="320">

These are screenshots of the real desktop app, showing the dark theme.

What is inside

PluginWhat it doesDefault hereOrigin
cockpitThe band, the Cockpit panel, next-task list, Continue / What's next / Compact buttons, guards, and a cheat-sheet skillonthis repo (MIT)
cache-taxShows the rewrite price before a cold send; opt-in keep-warm for the prompt cachewarns, never drops; keep-warm offkaranb192/cache-tax
claude-reflect (reflect-mod)Notices corrections and offers to save them as rules in CLAUDE.mdpausedBayramAnnakov/claude-reflect
skinsRedraws the transcript: tool rows, diff cards, table and code cards, spinneroff, its own band offhellosverre/claude-skins
filetreeIDE-style file tree in the sidebar with git status and read/write activityopens with /filetree (does not dock by itself)data-goblin/claude-code-filetree
replay-theaterStep through the last turn's file edits, one diff at a timeonanthropics/claude-code-playground
blast-radiusHolds rm -r, git reset --hard, git clean, force push and migrations until you press Proceedonanthropics/claude-code-playground
savvy-progressProgress bar and agents panel for flows and subagentsidle until something reportsJohnnyVizz/claude-kit

The seven community mods are vendored here at pinned commits, with small patches (listed in each folder's SOURCE.md) so they start quiet and stack above each other instead of replacing each other's band. Their original licences are kept.

Install

Needs Claude Code 2.1.287 or later.

/plugin marketplace add cGradying/claude-code-cockpit
/plugin install cockpit@janvin-mods

or from a terminal:

claude plugin marketplace add cGradying/claude-code-cockpit
claude plugin install cockpit@janvin-mods --scope user

cockpit lists the seven mods as dependencies, so installing it installs them all. If a dependency does not install by itself, install it by name, for example claude plugin install skins@janvin-mods. Then restart Claude Code (or the desktop app) and open a new session.

To remove everything: /plugin, pick cockpit, uninstall, and do the same for each mod you no longer want. Each one is independent.

A quick tour

The band. One line above the prompt: the cache countdown (◔ 42m, green, then amber, then red), context, and the next task. The 5-hour and weekly limits join the line only once they pass 60%, and 9router down shows only if it was up and stopped. Buttons sit at the right; click them, or focus the band (ctrl+x then tab) and press the key.

ButtonKeyDoes
ContinuecSends "continue with the next task" for the first open line of .claude/NEXT.md, and asks Claude to check it off when done. Hidden while a turn runs.
DetailsdOpens the Cockpit panel.
CompactkAppears past 70% context. Runs /compact and tells it to keep the plan, open tasks, PRs, decisions and open bugs.

The Cockpit panel. One tab in the side panel with room for each number: prompt cache (time left, a bar, what happens if it lapses), context (tokens, percent, window size, Compact), plan limits (each with a bar and when it resets), this session (cost, 9router), your next five tasks (Continue, What's next?), and your project shortcuts. Bars are solid boxes sized in percent and follow the app's light or dark theme. Open it with Details or /cockpit.

Guards that run quietly. A shell command carrying a Claude or Anthropic co-author line is refused. A prompt containing a pasted API key is held once (press Enter again to send it anyway). Prompts about UI or design get a hidden nudge toward the impeccable skill if you have it.

Commands. /cockpit, /next, /keepwarm, /cache-tax, /reflect-pause, /reflect-queue, /skin, /filetree, /replay, /agents-info. Details in docs/COMMANDS.md; inside Claude Code, the cockpit:help skill prints the same cheat sheet.

Quiet by default

Three things in the bundle can cost tokens or change your screen, so they start off:

  • Keep-warm (cache-tax) sends a real model request about every 50 minutes while armed. It is off; you arm it with /keepwarm. The cold-send guard only warns by default: it shows the rewrite price and still sends your message.
  • Correction checking (claude-reflect) sends short prompts to Haiku. It starts paused; /reflect-pause turns it on. remember: <rule> works either way and needs no model.
  • Skins redraw the whole transcript. It starts off; /skin noir turns it on.

How to change each: docs/CONFIGURATION.md.

Documentation

Status and limits

  • Written for Claude Code's mods feature, which is early access and may change between releases.
  • The band and panel were built by looking at the real desktop app, and are tested with Claude Code's plugin test kit on the terminal and desktop surfaces. They have been run in the desktop app's Code tab; the terminal layout is covered by tests but has had less eyes on it. Please open an issue with a screenshot if something looks wrong.
  • The file tree opens with /filetree (set autoOpen to on to dock it at session start). It needs a wide window, and upstream says it does not load in WSL sessions of the desktop app.
  • Cache timings assume the one-hour prompt cache. With a five-minute cache the countdown will be wrong.

Credits and licences

The code in cockpit/ is MIT, Copyright (c) 2026 cGradying (see LICENSE). Each vendored mod keeps its own licence file and authorship: the two Anthropic mods are Apache-2.0, the rest are MIT. See THIRD_PARTY_NOTICES.md. Thanks to the authors of all seven.

Source 8 files
hooks/register.tsx 16 lines
1import type { Register } from 'claude-code'
2
3import { register as band } from './band'
4import { register as git } from './git'
5import { register as guards } from './guards'
6import { register as panel } from './panel'
7
8// A plugin has one hooks module and may not hook the same event twice without a matcher,
9// so the parts that share this plugin are called from here and each owns its own events.
10export const register: Register = (on, options) => {
11  band(on, options)
12  panel(on)
13  git(on)
14  guards(on, options)
15}
16
hooks/band.tsx 260 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5  COMPACT_AT,
6  GIT_PANEL,
7  PANEL,
8  PANEL_TITLE,
9  ROUTER_URL,
10  TTL_MS,
11  DEFAULT_QUICK,
12  cacheTone,
13  continueText,
14  firstOpen,
15  heat,
16  isLightTheme,
17  palette,
18  tint,
19  nextFile,
20  openTasks,
21  projectRoot,
22} from './shared'
23import type { Quick } from '../types'
24
25const lastAt = atom({ plugin: 'cockpit', key: 'lastAt' } as const, null)
26const tick = atom({ plugin: 'cockpit', key: 'tick' } as const, 0)
27const next = atom({ plugin: 'cockpit', key: 'next' } as const, null)
28const tasks = atom({ plugin: 'cockpit', key: 'tasks' } as const, [])
29// null: 9router never answered this session, so it stays hidden
30const router = atom({ plugin: 'cockpit', key: 'router' } as const, null)
31const quick = atom({ plugin: 'cockpit', key: 'quick' } as const, [])
32
33async function loadQuick($: EngineInterface) {
34  const root = projectRoot(await $.session.root())
35  const file = `${root}/.claude/quick.json`
36  const own: Quick[] | null = (await $.fs.exists(file)) ? JSON.parse(await $.fs.read(file)) : null
37  const found = own ?? DEFAULT_QUICK.find(([re]) => re.test(root))?.[1] ?? []
38  await update($, quick, () => found.slice(0, 4))
39}
40
41async function pulse($: EngineInterface) {
42  await update($, tick, n => n + 1)
43  const isUp = await $.http
44    .fetch(ROUTER_URL)
45    .then(r => r.status < 500)
46    .catch(() => false)
47  const was = await read($, router)
48  const now = isUp ? true : was === null ? null : false
49  if (now !== was) await update($, router, () => now)
50}
51
52async function loadNext($: EngineInterface) {
53  const file = nextFile(await $.session.root())
54  const text = (await $.fs.exists(file)) ? await $.fs.read(file) : ''
55  const list = openTasks(text)
56  await update($, next, () => list[0] ?? null)
57  await update($, tasks, () => list.slice(0, 5))
58  return { file, text }
59}
60
61// A press queues a prompt, so a double click (or a click before the view redraws) would send it twice.
62let lastSent = { text: '', at: 0 }
63
64async function send($: EngineInterface, text: string) {
65  const now = await $.clock.now()
66  if (lastSent.text === text && now - lastSent.at < 10_000) return
67  lastSent = { text, at: now }
68  await $.prompt.submit({ text, asUser: true })
69}
70
71// A press's own work is dropped when the press ends, so /compact starts from a timer and the timer
72// returns the run, which keeps its dispatch alive until compaction is done.
73function compactNow($: EngineInterface, file: string) {
74  void $.clock.after(1, () =>
75    $.command
76      .run({
77        command: 'compact',
78        args: `Keep the current plan, the open tasks in ${file}, open PRs, decisions made and unresolved bugs.`,
79      })
80      .catch((error: unknown) => $.ui.toast(`Compacting failed: ${error instanceof Error ? error.message : String(error)}`)),
81  )
82}
83
84const openGit = ($: EngineInterface) => $.ui.open({ id: GIT_PANEL, title: 'Git' })
85
86async function openPanel($: EngineInterface) {
87  return $.ui.open({ id: PANEL, title: PANEL_TITLE })
88}
89
90
91// The one-line band above the prompt. It carries only what you glance at; the rest is in the panel.
92export const register: Register = on => {
93  on('session.start', async ($, e, nextHook) => {
94    if (e.seconds_since_last_response !== undefined) {
95      const at = (await $.clock.now()) - e.seconds_since_last_response * 1000
96      await update($, lastAt, () => at)
97    }
98    await loadNext($)
99    await $.command.register({
100      name: 'next',
101      description: 'Next task: /next <text> adds one on top, /next done checks it off',
102    })
103    await $.command.register({
104      name: 'cockpit',
105      description: 'Open the cockpit panel: cache, context, limits, cost and the next tasks',
106    })
107    await $.command.register({
108      name: 'git',
109      description: 'Open the git panel: branch, open and stacked PRs, worktrees and what is safe to tidy up',
110    })
111    await loadQuick($).catch(() => undefined)
112    void pulse($)
113    $.clock.every(30_000, () => void pulse($))
114
115    return nextHook(e)
116  })
117
118  let warnedCompact = false
119  on('turn.complete', async ($, e, nextHook) => {
120    const pct = (await $.session.usage()).context.percent ?? 0
121    if (pct >= COMPACT_AT && !warnedCompact) $.ui.toast(`Context at ${pct}%: compact soon. The cockpit panel has a button that keeps the plan.`)
122    warnedCompact = pct >= COMPACT_AT
123
124    if (e.agentId === undefined && e.usage) {
125      const at = await $.clock.now()
126      await update($, lastAt, () => at)
127    }
128    const { file, text } = await loadNext($)
129    const task = firstOpen(text)
130    if (e.agentId === undefined && task) void $.prompt.suggest({ text: continueText(task, file) })
131
132    return nextHook(e)
133  })
134
135  on('command.run', { command: 'next' }, async ($, e) => {
136    const { file, text } = await loadNext($)
137    const arg = e.args.trim()
138    if (arg === '') return { text: `next › ${firstOpen(text) ?? 'nothing queued'}  (${file})` }
139
140    const updated =
141      arg === 'done'
142        ? text.replace(/^(\s*- )\[ \]/m, '$1[x]')
143        : `- [ ] ${arg}\n${text}`
144    await $.fs.write(file, updated)
145    await loadNext($)
146
147    return { text: arg === 'done' ? 'Checked off.' : `Queued: ${arg}` }
148  })
149
150  on('command.run', { command: 'cockpit' }, async $ => {
151    const opened = await openPanel($)
152    return { text: opened.isPlaced ? 'Cockpit panel opened.' : `The panel waits: ${opened.reason}` }
153  })
154
155  on('command.run', { command: 'git' }, async $ => {
156    const opened = await openGit($)
157    return { text: opened.isPlaced ? 'Git panel opened.' : `The panel waits: ${opened.reason}` }
158  })
159
160  on('ui.render', { component: 'AbovePrompt' }, async ($, e, nextHook) => {
161    if (e.props.hasSurvey) return nextHook(e)
162
163    // What other plugins draw in this slot (replay hint, reflect proposal, ...) sits beneath our row.
164    const below = await nextHook(e)
165    await read($, tick) // the 30-second pulse redraws the countdown
166    const at = await read($, lastAt)
167    const task = await read($, next)
168    const usage = await $.session.usage()
169    const now = await $.clock.now()
170    const limit = (kind: string) => usage.rateLimits.find(r => r.kind === kind)?.percentUsed
171    const file = nextFile(await $.session.root())
172
173    const cache = cacheTone({ isWorking: e.props.isWorking, left: at === null ? null : TTL_MS - (now - at) })
174    const ctxPct = usage.context.percent
175    const needsCompact = (ctxPct ?? 0) >= COMPACT_AT
176    const isUp = await read($, router)
177    const { Box, Text, Button } = $.ui.resolve(e)
178    const pal = palette(isLightTheme(await $.config.list().catch(() => [])))
179
180    // One piece of the stats: a dim label and a value that turns amber, then red, as it fills.
181    const stat = (label: string, value: string, pct?: number) => (
182      <Text>
183        <Text dimColor>{label} </Text>
184        <Text color={tint(heat(pct), pal)}>{value}</Text>
185      </Text>
186    )
187    // Limits only earn a place on the line once they are worth noticing; the panel always shows them.
188    const five = limit('five_hour')
189    const week = limit('seven_day')
190
191    const stats = (
192      <Box flexDirection="row" gap={2} flexShrink={0}>
193        <Text bold color={tint(cache.color, pal)}>
194          {cache.glyph} {cache.label}
195        </Text>
196        {usage.context.tokens !== undefined &&
197          stat('ctx', `${(usage.context.tokens / 1000).toFixed(1)}k${ctxPct !== undefined && ctxPct >= 60 ? ` ${ctxPct}%` : ''}`, ctxPct)}
198        {five !== undefined && five >= 60 && stat('5h', `${Math.round(five)}%`, five)}
199        {week !== undefined && week >= 60 && stat('wk', `${Math.round(week)}%`, week)}
200        {isUp === false && <Text color={pal.hot}>9router down</Text>}
201      </Box>
202    )
203
204    const upNext = (
205      <Box flexGrow={1} flexShrink={1} minWidth={10}>
206        <Text wrap="truncate-end">
207          <Text bold color={pal.accent}>
208            Next{' '}
209          </Text>
210          {task ?? <Text dimColor>nothing queued</Text>}
211        </Text>
212      </Box>
213    )
214
215    const buttons = (
216      <Box flexDirection="row" gap={2} flexShrink={0}>
217        {!e.props.isWorking && needsCompact && (
218          <Button key="compact" plain hotkey="k" onPress={() => compactNow($, file)}>
219            <Text bold color={pal.accent}>{`[ Compact ${ctxPct}% ]`}</Text>
220          </Button>
221        )}
222        {!e.props.isWorking && task && (
223          <Button key="continue" plain hotkey="c" onPress={() => void send($, continueText(task, file))}>
224            <Text bold color={pal.accent}>[ Continue ]</Text>
225          </Button>
226        )}
227        <Button key="git" plain hotkey="g" onPress={() => void openGit($)}>
228          <Text>[ Git ]</Text>
229        </Button>
230        <Button key="details" plain hotkey="d" onPress={() => void openPanel($)}>
231          <Text>[ Details ]</Text>
232        </Button>
233      </Box>
234    )
235
236    // Wide: one row. Narrow: the stats on their own row so the task and buttons keep room.
237    const isNarrow = (e.props.bodyColumns ?? 120) < 72
238    return (
239      <Box flexDirection="column">
240        {isNarrow ? (
241          <Box flexDirection="column">
242            {stats}
243            <Box flexDirection="row" gap={2}>
244              {upNext}
245              {buttons}
246            </Box>
247          </Box>
248        ) : (
249          <Box flexDirection="row" alignItems="center" gap={3}>
250            {stats}
251            {upNext}
252            {buttons}
253          </Box>
254        )}
255        {below}
256      </Box>
257    )
258  })
259}
260
hooks/git.tsx 218 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { orderPrs, parseLines, parsePrs, parseStatus, parseWorktrees, repoName, tidyPlan } from './gitparse'
5import { GIT_PANEL, isLightTheme, palette } from './shared'
6import type { Palette } from './shared'
7import type { GitInfo } from '../types'
8
9const tick = atom({ plugin: 'cockpit', key: 'tick' } as const, 0)
10const git = atom({ plugin: 'cockpit', key: 'git' } as const, null)
11
12const FRESH_MS = 60_000 // git state is re-read at most this often while the panel is drawn
13const GH_FRESH_MS = 180_000 // gh asks the network, so less often
14
15let inflight = false
16let lastSent = { text: '', at: 0 }
17
18async function sh($: EngineInterface, argv: string[], timeoutMs = 20_000): Promise<string | null> {
19  try {
20    const r = await $.process.run(argv, { timeoutMs })
21    return r.exitCode === 0 ? r.stdout : null
22  } catch {
23    return null
24  }
25}
26
27// Reads the repo the session is in. Never changes anything: every change goes through Claude, as a prompt.
28async function refresh($: EngineInterface, withGh: boolean) {
29  if (inflight) return
30  inflight = true
31  try {
32    const now = await $.clock.now()
33    const old = await read($, git)
34    const status = await sh($, ['git', 'status', '--porcelain=v1', '-b'])
35    if (status === null) {
36      await update($, git, () => ({ ...(old ?? EMPTY), at: now, error: 'This folder is not a git repository.' }))
37      return
38    }
39    const root = (await $.session.root()) ?? ''
40    const base = ((await sh($, ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])) ?? 'origin/main').trim().replace(/^origin\//, '')
41    const worktrees = parseWorktrees((await sh($, ['git', 'worktree', 'list', '--porcelain'])) ?? '')
42    const merged = parseLines((await sh($, ['git', 'branch', '--merged', `origin/${base}`, '--format=%(refname:short)'])) ?? '')
43
44    let prs = old?.prs ?? null
45    let mergedHeads = old?.mergedHeads ?? []
46    let ghAt = old?.ghAt ?? 0
47    if (withGh) {
48      const open = await sh($, ['gh', 'pr', 'list', '--author', '@me', '--state', 'open', '--limit', '40', '--json', 'number,title,headRefName,baseRefName,isDraft,statusCheckRollup'], 30_000)
49      const done = await sh($, ['gh', 'pr', 'list', '--author', '@me', '--state', 'merged', '--limit', '40', '--json', 'headRefName'], 30_000)
50      prs = open === null ? null : parsePrs(open)
51      mergedHeads = done === null ? [] : (JSON.parse(done) as { headRefName: string }[]).map(r => r.headRefName)
52      ghAt = now
53    }
54    await update($, git, () => ({ root, base, worktrees, merged, prs, mergedHeads, at: now, ghAt, error: null, ...parseStatus(status) }))
55  } finally {
56    inflight = false
57  }
58}
59
60const EMPTY: GitInfo = {
61  root: '', branch: '', base: 'main', ahead: 0, behind: 0, hasUpstream: false, staged: 0, changed: 0, untracked: 0,
62  worktrees: [], merged: [], prs: null, mergedHeads: [], at: 0, ghAt: 0, error: null,
63}
64
65async function send($: EngineInterface, text: string) {
66  const now = await $.clock.now()
67  if (lastSent.text === text && now - lastSent.at < 10_000) return
68  lastSent = { text, at: now }
69  await $.prompt.submit({ text, asUser: true })
70}
71
72const PLAN = {
73  commit: 'Look at my uncommitted changes (git status, git diff). Group them into logical commits with clear messages and commit them. Do not push.',
74  push: 'Push the current branch to origin (set the upstream if there is none). Never force push.',
75  pr: (base: string) => `Open a pull request from the current branch into ${base} with a clear title and a description of what changed and why. Check first that no PR is already open for it.`,
76  sync: (base: string) => `Bring this branch up to date with ${base}. In an app-made worktree use the sync_with_base_branch tool; otherwise merge origin/${base} and resolve any conflicts. Tell me what conflicted.`,
77  merge: (n: number, head: string, base: string) =>
78    `Check that PR #${n} (${head} into ${base}) has green checks and no conflicts, then merge it the way this repo usually merges and delete its branch. If other open PRs are stacked on it, retarget them to ${base} afterwards.`,
79  tidy: (worktrees: string[], branches: string[]) =>
80    `Tidy up. These worktree branches and local branches are already merged: ${[...worktrees, ...branches].join(', ')}. For each, first confirm it has no unpushed or uncommitted work, then remove its worktree (use clean_up_worktrees) and delete the local branch. Do not touch anything not listed.`,
81}
82
83export function register(on: On) {
84  on('ui.render', { component: 'Pane', requestId: GIT_PANEL }, async ($, e, nextHook) => {
85    if (e.requestId !== GIT_PANEL) return nextHook(e)
86
87    await read($, tick) // redraws every 30 seconds
88    const g = await read($, git)
89    const now = await $.clock.now()
90    if (g === null || now - g.at > FRESH_MS || (g.ghAt === 0 && g.error === null) || now - g.ghAt > GH_FRESH_MS) {
91      void refresh($, g === null || now - g.ghAt > GH_FRESH_MS)
92    }
93
94    const ui = $.ui.resolve(e)
95    const { Box, Text, Button } = ui
96    const pal: Palette = palette(isLightTheme(await $.config.list().catch(() => [])))
97
98    const btn = (key: string, label: string, onPress: () => void, isMain = false) => (
99      <Button key={key} plain onPress={onPress}>
100        <Text bold={isMain} color={isMain ? pal.accent : undefined}>{`[ ${label} ]`}</Text>
101      </Button>
102    )
103    const section = (title: string, children: unknown) => (
104      <Box flexDirection="column">
105        <Text bold color={pal.muted}>{title}</Text>
106        {children}
107      </Box>
108    )
109
110    const body = (() => {
111      if (g === null) return <Text dimColor>Reading the repository…</Text>
112      if (g.error) return <Text color={pal.hot}>{g.error}</Text>
113
114      const dirty = g.staged + g.changed + g.untracked
115      const ordered = g.prs ? orderPrs(g.prs) : []
116      const hasPr = g.prs?.some(p => p.head === g.branch) ?? false
117      const plan = tidyPlan(g)
118      const tidyCount = plan.worktrees.length + plan.branches.length
119      const mark = (c: string) => (c === 'pass' ? '✓ checks pass' : c === 'fail' ? '✗ checks fail' : c === 'pending' ? '… checks running' : 'no checks')
120      const markColor = (c: string) => (c === 'pass' ? pal.accent : c === 'fail' ? pal.hot : c === 'pending' ? pal.warn : pal.muted)
121
122      return (
123        <Box flexDirection="column" gap={1}>
124          {section(
125            'Branch',
126            <Box flexDirection="column">
127              <Text>
128                <Text bold color={pal.accent}>{g.branch}</Text>
129                <Text dimColor>{` from ${g.base}`}</Text>
130              </Text>
131              <Text>
132                <Text color={dirty === 0 ? pal.accent : pal.warn}>{dirty === 0 ? 'clean' : `${g.staged} staged · ${g.changed} changed · ${g.untracked} new`}</Text>
133                <Text dimColor>{g.hasUpstream ? `  ↑${g.ahead} ↓${g.behind}` : '  not pushed yet'}</Text>
134              </Text>
135              <Box flexDirection="row" gap={2} flexWrap="wrap">
136                {dirty > 0 && btn('commit', 'Commit', () => void send($, PLAN.commit), true)}
137                {(g.ahead > 0 || !g.hasUpstream) && btn('push', 'Push', () => void send($, PLAN.push), dirty === 0)}
138                {!hasPr && (g.ahead > 0 || g.hasUpstream) && g.branch !== g.base && btn('pr', 'Open PR', () => void send($, PLAN.pr(g.base)))}
139                {g.behind > 0 && btn('sync', `Sync with ${g.base}`, () => void send($, PLAN.sync(g.base)), true)}
140              </Box>
141            </Box>,
142          )}
143
144          {section(
145            g.prs === null ? 'Pull requests' : `Pull requests · ${g.prs.length}`,
146            g.prs === null ? (
147              <Text dimColor wrap="wrap">GitHub CLI (gh) is not signed in here, so open PRs are hidden.</Text>
148            ) : ordered.length === 0 ? (
149              <Text dimColor>No open pull requests.</Text>
150            ) : (
151              <Box flexDirection="column" gap={1}>
152                {ordered.map(({ pr, depth }) => (
153                  <Box key={`pr${pr.number}`} flexDirection="row" alignItems="center" gap={2}>
154                    <Box flexGrow={1} flexShrink={1} flexDirection="column">
155                      <Text wrap="truncate-end" bold={pr.head === g.branch}>{`${depth > 0 ? `${'  '.repeat(depth - 1)}└ ` : ''}#${pr.number} ${pr.title}`}</Text>
156                      <Text wrap="truncate-end">
157                        <Text dimColor>{`${pr.head} → ${pr.base}${pr.isDraft ? ' · draft' : ''} · `}</Text>
158                        <Text color={markColor(pr.checks)}>{mark(pr.checks)}</Text>
159                      </Text>
160                    </Box>
161                    {!pr.isDraft && pr.checks === 'pass' && btn(`merge-${pr.number}`, 'Merge', () => void send($, PLAN.merge(pr.number, pr.head, pr.base)), depth === 0)}
162                  </Box>
163                ))}
164                {ordered.some(o => o.depth > 0) && <Text dimColor wrap="wrap">Stacked PRs: merge the top one first, then the ones under it.</Text>}
165              </Box>
166            ),
167          )}
168
169          {section(
170            `Worktrees · ${g.worktrees.length}`,
171            <Box flexDirection="column">
172              {g.worktrees.slice(0, 8).map(w => {
173                const landed = !w.isMain && w.branch !== null && plan.worktrees.some(p => p.path === w.path)
174                return (
175                  <Text key={w.path} wrap="truncate-end">
176                    <Text color={landed ? pal.muted : undefined}>{w.branch ?? 'detached'}</Text>
177                    {w.isMain && <Text dimColor> · main checkout</Text>}
178                    {landed && <Text color={pal.accent}> · merged</Text>}
179                  </Text>
180                )
181              })}
182              {g.worktrees.length > 8 && <Text dimColor>{`+ ${g.worktrees.length - 8} more`}</Text>}
183            </Box>,
184          )}
185
186          {section(
187            'Tidy up',
188            tidyCount === 0 ? (
189              <Text dimColor>Nothing merged is left lying around.</Text>
190            ) : (
191              <Box flexDirection="column">
192                <Text wrap="wrap">{`${plan.worktrees.length} merged worktrees and ${plan.branches.length} merged branches can go.`}</Text>
193                <Box>{btn('tidy', `Tidy up ${tidyCount}`, () => void send($, PLAN.tidy(plan.worktrees.map(w => w.branch!), plan.branches)), true)}</Box>
194              </Box>
195            ),
196          )}
197        </Box>
198      )
199    })()
200
201    return (
202      <Box flexDirection="column" paddingX={2} paddingY={1} gap={1} flexGrow={1} backgroundColor={pal.bg}>
203        <Box flexDirection="row" alignItems="center" gap={2}>
204          <Box flexGrow={1}>
205            <Text>
206              <Text bold color={pal.accent}>git </Text>
207              <Text dimColor>{g ? repoName(g.root) : ''}</Text>
208            </Text>
209          </Box>
210          {btn('refresh', 'Refresh', () => void refresh($, true))}
211        </Box>
212        {body}
213        <Text dimColor wrap="wrap">Buttons ask Claude to do the change, so you see every command. This panel only reads.</Text>
214      </Box>
215    )
216  })
217}
218
hooks/guards.ts 67 lines
1import type { Register } from 'claude-code'
2
3// The user's rule: Claude never appears as a co-author on commits, PRs or merges.
4const CLAUDE_CREDIT =
5  /co-authored-by:[^\n"']*(claude|anthropic)|noreply@anthropic\.com|generated with \[?claude code/i
6
7const SECRETS: [string, RegExp][] = [
8  ['sk- API key', /\bsk-[A-Za-z0-9_-]{16,}/],
9  ['sk_ API key', /\bsk_[A-Za-z0-9]{24,}/],
10  ['GitHub token', /\b(ghp_|gho_|ghs_|github_pat_)[A-Za-z0-9_]{20,}/],
11  ['AWS access key', /\bAKIA[0-9A-Z]{16}\b/],
12  ['Slack token', /\bxox[abpr]-[A-Za-z0-9-]{10,}/],
13  ['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
14]
15
16const UI_WORDS =
17  /\b(ui|ux|design|redesign|layout|colou?rs?|theme|palette|animations?|animate|pixel|fonts?|typography|css|styling|landing page|visual)\b/i
18
19function findSecret(text: string): string | null {
20  return SECRETS.find(([, re]) => re.test(text))?.[0] ?? null
21}
22
23function hasClaudeCredit(command: string): boolean {
24  return CLAUDE_CREDIT.test(command)
25}
26
27export const register: Register = on => {
28  let allowOnce: string | null = null
29
30  on('tool.call', { tool: 'Bash' }, ($, e, next) =>
31    hasClaudeCredit(e.command)
32      ? {
33          deny:
34            'guards: the user never wants Claude credited. Remove every Co-Authored-By Claude/Anthropic trailer and any "Generated with Claude Code" line, then run it again.',
35        }
36      : next(e),
37  )
38
39  on('prompt.submit', async ($, e, next) => {
40    if (e.origin?.kind === 'plugin') return next(e)
41
42    const secret = findSecret(e.text)
43    if (secret && allowOnce !== e.text) {
44      allowOnce = e.text
45      void $.prompt.fill({ text: e.text }).catch(() => undefined)
46      return {
47        drop: `Held back: this looks like a ${secret}. Anything sent stays in plain text in the transcript. Put it in an env file or a config instead, or press Enter again to send anyway.`,
48      }
49    }
50    allowOnce = null
51
52    const wantsDesign =
53      !e.text.startsWith('/') && UI_WORDS.test(e.text) && !/impeccable|frontend-design/i.test(e.text)
54    if (wantsDesign) {
55      return next({
56        ...e,
57        context: [
58          ...(e.context ?? []),
59          'This request touches UI or visual design: load and follow the impeccable skill (and frontend-design:frontend-design where it fits) before designing.',
60        ],
61      })
62    }
63
64    return next(e)
65  })
66}
67
hooks/panel.tsx 276 lines
1import { atom, read } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import {
5  COMPACT_AT,
6  MARK,
7  PANEL,
8  TTL_MS,
9  cacheTone,
10  continueText,
11  heat,
12  isLightTheme,
13  nextFile,
14  palette,
15  planText,
16  tint,
17  until,
18} from './shared'
19import type { Palette, Tone } from './shared'
20
21// The same values the band keeps (same plugin and key, so the same state).
22const lastAt = atom({ plugin: 'cockpit', key: 'lastAt' } as const, null)
23const tick = atom({ plugin: 'cockpit', key: 'tick' } as const, 0)
24const tasks = atom({ plugin: 'cockpit', key: 'tasks' } as const, [])
25// null: 9router never answered this session, so it stays hidden
26const router = atom({ plugin: 'cockpit', key: 'router' } as const, null)
27const quick = atom({ plugin: 'cockpit', key: 'quick' } as const, [])
28
29// A press queues a prompt, so a double click would send it twice.
30let lastSent = { text: '', at: 0 }
31
32async function send($: EngineInterface, text: string) {
33  const now = await $.clock.now()
34  if (lastSent.text === text && now - lastSent.at < 10_000) return
35  lastSent = { text, at: now }
36  await $.prompt.submit({ text, asUser: true })
37}
38
39// A press's own work is dropped when the press ends, so /compact starts from a timer.
40function compactNow($: EngineInterface, file: string) {
41  void $.clock.after(1, () =>
42    $.command
43      .run({
44        command: 'compact',
45        args: `Keep the current plan, the open tasks in ${file}, open PRs, decisions made and unresolved bugs.`,
46      })
47      .catch((error: unknown) => $.ui.toast(`Compacting failed: ${error instanceof Error ? error.message : String(error)}`)),
48  )
49}
50
51// "1M", "200k": the context window as people say it.
52const windowLabel = (n: number) => (n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
53
54// A mod's command, started from a timer so releasing the press does not cancel it.
55function runMod($: EngineInterface, command: string, args?: string) {
56  void $.clock.after(1, () =>
57    $.command
58      .run(args === undefined ? { command } : { command, args })
59      .catch((error: unknown) => $.ui.toast(`/${command} failed: ${error instanceof Error ? error.message : String(error)}`)),
60  )
61}
62
63// The mods this plugin bundles, each with the presses that drive it. Their state lives in their own
64// plugins and cannot be read from here, so a button names the action it does, never a state.
65const MODS: { name: string; note: string; actions: { label: string; command: string; args?: string }[] }[] = [
66  { name: 'Agents & progress', note: 'subagents, cost, flow progress', actions: [{ label: 'Open', command: 'agents-info' }] },
67  { name: 'File tree', note: 'sidebar with git status', actions: [{ label: 'Open', command: 'filetree' }] },
68  { name: 'Replay', note: 'step through the last edits', actions: [{ label: 'Open', command: 'replay' }] },
69  {
70    name: 'Skin',
71    note: 'transcript restyle',
72    actions: [
73      { label: 'Noir', command: 'skin', args: 'noir' },
74      { label: 'Off', command: 'skin', args: 'off' },
75    ],
76  },
77  { name: 'Reflect', note: 'catch corrections (uses Haiku)', actions: [{ label: 'On / off', command: 'reflect-pause' }] },
78  {
79    name: 'Keep-warm',
80    note: 'spends tokens every ~50 min',
81    actions: [
82      { label: 'Arm', command: 'keepwarm' },
83      { label: 'Stop', command: 'keepwarm', args: 'off' },
84    ],
85  },
86]
87
88// The mark and the name, all square: a prompt in pixel cells and a block cursor, drawn from the palette.
89// Drawn as an image, so it needs the namespace; the surface's own text falls back where it has no Svg.
90const headerSvg = (p: Palette) => {
91  const cells = MARK.flatMap((row, y) =>
92    [...row].map((c, x) => (c === '#' ? `<rect x="${x * 4 + 6}" y="${y * 4 + 6}" width="4" height="4" fill="${y >= 6 ? p.info : p.accent}"/>` : '')),
93  ).join('')
94  return (
95    `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="52" viewBox="0 0 300 52" shape-rendering="crispEdges">` +
96    `<rect x="1" y="1" width="42" height="42" fill="${p.bg ?? '#f6f4ee'}" stroke="${p.accent}" stroke-width="2"/>` +
97    cells +
98    `<text x="58" y="26" font-family="SF Mono, Menlo, ui-monospace, monospace" font-size="22" font-weight="700" letter-spacing="1" fill="${p.fg}">cockpit</text>` +
99    `<rect x="164" y="9" width="12" height="19" fill="${p.accent}"/>` +
100    `<text x="58" y="41" font-family="SF Mono, Menlo, ui-monospace, monospace" font-size="11" fill="${p.muted}">cache, context, limits, mods</text>` +
101    `</svg>`
102  )
103}
104
105const LIMIT_NAMES: Record<string, string> = { five_hour: '5-hour limit', seven_day: 'Weekly limit' }
106
107// The roomy view: everything the band leaves out, in one place beside the conversation.
108export function register(on: On) {
109  on('ui.render', { component: 'Pane', requestId: PANEL }, async ($, e, nextHook) => {
110    if (e.requestId !== PANEL) return nextHook(e)
111
112    await read($, tick) // redraws every 30 seconds
113    const at = await read($, lastAt)
114    const list = await read($, tasks)
115    const shortcuts = await read($, quick)
116    const isUp = await read($, router)
117    const usage = await $.session.usage()
118    const now = await $.clock.now()
119    const file = nextFile(await $.session.root())
120    const ui = $.ui.resolve(e)
121    const { Box, Text, Button } = ui
122    const pal = palette(isLightTheme(await $.config.list().catch(() => [])))
123
124    const left = at === null ? null : TTL_MS - (now - at)
125    const cache = cacheTone({ isWorking: false, left })
126    const ctxPct = usage.context.percent
127
128    // One labelled block; the title is dim and small so the numbers lead.
129    const section = (title: string, children: unknown) => (
130      <Box flexDirection="column">
131        <Text bold color={pal.muted}>{title}</Text>
132        {children}
133      </Box>
134    )
135    // A solid bar: a track and a filled part sized in percent, so it is always exactly as wide as the
136    // panel and can never wrap or show seams the way a row of block characters does.
137    // A control drawn as text, `[ Open ]`, so nothing about it is rounded. The label stays its name.
138    const btn = (key: string, label: string, onPress: () => void, o: { hotkey?: string; isMain?: boolean } = {}) => (
139      <Button key={key} plain hotkey={o.hotkey} onPress={onPress}>
140        <Text bold={o.isMain} color={o.isMain ? pal.accent : undefined}>{`[ ${label} ]`}</Text>
141      </Button>
142    )
143    const meter = (pct: number, tone?: Tone['color']) => (
144      <Box width="100%" height={1} backgroundColor={pal.track}>
145        <Box width={`${Math.max(0, Math.min(100, Math.round(pct)))}%`} height={1} backgroundColor={tint(tone, pal) ?? pal.accent} />
146      </Box>
147    )
148
149    return (
150      <Box flexDirection="column" paddingX={2} paddingY={1} gap={1} flexGrow={1} backgroundColor={pal.bg}>
151        {'Svg' in ui ? (
152          <ui.Svg source={headerSvg(pal)} alt="cockpit" width={300} height={52} />
153        ) : (
154          <Text bold color={pal.accent}>cockpit</Text>
155        )}
156
157        {section(
158          'Prompt cache',
159          <Box flexDirection="column">
160            <Text>
161              <Text bold color={tint(cache.color, pal)}>
162                {`${cache.glyph} ${cache.label === 'expired' || cache.label === 'cold' ? cache.label : `${cache.label} left`}`}
163              </Text>
164              <Text dimColor>{` of ${TTL_MS / 3_600_000} hour`}</Text>
165            </Text>
166            {meter(cache.frac * 100, cache.color)}
167            <Text dimColor wrap="wrap">
168              {left !== null && left > 0 ? 'Refreshes after every answer.' : 'The next message re-writes the context at full price.'}
169            </Text>
170          </Box>,
171        )}
172
173        {section(
174          'Context',
175          <Box flexDirection="column">
176            <Text>
177              <Text bold>{usage.context.tokens === undefined ? 'no reading yet' : `${(usage.context.tokens / 1000).toFixed(1)}k tokens`}</Text>
178              {ctxPct !== undefined && <Text color={tint(heat(ctxPct), pal)}>{` · ${ctxPct}%`}</Text>}
179              <Text dimColor>{` of ${windowLabel(usage.context.window)}`}</Text>
180            </Text>
181            {ctxPct !== undefined && meter(ctxPct, heat(ctxPct))}
182            <Box>
183              {btn('compact', 'Compact', () => compactNow($, file), { hotkey: 'k', isMain: (ctxPct ?? 0) >= COMPACT_AT })}
184            </Box>
185          </Box>,
186        )}
187
188        {usage.rateLimits.length > 0 &&
189          section(
190            'Plan limits',
191            <Box flexDirection="column" gap={1}>
192              {usage.rateLimits.map(r => {
193                const reset = r.resetsAt ? Date.parse(r.resetsAt) - now : NaN
194                return (
195                  <Box key={r.kind} flexDirection="column">
196                    <Text>
197                      <Text>{LIMIT_NAMES[r.kind] ?? r.kind}</Text>
198                      <Text bold color={tint(heat(r.percentUsed), pal)}>{` ${Math.round(r.percentUsed)}%`}</Text>
199                      {Number.isFinite(reset) && <Text dimColor>{` · resets in ${until(reset)}`}</Text>}
200                    </Text>
201                    {meter(Math.min(100, r.percentUsed), heat(r.percentUsed))}
202                  </Box>
203                )
204              })}
205            </Box>,
206          )}
207
208        {section(
209          'This session',
210          <Box flexDirection="column">
211            {usage.cost !== undefined && <Text bold>{`$${usage.cost.usd.toFixed(2)}`}</Text>}
212            {isUp !== null && (
213              <Text>
214                <Text color={isUp ? pal.accent : pal.hot}>● </Text>
215                <Text dimColor>{`9router ${isUp ? 'running' : 'not answering'}`}</Text>
216              </Text>
217            )}
218          </Box>,
219        )}
220
221        {section(
222          'Next up',
223          <Box flexDirection="column" gap={1}>
224            {list.length === 0 ? (
225              <Text dimColor wrap="wrap">Nothing queued. Press What's next? and Claude will write the next three.</Text>
226            ) : (
227              <Box flexDirection="column">
228                {list.map((t, i) => (
229                  <Text key={`t${i}`} wrap="wrap" bold={i === 0} dimColor={i > 0}>
230                    {`${i + 1}  ${t}`}
231                  </Text>
232                ))}
233              </Box>
234            )}
235            <Box flexDirection="row" gap={2}>
236              {list[0] && btn('continue', 'Continue', () => void send($, continueText(list[0]!, file)), { hotkey: 'c', isMain: true })}
237              {btn('plan', "What's next?", () => void send($, planText(file)), { hotkey: 'n' })}
238            </Box>
239          </Box>,
240        )}
241
242        {section(
243          'Mods',
244          <Box flexDirection="column" gap={1}>
245            {MODS.map(m => (
246              <Box key={m.name} flexDirection="row" alignItems="center" gap={2}>
247                <Box flexGrow={1} flexShrink={1} flexDirection="column">
248                  <Text bold>{m.name}</Text>
249                  <Text dimColor wrap="truncate-end">{m.note}</Text>
250                </Box>
251                {m.actions.map(a => (
252                  btn(`${m.name}-${a.label}`, a.label, () => runMod($, a.command, a.args))
253                ))}
254              </Box>
255            ))}
256          </Box>,
257        )}
258
259        {shortcuts.length > 0 &&
260          section(
261            'Shortcuts',
262            <Box flexDirection="row" gap={2} flexWrap="wrap">
263              {shortcuts.map((q, i) => (
264                btn(`quick-${i}`, q.label, () => void send($, q.prompt), { hotkey: String(i + 1) })
265              ))}
266            </Box>,
267          )}
268
269        <Text dimColor wrap="wrap">
270          Tasks live in .claude/NEXT.md. /next adds one, /next done checks one off.
271        </Text>
272      </Box>
273    )
274  })
275}
276
hooks/shared.ts 104 lines
1import type { Quick } from '../types'
2
3// Everything the band and the panel both use: the values they draw, the project's files, the look.
4// Nothing here takes the engine `$` (the validator only lets `$` go into functions of the same file),
5// and the atoms are declared in each file that uses them (it must see them next to `read` and `update`).
6
7// ponytail: assumes the 1h prompt-cache TTL this session runs on; the engine exposes cache_ttl only on model switch
8export const TTL_MS = 60 * 60 * 1000
9export const COMPACT_AT = 70
10export const ROUTER_URL = 'http://127.0.0.1:20128/v1/models'
11export const PANEL = 'cockpit'
12export const PANEL_TITLE = 'Cockpit'
13export const GIT_PANEL = 'cockpit-git'
14
15export const DEFAULT_QUICK: [RegExp, Quick[]][] = [
16  [
17    /PUPSISPortal|IntPortal|intportal/,
18    [
19      { label: 'Rebuild', prompt: 'Rebuild the app and launch it so I can check the latest change.' },
20      { label: 'Simulator', prompt: 'Build and run the app in the iOS Simulator and open the live panel.' },
21      { label: 'Phone', prompt: 'Build and install the app on my real iPhone, following the phone notes in memory.' },
22    ],
23  ],
24  [
25    /Obsidian/,
26    [
27      { label: 'File → notes', prompt: "Run /file-to-notes on the newest PDF in this course's Resources that has no notes yet." },
28      { label: 'Export PDF', prompt: 'Run /export-to-pdf on the note I edited most recently.' },
29      { label: 'Assignment', prompt: "Run /assignment-notes on this course's newest assignment." },
30    ],
31  ],
32]
33
34// Worktrees share the main checkout's task list.
35export const projectRoot = (root: string) => root.replace(/\/\.claude\/worktrees\/[^/]+$/, '')
36export const nextFile = (root: string) => `${projectRoot(root)}/.claude/NEXT.md`
37
38export const openTasks = (text: string): string[] =>
39  text
40    .split('\n')
41    .filter(line => /^\s*- \[ \]/.test(line))
42    .map(line => line.replace(/^\s*- \[ \]\s*/, ''))
43
44export const firstOpen = (text: string) => openTasks(text)[0] ?? null
45
46export const continueText = (task: string, file: string) =>
47  `Continue with the next task: "${task}". When it is done, check it off in ${file} (- [x]) and add any follow-up tasks as "- [ ]" lines in order.`
48
49export const planText = (file: string) =>
50  `What's next? Read this project's plan, recent commits and open PRs, then write the next 3 concrete tasks as "- [ ]" lines at the top of ${file}. Don't start them.`
51
52// ---- the look ----------------------------------------------------------------------------------
53
54// Monokai on the dark theme, the same hues deepened enough to read on the light one.
55export type Palette = {
56  accent: string // Monokai green: brand, primary buttons, healthy
57  warn: string
58  hot: string
59  info: string
60  fg: string
61  muted: string
62  track: string // the empty part of a bar
63  bg?: string // the panel's own ground; the light theme keeps the app's
64}
65const DARK: Palette = { accent: '#a6e22e', warn: '#e6db74', hot: '#f92672', info: '#66d9ef', fg: '#f8f8f2', muted: '#75715e', track: '#3e3d32', bg: '#272822' }
66const LIGHT: Palette = { accent: '#4d7c0f', warn: '#8a6d00', hot: '#c2185b', info: '#0b7285', fg: '#272822', muted: '#75715e', track: '#e6e3dc' }
67export const palette = (isLight: boolean): Palette => (isLight ? LIGHT : DARK)
68export const isLightTheme = (rows: { key: string; value?: unknown }[]) =>
69  String(rows.find(r => r.key === 'theme')?.value ?? 'dark').includes('light')
70
71export type Tone = { color: 'accent' | 'warn' | 'hot'; glyph: string; label: string; frac: number }
72
73// Tone names become the palette's colours here, so the logic never knows a hex.
74export const tint = (c: Tone['color'] | undefined, p: Palette) => (c === undefined ? undefined : p[c])
75
76// A pie glyph for the share of the cache's hour that is left.
77export const pie = (frac: number) => (frac > 0.875 ? '●' : frac > 0.625 ? '◕' : frac > 0.375 ? '◑' : frac > 0.125 ? '◔' : '○')
78
79export function cacheTone(v: { isWorking: boolean; left: number | null }): Tone {
80  if (v.isWorking) return { color: 'accent', glyph: '✻', label: 'working', frac: 1 }
81  if (v.left === null) return { color: 'hot', glyph: '○', label: 'cold', frac: 0 }
82  if (v.left <= 0) return { color: 'hot', glyph: '○', label: 'expired', frac: 0 }
83  return {
84    color: v.left > 10 * 60_000 ? 'accent' : 'warn',
85    glyph: pie(v.left / TTL_MS),
86    label: `${Math.ceil(v.left / 60_000)}m`,
87    frac: v.left / TTL_MS,
88  }
89}
90
91// Plain until it matters.
92export const heat = (pct?: number): Tone['color'] | undefined => (pct === undefined ? undefined : pct >= 85 ? 'hot' : pct >= 60 ? 'warn' : undefined)
93
94// "2h 10m", "3d 4h", "40m": how long until a limit resets.
95export function until(ms: number): string {
96  const m = Math.max(0, Math.round(ms / 60_000))
97  if (m >= 2880) return `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h`
98  if (m >= 60) return `${Math.floor(m / 60)}h ${m % 60}m`
99  return `${m}m`
100}
101
102// The mark: a prompt in an 8x8 bitmap, drawn as square cells (rows from 6 down are the cursor line).
103export const MARK = ['#.......', '.#......', '..#.....', '.#......', '#.......', '........', '....####', '........']
104
hooks/gitparse.ts 94 lines
1import type { GitInfo, Pr, Worktree } from '../types'
2
3// Pure readers of what git and gh print, so they can be tested without a repository.
4
5export function parseStatus(text: string) {
6  const lines = text.split('\n').filter(Boolean)
7  const head = lines[0]?.startsWith('## ') ? lines[0].slice(3) : ''
8  const branch = head.split('...')[0]!.replace(/^No commits yet on /, '').replace(/^HEAD \(no branch\)/, 'detached HEAD')
9  const hasUpstream = head.includes('...')
10  const ahead = Number(/ahead (\d+)/.exec(head)?.[1] ?? 0)
11  const behind = Number(/behind (\d+)/.exec(head)?.[1] ?? 0)
12  let staged = 0
13  let changed = 0
14  let untracked = 0
15  for (const l of lines.slice(1)) {
16    if (l.startsWith('??')) untracked++
17    else {
18      if (l[0] !== ' ') staged++
19      if (l[1] !== ' ') changed++
20    }
21  }
22  return { branch, hasUpstream, ahead, behind, staged, changed, untracked }
23}
24
25export function parseWorktrees(text: string): Worktree[] {
26  return text
27    .split('\n\n')
28    .map(b => b.trim())
29    .filter(Boolean)
30    .map((b, i) => ({
31      path: /^worktree (.+)$/m.exec(b)?.[1] ?? '',
32      branch: /^branch refs\/heads\/(.+)$/m.exec(b)?.[1] ?? null,
33      isMain: i === 0,
34    }))
35}
36
37export const parseLines = (text: string) => text.split('\n').map(l => l.trim()).filter(Boolean)
38
39type Check = { conclusion?: string | null; status?: string | null; state?: string | null }
40function rollup(checks: Check[] | undefined): Pr['checks'] {
41  if (!checks || checks.length === 0) return 'none'
42  const bad = ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED']
43  if (checks.some(c => bad.includes(c.conclusion ?? '') || bad.includes(c.state ?? ''))) return 'fail'
44  if (checks.some(c => (c.status && c.status !== 'COMPLETED') || c.state === 'PENDING' || c.state === 'EXPECTED')) return 'pending'
45  return 'pass'
46}
47
48export function parsePrs(json: string): Pr[] {
49  const rows = JSON.parse(json) as {
50    number: number
51    title: string
52    headRefName: string
53    baseRefName: string
54    isDraft: boolean
55    statusCheckRollup?: Check[]
56  }[]
57  return rows.map(r => ({
58    number: r.number,
59    title: r.title,
60    head: r.headRefName,
61    base: r.baseRefName,
62    isDraft: r.isDraft,
63    checks: rollup(r.statusCheckRollup),
64  }))
65}
66
67// Stacks first-to-merge on top: a PR whose base is another open PR's head sits under that PR, indented.
68export function orderPrs(prs: Pr[]): { pr: Pr; depth: number }[] {
69  const heads = new Set(prs.map(p => p.head))
70  const out: { pr: Pr; depth: number }[] = []
71  const place = (pr: Pr, depth: number, seen: Set<number>) => {
72    if (seen.has(pr.number)) return
73    seen.add(pr.number)
74    out.push({ pr, depth })
75    for (const child of prs.filter(c => c.base === pr.head)) place(child, depth + 1, seen)
76  }
77  const seen = new Set<number>()
78  for (const pr of prs.filter(p => !heads.has(p.base))) place(pr, 0, seen)
79  for (const pr of prs) place(pr, 0, seen) // anything in a cycle
80  return out
81}
82
83// What is safe to remove: other worktrees and local branches whose work already landed on the base.
84export function tidyPlan(g: Pick<GitInfo, 'branch' | 'base' | 'worktrees' | 'merged' | 'mergedHeads'>) {
85  const landed = new Set([...g.merged, ...g.mergedHeads])
86  const keep = new Set([g.branch, g.base, 'main', 'master', 'develop'])
87  const worktrees = g.worktrees.filter(w => !w.isMain && w.branch && !keep.has(w.branch) && landed.has(w.branch))
88  const inWorktree = new Set(worktrees.map(w => w.branch))
89  const branches = g.merged.filter(b => !keep.has(b) && !inWorktree.has(b))
90  return { worktrees, branches }
91}
92
93export const repoName = (root: string) => root.replace(/\/\.claude\/worktrees\/[^/]+$/, '').split('/').pop() ?? root
94
types/index.d.ts 45 lines
1export type NextTask = string | null
2export type Quick = { label: string; prompt: string }
3
4export type Worktree = { path: string; branch: string | null; isMain: boolean }
5export type Pr = {
6  number: number
7  title: string
8  head: string
9  base: string
10  isDraft: boolean
11  checks: 'pass' | 'fail' | 'pending' | 'none'
12}
13export type GitInfo = {
14  root: string
15  branch: string
16  base: string
17  ahead: number
18  behind: number
19  hasUpstream: boolean
20  staged: number
21  changed: number
22  untracked: number
23  worktrees: Worktree[]
24  merged: string[] // local branches already merged into the base
25  prs: Pr[] | null // null: gh is missing or not signed in
26  mergedHeads: string[] // heads of my recently merged PRs
27  at: number
28  ghAt: number
29  error: string | null
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    'cockpit': {
35      lastAt: number | null
36      tick: number
37      next: NextTask
38      tasks: string[]
39      router: boolean | null
40      quick: Quick[]
41      git: GitInfo | null
42    }
43  }
44}
45