A progress bar per session: a cheap model estimates how far the project is, paused while you are away, when the bar shows the context left instead.

A Claude Code mod that shows how far each session's project has come, without opening the session.
Every minute a small, cheap model (Haiku by default) reads a short digest of the session and estimates the share of the goal that is done. When you step away from the computer, estimates pause and the progress gives way to how full the context window is, so no tokens are spent while nobody is watching.

In the sidebar (Claude desktop app): the start of each session's title says what the session is doing.
[◑ 60%] Share card clipping ← working: the pie fills in quarters (○ ◔ ◑ ◕ ●)
[ctx 25%] API refactor ← working while you are away: how full the context is
[❓] Docs site search ← waiting for your answer
[✅] Blog migration ← done
[⏹️] Data import ← stopped by you
[⚠️] Deploy script ← stopped on an error
[❓] shows only when Claude actually asked you something: its wrap-up put a question to you, or its last words end in a question mark./progress title off puts it back in the title. Rename a session and the mod takes the new name.Above the prompt: two rows, the work's progress and how full the context is.
████████░░░░ Fix the clipping on the share card · 62% · now: running the checkout tests · 3/5 steps · left: tests and deploy · ⏱ 1h 20m
███████░░░░░ context 59% · 118k of 200k · 5h limit 34% · estimator today 12k tokens
now: is the task list's step in progress, or the model's words for what Claude is doing. Once the turn ends, the row starts with the session's status (❓ waiting for your answer, ✅ done, ...).The projects pane: /progress (or the Projects button above the prompt) opens a pane that lists every session running the mod, each with the same two rows: what needs you first, then what is working.
The pane and the window show only current work:
A line at the bottom says how many projects are hidden.
The projects window: /progress window (or the Window button above the prompt, or Open in a window in the pane) opens every project in a window of its own, outside any session: a Chrome app window, or your default browser when Chrome is not there. It is a local page (~/.claude/project-progress/dashboard.html) that every running session rewrites as it checks in, and it reloads itself every 10 seconds.
<img src="docs/window.png" width="540" alt="The projects window: every session with its progress and context bars, what needs you first">
3/5 steps) and the step in progress are read straight from Claude's task list and cost no tokens.At the prompt of a Claude Code terminal session:
/plugin install project-progress --marketplace apada-appleid/claude-project-progress
From a shell:
claude plugin install project-progress --marketplace apada-appleid/claude-project-progress
In the Claude desktop app: click + next to the prompt box, then Plugins, Add marketplace with apada-appleid/claude-project-progress, then Add plugin.
New sessions load the mod by themselves. In a session that is already open, run /reload-plugins.
| Command | What it does |
|---|---|
/progress | Open the projects pane |
/progress window | Open every project in a window of its own |
/progress now | Estimate right away, even if nothing changed |
/progress title off | Remove the bar from this session's title |
/progress title on | Put it back |
Set them with /plugin configure project-progress@apada, or in settings.json under pluginConfigs.
| Option | Default | Meaning |
|---|---|---|
idleMinutes | 3 | Minutes without mouse or keyboard input before estimates pause |
intervalSeconds | 60 | How often the session is checked |
model | haiku | The estimator model: an alias or a full model id |
renameTitles | true | Put the bar at the start of the session title |
dailyTokenCap | 400000 | Tokens the estimator may use per session per day |
hideDoneAfterMinutes | 15 | Minutes after a finished project was seen before the pane and window leave it out |
HIDIdleTime through ioreg on macOS. Elsewhere the mod falls back to the time since your last prompt in any session.~/.claude/project-progress/sessions/ so the projects pane can show every session.hooks/register.tsx 591 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Snapshot, Status, TitleRecord } from '../types'
5import type { Estimate } from './estimate'
6import { digest, endedStatus, fingerprint, hasWork, parseEstimate, SYSTEM } from './estimate'
7import { runningShells } from './background'
8import { dashboardHtml, hiddenText, listed, parseSnapshot, STALE_MS } from './dashboard'
9import { BAR_CELLS, BAR_PX, cells, ctxColor, ctxUsed, fit, GREEN, progressParts, svgBar, usageParts } from './line'
10import { planItems, planSteps } from './plan'
11import { parseIdleMs } from './presence'
12import { cutName, prefixFor, SHORT_AT, stripPrefix } from './title'
13
14const snapshot = atom({ plugin: 'project-progress', key: 'snapshot' } as const, null)
15const titleRecord = atom({ plugin: 'project-progress', key: 'titleRecord' } as const, null)
16const tick = atom({ plugin: 'project-progress', key: 'tick' } as const, 0)
17
18const PANE = 'projects'
19const PANE_TITLE = 'Project progress'
20const WEEK_MS = 7 * 24 * 3600_000
21
22// 'quiet' never estimates, 'tick' when something changed, 'force' always, and 'final', once a turn
23// ends, always (away or not) and reads from it why the turn stopped.
24type Mode = 'quiet' | 'tick' | 'force' | 'final'
25
26const RANK: Readonly<Record<Mode, number>> = { quiet: 0, tick: 1, force: 2, final: 3 }
27
28// What session.start builds over its own $, for the timer and the other hooks to call.
29type Session = {
30 refresh: (mode: Mode) => Promise<void>
31 clearTitle: () => Promise<void>
32 openWindow: () => Promise<string>
33}
34
35// The session's title as read back: the app's id and title, the mod's record, and the full name.
36type TitleRead = { id: string; title: string; record: TitleRecord | null; base: string }
37
38const textOf = (blocks: readonly { text?: string }[]): string => blocks.map(block => block.text ?? '').join('')
39
40const basename = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
41
42const fileName = (id: string): string => id.replace(/[^\w-]/g, '_')
43
44export const register: Register = (on, options) => {
45 const awayMs = Number(options.idleMinutes ?? 3) * 60_000
46 const intervalMs = Math.max(15, Number(options.intervalSeconds ?? 60)) * 1000
47 const model = String(options.model ?? 'haiku')
48 const tokenCap = Number(options.dailyTokenCap ?? 400_000)
49 const hideAfterMs = Number(options.hideDoneAfterMinutes ?? 15) * 60_000
50
51 let session: Session | null = null
52 let sharedDir = ''
53 // Counts the main loop's turns, so a turn's late verdict never lands on the next one.
54 let turn = 0
55 let isTurnOpen = false
56 // A question or a plan waiting for your answer, inside a turn.
57 let isAsking = false
58 // Between a turn's end and the final estimate's verdict, the session still reads as working.
59 let isAwaitingVerdict = false
60 // How the last turn ended: stopped by you or an error (known at once), else the model's verdict.
61 let ending: Status | null = null
62 let verdict: Status | null = null
63 // When the last turn ended, and when you last had the session on screen after that.
64 let endedAt: number | null = null
65 let seenAt: number | null = null
66
67 // Working while a turn runs or background work goes on; a question only when Claude asked one.
68 const statusOf = (background: number): Status => {
69 if (isAsking) return 'question'
70 if (isTurnOpen || isAwaitingVerdict) return 'working'
71 if (ending !== null) return ending
72 if (verdict === 'question') return 'question'
73 if (background > 0) return 'working'
74
75 return verdict ?? 'idle'
76 }
77 let isTitleOn = options.renameTitles !== false
78
79 on('session.start', async ($, e, next) => {
80 const sessionId = fileName(await $.session.id())
81 const home = await $.env.get('HOME')
82 sharedDir = home === undefined ? '' : `${home}/.claude/project-progress`
83 const kept = await read($, snapshot)
84 const before = kept?.status
85 ending = before === 'stopped' ? before : null
86 verdict = before === 'question' || before === 'done' || before === 'error' ? before : null
87 endedAt = kept?.endedAt ?? null
88 seenAt = kept?.seenAt ?? null
89
90 let lastFingerprint = ''
91 let isBusy = false
92 let again: Mode | null = null
93
94 // The session as the desktop app knows it: its own id, which outlives a restart, and its title.
95 const appSession = async (): Promise<{ id: string; title: string }> => {
96 const info = await $.mcp.call('ccd_session_mgmt', 'get_session', { session_id: 'self' })
97 if (info.isError) throw new Error(textOf(info.content))
98 const value = JSON.parse(textOf(info.content)) as { sessionId?: unknown; title?: unknown }
99
100 return { id: String(value.sessionId ?? sessionId), title: String(value.title ?? '') }
101 }
102
103 const setTitle = async (title: string): Promise<void> => {
104 const set = await $.mcp.call('ccd_session_mgmt', 'set_session_title', { session_id: 'self', title })
105 if (set.isError) throw new Error(textOf(set.content))
106 }
107
108 const recordFile = (id: string) => `${sharedDir}/titles/${fileName(id)}.json`
109
110 // What the mod last set: held for this run, and on disk for after a restart.
111 const loadRecord = async (id: string): Promise<TitleRecord | null> => {
112 const held = await read($, titleRecord)
113 if (held !== null || sharedDir === '') return held
114
115 try {
116 return JSON.parse(await $.fs.read(recordFile(id))) as TitleRecord
117 } catch {
118 return null
119 }
120 }
121
122 const saveRecord = async (id: string, record: TitleRecord): Promise<void> => {
123 await update($, titleRecord, () => record)
124 if (sharedDir !== '') await $.fs.write(recordFile(id), JSON.stringify(record)).catch(() => undefined)
125 }
126
127 // The full name: the one the mod kept while the title is still the one it set, else the title as
128 // you or the app renamed it, less any prefix.
129 const readTitle = async (): Promise<TitleRead> => {
130 const app = await appSession()
131 const record = await loadRecord(app.id)
132 const base = record !== null && app.title === record.shown ? record.base : stripPrefix(app.title)
133
134 return { ...app, record, base }
135 }
136
137 const clearTitle = async (): Promise<void> => {
138 try {
139 const read = await readTitle()
140 if (read.base !== '' && read.base !== read.title) await setTitle(read.base)
141 await saveRecord(read.id, { base: read.base, shown: read.base, short: read.record?.short ?? null })
142 } catch {
143 // Nothing to restore where the title cannot be read.
144 }
145 }
146
147 // The machine's idle time; where it cannot say, the time since a prompt in any session.
148 const idleFor = async (now: number): Promise<{ ms: number | null; source: Snapshot['idleSource'] }> => {
149 try {
150 const ran = await $.process.run(['ioreg', '-c', 'IOHIDSystem', '-r', '-d', '1'], { timeoutMs: 5000 })
151 const ms = ran.exitCode === 0 ? parseIdleMs(ran.stdout) : null
152 if (ms !== null) return { ms, source: 'machine' }
153 } catch {
154 // No host commands here: fall back to the last prompt.
155 }
156 if (sharedDir === '') return { ms: null, source: null }
157
158 try {
159 return { ms: now - Number(await $.fs.read(`${sharedDir}/last-seen`)), source: 'prompt' }
160 } catch {
161 return { ms: null, source: null }
162 }
163 }
164
165 // Every session's latest snapshot, from the files each one writes.
166 const loadAll = async (now: number): Promise<Snapshot[]> => {
167 const dir = `${sharedDir}/sessions`
168 try {
169 const files = (await $.fs.list(dir)).filter(
170 f => f.kind === 'file' && f.name.endsWith('.json') && now - f.mtimeMs < WEEK_MS,
171 )
172 const loaded = await Promise.all(files.map(f => $.fs.read(`${dir}/${f.name}`).then(parseSnapshot).catch(() => null)))
173
174 return loaded.filter((s): s is Snapshot => s !== null)
175 } catch {
176 return []
177 }
178 }
179
180 const dashboardFile = () => `${sharedDir}/dashboard.html`
181
182 // The page the projects window shows: any session that refreshes rewrites it with all of them.
183 const writeDashboard = async (now: number): Promise<void> => {
184 await $.fs.write(dashboardFile(), dashboardHtml(await loadAll(now), now, hideAfterMs)).catch(() => undefined)
185 }
186
187 // A window of its own (a Chrome app window, else the default browser), not tied to any session.
188 const openWindow = async (): Promise<string> => {
189 if (sharedDir === '') return 'There is no home folder to keep the projects page in.'
190 await writeDashboard(await $.clock.now())
191 const url = `file://${encodeURI(dashboardFile())}`
192
193 try {
194 const chrome = await $.process.run(['open', '-na', 'Google Chrome', '--args', `--app=${url}`, '--window-size=520,760'], {
195 timeoutMs: 10_000,
196 })
197 if (chrome.exitCode === 0) return 'Projects window opened.'
198 const plain = await $.process.run(['open', dashboardFile()], { timeoutMs: 10_000 })
199 if (plain.exitCode === 0) return 'Projects window opened in your browser.'
200 } catch {
201 // No host commands here.
202 }
203
204 return `Open ${dashboardFile()} in a browser to see every project.`
205 }
206
207 const refresh = async (mode: Mode): Promise<void> => {
208 if (isBusy) {
209 again = again === null || RANK[mode] > RANK[again] ? mode : again
210 return
211 }
212 isBusy = true
213
214 try {
215 if ((await $.session.surfaces()).length === 0) return
216
217 const now = await $.clock.now()
218 const [usage, messages, idle] = await Promise.all([$.session.usage(), $.session.messages(), idleFor(now)])
219 const prev = await read($, snapshot)
220 const isAway = mode !== 'force' && idle.ms !== null && idle.ms >= awayMs
221 const ofTurn = turn
222 const items = planItems(messages)
223 const today = new Date(now).toISOString().slice(0, 10)
224 let estimator = prev?.estimator.date === today ? prev.estimator : { date: today, tokens: 0 }
225 let percent = prev?.percent ?? null
226 let left = prev?.left ?? ''
227 let doing = prev?.now ?? ''
228
229 // Outside the desktop app there is no session title to change; the band and the pane still work.
230 let title: TitleRead | null = null
231 if (isTitleOn) {
232 try {
233 title = await readTitle()
234 } catch {
235 isTitleOn = false
236 }
237 }
238 const base = title === null || title.base === '' ? null : title.base
239 const isLong = base !== null && base.length > SHORT_AT
240 let short = base !== null && title?.record?.short?.for === base ? title.record.short : null
241 const isShortWanted = isLong && short === null
242
243 const print = fingerprint(messages)
244 const isChanged = mode === 'force' || mode === 'final' || (mode === 'tick' && print !== lastFingerprint)
245 let estimate: Estimate | null = null
246 if ((mode === 'final' || !isAway) && isChanged && hasWork(messages) && estimator.tokens < tokenCap) {
247 lastFingerprint = print
248 const answer = await $.model.complete({
249 model,
250 system: [{ text: SYSTEM, cache: true }],
251 prompt: digest(messages, items, { percent, left }, mode === 'final', isShortWanted ? (base ?? undefined) : undefined),
252 maxTokens: 200,
253 timeoutMs: 30_000,
254 })
255 const used = answer.usage
256 estimator = {
257 date: today,
258 tokens:
259 estimator.tokens +
260 used.input_tokens +
261 used.output_tokens +
262 (used.cache_read_input_tokens ?? 0) +
263 (used.cache_creation_input_tokens ?? 0),
264 }
265 estimate = answer.isAnswered ? parseEstimate(answer.text) : null
266 if (estimate !== null) {
267 ;({ percent, left } = estimate)
268 if (estimate.now !== '') doing = estimate.now
269 if (isShortWanted && base !== null && estimate.short !== undefined) short = { for: base, text: estimate.short }
270 }
271 }
272 if (mode === 'final' && ofTurn === turn && !isTurnOpen) {
273 verdict = endedStatus(messages, estimate?.status)
274 isAwaitingVerdict = false
275 }
276 const agents = (await $.agent.list()).filter(a => a.status === 'pending' || a.status === 'running' || a.status === 'waiting')
277 const background = runningShells(messages).length + agents.length
278 const status = statusOf(background)
279 // The task list's step in progress says what Claude is doing better than any guess.
280 const step = items?.find(item => item.status === 'in_progress')?.content
281 if (step !== undefined && step !== '') doing = step.length > 60 ? `${step.slice(0, 59)}…` : step
282
283 const rate = usage.rateLimits.find(limit => limit.kind === 'five_hour')
284 const fresh: Snapshot = {
285 sessionId,
286 project: base ?? basename(await $.session.cwd()),
287 shortName: isLong && base !== null ? (short?.text ?? cutName(base)) : null,
288 percent,
289 left,
290 now: doing,
291 steps: planSteps(items),
292 contextFreePct: usage.context.percent === undefined ? null : 100 - usage.context.percent,
293 contextTokens: usage.context.tokens ?? null,
294 contextWindow: usage.context.window,
295 rate5hPct: rate === undefined ? null : Math.round(rate.percentUsed),
296 status,
297 background,
298 isAway,
299 idleSource: idle.source,
300 startedAt: usage.startedAt,
301 updatedAt: now,
302 endedAt,
303 seenAt,
304 estimator,
305 }
306
307 // The title changes only when what it shows changes, so the sidebar is not rewritten every minute.
308 if (title !== null && base !== null) {
309 const prefix = prefixFor(fresh)
310 const shown = prefix === null ? base : prefix + (fresh.shortName ?? base)
311 try {
312 if (shown !== title.title) await setTitle(shown)
313 const record: TitleRecord = { base, shown, short }
314 if (JSON.stringify(record) !== JSON.stringify(title.record)) await saveRecord(title.id, record)
315 } catch {
316 isTitleOn = false
317 }
318 }
319
320 await update($, snapshot, () => fresh)
321 await update($, tick, n => n + 1)
322 if (sharedDir !== '') {
323 await $.fs.write(`${sharedDir}/sessions/${sessionId}.json`, JSON.stringify(fresh)).catch(() => undefined)
324 await writeDashboard(now)
325 }
326 } finally {
327 isBusy = false
328 const pending = again
329 again = null
330 if (pending !== null) void refresh(pending)
331 }
332 }
333
334 session = { refresh, clearTitle, openWindow }
335
336 await $.command.register({
337 name: 'progress',
338 description:
339 'Project progress: open the projects pane; "window" opens every project in a window of its own; "now" estimates at once; "title off|on" toggles the bar in the session title',
340 argumentHint: '[window | now | title off | title on]',
341 })
342 $.clock.every(intervalMs, () => void refresh('tick'))
343 $.clock.after(2000, () => void refresh('tick'))
344
345 return next(e)
346 })
347
348 on('turn.start', async ($, e, next) => {
349 if (e.agentId === undefined) {
350 turn += 1
351 endedAt = null
352 isTurnOpen = true
353 isAwaitingVerdict = false
354 ending = null
355 verdict = null
356 $.clock.after(300, () => void session?.refresh('quiet'))
357 }
358
359 return next(e)
360 })
361
362 // Stopped by you or by an error is known at once; whether Claude asked something or finished
363 // is read by the final estimate.
364 on('turn.complete', async ($, e, next) => {
365 const done = await next(e)
366 if (e.agentId === undefined) {
367 isTurnOpen = false
368 endedAt = await $.clock.now()
369 if (e.reason === 'answer') {
370 isAwaitingVerdict = true
371 $.clock.after(300, () => void session?.refresh('final'))
372 } else {
373 ending = e.reason === 'aborted' ? 'stopped' : 'error'
374 $.clock.after(300, () => void session?.refresh('quiet'))
375 }
376 }
377
378 return done
379 })
380
381 // A question or a plan waiting for your answer holds the turn open: show it as a question meanwhile.
382 on('tool.call', async ($, e, next) => {
383 const asks = e.agentId === undefined && (e.tool === 'AskUserQuestion' || e.tool === 'ExitPlanMode')
384 if (!asks) return next(e)
385
386 isAsking = true
387 $.clock.after(0, () => void session?.refresh('quiet'))
388 try {
389 return await next(e)
390 } finally {
391 isAsking = false
392 $.clock.after(0, () => void session?.refresh('quiet'))
393 }
394 })
395
396 // Where the machine cannot report its idle time, a prompt in any session marks you present.
397 on('prompt.submit', async ($, e, next) => {
398 const submitted = await next(e)
399 if (sharedDir !== '' && e.isMeta !== true) {
400 await $.fs.write(`${sharedDir}/last-seen`, String(await $.clock.now())).catch(() => undefined)
401 }
402
403 return submitted
404 })
405
406 on('command.run', { command: 'progress' }, async ($, e) => {
407 const args = e.args.trim()
408
409 if (args === 'window') {
410 return { text: (await session?.openWindow()) ?? 'The session is not ready yet.' }
411 }
412 if (args === 'now') {
413 await session?.refresh('force')
414 return { text: 'Fresh estimate done.' }
415 }
416 if (args === 'title off') {
417 isTitleOn = false
418 await session?.clearTitle()
419 return { text: 'Bar removed from the session title.' }
420 }
421 if (args === 'title on') {
422 isTitleOn = true
423 await session?.refresh('quiet')
424 return {
425 text: isTitleOn
426 ? 'Bar back in the session title.'
427 : "This session's title can't be changed here; that needs the Claude desktop app.",
428 }
429 }
430
431 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
432
433 return {
434 text: opened.isPlaced
435 ? 'Projects pane opened.'
436 : 'Projects pane opened, but this screen has no room to show it.',
437 }
438 })
439
440 // Two rows: the work's progress in green, and how full the context is in blue, yellow, then red.
441 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
442 const s = await read($, snapshot)
443 if (e.props.hasSurvey || s === null) return next(e)
444 // On screen after the turn ended: you have seen how it ended.
445 if (endedAt !== null && !isTurnOpen) seenAt = await $.clock.now()
446
447 const progress = fit(progressParts(s, await $.clock.now()), e.props.bodyColumns - 28)
448 const usage = fit(usageParts(s), e.props.bodyColumns - 16)
449 const used = ctxUsed(s) ?? 0
450
451 if (e.surface === 'terminal') {
452 const { Box, Button, Text } = $.ui.resolve(e)
453 const bar = (pct: number, color: string) => {
454 const [full, empty] = cells(pct)
455
456 return (
457 <Box>
458 <Text color={color}>{full}</Text>
459 <Text dimColor>{empty}</Text>
460 </Box>
461 )
462 }
463
464 return (
465 <Box flexDirection="column">
466 <Box flexDirection="row" gap={1}>
467 {bar(s.percent ?? 0, GREEN)}
468 <Text wrap="truncate-end">{progress}</Text>
469 <Button key="open" label="Projects" plain dimColor onPress={() => void $.ui.open({ id: PANE, title: PANE_TITLE })} />
470 <Button key="window" label="Window" plain dimColor onPress={() => void session?.openWindow()} />
471 </Box>
472 <Box flexDirection="row" gap={1}>
473 {bar(used, ctxColor(s))}
474 <Text dimColor wrap="truncate-end">
475 {usage}
476 </Text>
477 </Box>
478 </Box>
479 )
480 }
481
482 const { Box, Button, Svg, Text } = $.ui.resolve(e)
483
484 return (
485 <Box flexDirection="column">
486 <Box flexDirection="row" gap={1} alignItems="center">
487 <Svg source={svgBar(s.percent ?? 0, GREEN)} alt={`${s.percent ?? 0}% done`} width={BAR_PX} height={8} />
488 <Text wrap="truncate-end">{progress}</Text>
489 <Button key="open" label="Projects" plain dimColor onPress={() => void $.ui.open({ id: PANE, title: PANE_TITLE })} />
490 <Button key="window" label="Window" plain dimColor onPress={() => void session?.openWindow()} />
491 </Box>
492 <Box flexDirection="row" gap={1} alignItems="center">
493 <Svg source={svgBar(used, ctxColor(s))} alt={`context ${used}% full`} width={BAR_PX} height={8} />
494 <Text dimColor wrap="truncate-end">
495 {usage}
496 </Text>
497 </Box>
498 </Box>
499 )
500 })
501
502 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
503 await read($, tick)
504 const now = await $.clock.now()
505
506 // Every session writes its own file, so the pane sees the projects of all of them.
507 let list: Snapshot[] = []
508 let hidden = 0
509 if (sharedDir !== '') {
510 const dir = `${sharedDir}/sessions`
511 try {
512 const files = (await $.fs.list(dir)).filter(
513 f => f.kind === 'file' && f.name.endsWith('.json') && now - f.mtimeMs < WEEK_MS,
514 )
515 const loaded = await Promise.all(files.map(f => $.fs.read(`${dir}/${f.name}`).then(parseSnapshot).catch(() => null)))
516 ;({ shown: list, hidden } = listed(loaded.filter((s): s is Snapshot => s !== null), now, hideAfterMs))
517 } catch {
518 list = []
519 }
520 }
521 const empty = list.length === 0 && hidden === 0 ? 'No projects yet.' : 'Nothing in progress right now.'
522
523 const columns = e.props.bodyColumns - (e.surface === 'terminal' ? BAR_CELLS + 2 : 16)
524 const isStale = (s: Snapshot) => now - s.updatedAt > STALE_MS
525 const name = (s: Snapshot) => `${s.project}${isStale(s) ? ' — last estimate' : ''}`
526
527 if (e.surface === 'terminal') {
528 const { Box, Button, Text } = $.ui.resolve(e)
529 const row = (pct: number, color: string, text: string) => {
530 const [full, empty] = cells(pct)
531
532 return (
533 <Box flexDirection="row" gap={1}>
534 <Box>
535 <Text color={color}>{full}</Text>
536 <Text dimColor>{empty}</Text>
537 </Box>
538 <Text dimColor wrap="truncate-end">
539 {text}
540 </Text>
541 </Box>
542 )
543 }
544
545 return (
546 <Box flexDirection="column" gap={1}>
547 <Button key="window" label="Open in a window" plain onPress={() => void session?.openWindow()} />
548 {list.length === 0 && <Text dimColor>{empty}</Text>}
549 {list.map(s => (
550 <Box key={s.sessionId} flexDirection="column">
551 <Text bold dimColor={isStale(s)} wrap="truncate-end">
552 {name(s)}
553 </Text>
554 {row(s.percent ?? 0, GREEN, fit(progressParts(s, now, false), columns))}
555 {row(ctxUsed(s) ?? 0, ctxColor(s), fit(usageParts(s), columns))}
556 </Box>
557 ))}
558 {hidden > 0 && <Text dimColor>{hiddenText(hidden)}</Text>}
559 </Box>
560 )
561 }
562
563 const { Box, Button, Svg, Text } = $.ui.resolve(e)
564 const row = (pct: number, color: string, alt: string, text: string) => (
565 <Box flexDirection="row" gap={1} alignItems="center">
566 <Svg source={svgBar(pct, color)} alt={alt} width={BAR_PX} height={8} />
567 <Text dimColor wrap="truncate-end">
568 {text}
569 </Text>
570 </Box>
571 )
572
573 return (
574 <Box flexDirection="column" gap={1}>
575 <Button key="window" label="Open in a window" plain onPress={() => void session?.openWindow()} />
576 {list.length === 0 && <Text dimColor>{empty}</Text>}
577 {list.map(s => (
578 <Box key={s.sessionId} flexDirection="column">
579 <Text bold dimColor={isStale(s)} wrap="truncate-end">
580 {name(s)}
581 </Text>
582 {row(s.percent ?? 0, GREEN, `${s.percent ?? 0}% done`, fit(progressParts(s, now, false), columns))}
583 {row(ctxUsed(s) ?? 0, ctxColor(s), `context ${ctxUsed(s) ?? 0}% full`, fit(usageParts(s), columns))}
584 </Box>
585 ))}
586 {hidden > 0 && <Text dimColor>{hiddenText(hidden)}</Text>}
587 </Box>
588 )
589 })
590}
591hooks/estimate.ts 140 lines1import type { SessionMessage } from 'claude-code'
2
3import type { Status } from '../types'
4import type { PlanItem } from './plan'
5
6export const SYSTEM = `You estimate how complete the project worked on in a Claude Code session is.
7You get the goal (the person's first request and any later requests), Claude's task list when there is one, the previous estimate, and the latest messages.
8Reply with ONLY a JSON object, no prose, no code fence:
9{"percent": <integer 0-100>, "left": "<what is left, in English, at most 8 words>", "now": "<what Claude is doing, at most 6 words>", "status": "<only when the turn has ended>", "short": "<only when asked>"}
10Rules:
11- percent is the share of the WHOLE goal that is done, judged from evidence in the messages, not from promises.
12- When a task list exists, weigh it heavily; a step in progress counts as half done.
13- Planning or just started: 0-15. Built but not verified: 70-85. Verified and handed over: 95-100.
14- Stay close to the previous estimate unless the messages show real progress; new requirements from the person may lower it.
15- "left" names the concrete remaining work (e.g. "test and fix the checkout page"); when done, "done".
16- When the digest says Claude's turn has ended, "status" is "blocked" when Claude stopped without finishing, on an error or something it cannot do, and "done" otherwise (a report, work still running in the background, or a question to the person). While Claude is still working, leave "status" out.
17- "now" names what Claude is doing at this moment (e.g. "running the checkout tests"); once the turn has ended, what it did last.
18- When the digest gives a SESSION NAME TO SHORTEN, "short" is that name in at most 3 words and 20 characters, keeping its meaning (e.g. "Fix the clipping on the share card" becomes "Share card clipping"). Otherwise leave "short" out.`
19
20const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
21
22const clean = (text: string): string => text.replace(REMINDER, '').trim()
23
24const cut = (text: string, max: number): string =>
25 text.length <= max ? text : `${text.slice(0, max)}…`
26
27// What the person typed: a user row with text, not one carrying tool results.
28const isPrompt = (m: SessionMessage): boolean =>
29 m.role === 'user' && (m.toolResults?.length ?? 0) === 0 && clean(m.text) !== ''
30
31export const hasWork = (messages: readonly SessionMessage[]): boolean => messages.some(isPrompt)
32
33// Changes whenever the transcript grows or a tool call in its last message settles.
34export const fingerprint = (messages: readonly SessionMessage[]): string => {
35 const last = messages[messages.length - 1]
36 const settled = last?.toolUses.filter(use => use.text !== undefined).length ?? 0
37
38 return `${messages.length}:${last?.text.length ?? 0}:${last?.toolUses.length ?? 0}:${settled}`
39}
40
41export const digest = (
42 messages: readonly SessionMessage[],
43 items: readonly PlanItem[] | null,
44 previous: { percent: number | null; left: string },
45 hasEnded: boolean,
46 nameToShorten?: string,
47): string => {
48 const prompts = messages.filter(isPrompt).map(m => clean(m.text))
49 const goal = [cut(prompts[0] ?? '', 1500), ...prompts.slice(1).slice(-3).map(p => cut(p, 300))]
50
51 const tasks =
52 items === null
53 ? '(none)'
54 : items
55 .map(item => `${item.status === 'completed' ? '[x]' : item.status === 'in_progress' ? '[>]' : '[ ]'} ${item.content}`)
56 .join('\n')
57
58 const recent = messages
59 .filter(m => isPrompt(m) || (m.role === 'assistant' && (clean(m.text) !== '' || m.toolUses.length > 0)))
60 .slice(-10)
61 .map(m => {
62 const tools = [...new Set(m.toolUses.map(use => use.tool.replace(/^mcp__[^_]+(?:_[^_]+)*__/, '')))].slice(0, 6)
63 const text = cut(clean(m.text), 400)
64
65 return `${m.role === 'user' ? 'person' : 'claude'}: ${text}${tools.length > 0 ? ` [tools: ${tools.join(', ')}]` : ''}`
66 })
67
68 const before = previous.percent === null ? '(none)' : `${previous.percent}% — left: ${previous.left}`
69
70 return [
71 'GOAL (first request, then later ones):',
72 goal.filter(Boolean).join('\n---\n'),
73 '',
74 'TASK LIST:',
75 tasks,
76 '',
77 `PREVIOUS ESTIMATE: ${before}`,
78 '',
79 `SESSION: ${hasEnded ? "Claude's turn has just ended; give the status." : 'Claude is working now.'}`,
80 ...(nameToShorten === undefined ? [] : [`SESSION NAME TO SHORTEN: ${nameToShorten}`]),
81 '',
82 'LATEST MESSAGES (oldest first):',
83 recent.join('\n'),
84 ].join('\n')
85}
86
87export type Estimate = { percent: number; left: string; now: string; status?: Status; short?: string }
88
89const STATUS_OF: Readonly<Record<string, Status>> = { done: 'done', blocked: 'error' }
90
91export const parseEstimate = (text: string): Estimate | null => {
92 const json = /\{[\s\S]*\}/.exec(text)
93 if (json === null) return null
94
95 try {
96 const value = JSON.parse(json[0]) as { percent?: unknown; left?: unknown; now?: unknown; status?: unknown; short?: unknown }
97 const percent = Number(value.percent)
98 if (!Number.isFinite(percent)) return null
99
100 const status = STATUS_OF[String(value.status ?? '')]
101 const short = String(value.short ?? '').trim()
102
103 return {
104 percent: Math.max(0, Math.min(100, Math.round(percent))),
105 left: cut(String(value.left ?? '').trim(), 60),
106 now: cut(String(value.now ?? '').trim(), 60),
107 ...(status === undefined ? {} : { status }),
108 ...(short === '' ? {} : { short: cut(short, 24) }),
109 }
110 } catch {
111 return null
112 }
113}
114
115const WRAP_TOOL = 'mcp__ccd_turn__wrap_up'
116
117// The last turn's own messages: everything after the last thing the person, or a notification, sent.
118const lastTurn = (messages: readonly SessionMessage[]): SessionMessage[] => {
119 let start = messages.length
120 while (start > 0 && !isPrompt(messages[start - 1] as SessionMessage)) start -= 1
121
122 return messages.slice(start)
123}
124
125// Whether the turn ended on a question to the person: the app's wrap-up asked one, or Claude's last
126// words end in a question mark (Latin or Arabic). The model's reading alone is not enough.
127export const asksPerson = (messages: readonly SessionMessage[]): boolean => {
128 const turn = lastTurn(messages)
129 const isAsked = turn.some(m => m.toolUses.some(use => use.tool === WRAP_TOOL && String(use.input.ask ?? '').trim() !== ''))
130 if (isAsked) return true
131 const last = [...turn].reverse().find(m => m.role === 'assistant' && clean(m.text) !== '')
132
133 return last !== undefined && /[?\u061F]$/u.test(clean(last.text))
134}
135
136// Why a turn ended: a question only on proof of one, an error when the model says it stopped short,
137// else done.
138export const endedStatus = (messages: readonly SessionMessage[], modelStatus?: Status): Status =>
139 asksPerson(messages) ? 'question' : modelStatus === 'error' ? 'error' : 'done'
140hooks/background.ts 25 lines1import type { SessionMessage } from 'claude-code'
2
3const TASK_ID = /<task-id>([^<\s]+)<\/task-id>/g
4const STOP_TOOLS = new Set(['TaskStop', 'KillShell'])
5
6// Background shells still running, read from the transcript itself, so it survives a reload: each
7// call that went to the background names its task, and the notification of its end names it again.
8export const runningShells = (messages: readonly SessionMessage[]): string[] => {
9 const started = new Set<string>()
10 const ended = new Set<string>()
11
12 for (const message of messages) {
13 for (const use of message.toolUses) {
14 const id = (use.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId
15 if (typeof id === 'string' && id !== '') started.add(id)
16 if (STOP_TOOLS.has(use.tool)) ended.add(String(use.input.task_id ?? use.input.shell_id ?? ''))
17 }
18 if (message.role === 'user') {
19 for (const match of message.text.matchAll(TASK_ID)) if (match[1] !== undefined) ended.add(match[1])
20 }
21 }
22
23 return [...started].filter(id => !ended.has(id))
24}
25hooks/dashboard.ts 116 lines1import type { Snapshot, Status } from '../types'
2import { ctxColor, ctxUsed, fit, GREEN, progressParts, usageParts } from './line'
3import { prefixFor } from './title'
4
5export const STALE_MS = 10 * 60_000
6export const REFRESH_SECONDS = 10
7export const HIDE_AFTER_MS = 15 * 60_000
8// A finished session that stopped checking in this long ago is off the list, seen or not; any session, after a day.
9export const CLOSED_SEEN_MS = 30 * 60_000
10export const CLOSED_DONE_MS = 6 * 3600_000
11export const CLOSED_MS = 24 * 3600_000
12
13// What needs you first, then what is working, then the rest; newest first within each.
14const ORDER: Readonly<Record<Status, number>> = { question: 0, working: 1, error: 2, stopped: 3, done: 4, idle: 5 }
15
16export const sortProjects = (list: readonly Snapshot[]): Snapshot[] =>
17 [...list].sort((a, b) => (ORDER[a.status] ?? 9) - (ORDER[b.status] ?? 9) || b.updatedAt - a.updatedAt)
18
19// Off the list: a session closed half an hour ago whose last state you have read; a finished project
20// you have seen and that has been quiet a while; a session that stopped checking in long ago. In an
21// open session a question, a stop or an error stays: it still needs you.
22export const isListed = (s: Snapshot, now: number, hideAfterMs = HIDE_AFTER_MS): boolean => {
23 const sinceCheckIn = now - s.updatedAt
24 const isSeen = s.seenAt !== null && s.endedAt !== null && s.seenAt >= s.endedAt
25 if (sinceCheckIn > CLOSED_MS) return false
26 if (isSeen && sinceCheckIn >= CLOSED_SEEN_MS) return false
27 if (s.status !== 'done') return true
28 if (sinceCheckIn > CLOSED_DONE_MS) return false
29
30 return !(isSeen && s.endedAt !== null && now - s.endedAt >= hideAfterMs)
31}
32
33export const listed = (
34 list: readonly Snapshot[],
35 now: number,
36 hideAfterMs = HIDE_AFTER_MS,
37): { shown: Snapshot[]; hidden: number } => {
38 const shown = sortProjects(list.filter(s => isListed(s, now, hideAfterMs)))
39
40 return { shown, hidden: list.length - shown.length }
41}
42
43export const hiddenText = (hidden: number): string =>
44 `${hidden} finished or closed project${hidden === 1 ? '' : 's'} hidden`
45
46// A session file as read back: a session still on an earlier version writes fewer fields.
47export const parseSnapshot = (text: string): Snapshot | null => {
48 try {
49 return {
50 shortName: null,
51 now: '',
52 status: 'idle',
53 background: 0,
54 contextTokens: null,
55 contextWindow: null,
56 endedAt: null,
57 seenAt: null,
58 ...(JSON.parse(text) as object),
59 } as Snapshot
60 } catch {
61 return null
62 }
63}
64
65const escape = (text: string): string =>
66 text.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c)
67
68const bar = (pct: number, color: string): string =>
69 `<div class="bar"><i style="width:${Math.max(0, Math.min(100, pct))}%;background:${color}"></i></div>`
70
71const project = (s: Snapshot, now: number): string => {
72 const isStale = now - s.updatedAt > STALE_MS
73 const tag = prefixFor(s)?.trim() ?? ''
74
75 return `<section class="${isStale ? 'stale' : ''}">
76<h2>${tag === '' ? '' : `<span class="tag">${escape(tag)}</span> `}<bdi>${escape(s.project)}</bdi>${isStale ? ' <span class="note">last estimate</span>' : ''}</h2>
77<div class="row">${bar(s.percent ?? 0, GREEN)}<span>${escape(fit(progressParts(s, now, false), 1000))}</span></div>
78<div class="row">${bar(ctxUsed(s) ?? 0, ctxColor(s))}<span class="dim">${escape(fit(usageParts(s), 1000))}</span></div>
79</section>`
80}
81
82// A page of its own, outside any session: every project, reloading itself every few seconds.
83export const dashboardHtml = (list: readonly Snapshot[], now: number, hideAfterMs = HIDE_AFTER_MS): string => {
84 const { shown, hidden } = listed(list, now, hideAfterMs)
85 const empty = list.length === 0 ? 'No projects yet. Sessions running the mod show up here.' : 'Nothing in progress right now.'
86
87 return `<!doctype html>
88<html lang="en"><head><meta charset="utf-8">
89<meta http-equiv="refresh" content="${REFRESH_SECONDS}">
90<meta name="viewport" content="width=device-width, initial-scale=1">
91<title>Project progress</title>
92<style>
93:root{color-scheme:light dark;--bg:#fafaf9;--fg:#1c1917;--dim:#78716c;--line:#e7e5e4;--track:rgba(120,113,108,.22)}
94@media (prefers-color-scheme:dark){:root{--bg:#1c1b1a;--fg:#f5f5f4;--dim:#a8a29e;--line:#2e2c2a}}
95body{margin:0;padding:16px;background:var(--bg);color:var(--fg);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
96header{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:8px}
97h1{font-size:15px;font-weight:600;margin:0}
98section{padding:10px 0;border-top:1px solid var(--line)}
99section.stale{opacity:.55}
100h2{font-size:13px;font-weight:600;margin:0 0 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
101.tag{color:var(--dim);font-weight:500}
102.note,.dim,header span{color:var(--dim);font-weight:400}
103.row{display:flex;align-items:center;gap:8px;margin-top:3px}
104.row span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
105.bar{flex:0 0 96px;height:6px;border-radius:3px;background:var(--track);overflow:hidden}
106.bar i{display:block;height:100%;border-radius:3px}
107.empty{color:var(--dim);padding:24px 0}
108.hidden{color:var(--dim);border-top:1px solid var(--line);padding-top:10px;margin:0}
109</style></head><body>
110<header><h1>Project progress</h1><span>${new Date(now).toTimeString().slice(0, 8)}</span></header>
111${shown.length === 0 ? `<p class="empty">${empty}</p>` : shown.map(s => project(s, now)).join('\n')}
112${hidden > 0 ? `<p class="hidden">${hiddenText(hidden)}</p>` : ''}
113</body></html>
114`
115}
116hooks/line.ts 103 lines1import type { Snapshot, Status } from '../types'
2import { STATUS_EMOJI } from './title'
3
4export const GREEN = '#22c55e'
5export const BLUE = '#3b82f6'
6export const YELLOW = '#eab308'
7export const RED = '#ef4444'
8export const BAR_PX = 110
9export const BAR_CELLS = 12
10
11// Where the context bar turns from blue to yellow, and from yellow to red.
12export const CTX_WARN = 70
13export const CTX_FULL = 85
14
15const STATUS_TEXT: Partial<Record<Status, string>> = {
16 question: 'waiting for your answer',
17 done: 'done',
18 stopped: 'stopped by you',
19 error: 'stopped on an error',
20}
21
22// One piece of a line; the highest `drop` gives way first when room runs out, 0 never.
23export type Part = { text: string; drop: number }
24
25const elapsed = (ms: number): string => {
26 const minutes = Math.max(0, Math.round(ms / 60_000))
27
28 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
29}
30
31const tokens = (n: number): string => (n < 1000 ? String(n) : `${Math.round(n / 1000)}k`)
32
33// How full the context window is.
34export const ctxUsed = (s: Snapshot): number | null => (s.contextFreePct === null ? null : 100 - s.contextFreePct)
35
36// Blue while there is room, yellow as it fills, red near full.
37export const ctxColor = (s: Snapshot): string => {
38 const used = ctxUsed(s) ?? 0
39
40 return used >= CTX_FULL ? RED : used >= CTX_WARN ? YELLOW : BLUE
41}
42
43// The first row: how far the work is. Where a heading already names the project, leave the name out.
44export const progressParts = (s: Snapshot, now: number, withName = true): Part[] => {
45 const parts: Part[] = []
46 const said = STATUS_TEXT[s.status]
47 if (said !== undefined) parts.push({ text: `${STATUS_EMOJI[s.status] ?? ''} ${said}`, drop: 0 })
48 // The sidebar shows a short name; here is the full one.
49 if (withName && s.shortName !== null) parts.push({ text: s.project, drop: 2 })
50
51 parts.push({ text: s.percent === null ? 'estimating…' : `${s.percent}%`, drop: 0 })
52 if (s.isAway && s.status === 'working') parts.push({ text: '⏸ estimates paused while you are away', drop: 3 })
53 else if (s.status === 'working' && s.now !== '') parts.push({ text: `now: ${s.now}`, drop: 1 })
54 if (s.background > 0) parts.push({ text: `⟳ ${s.background} in background`, drop: 1 })
55 if (s.steps !== null) parts.push({ text: `${s.steps.done}/${s.steps.total} steps`, drop: 1 })
56 if (s.left !== '') parts.push({ text: `left: ${s.left}`, drop: 4 })
57 parts.push({ text: `⏱ ${elapsed(now - s.startedAt)}`, drop: 5 })
58
59 return parts
60}
61
62// The second row: how much of the context, the plan and the estimator is used.
63export const usageParts = (s: Snapshot): Part[] => {
64 const used = ctxUsed(s)
65 const parts: Part[] = [{ text: `context ${used ?? '?'}%`, drop: 0 }]
66 if (s.contextTokens !== null && s.contextWindow !== null) {
67 parts.push({ text: `${tokens(s.contextTokens)} of ${tokens(s.contextWindow)}`, drop: 3 })
68 }
69 if (s.rate5hPct !== null) parts.push({ text: `5h limit ${s.rate5hPct}%`, drop: 2 })
70 parts.push({ text: `estimator today ${tokens(s.estimator.tokens)} tokens`, drop: 4 })
71
72 return parts
73}
74
75export const fit = (parts: readonly Part[], columns: number): string => {
76 let kept = [...parts]
77 const width = (list: readonly Part[]) => list.reduce((n, part) => n + part.text.length + 3, 0)
78
79 while (width(kept) > columns) {
80 const worst = Math.max(...kept.map(part => part.drop))
81 if (worst === 0) break
82 const at = kept.map(part => part.drop).lastIndexOf(worst)
83 kept = kept.filter((_, i) => i !== at)
84 }
85
86 // Each part isolated from its neighbours' direction, so a name or step in a right-to-left script
87 // keeps the line's order.
88 return kept.map(part => `\u2068${part.text}\u2069`).join(' · ')
89}
90
91export const svgBar = (pct: number, color: string): string => {
92 const filled = Math.round((BAR_PX * Math.max(0, Math.min(100, pct))) / 100)
93 const fill = filled > 0 ? `<rect width="${filled}" height="8" rx="4" fill="${color}"/>` : ''
94
95 return `<svg xmlns="http://www.w3.org/2000/svg" width="${BAR_PX}" height="8" viewBox="0 0 ${BAR_PX} 8"><rect width="${BAR_PX}" height="8" rx="4" fill="#888888" fill-opacity="0.25"/>${fill}</svg>`
96}
97
98export const cells = (pct: number): [string, string] => {
99 const filled = Math.round((BAR_CELLS * Math.max(0, Math.min(100, pct))) / 100)
100
101 return ['█'.repeat(filled), '░'.repeat(BAR_CELLS - filled)]
102}
103hooks/plan.ts 52 lines1import type { SessionMessage } from 'claude-code'
2
3import type { Steps } from '../types'
4
5export type PlanItem = { content: string; status: 'pending' | 'in_progress' | 'completed' }
6
7const PLAN_TOOL = 'mcp__ccd_turn__update_plan'
8
9const mark = (items: PlanItem[], at: number, status: PlanItem['status']): void => {
10 const item = items[at]
11 if (item !== undefined) items[at] = { ...item, status }
12}
13
14// Replays the session's task-list calls in order, the way the app keeps the list (a turn's wrap-up
15// leaves it as it is).
16export const planItems = (messages: readonly SessionMessage[]): PlanItem[] | null => {
17 let items: PlanItem[] | null = null
18
19 for (const message of messages) {
20 for (const use of message.toolUses) {
21 if (use.tool !== PLAN_TOOL) continue
22
23 const input = use.input
24 if (Array.isArray(input.items)) {
25 items = input.items.map(raw => {
26 const one = (raw ?? {}) as { content?: unknown; status?: unknown }
27 const status = one.status === 'completed' || one.status === 'in_progress' ? one.status : 'pending'
28
29 return { content: String(one.content ?? ''), status }
30 })
31 }
32 if (items === null) continue
33
34 if (input.advance === true) {
35 mark(items, items.findIndex(item => item.status === 'in_progress'), 'completed')
36 mark(items, items.findIndex(item => item.status === 'pending'), 'in_progress')
37 }
38 if (Array.isArray(input.done)) {
39 for (const n of input.done) mark(items, Number(n) - 1, 'completed')
40 }
41 if (typeof input.start === 'number') mark(items, input.start - 1, 'in_progress')
42 }
43 }
44
45 return items
46}
47
48export const planSteps = (items: readonly PlanItem[] | null): Steps | null =>
49 items === null || items.length === 0
50 ? null
51 : { done: items.filter(item => item.status === 'completed').length, total: items.length }
52hooks/presence.ts 7 lines1// HIDIdleTime is the whole machine's time since the last mouse or keyboard input, in nanoseconds.
2export const parseIdleMs = (ioreg: string): number | null => {
3 const match = /"HIDIdleTime"\s*=\s*(\d+)/.exec(ioreg)
4
5 return match ? Number(match[1]) / 1e6 : null
6}
7hooks/title.ts 52 lines1import type { Snapshot, Status } from '../types'
2
3// Names longer than this are shown shortened in the sidebar; the full name stays in the band.
4export const SHORT_AT = 22
5
6// The emoji a session that is not working shows in place of the progress; none for one not started.
7export const STATUS_EMOJI: Partial<Record<Status, string>> = {
8 question: '\u2753',
9 done: '\u2705',
10 stopped: '\u23F9\uFE0F',
11 error: '\u26A0\uFE0F',
12}
13
14const PIE = ['\u25CB', '\u25D4', '\u25D1', '\u25D5', '\u25CF'] as const
15
16// A quarter-step pie: 0-12% empty, 13-37% a quarter, ... 88-100% full.
17export const pie = (pct: number): string => PIE[Math.max(0, Math.min(4, Math.round(pct / 25)))] ?? PIE[0]
18
19// This version's bracketed prefix, and the ones earlier versions wrote (a bar of squares or an emoji,
20// then a dot), so any of them is stripped.
21const PREFIX =
22 /^(?:\[(?:[\u25CB\u25D4\u25D1\u25D5\u25CF] \d+%|(?:ctx|\u{1F50B}) \d+%|\u2753|\u2705|\u23F9\uFE0F?|\u26A0\uFE0F?)\] |(?:[🟩🟦⬜]{5} \p{Nd}+[%\u066A](?: 💬)?|\u2753|\u2705|\u23F9\uFE0F?|\u26A0\uFE0F?) · )/u
23
24export const stripPrefix = (title: string): string => {
25 let base = title
26 while (PREFIX.test(base)) base = base.replace(PREFIX, '')
27
28 return base
29}
30
31// The pie and percent only while the session works (how full the context is while you are away);
32// otherwise the status emoji alone. In brackets, so it reads as not part of the name.
33export const prefixFor = (s: Snapshot): string | null => {
34 if (s.status !== 'working') {
35 const emoji = STATUS_EMOJI[s.status]
36
37 return emoji === undefined ? null : `[${emoji}] `
38 }
39 if (s.isAway) return s.contextFreePct === null ? null : `[ctx ${Math.round((100 - s.contextFreePct) / 5) * 5}%] `
40
41 return s.percent === null ? null : `[${pie(s.percent)} ${Math.round(s.percent / 5) * 5}%] `
42}
43
44// Until the model has given a short name: the name cut at a word, with an ellipsis.
45export const cutName = (name: string): string => {
46 if (name.length <= SHORT_AT) return name
47 const head = name.slice(0, SHORT_AT - 1)
48 const space = head.lastIndexOf(' ')
49
50 return `${(space > 8 ? head.slice(0, space) : head).trimEnd()}…`
51}
52types/index.d.ts 58 lines1export type Steps = { done: number; total: number }
2
3/**
4 * What the session is doing: `working` while a turn runs; once it ends, whether Claude asked
5 * something (`question`), finished (`done`), was stopped by you (`stopped`) or by an error
6 * (`error`); `idle` before the first turn.
7 */
8export type Status = 'idle' | 'working' | 'question' | 'done' | 'stopped' | 'error'
9
10/**
11 * What the mod knows of the session's title: the full name, the title it last set, and the short
12 * name the model gave for that full name.
13 */
14export type TitleRecord = { base: string; shown: string; short: { for: string; text: string } | null }
15
16export type Snapshot = {
17 sessionId: string
18 /** The session's full name. */
19 project: string
20 /** What the sidebar shows in place of a long name; null when the name is short enough. */
21 shortName: string | null
22 /** The estimator's percent of the whole goal done; null before its first answer. */
23 percent: number | null
24 /** What is left, in a few words. */
25 left: string
26 /** What Claude is doing now: the task list's step in progress, else the model's words. */
27 now: string
28 steps: Steps | null
29 contextFreePct: number | null
30 /** The tokens the context holds, and the window they fill; null until the first response. */
31 contextTokens: number | null
32 contextWindow: number | null
33 rate5hPct: number | null
34 status: Status
35 /** Background shells and subagents still running; a session with any is still working. */
36 background: number
37 isAway: boolean
38 /** Where "away" was judged from: the machine's idle time, the last prompt in any session, or nothing. */
39 idleSource: 'machine' | 'prompt' | null
40 startedAt: number
41 updatedAt: number
42 /** When the last turn ended; null while one runs or before the first. */
43 endedAt: number | null
44 /** When the session was last on screen after a turn ended: you have seen how it ended. */
45 seenAt: number | null
46 estimator: { date: string; tokens: number }
47}
48
49declare module 'claude-code' {
50 interface PluginState {
51 'project-progress': {
52 snapshot: Snapshot | null
53 titleRecord: TitleRecord | null
54 tick: number
55 }
56 }
57}
58