Band above the prompt that watches context fill and saves a HANDOFF.md before you lose your place.

Lawrence Chase's Claude Code marketplace: code review, diagram, and test-evidence skills, plus a set of small Claude Code mods that add band lines and panes to the terminal UI.
/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase
/plugin install test-drive@lchase
/plugin install cache-clock@lchase
/plugin install usage-gauge@lchase
/plugin install auto-handoff@lchase
/plugin install agent-watch@lchase
/plugin install recording-mode@lchase
Each plugin is independent; install only what you want. @lchase is the marketplace name (from .claude-plugin/marketplace.json), not the GitHub slug.
New versions ship whenever the version string in a plugin's manifest changes. Claude Code refreshes marketplaces in the background by default, so you normally pick up updates without doing anything. To pull them right now:
/plugin marketplace update lchase # refresh this marketplace's manifest
/plugin update smart-review@lchase # apply a pending update to one plugin
/plugin update tldraw@lchase
/plugin marketplace update (no name) refreshes every marketplace. From a shell: claude plugin marketplace update lchase and claude plugin update <plugin>@lchase.
If a plugin gets into a bad state (stale cached script, half-applied update), do a clean reinstall:
/plugin uninstall tldraw
/plugin marketplace update lchase
/plugin install tldraw@lchase
Nuclear option, re-register the whole marketplace:
/plugin marketplace remove lchase
/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase
Before this repo became a multi-plugin marketplace it published one plugin from the repo root under a marketplace named chase. If you installed it back then, clear the old registration first:
/plugin uninstall smart-review
/plugin uninstall tldraw # only if you installed the short-lived tldraw@chase
/plugin marketplace remove chase
/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase
The tldraw skill's command also changed: /tldraw:tldraw → /tldraw:diagram.
| Plugin | What it does | Skills / commands | Harnesses |
|---|---|---|---|
| smart-review | Ensemble code review: six specialized lenses over a diff, merged into one deduplicated, severity-ranked verdict | /smart-review:{review,min,max,add-pr-review,review-pr-comments} + auto-trigger | Claude Code, Cursor, Codex, Gemini CLI, any AGENTS.md agent |
| tldraw | Natural-language description → editable tldraw document (.tldr) + rendered PNG/SVG | /tldraw:diagram + auto-trigger | Claude Code only (needs Node + npx) |
| test-drive | Before/after evidence of a UI or API change: record the app before you edit, replay the same scripted scenario after, with a deterministic verdict | /test-drive:{baseline,compare} + auto-trigger | Claude Code only (needs Node; UI also needs Playwright and ffmpeg) |
| cache-clock | Mod: band line showing whether the prompt cache is warm, with countdown, reuse %, cached tokens, and estimated cold-rewrite cost | Compact button, /cache-clock | Claude Code only |
| usage-gauge | Mod: band line with plan usage windows (5-hour, weekly, spend limit), reset times, pace warning, session cost on API billing, threshold toasts | /usage-gauge (detail pane) | Claude Code only |
| auto-handoff | Mod: band line showing how full the chat is; saves a HANDOFF.md at a threshold (auto or on request), then clear and continue or resume in a new session | /auto-handoff | Claude Code only |
| agent-watch | Mod: band line showing who is doing the work this turn (main chat, helper agents, models, tasks, status, skills), plus a pane with per-agent blocks, per-model totals, timeline | /agent-watch | Claude Code only |
| recording-mode | Mod: screen-record without leaking secrets; tells the model to keep keys, emails, private words off screen and masks them in the transcript display only, red REC chip | /rec | Claude Code only |
An ensemble code review: several specialized lenses, one merged verdict, not another pile of comments.
Most code review, human or AI, is one reviewer making a single pass and leaving a list of comments. This runs six specialized lenses instead (spec-conformance, correctness, security, performance, design, and tests), then merges the results into one verdict: duplicates collapsed, findings that several lenses independently raise weighted up, conflicting advice reconciled, severity rolled up, structural problems first, nits capped. The merge is the point. An ensemble without it is just louder, and a longer checklist handed to one reviewer only dilutes its attention and repeats its own misses.
Two modes: a fast single pass (min) for tight loops, and an orchestrated ensemble (max) for pre-merge review. Auto-routing picks between them by change size and sensitivity.
The skill lives in plugins/smart-review/skills/smart-review/ as harness-neutral markdown. Each harness gets a thin manifest pointing at that same directory, so no skill content is copied or forked.
| Harness | Install | min | max | Slash commands / auto-trigger |
|---|---|---|---|---|
| Claude Code | /plugin marketplace add lchase/skills then /plugin install smart-review@lchase | ✅ | ✅ isolated parallel subagents | ✅ |
| Cursor | point Cursor at plugins/smart-review/.cursor-plugin/ | ✅ | ✅ sequential ensemble | via session-start hook |
| Codex | plugins/smart-review/.codex-plugin/ manifest | ✅ | ✅ sequential ensemble | no |
| Gemini CLI | gemini extensions install https://github.com/lchase/skills (reads root gemini-extension.json) | ✅ | ✅ sequential ensemble | via GEMINI.md |
| Any AGENTS.md agent | reads root AGENTS.md | ✅ | ✅ sequential ensemble | no |
max's isolated parallel subagents are a Claude Code capability. A model panel (plugins/smart-review/skills/smart-review/references/ensemble.md, off by default) rides on top when the harness can route more than one model. Otherwise max is single-model. Elsewhere max runs the same spec-gate, six lenses, and merge as a disciplined sequential walk. Design and internals: plugins/smart-review/SMART-REVIEW.md.
Claude Code slash commands:
/smart-review:review: auto (routes to min or max by size + sensitivity)/smart-review:min: fast single-pass review/smart-review:max: orchestrated ensemble review/smart-review:add-pr-review <PR number or URL>: reviews a GitHub PR and, after you confirm what to publish, posts the findings as PR comments/smart-review:review-pr-comments <PR number or URL>: triages a PR's existing unresolved review comments, fixes or explains each after your approval, replies, and resolves the threadThe skill also triggers automatically when you ask Claude to review a diff, PR, or branch.
Turn a described diagram (flowchart, architecture / system diagram, process map, decision tree, state machine) into two artifacts: an editable .tldr document (opens at tldraw.com or in the tldraw editor) and a rendered PNG/SVG for docs and PRs.
Claude writes a {nodes, edges} spec from your description; a bundled Node script does a simple layered auto-layout, emits the .tldr, and renders images by loading it into a headless-Chromium tldraw instance via @kitschpatrol/tldraw-cli (first run downloads Chromium, ~1 min, cached).
/plugin install tldraw@lchase
Claude Code only. Skill: /tldraw:diagram (also auto-triggers on "draw this diagram", "diagram this flow", "make a tldraw of…"). Details: plugins/tldraw/skills/diagram/SKILL.md.
A test drive is an e2e test that happens to produce video, and the "before" is part of the process rather than something reconstructed later. Run /test-drive:baseline before you edit: it writes a short browser scenario and records the app as it stands. Make the change, then run /test-drive:compare: it replays the identical scenario with Playwright and composes a labeled side-by-side MP4. The pass/warn/fail verdict comes from the scenario's assertions, not from reading the video.
Backend changes work the same way with "kind": "api": the scenario is a list of HTTP steps (method, path, body, expected status, JSON fields, and server-log lines to expect or avoid). There is nothing to screen-record, so each step captures the request, the response, and the server log lines it produced, and compare writes a before/after report.md instead of a video. Secrets are redacted from the stored transcript (best effort, extendable per scenario with redact), and the finished report is scanned and flagged if anything still looks secret.
/plugin install test-drive@lchase
Claude Code only. UI scenarios need ffmpeg and Playwright in the repo under test; API scenarios need only Node 18+. Only the working tree is ever run, so there is no second checkout to boot. Details: baseline and compare.
Five small plugins that change Claude Code's own interface instead of adding a task skill. Each draws a band line above the prompt (some add a side pane or slash command), is independent of the others, and works on Claude Code only.
cacheMinutes (5 or 60, default 60) to match your cache TTL./usage-gauge opens a detail pane.HANDOFF.md at a threshold (automatically or on request), then clears and continues, or resumes in a new session. /auto-handoff./agent-watch opens a pane with per-agent blocks, per-model totals, and a timeline./rec to toggle./plugin install cache-clock@lchase # or usage-gauge, auto-handoff, agent-watch, recording-mode
.claude-plugin/marketplace.json # the "lchase" marketplace: 8 plugin entries
AGENTS.md # repo-level agent pointer (→ smart-review)
gemini-extension.json + GEMINI.md # Gemini CLI installs the repo as an extension (→ smart-review)
plugins/smart-review/ # plugin 1, multi-harness code review
.claude-plugin/plugin.json
skills/smart-review/ # the harness-neutral core (SKILL.md + references/)
agents/ commands/ hooks/ # Claude Code packaging
.cursor-plugin/ .codex-plugin/ # thin per-harness manifests
scripts/ # validate-adapters.sh, bump-version.sh
evals/ # dev-only eval kit (not shipped to installers)
SMART-REVIEW.md # design writeup
plugins/tldraw/ # plugin 2, Claude Code only
.claude-plugin/plugin.json
skills/diagram/ # SKILL.md + scripts/build-tldr.mjs + references/
plugins/test-drive/ # plugin 3, Claude Code only
.claude-plugin/plugin.json
skills/{baseline,compare}/ # SKILL.md each
scripts/{lib.mjs,record.mjs,record-api.mjs,redact.mjs,verdict.mjs,compose.sh,report.mjs} # shared by both skills
references/ # UI and API scenario schemas + examples
plugins/{cache-clock,usage-gauge,auto-handoff,agent-watch,recording-mode}/ # plugins 4-8, Claude Code mods
.claude-plugin/plugin.json # each with its own version
hooks/ # register.tsx (the mod) + hooks.json, tests where present
types/ # mod API typings
claude plugin validate . # marketplace + all plugins
./plugins/smart-review/scripts/validate-adapters.sh # smart-review's per-harness manifests
Releasing: users only get an update when a plugin's version string changes, so bump it on every user-facing change and push to main.
./plugins/smart-review/scripts/bump-version.sh <version>..claude-plugin/plugin.json.Once pushed, installed clients pick it up on their next background marketplace refresh, or immediately with /plugin marketplace update lchase.
hooks/register.tsx 361 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Band } from '../types'
5
6type Mode = 'auto' | 'ask'
7type Settings = { on: boolean; mode: Mode; threshold: number; hidden: boolean }
8
9const DEFAULTS: Settings = { on: true, mode: 'ask', threshold: 80, hidden: false }
10const FRESH_MS = 10 * 60 * 1000
11const FILE = 'HANDOFF.md'
12
13const CHIP_BG = '#3b2160'
14const BRANCH_COLOR = '#b794f4'
15const BRANCH_MAX = 24
16const CHIP_FG = '#b8aece'
17const GREEN = '#4ade80'
18const AMBER = '#fbbf24'
19const RED = '#f87171'
20
21const FALLBACK_SAVE =
22 `Write a handoff for this session to ${FILE} in the current folder, so a fresh chat can continue without me. ` +
23 'Use these sections: Objective, Done / In progress / Next, Decisions and why, Open questions, Exact next step. ' +
24 'Be concrete (file paths, commands, names). Write the file now, then reply with one line.'
25const FALLBACK_RESUME =
26 `Read ${FILE} in the current folder and continue the work from its "Exact next step". ` +
27 'Summarise the objective back to me in one line first.'
28
29const IDLE: Band = { percent: null, phase: 'idle', savedAt: null, seen: 0, from: null }
30const band = atom({ plugin: 'auto-handoff', key: 'band' } as const, IDLE)
31
32const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
33
34export const ago = (ms: number): string => {
35 const mins = Math.floor(Math.max(0, ms) / 60000)
36 if (mins < 1) return 'just now'
37 if (mins < 60) return `${mins}m ago`
38 const hours = Math.floor(mins / 60)
39 if (hours < 24) return `${hours}h ago`
40 return `${Math.floor(hours / 24)}d ago`
41}
42
43const hhmm = (ms: number): string => {
44 const d = new Date(ms)
45 const p = (n: number) => String(n).padStart(2, '0')
46 return `${p(d.getHours())}:${p(d.getMinutes())}`
47}
48
49export const meter = (percent: number): string => {
50 const filled = clamp(Math.round((percent / 100) * 6), 0, 6)
51 return '▰'.repeat(filled) + '▱'.repeat(6 - filled)
52}
53
54export const objectiveOf = (text: string): string => {
55 const lines = text.split('\n')
56 const at = lines.findIndex(l => /objective/i.test(l))
57 if (at < 0) return ''
58 const inline = lines[at]!.replace(/^[#*\s>-]*objective[:*\s]*/i, '').trim()
59 const raw = inline || lines.slice(at + 1).find(l => l.trim() !== '' && !/^#/.test(l.trim())) || ''
60 const clean = raw.replace(/^[\s>*-]+/, '').replace(/[*_`]/g, '').trim()
61 return clean.length > 60 ? `${clean.slice(0, 59)}…` : clean
62}
63
64async function load($: EngineInterface): Promise<Settings> {
65 const raw = (await $.store.get('settings')) as Partial<Settings> | undefined
66 const s = { ...DEFAULTS, ...(raw && typeof raw === 'object' ? raw : {}) }
67 s.threshold = clamp(Math.round(Number(s.threshold) || DEFAULTS.threshold), 10, 99)
68 s.mode = s.mode === 'auto' ? 'auto' : 'ask'
69 return s
70}
71
72async function save($: EngineInterface, s: Settings) {
73 await $.store.set('settings', s)
74 $.ui.invalidate('ui.render')
75}
76
77// control chars stripped so a crafted branch name cannot inject terminal escapes
78export const branchFromHead = (head: string): string =>
79 (head.match(/^ref: refs\/heads\/(.+)$/m)?.[1]?.trim() ?? head.trim().slice(0, 7)).replace(/[\x00-\x1f\x7f-\x9f]/g, '')
80
81// a .git pointer file is untrusted: follow it only to a local worktree/submodule gitdir, never a UNC or network path (any two leading slashes, either kind, are UNC on Windows)
82export const isSafeGitdir = (p: string): boolean => !/^[\\/]{2}/.test(p) && /^(\/|[A-Za-z]:[\\/])/.test(p) && /[\\/]\.git[\\/](worktrees|modules)[\\/]/.test(p)
83
84// ponytail: reads .git/HEAD from the cwd up (5 levels), no git subprocess. Worktrees follow the .git pointer file.
85async function readBranch($: EngineInterface): Promise<string> {
86 let dir = '.'
87 for (let i = 0; i < 5; i++, dir += '/..') {
88 const head = await $.fs.read(`${dir}/.git/HEAD`).catch(() => '')
89 if (head) return branchFromHead(head)
90 const ptr = ((await $.fs.read(`${dir}/.git`).catch(() => '')) as string).match(/^gitdir: (.+)$/m)?.[1]
91 if (ptr && isSafeGitdir(ptr.trim())) return branchFromHead(await $.fs.read(`${ptr.trim()}/HEAD`).catch(() => ''))
92 }
93 return ''
94}
95
96async function mtimeOf($: EngineInterface): Promise<number | undefined> {
97 const stat = await $.fs.stat(FILE).catch(() => undefined)
98 return stat ? (stat.mtimeMs as number) : undefined
99}
100
101async function hasCommand($: EngineInterface, name: string): Promise<boolean> {
102 return (await $.command.list()).some((c: { name: string }) => c.name === name)
103}
104
105async function runSave($: EngineInterface) {
106 try {
107 if (await hasCommand($, 'handoff')) await $.command.run({ command: 'handoff' })
108 else await $.prompt.submit({ text: FALLBACK_SAVE })
109 } catch (err) {
110 $.ui.toast(`auto-handoff: could not save (${(err as Error).message})`)
111 }
112}
113
114async function runResume($: EngineInterface) {
115 try {
116 if (await hasCommand($, 'handoff')) await $.command.run({ command: 'handoff', args: 'resume' })
117 else await $.prompt.submit({ text: FALLBACK_RESUME })
118 } catch (err) {
119 $.ui.toast(`auto-handoff: could not resume (${(err as Error).message})`)
120 }
121}
122
123// Slash commands run from a timer, never inside the hook the turn waits on.
124function later($: EngineInterface, fn: () => Promise<void>) {
125 $.clock.after(50, () => void fn())
126}
127
128async function startSave($: EngineInterface, why: 'threshold' | 'manual') {
129 await update($, band, (b): Band => ({ ...b, phase: 'saving', from: null }))
130 if (why === 'manual') $.ui.toast('Saving a handoff')
131 later($, () => runSave($))
132}
133
134async function clearAndContinue($: EngineInterface) {
135 await $.store.set('resumeAfterClear', true)
136 later($, async () => {
137 try {
138 await $.command.run({ command: 'clear' })
139 } catch (err) {
140 await $.store.delete('resumeAfterClear')
141 $.ui.toast(`auto-handoff: /clear failed (${(err as Error).message})`)
142 return
143 }
144 // A real /clear fires session.start, which consumes the flag. If it did not, finish here.
145 await $.clock.sleep(1500)
146 if ((await $.store.get('resumeAfterClear')) === true) {
147 await $.store.delete('resumeAfterClear')
148 await runResume($)
149 }
150 })
151}
152
153async function warnStale($: EngineInterface) {
154 const s = await load($)
155 const mtime = (await mtimeOf($)) ?? ((await $.store.get('lastHandoffAt')) as number | undefined)
156 const now = await $.clock.now()
157 const isFresh = mtime !== undefined && now - mtime < FRESH_MS
158 if (s.on && !isFresh) $.ui.toast('Compacting without a fresh handoff')
159}
160
161async function detectSaved($: EngineInterface) {
162 const mtime = await mtimeOf($)
163 if (mtime === undefined) return
164 const b = await read($, band)
165 if (mtime > b.seen && b.phase !== 'saved') {
166 await $.store.set('lastHandoffAt', mtime)
167 await update($, band, (cur): Band => ({ ...cur, phase: 'saved', savedAt: mtime, seen: mtime }))
168 }
169}
170
171export const register: Register = on => {
172 on('session.start', async ($, e, next) => {
173 await $.command.register({
174 name: 'auto-handoff',
175 description: 'Context watcher: on|off|now|resume|threshold <n>|mode auto|ask|hide|show|status',
176 argumentHint: 'on|off|now|resume|threshold <n>|mode auto|ask|hide|show|status',
177 })
178
179 const mtime = await mtimeOf($)
180 const now = await $.clock.now()
181 const resumeFlag = (await $.store.get('resumeAfterClear')) === true
182
183 // Load-time baseline: a file already there is "seen", not "just saved".
184 await update($, band, () => ({
185 ...IDLE,
186 seen: mtime ?? 0,
187 from: mtime !== undefined && !resumeFlag ? { at: mtime, objective: '' } : null,
188 }))
189
190 if (resumeFlag) {
191 await $.store.delete('resumeAfterClear')
192 later($, () => runResume($))
193 } else if (mtime !== undefined) {
194 const text = await $.fs.read(FILE).catch(() => '')
195 const objective = objectiveOf(text)
196 await update($, band, b => ({ ...b, from: { at: mtime, objective } }))
197 const s = await load($)
198 if (s.on) $.ui.toast(`Handoff from ${ago(now - mtime)} available, /handoff resume`)
199 }
200
201 $.clock.every(4000, () => void detectSaved($))
202
203 return next(e)
204 })
205
206 on('prompt.submit', async ($, e, next) => {
207 await update($, band, cur => (cur.from === null ? cur : { ...cur, from: null })).catch(() => undefined)
208 return next(e)
209 })
210
211 on('turn.complete', async ($, e, next) => {
212 if (e.agentId === undefined) await detectSaved($)
213 return next(e)
214 })
215
216 on('session.measure', async ($, e, next) => {
217 const percent = e.context.percent
218 if (typeof percent === 'number') {
219 const s = await load($)
220 const b = await read($, band)
221 const below = percent < s.threshold
222
223 if (b.percent !== percent) await update($, band, cur => ({ ...cur, percent }))
224
225 if (s.on && !below && b.phase === 'idle') {
226 $.ui.toast(`Chat is ${s.threshold}% full, saving a handoff`)
227 if (s.mode === 'auto') await startSave($, 'threshold')
228 else await update($, band, (cur): Band => ({ ...cur, phase: 'ask' }))
229 } else if (below && (b.phase === 'ask' || b.phase === 'saved')) {
230 // Context shrank (compact or clear): arm again.
231 await update($, band, (cur): Band => ({ ...cur, phase: 'idle' }))
232 }
233 }
234 return next(e)
235 })
236
237 on('session.compact', async ($, e, next) => {
238 if (e.agentId === undefined && e.trigger !== 'precompute') {
239 await warnStale($).catch(() => undefined)
240 }
241 return next(e)
242 })
243
244 on('command.run', { command: 'auto-handoff' }, async ($, e) => {
245 const [verb = 'status', arg] = e.args.trim().split(/\s+/)
246 const s = await load($)
247 const b = await read($, band)
248 const line = `auto-handoff ${s.on ? 'on' : 'off'}, mode ${s.mode}, threshold ${s.threshold}%, band ${s.hidden ? 'hidden' : 'shown'}, context ${b.percent ?? '?'}%`
249
250 switch (verb.toLowerCase()) {
251 case 'on':
252 case 'off':
253 await save($, { ...s, on: verb.toLowerCase() === 'on' })
254 return { text: `auto-handoff ${verb.toLowerCase()}` }
255 case 'hide':
256 case 'show':
257 await save($, { ...s, hidden: verb.toLowerCase() === 'hide' })
258 return { text: `band ${verb.toLowerCase() === 'hide' ? 'hidden' : 'shown'}` }
259 case 'mode':
260 if (arg !== 'auto' && arg !== 'ask') return { text: 'usage: /auto-handoff mode auto|ask' }
261 await save($, { ...s, mode: arg })
262 return { text: `mode ${arg}` }
263 case 'threshold': {
264 const n = Number(arg)
265 if (!Number.isFinite(n) || n < 10 || n > 99) return { text: 'usage: /auto-handoff threshold <10-99>' }
266 await save($, { ...s, threshold: Math.round(n) })
267 return { text: `threshold ${Math.round(n)}%` }
268 }
269 case 'now':
270 await startSave($, 'manual')
271 return { text: 'saving a handoff' }
272 case 'resume':
273 later($, () => runResume($))
274 return { text: 'resuming from HANDOFF.md' }
275 case 'status':
276 return { text: line }
277 default:
278 return { text: `${line}\nusage: /auto-handoff on|off|now|resume|threshold <n>|mode auto|ask|hide|show|status` }
279 }
280 })
281
282 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
283 const below = await next(e)
284 const s = await load($)
285 const b = await read($, band)
286
287 if (e.props.hasSurvey || !s.on || s.hidden) return below
288
289 const { Box, Text, Button } = $.ui.resolve(e)
290 const now = await $.clock.now()
291
292 const chip = (
293 <Text bold color={CHIP_FG} backgroundColor={CHIP_BG}>
294 {' HANDOFF ⇄ '}
295 </Text>
296 )
297
298 let body
299 if (b.phase === 'saved' && b.savedAt !== null) {
300 body = (
301 <Box>
302 <Text color={GREEN}>{`✓ saved ${hhmm(b.savedAt)} `}</Text>
303 <Button key="clear" plain hotkey="c" label="clear & continue" onPress={() => clearAndContinue($)} />
304 </Box>
305 )
306 } else if (b.phase === 'idle' && b.from !== null && b.percent !== null && b.percent < s.threshold && b.from.at > 0) {
307 const what = b.from.objective === '' ? 'handoff' : b.from.objective
308 body = (
309 <Box>
310 <Text>{`from ${ago(now - b.from.at)}: ${what} `}</Text>
311 <Button
312 key="resume"
313 plain
314 hotkey="r"
315 label="resume"
316 onPress={() => {
317 later($, () => runResume($))
318 return update($, band, cur => ({ ...cur, from: null }))
319 }}
320 />
321 </Box>
322 )
323 } else {
324 const percent = b.percent ?? 0
325 const isHot = b.phase === 'ask' || b.phase === 'saving' || percent >= s.threshold
326 const color = isHot ? RED : percent >= 60 ? AMBER : GREEN
327 body = (
328 <Box>
329 <Text color={color}>{`ctx ${meter(percent)} ${Math.round(percent)}% `}</Text>
330 <Text dimColor>{b.phase === 'saving' ? '· saving handoff… ' : `· ${s.mode === 'auto' ? 'auto-saves' : 'saves'} at ${s.threshold}% `}</Text>
331 {b.phase !== 'saving' && (
332 <Button key="save" plain hotkey="h" label="save now" onPress={() => startSave($, 'manual')} />
333 )}
334 </Box>
335 )
336 }
337
338 const branch = await readBranch($)
339 const isDefault = branch === 'main' || branch === 'master'
340 const shown = branch.length > BRANCH_MAX ? `${branch.slice(0, BRANCH_MAX - 1)}…` : branch
341
342 const line = (
343 <Box>
344 {chip}
345 <Text> </Text>
346 {body}
347 {branch !== '' && (isDefault ? <Text dimColor>{` ⎇ ${shown}`}</Text> : <Text color={BRANCH_COLOR}>{` ⎇ ${shown}`}</Text>)}
348 </Box>
349 )
350
351 return below ? (
352 <Box flexDirection="column">
353 {below}
354 {line}
355 </Box>
356 ) : (
357 line
358 )
359 })
360}
361types/index.d.ts 16 lines1export type Phase = 'idle' | 'ask' | 'saving' | 'saved'
2
3export type Band = {
4 percent: number | null
5 phase: Phase
6 savedAt: number | null
7 seen: number
8 from: { at: number; objective: string } | null
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'auto-handoff': { band: Band }
14 }
15}
16