SLOPSHOPPER

project-progress

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.

newpanebandguardcommandprompt
v0.1.0MITupdated 2026-10-23apada-appleid/claude-project-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · project-progress
│ ┃ Project progress ✕ › fix the failing auth test and add an audit log call │ ┃ Open in a window │ ┃ ⏺ Read(src/auth.ts) │ ┃ No projects yet. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /progress │ ⎿ project-progress: Projects pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Project progress
Open in a window No projects yet.
README

project-progress

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.

Session titles in the sidebar, and the two rows above the prompt

What you see

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
  • What the mod adds sits in brackets, so it never reads as part of the name.
  • The pie and percent show only while Claude is working, so the sidebar stays quiet. Background work (a shell or a subagent still running after the turn) counts as working.
  • When a turn ends, the estimate that runs at that moment also reads whether Claude asked you something or finished; a stop by you or an error is known at once. [❓] 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.
  • A name longer than 22 characters is shown shortened: the same estimate gives a version of 3 words at most ("Fix the clipping on the share card" becomes "Share card clipping"). The full name stays in the line above the prompt, and /progress title off puts it back in the title. Rename a session and the mod takes the new name.
  • The title changes only when what it shows changes (the percent in 5% steps), not every minute.

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
  • The first bar is green. The full name shows when the sidebar shortens it, and 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 second bar is the context window: blue while there is room, yellow from 70% full, red from 85%.
  • When a row is too narrow, its least important parts drop first.

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 finished project leaves once you have seen it (its session was on screen after the turn ended) and it has been quiet for 15 minutes.
  • A session you closed leaves half an hour later if you had read its last state, whatever that state was.
  • A finished session that stopped checking in leaves after 6 hours, seen or not, and any session after a day.
  • In an open session, a question, a stop or an error stays until you deal with it.

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">

How it keeps token use low

  • The model is asked only when the session changed since the last estimate. A session waiting for you costs nothing.
  • Nothing is estimated while you are away: no mouse or keyboard input for 3 minutes (read from the machine's idle time on macOS). The one exception is the single estimate when a turn ends, which tells a question from a finished job.
  • Each estimate costs about 1.5–3k tokens: a digest of the goal, Claude's task list, the previous estimate and the last 10 messages, each cut short.
  • The step count (3/5 steps) and the step in progress are read straight from Claude's task list and cost no tokens.
  • The end-of-turn status and the short name ride on the same estimate, so they cost no extra call.
  • A daily cap per session stops estimates once the estimator has used 400k tokens that day.

Install

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.

Commands

CommandWhat it does
/progressOpen the projects pane
/progress windowOpen every project in a window of its own
/progress nowEstimate right away, even if nothing changed
/progress title offRemove the bar from this session's title
/progress title onPut it back

Options

Set them with /plugin configure project-progress@apada, or in settings.json under pluginConfigs.

OptionDefaultMeaning
idleMinutes3Minutes without mouse or keyboard input before estimates pause
intervalSeconds60How often the session is checked
modelhaikuThe estimator model: an alias or a full model id
renameTitlestruePut the bar at the start of the session title
dailyTokenCap400000Tokens the estimator may use per session per day
hideDoneAfterMinutes15Minutes after a finished project was seen before the pane and window leave it out

Notes

  • The sidebar bar needs the Claude desktop app, which lets a session rename itself. In a terminal the line above the prompt and the projects pane still work.
  • Away detection reads HIDIdleTime through ioreg on macOS. Elsewhere the mod falls back to the time since your last prompt in any session.
  • Estimates run on your own Claude account, through the session's own client. Each session writes its latest snapshot to ~/.claude/project-progress/sessions/ so the projects pane can show every session.

Author

Made by Apada: GitHub · apadaa.ir

Source 9 files
hooks/register.tsx 591 lines
1import { 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}
591
hooks/estimate.ts 140 lines
1import 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'
140
hooks/background.ts 25 lines
1import 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}
25
hooks/dashboard.ts 116 lines
1import 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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}
116
hooks/line.ts 103 lines
1import 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}
103
hooks/plan.ts 52 lines
1import 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 }
52
hooks/presence.ts 7 lines
1// 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}
7
hooks/title.ts 52 lines
1import 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}
52
types/index.d.ts 58 lines
1export 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