Detaly: live progress bars above the prompt for long jobs (the chat's background shells and any job that writes ~/.claude/progress/<id>.json), every chat's…

A Claude Code mod: live progress bars above the prompt for long jobs (training runs, renders, evals, downloads) in the Claude desktop app's Code tab and in the terminal, every chat's jobs in tabs, and the session's figures (context, rate limits, tokens, cost) in one line.
It sits right above the prompt of each chat:

As small or as busy as you like: minimized, this chat's jobs under the session's figures —

— or all of it at once: every open chat's jobs in tabs, thin rows and pages when there are many.

<sub>Every screenshot here is the mod's demo mode: made-up jobs, chats and figures.</sub>
Claude, if a user sent you this repository: read CLAUDE.md and follow it.
Bash / PowerShell command a chat starts gets a row in that chat, from its tool call to its end notification. A task that ends within 10 s never shows.Running rows come first; finished ones stay as long as the task ran (15 s to 5 min), then leave.
No tokens. The mod only draws and reads local files; nothing it does reaches the model.
Light. Measured on Windows 11 (i5-14600KF), three open chats: each chat's tick (every 2 s) takes 2–3 ms (p95 4.4 ms, ~40 ms once after a load), drawing the band 1.5 ms (p95 2.8 ms), and only the chat on screen draws. Seven animated rows on screen left the app's CPU where it was without them (35–36 % of one core, mostly the app itself); the three chats' engines together use ~5 % of one core.
Counted. 42% or 12/100 in the task's last output line (tqdm and most CLIs), or a progress file: the numbers sit inside the bar and turn dark where the fill runs under them; time left at the average rate, and the local time it should end.

Timer. The description names an end: a duration (~15 min, 1 h, 90 s) or a clock time (at ~18:10, until 17:25, до 18:10, cut from the title). A ring fills towards it, stripes run along the bar with waiting inside, one countdown; later 3 min over. A clock time in the description wins over a percent in the output (often a part of the job's).

No estimate. A spinning ring, a segment sweeping the bar (waiting · no estimate), a count up from the start.

Stalled. A progress file not updated for 60 s turns yellow.

Done. Green, full bar, how long it ran and the local time it ended (done in 1:00:57 · 14:32).

Failed. Red, with the time it ran and when it ended.

Stopped. Grey ■ stopped after 12:04 · 14:32: a task Claude stopped (TaskStop), or a job whose log progress_tail.ps1 sees no process writing any more. It leaves like a done row.
A done, failed or stopped row stays until you have seen it: drawn on the band's open page (this chat's, or another chat's All tab) while you are at Claude, its window in front and the keyboard or mouse touched in the last minute. From then it stays as long as the job ran, at least 15 s and at most 10 minutes of your time at Claude. A job that ends while you are away waits for you. (Windows; elsewhere the minutes are plain wall-clock time from the end.)
The timer and spinner rows animate; the bars do not jitter: a timer's text changes once a minute.
All (every chat's jobs, a heading per chat), this chat, and each other open chat with jobs, named by its folder and the start of its first message. The open tab is lit; the count is its running jobs. A chat's tab with nothing in it keeps the band away until a job starts (or /detaly, which opens All).
Hover a tab and its tools show in a card under it:


walk14 in walk14_train.log). Split evenly when a response was about several; dollars as the session's cost moved, tokens in and out. A file job's card also has its start, run time and average rate.
▾ Minimize (top right) keeps this chat's rows only, under one line of the session's figures:

/cost totals it.Hover the line for the details (context size, when each limit resets, cache reads and writes, session length). ⟳ Refresh there refreshes this chat; ▴ Tabs brings the tabs back. Minimized, the line stays even with no jobs.
On the tab row, ⓘ Info (left of Minimize) shows the same figures and details in a card; press it to pin the card.

/status shows the version.USERPROFILE or HOME) and has a terminal fallback for every picture, but macOS, Linux and the terminal are untried; reports welcome. bin/progress_tail.ps1 needs PowerShell (pwsh on macOS / Linux).$.store, one per machine): each open chat's rows under the chat's name, its folder and the start of its first message (40 characters); every other chat reads them for its All tab. A chat's entry leaves when the chat ends, or after an hour of silence (whichever chat sees it first removes it), so nothing piles up.~/.claude/progress/<id>.json: progress files that jobs outside Claude write (below). The helpers remove the ones nobody has written for a week when they start; the mod itself cannot delete files. A sessions/ folder there is left from versions before 0.1.0: delete it.presence: how many seconds you have spent at Claude, never what you did) and, per finished row, where that clock stood when you first saw it (seen:<row>, removed with the row, or after a day).A. With the claude CLI in a terminal
claude plugin marketplace add qr1x55/Detaly-Claude-Mod
claude plugin install detaly@detaly
(or, at the prompt of a terminal session: /plugin install detaly --marketplace qr1x55/Detaly-Claude-Mod). A plugin installed at the user scope loads in the desktop app's local sessions too.
B. Desktop app only (no CLI on PATH): clone and name the folder in your user settings.
git clone https://github.com/qr1x55/Detaly-Claude-Mod "%USERPROFILE%\.claude\mods\detaly"%USERPROFILE%\.claude\settings.json (create it if missing; keep everything already there) add to env: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\<you>\\.claude\\mods\\detaly", "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1" } } ` CLAUDE_CODE_PLUGIN_DIRS takes several folders separated by ; (Windows) or :. CLAUDE_CODE_PLUGIN_DIR_WATCH is optional: later updates of the folder (git pull`) reload in open sessions without a restart.Then fully quit the Claude app (tray icon → Quit) and open it again. Mods load when a session starts. A restart ends every running background task in every chat, so wait until they finish.
A job writes ~/.claude/progress/<id>.json every ~2 s, atomically (write <id>.json.tmp, then rename):
{"title": "train run", "done": 1650, "total": 3001, "unit": "it", "started": 1791898456, "eta_s": 1338,
"updated": 1791898464, "finished": false, "session": null, "project": "D:\\runs"}
Times are unix seconds. eta_s: null: the mod estimates it from the rate. No total: a count without a bar. "stopped": true (optional): it ended short of its total on purpose; the row turns grey and leaves like a done one. Whose chat shows it: session set = only that Claude session (the mod sets CLAUDE_JOB_SESSION for everything its session launches and the helpers copy it); else project = sessions whose project holds that path (a worktree session counts as its repository); neither = every session.
Helpers in bin/ (the folder is on PATH for commands Claude runs):
bin/progress.py: report(id, title, done, total, unit="it", eta_s=None, finished=False); call it every step, it writes at most every 2 s.bin/progress_tail.ps1: tails a log by regex and exits when the job ends, when nothing holds the log open for writing any more (the job was stopped: the row says so), or after 30 quiet minutes. Defaults read rsl_rl (Learning iteration 1234/3000, ETA: 0:12:00): ``powershell Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File <mod>\bin\progress_tail.ps1 -Log D:\runs\train.log' ` Other logs: -Pattern '<regex: done, total>' -Eta '<regex: one group>' -Unit step -Id build; -Absolute` for logs counting 1..N.Start such helpers detached (Start-Process), not as a Claude background task: a background task wakes its chat (a paid turn) when it exits.
How chats see each other: each chat's mod puts its rows in the shared mod store when they change and every 10 s; an entry quiet for a minute is a closed chat, and after an hour it is removed.
≥ 0:02); a clock time in their description (at ~18:10) still gives the exact countdown.~/.claude/progress every 2 s (files untouched for an hour are skipped), and a file again only when it changed.Keep the clone you edit apart from the installed one (route B's folder, the one CLAUDE_CODE_PLUGIN_DIRS names). After a change:
claude plugin validate <dev clone>
claude plugin test <dev clone>
git commit, git push (in the dev clone)
git pull (in the installed clone; or: git pull <dev clone> main)
With CLAUDE_CODE_PLUGIN_DIR_WATCH=1 the pull reloads the mod in open sessions (once their turn ends).
hooks/register.tsx is the mod (jobs, background tasks, chats, band); hooks/pager.tsx the page column (a surface module: it takes the pointer); tests/band.test.ts; bin/ the helpers.
Screenshots without real figures: demo mode. Write ~/.claude/progress/demo.json, e.g. {"scene": "all", "label": "my-game · train the T-800 walk cycle"}: made-up figures and spend, and no real job at all: this chat under that label with the rows of sessions/demo-self.json, only made-up chats and job files (named demo-*.json), and scene picks the view: all, self, mini, info (pinned), tools (the open tab's), name (the first cut name's card), spent (the first row's card). Delete the file to leave demo mode.
Found a bug or a row that reads wrong? Open an issue with a screenshot of the band and your platform (OS, desktop or terminal, Claude Code version).
MIT: see LICENSE.
hooks/register.tsx 1290 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { JobProgressTask, Spent } from '../types'
5
6// One file per job in ~/.claude/progress/<id>.json, written by the job (bin/progress.py, bin/progress_tail.ps1):
7// { title, done, total, unit, started, eta_s, updated, finished, stopped, session, project }, times in unix seconds.
8// `session`: the Claude session that launched the job (CLAUDE_JOB_SESSION, set below for everything this session
9// starts); `project`: where the job runs, for jobs started outside Claude.
10type Job = {
11 title?: string
12 done?: number
13 total?: number
14 unit?: string
15 started?: number
16 eta_s?: number | null
17 updated?: number
18 finished?: boolean
19 stopped?: boolean // ended before its total (progress_tail: the log's writer is gone)
20 session?: string | null
21 project?: string | null
22}
23type Bg = JobProgressTask // a background shell of this session (types/index.d.ts)
24type State = 'run' | 'stale' | 'done' | 'fail' | 'stop' // stop: stopped on purpose, kept and dropped like done
25// isTimer: time, not counted work: a ring in place of the dot and a wait bar (filled to f, or sweeping without f)
26// `spent`: what Claude's responses about the job cost; `info`: a file job's start, run time and rate; both in a card
27// while the row is hovered. `seen`: a finished row's key in the store's `seen:` entries
28type Row = { id: string; title: string; f: number | undefined; counts: string; tail: string; state: State; isTimer?: boolean; spent?: string; info?: string; seen?: string }
29
30const TICK_MS = 2000
31const STALE_S = 60 // no update for this long: stalled
32// A finished row stays as long as the job ran, 15 s to KEEP_FINISHED_S, counted on the presence clock from when the
33// person first saw it: drawn on the band's open page while they were at Claude. Unseen, it waits for them.
34const KEEP_FINISHED_S = 600
35const KEEP_ENDED_S = 12 * 3600 // a finished file job's file is read this long at most, waiting for the person
36const SHOW_AFTER_S = 10 // a background task shows once it has run this long
37const KEEP_S = 3600 // an unfinished job's file not touched for this long is dropped (a crashed job)
38const OUTPUT_MAX = 1 << 20 // a task's output is parsed for progress while under this size
39
40// A tab of the band: this session's jobs, another session's (from what its own mod publishes), or file jobs of a
41// project with no session.
42type Group = { id: string; label: string; rows: Row[]; isSelf?: boolean }
43// What a session's mod publishes for the others, in the store every session on the machine shares ($.store, key
44// chat:<id>): rewritten when its rows change and every PUBLISH_S, so an entry gone quiet past ALIVE_S is a closed chat,
45// and past FORGET_S it is deleted (by whichever chat sees it), as a chat's own is when it ends. sync:<id> holds the
46// last sync another chat's ⟳ Refresh asked of it. Files could not be deleted ($.fs has no delete), so they piled up.
47// `jobs`: what Claude spent on its file jobs (by "<id>@<started>"), which the other chats read from the files themselves
48type Published = { session: string; label: string; updated: number; rows: Row[]; jobs?: Record<string, string> }
49const PUBLISH_S = 10
50const ALIVE_S = 60
51const FORGET_S = 3600
52
53let groups: Group[] = []
54let selfId = '' // this session
55let label = '' // this session's tab: "<folder> · <start of its first message>"
56let lastLive: { id: string; type: string; description: string }[] = [] // what ran at the last turn's end
57let published = ''
58let publishedAt = 0
59const lastPub = new Map<string, { mtime: number; v: Published }>() // read again only when the file moved
60let runNow: (() => Promise<void>) | undefined // the tick, for a press that should show at once
61let progressDir = '' // ~/.claude/progress
62let syncSeen: number | undefined // when the last sync request another chat left for this one was made (undefined: not listed yet)
63let folder = '' // this session's project folder
64const lastGood = new Map<string, { mtime: number; v: Job }>() // read again only when the file moved
65const seenAt = new Map<string, number>() // the presence clock when a finished row was first seen, by its `seen` key
66const marked = new Set<string>() // the rows this chat has marked seen
67// a task's output, read whole (no partial reads): its last line (all the progress lives in), its size then, when
68const lastLine = new Map<string, { size: number; at: number; line: string }>()
69const BIG_OUTPUT = 64 << 10 // a growing output past this is read again every BIG_EVERY_S at most
70const BIG_EVERY_S = 10
71const firstSeen = new Map<string, { t: number; done: number }>() // the rate is measured from here
72const isOver = (r: Row) => r.state === 'done' || r.state === 'fail' || r.state === 'stop'
73// Rows the person dismissed: "bg-<id>" for a background task, "<id>@<started>" for a file job (a new run of it shows
74// again). ponytail: module memory, so a reload of the mod shows a dismissed file job again until its file goes quiet
75// for an hour. Upgrade: keep them in $.state beside the tasks.
76const gone = new Set<string>()
77let asking: string | undefined // the running row whose "stop tracking?" is on screen
78// The session's background shells: worked on here, kept in $.state so a reload of the mod (an update) keeps them.
79const bg = new Map<string, Bg>()
80const tasks = atom({ plugin: 'detaly', key: 'tasks' } as const, [] as [string, Bg][])
81function save($: EngineInterface) {
82 return update($, tasks, () => [...bg])
83}
84
85const num = (x: number) => (Number.isInteger(x) ? String(x) : x.toFixed(1))
86
87const dur = (s: number) => {
88 if (s < 60) return `${Math.round(s)} s`
89 if (s < 3600) return `${Math.round(s / 60)} min`
90 const m = Math.round(s / 60)
91 return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`
92}
93
94const clock = (s: number) => {
95 const t = Math.floor(s)
96 const mm = String(Math.floor(t / 60) % 60).padStart(t >= 3600 ? 2 : 1, '0')
97 return `${t >= 3600 ? `${Math.floor(t / 3600)}:` : ''}${mm}:${String(t % 60).padStart(2, '0')}`
98}
99
100// the local clock time `dt` s from now: "17:42", "09:10 +1d" on a later day, "22:30 -1d" on an earlier one
101const clockAt = (now: number, dt: number) => {
102 const end = new Date((now + dt) * 1000)
103 const day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
104 const days = Math.round((day(end) - day(new Date(now * 1000))) / 86_400_000)
105 const hm = `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`
106 return `${hm}${days > 0 ? ` +${days}d` : days < 0 ? ` ${days}d` : ''}`
107}
108// "17 min left · 17:42": what is left and the local clock time it ends at
109// `isCoarse`: whole minutes ("<1 min"), so a timer's row changes once a minute, not every tick
110export const leftAt = (now: number, left: number, isCoarse = false) => `${isCoarse ? coarse(left) : dur(left)} left · ${clockAt(now, left)}`
111const coarse = (s: number) => (s < 60 ? '<1 min' : dur(s))
112
113// "~15 min", "1 h 20 min", "90 s", "~2ч": the duration a description names, in seconds
114export const expectOf = (text: string) => {
115 let s = 0
116 for (const [, n = '', unit = ''] of text.matchAll(/(\d+(?:[.,]\d+)?)\s*([a-zа-я]+)/giu)) {
117 const x = Number(n.replace(',', '.'))
118 const u = unit.toLowerCase()
119 if (/^(h|hr|hrs|hours?|ч|час\p{L}*)$/u.test(u)) s += x * 3600
120 else if (/^(m|min|mins|minutes?|мин\p{L}*)$/u.test(u)) s += x * 60
121 else if (/^(s|sec|secs|seconds?|с|сек\p{L}*)$/u.test(u)) s += x
122 }
123 return s > 0 ? s : undefined
124}
125
126// Progress in a task's last output line: "42%" or "12/100" (tqdm, most CLIs)
127const lastLineOf = (text: string) => text.split(/[\r\n]+/).filter(l => l.trim()).pop() ?? ''
128export const progressOf = (output: string) => {
129 const line = lastLineOf(output)
130 const kn = [...line.matchAll(/(?:^|[\s[(|])(\d+)\s*\/\s*(\d+)(?=$|[\s\])|,:])/g)].pop()
131 if (kn && Number(kn[2]) > 0 && Number(kn[1]) <= Number(kn[2])) return { f: Number(kn[1]) / Number(kn[2]), counts: `${kn[1]} / ${kn[2]}` }
132 const pct = [...line.matchAll(/(\d{1,3}(?:\.\d+)?)\s*%/g)].pop()
133 if (pct && Number(pct[1]) <= 100) return { f: Number(pct[1]) / 100, counts: '' }
134 return undefined
135}
136
137// "at ~18:10", "until 17:25", "до 18:10": seconds from now to that local clock time (tomorrow once it has passed)
138const UNTIL = /(?:\b(?:at|until|till|by)|(?:^|\s)(?:до|к|в))\s*~?\s*(\d{1,2}):(\d{2})(?![:\d])/iu
139export const untilOf = (now: number, text: string) => {
140 const m = UNTIL.exec(text)
141 if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) return undefined
142 const end = new Date(now * 1000)
143 end.setHours(Number(m[1]), Number(m[2]), 0, 0)
144 const s = end.getTime() / 1000 - now
145 return s < -60 ? s + 86_400 : s > 0 ? s : undefined
146}
147
148// A task seen at `now`: a clock time its description names leaves the title (the row ends with it) and is the end,
149// exact even when the start is unknown (`isUntil`); else a duration it names, counted from the start.
150function trackOf(now: number, description: string, more: Partial<Bg>): Bg {
151 const until = untilOf(now, description)
152 if (until === undefined) return { title: description, started: now, expectS: more.isLate ? undefined : expectOf(description), ...more }
153 const title = description.replace(UNTIL, '').replace(/\s{2,}/g, ' ').replace(/[\s,;:]+$/, '') || description
154 return { title, started: now, expectS: until, isUntil: true, ...more }
155}
156
157// A background shell's launch: its result names the task id and the file its output goes to.
158function launched(now: number, description: string | undefined, command: string, text: string | undefined) {
159 const id = text ? /with ID: (\w+)/.exec(text)?.[1] : undefined
160 if (!id) return
161 bg.set(id, trackOf(now, description?.trim() || command.slice(0, 60), { output: /written to: (.+?\.output)/.exec(text ?? '')?.[1] }))
162 return id
163}
164
165// What Claude's responses about a background shell cost. A response is charged to the shells it launches, the ones its
166// tool calls name (their id is in the output file's name too), and, through a turn the shells' ends woke, to those;
167// split evenly when it is several. Its dollars are the session's cost before and after it.
168let isTurn = false
169let woke: string[] = [] // shells whose end arrived while no turn ran: the next turn is theirs
170let turnOf: string[] = [] // the shells the running turn is charged to
171let launchShare: Spent | undefined // the last response's share for each shell it launched
172// the shells launched while a response still streams: the engine runs a call as soon as it is whole, before the
173// response ends and its cost is known
174let launchedInStep: string[] | undefined
175const plus = (a: Spent | undefined, b: Spent): Spent => ({
176 tokensIn: (a?.tokensIn ?? 0) + b.tokensIn,
177 out: (a?.out ?? 0) + b.out,
178 usd: (a?.usd ?? 0) + b.usd,
179 steps: (a?.steps ?? 0) + b.steps,
180})
181const share = (s: Spent, n: number): Spent => ({ tokensIn: s.tokensIn / n, out: s.out / n, usd: s.usd / n, steps: s.steps / n })
182// the shells a response is about: the turn's and the ones its tool calls name; and how many it launches. `jobs`: this
183// chat's file jobs ("<id>@<started>"), about which a response is when its calls name the id as a word ("walk14" in
184// walk14_train.log, not in walk14b)
185export function chargesOf(uses: readonly { name: string; input: unknown }[], ids: Iterable<string>, turn: readonly string[], jobs: Iterable<string> = []) {
186 const named = new Set(turn)
187 const text = uses.map(u => JSON.stringify(u.input ?? ''))
188 for (const id of ids) if (text.some(x => x.includes(id))) named.add(id)
189 const isBg = (u: { name: string; input: unknown }) =>
190 /^(Bash|PowerShell)$/.test(u.name) && (u.input as { run_in_background?: unknown } | null)?.run_in_background === true
191 const isWord = (id: string) => {
192 const re = new RegExp(`(?<![A-Za-z0-9])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Za-z0-9])`)
193 return text.some(x => re.test(x))
194 }
195 const namedJobs = [...jobs].filter(k => isWord(k.slice(0, k.lastIndexOf('@'))))
196 return { named: [...named], launches: uses.filter(isBg).length, jobs: namedJobs }
197}
198export const spentText = (s: Spent) => {
199 const n = Math.round(s.steps)
200 return `Claude on it: $${s.usd.toFixed(2)} · ${big(Math.round(s.tokensIn))} in · ${big(Math.round(s.out))} out · ${n} ${n === 1 ? 'response' : 'responses'}`
201}
202
203// The presence clock: seconds the person has spent at Claude (its window in front, input within IDLE_S), summed by one
204// chat's watcher, a PowerShell child asking Windows every PRESENCE_EVERY_S, and kept in the store every chat shares
205// (`presence`), so all of them read one clock; a chat that finds it stale starts its own watcher. undefined (another
206// OS, no watcher yet): rows go by the wall clock.
207type Presence = { activeS: number; at: number; by: string; here: boolean }
208const PRESENCE_EVERY_S = 10
209const PRESENCE_STALE_S = 30
210const IDLE_S = 60
211let activeNow: number | undefined
212let isHere = false // the person at Claude at the last sample
213let isWindows = false
214let watchFailed = false // no sample ever came: the wall clock serves
215let watchTried = 0
216const PRESENCE_PS =
217 `Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class DetalyIdle { ` +
218 `[StructLayout(LayoutKind.Sequential)] public struct L { public uint Size; public uint Time; } ` +
219 `[DllImport("user32.dll")] static extern bool GetLastInputInfo(ref L l); [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow(); ` +
220 `[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint p); ` +
221 `public static string Now() { L l = new L(); l.Size = 8; GetLastInputInfo(ref l); uint idle = unchecked((uint)Environment.TickCount - l.Time) / 1000; ` +
222 `uint p; GetWindowThreadProcessId(GetForegroundWindow(), out p); string n = ""; ` +
223 `try { n = System.Diagnostics.Process.GetProcessById((int)p).ProcessName; } catch { } return idle + " " + n; } }'\n` +
224 `while ($true) { [DetalyIdle]::Now(); Start-Sleep ${PRESENCE_EVERY_S} }\n`
225// The watcher: "<idle s> <process in front>" a line; written to the store while no other chat's watcher is live.
226// ponytail: a watcher that yields to another breaks its loop and leaves its child to the runtime; should it run on,
227// it is an idle PowerShell (~70 MB) until the chat ends.
228async function watch($: EngineInterface) {
229 let samples = 0
230 try {
231 let rest = ''
232 const argv = ['powershell', '-NoProfile', '-NonInteractive', '-Command', '-']
233 for await (const { stream, text } of $.process.spawn({ argv, input: PRESENCE_PS })) {
234 if (stream !== 'stdout') continue
235 const lines = (rest + text).split('\n')
236 rest = lines.pop() ?? ''
237 const m = lines.map(l => /^(\d+) (\S*)/.exec(l.trim())).filter(x => x !== null).pop()
238 if (!m) continue
239 samples += 1
240 const now = (await $.clock.now()) / 1000
241 const p = (await $.store.get('presence').catch(() => undefined)) as Presence | undefined
242 if (p && p.by !== selfId && now - p.at < PRESENCE_STALE_S) break
243 const here = Number(m[1]) < IDLE_S && /^claude$/i.test(m[2] ?? '')
244 const dt = p ? Math.max(0, Math.min(now - p.at, 2 * PRESENCE_EVERY_S)) : 0
245 await $.store.set('presence', { activeS: (p?.activeS ?? 0) + (here ? dt : 0), at: now, by: selfId, here }).catch(() => {})
246 }
247 } catch {
248 // no PowerShell, or it failed
249 }
250 if (!samples) watchFailed = true
251}
252// whether a row that ended at `endedAt` (wall s) after `ran` s, first seen at `seen` on the presence clock, still
253// stays: unseen, for good while a presence clock can run (one between watchers too), else by the wall clock
254const isKept = (now: number, endedAt: number, seen: number | undefined, ran: number) => {
255 const keep = Math.min(KEEP_FINISHED_S, Math.max(15, ran))
256 if (isWindows && !watchFailed) return seen === undefined || activeNow === undefined || activeNow - seen <= keep
257 return now - endedAt <= keep
258}
259
260// A finished row's mark in the store (seen:<key> = { active, at }), read until it is there.
261let sweptAt = 0 // when the store's marks were last swept
262async function seenOf($: EngineInterface, key: string) {
263 if (seenAt.has(key)) return
264 const v = (await $.store.get(`seen:${key}`).catch(() => undefined)) as { active: number; at: number } | undefined
265 if (v) seenAt.set(key, v.active)
266}
267async function forget($: EngineInterface, key: string) {
268 seenAt.delete(key)
269 marked.delete(key)
270 await $.store.delete(`seen:${key}`).catch(() => {})
271}
272// The finished rows on the band's open page, drawn while the person is at Claude: seen, unless a chat saw them first.
273async function markSeen($: EngineInterface, rows: (Row | undefined)[], nowS: number) {
274 if (!isHere || activeNow === undefined || demo) return
275 for (const r of rows) {
276 if (!r?.seen || marked.has(r.seen)) continue
277 marked.add(r.seen)
278 if ((await $.store.get(`seen:${r.seen}`).catch(() => undefined)) === undefined)
279 await $.store.set(`seen:${r.seen}`, { active: activeNow, at: nowS }).catch(() => {})
280 }
281}
282
283// The row of a background task; undefined: drop the task, null: not yet shown
284export function bgRowOf(id: string, t: Bg, now: number, output?: string): Row | null | undefined {
285 const el = (t.endedAt ?? now) - t.started
286 // a quick task never shows; a finished one stays as long as it ran, 15 s to 10 min of the person's time
287 if (t.endedAt !== undefined && (el < SHOW_AFTER_S || !isKept(now, t.endedAt, seenAt.get(`bg-${id}`), el))) return undefined
288 if (el < SHOW_AFTER_S && !t.isLate) return null
289 const shown = `${t.isLate ? '≥ ' : ''}${clock(el)}`
290 if (t.endedAt !== undefined) {
291 const isOk = t.status === undefined || t.status === 'completed'
292 const isStop = t.status === 'stopped' || t.status === 'killed'
293 const tail = `${isOk ? 'done in' : `${isStop ? 'stopped' : t.status} after`} ${shown} · ${clockAt(now, t.endedAt - now)}` // and when
294 const state = isOk ? 'done' : isStop ? 'stop' : 'fail'
295 return { id: `bg-${id}`, title: t.title, f: isOk ? 1 : undefined, counts: '', tail, state, isTimer: !t.isCounted, seen: `bg-${id}` }
296 }
297 // an end in the description ("until ~21:35") is the clock to trust; a percent in the output may be a part of the job
298 const p = output === undefined || t.isUntil ? undefined : progressOf(output)
299 if (p) {
300 t.isCounted = true
301 // the average rate so far: the time the done share took, for the share left
302 const tail = !t.isLate && p.f >= 0.02 && p.f < 1 && el >= 5 ? `~${leftAt(now, (el * (1 - p.f)) / p.f)}` : ''
303 return { id: `bg-${id}`, title: t.title, f: p.f, counts: p.counts || shown, tail, state: 'run' }
304 }
305 // A timer row has one clock: counting down to its estimate, or up from its start without one. Minutes and 1/32
306 // steps of the wait, so the row (and its pictures) change seldom rather than every tick.
307 if (t.expectS && (!t.isLate || t.isUntil)) {
308 const left = t.expectS - el
309 const tail = left > 0 ? `${left < 60 ? '' : '~'}${leftAt(now, left, true)}` : `${coarse(-left)} over`
310 return { id: `bg-${id}`, title: t.title, f: Math.min(31, Math.floor((el / t.expectS) * 32)) / 32, counts: '', tail, state: 'run', isTimer: true }
311 }
312 return { id: `bg-${id}`, title: t.title, f: undefined, counts: '', tail: `${t.isLate ? '≥ ' : ''}${coarse(el)}`, state: 'run', isTimer: true }
313}
314
315const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
316
317export const isMine = (j: Job, session: string, root: string) =>
318 j.session ? j.session === session : j.project ? (norm(j.project) + '/').startsWith(norm(root) + '/') : true
319
320export function rowOf(id: string, j: Job, now: number): Row | undefined {
321 const updated = j.updated ?? now
322 const age = now - updated
323 const seen = j.finished || j.stopped ? `${id}@${j.started}` : undefined
324 if (seen && !isKept(now, updated, seenAt.get(seen), updated - (j.started ?? updated))) return undefined
325 const done = j.done ?? 0
326 const total = j.total ?? 0
327 const unit = j.unit ? ` ${j.unit}` : ''
328 const state: State = j.finished ? 'done' : j.stopped ? 'stop' : age > STALE_S ? 'stale' : 'run'
329 const at = clockAt(now, updated - now) // when it ended: its last update
330 let tail = state === 'done' ? `done · ${at}` : state === 'stop' ? `stopped · ${at}` : state === 'stale' ? `stalled ${dur(age)}` : ''
331 if (state === 'run' && total > 0) {
332 let eta = j.eta_s ?? undefined
333 if (eta === undefined) {
334 const key = `${id}@${j.started}`
335 const seen = firstSeen.get(key) ?? { t: updated, done }
336 firstSeen.set(key, seen)
337 const rate =
338 updated - seen.t >= 10 && done > seen.done
339 ? (done - seen.done) / (updated - seen.t)
340 : j.started && updated > j.started && done > 0
341 ? done / (updated - j.started)
342 : 0
343 if (rate > 0) eta = (total - done) / rate
344 }
345 if (eta !== undefined) tail = leftAt(updated, eta)
346 }
347 // "Started 10:42 · running 1 h 12 min · 4.1 it/s": the rate is the run's average
348 const ran = (state === 'run' || state === 'stale' ? now : updated) - (j.started ?? now)
349 const rate = ran > 0 && done > 0 ? ` · ${perTime(done / ran, j.unit)}` : ''
350 const info = j.started ? `Started ${clockAt(now, j.started - now)} · ${state === 'run' || state === 'stale' ? 'running' : 'ran'} ${dur(ran)}${rate}` : undefined
351 return {
352 id,
353 title: j.title ?? id,
354 f: total > 0 ? Math.min(1, Math.max(0, done / total)) : undefined,
355 counts: total > 0 ? `${num(done)} / ${num(total)}${unit}` : `${num(done)}${unit}`,
356 tail,
357 state,
358 info,
359 ...(seen ? { seen } : {}),
360 }
361}
362// "4.1 it/s", "16 it/min", "2.5/h"
363const perTime = (perS: number, unit?: string) => {
364 const [x, per] = perS >= 1 ? [perS, 's'] : perS * 60 >= 1 ? [perS * 60, 'min'] : [perS * 3600, 'h']
365 return `${x >= 10 ? Math.round(x) : x.toFixed(1)}${unit ? ` ${unit}` : ''}/${per}`
366}
367
368const tabAtom = atom({ plugin: 'detaly', key: 'tab' } as const, '')
369const closedAtom = atom({ plugin: 'detaly', key: 'closed' } as const, [] as string[])
370const miniAtom = atom({ plugin: 'detaly', key: 'mini' } as const, false)
371// The band never grows past its room, so the engine never scrolls it: a desktop moved a taller band itself before
372// the mod could answer, and the tabs shook (anthropics/claude-code#99449). The rows come in pages of the room, picked
373// in a numbered column at the right (its own surface module, which takes the pointer); the wheel stays the chat's.
374let page = 0 // the open page
375let pageOf = '' // the tab it belongs to
376
377// The ✖ on a row. This session's: a finished one leaves at once, a running one after "stop tracking? yes"; the job
378// goes on, the band stops following it. Another session's row is hidden here only.
379function dismiss($: EngineInterface, gid: string, id: string) {
380 if (gid !== selfId) gone.add(`${gid}:${id}`)
381 else if (id.startsWith('bg-')) {
382 bg.delete(id.slice(3))
383 gone.add(id)
384 void save($)
385 } else gone.add(`${id}@${lastGood.get(id)?.v.started}`)
386 asking = undefined
387 groups = groups.map(g => (g.id === gid ? { ...g, rows: g.rows.filter(r => r.id !== id) } : g))
388 $.ui.invalidate('ui.render')
389}
390function ask($: EngineInterface, id: string | undefined) {
391 asking = id
392 $.ui.invalidate('ui.render')
393}
394
395// This chat's own sync: what it dismissed comes back, and the shells the last turn's end saw running that it does not
396// follow (missed, or dismissed while still running) are followed again, start unknown. Its own dismissals are the keys
397// with no ':' ("bg-<id>", "<file id>@<started>"); another chat's rows hidden here are "<chat>:<row>".
398async function syncSelf($: EngineInterface) {
399 for (const k of [...gone]) if (!k.includes(':')) gone.delete(k)
400 const now = (await $.clock.now()) / 1000
401 for (const t of lastLive) if (t.type === 'shell' && !bg.has(t.id)) bg.set(t.id, trackOf(now, t.description, { isLate: true }))
402 for (const t of bg.values()) if (t.endedAt === undefined) t.isCounted = undefined // its kind read again from its output
403 await save($)
404}
405
406// A tab's sync. This chat: its own sync. Another chat: what was hidden here comes back, and that chat is asked to sync
407// itself (a request file its mod reads on its next tick). All: both, for every chat, and closed tabs come back.
408async function syncTab($: EngineInterface, id: string) {
409 if (id === 'all' || id === selfId) await syncSelf($)
410 for (const g of groups) {
411 if (g.isSelf || (id !== 'all' && g.id !== id)) continue
412 for (const k of [...gone]) if (k.startsWith(`${g.id}:`)) gone.delete(k)
413 if (!g.id.startsWith('project:')) await $.store.set(`sync:${g.id}`, Date.now()).catch(() => {})
414 }
415 if (id === 'all') await update($, closedAtom, () => [])
416 await runNow?.()
417}
418
419// A tab's ⟳ Refresh: its sync, then its finished rows leave (this chat's for good, another chat's here).
420async function refreshTab($: EngineInterface, id: string) {
421 await syncTab($, id)
422 for (const g of groups) if (id === 'all' || g.id === id) for (const r of g.rows) if (isOver(r)) dismiss($, g.id, r.id)
423}
424
425// A small JSON file read again only when its mtime moved; one caught half-written keeps its last content.
426async function fresh<T>($: EngineInterface, cache: Map<string, { mtime: number; v: T }>, key: string, path: string, mtime: number) {
427 const was = cache.get(key)
428 if (was?.mtime === mtime) return was.v
429 const v = await $.fs.read(path).then(text => JSON.parse(text) as T).catch(() => undefined)
430 if (v === undefined) return was?.v
431 cache.set(key, { mtime, v })
432 return v
433}
434
435async function tick($: EngineInterface, dir: string, root: string) {
436 const now = (await $.clock.now()) / 1000
437 // the presence clock, and a watcher of this chat's when none is live
438 const pr = (await $.store.get('presence').catch(() => undefined)) as Presence | undefined
439 activeNow = pr && now - pr.at < PRESENCE_STALE_S ? pr.activeS : undefined
440 const wasHere = isHere
441 isHere = activeNow !== undefined && pr?.here === true
442 if (isHere && !wasHere) $.ui.invalidate('ui.render') // back at Claude: what the band shows now is seen
443 if (activeNow === undefined && isWindows && !demo && now - watchTried > 60) {
444 watchTried = now
445 void watch($)
446 }
447 // file jobs (any session's or none)
448 const files: { id: string; job: Job; row: Row }[] = []
449 const listed = new Set<string>()
450 for (const f of (await $.fs.list(dir).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name))) {
451 if (f.kind !== 'file' || !f.name.endsWith('.json') || f.name === 'demo.json' || now - f.mtimeMs / 1000 > KEEP_ENDED_S) continue
452 const id = f.name.slice(0, -5)
453 listed.add(id)
454 const job = await fresh<Job>($, lastGood, id, `${dir}/${f.name}`, f.mtimeMs)
455 if (!job) continue
456 const isEnded = job.finished || job.stopped
457 if (!isEnded && now - f.mtimeMs / 1000 > KEEP_S) continue
458 if (isEnded) await seenOf($, `${id}@${job.started}`)
459 const row = rowOf(id, job, now)
460 if (row && !gone.has(`${id}@${job.started}`)) files.push({ id, job, row })
461 }
462 // the caches keep only what is still listed
463 for (const id of lastGood.keys()) if (!listed.has(id)) lastGood.delete(id)
464
465 for (const k of firstSeen.keys()) if (!listed.has(k.slice(0, k.lastIndexOf('@')))) firstSeen.delete(k)
466 for (const id of lastLine.keys()) if (!bg.has(id)) lastLine.delete(id)
467 // this session's background shells
468 const own: Row[] = []
469 let isChanged = false
470 for (const [id, t] of bg) {
471 let output: string | undefined
472 if (t.endedAt === undefined && t.output) {
473 // read again only when it grew, and a big one every BIG_EVERY_S at most
474 const st = await $.fs.stat(t.output).catch(() => undefined)
475 const was = lastLine.get(id)
476 if (was && st && (st.size === was.size || (st.size > BIG_OUTPUT && now - was.at < BIG_EVERY_S))) output = was.line
477 else if (st && st.size > 0 && st.size < OUTPUT_MAX) {
478 const text = await $.fs.read(t.output).catch(() => undefined)
479 if (text !== undefined) lastLine.set(id, { size: st.size, at: now, line: lastLineOf(text) })
480 output = lastLine.get(id)?.line
481 }
482 }
483 const wasCounted = t.isCounted
484 if (t.endedAt !== undefined) await seenOf($, `bg-${id}`)
485 const row = bgRowOf(id, t, now, output)
486 isChanged ||= t.isCounted !== wasCounted || row === undefined
487 if (row) own.push(t.spent ? { ...row, spent: spentText(t.spent) } : row)
488 else if (row === undefined) {
489 bg.delete(id)
490 await forget($, `bg-${id}`)
491 }
492 }
493 if (isChanged) await save($)
494 // what Claude spent on this chat's file jobs; a job no longer listed is forgotten
495 ownJobs = files.filter(f => isMine(f.job, selfId, root)).map(f => `${f.id}@${f.job.started}`)
496 const jobSpent = await read($, jobSpentAtom)
497 if (Object.keys(jobSpent).some(k => !listed.has(k.slice(0, k.lastIndexOf('@')))))
498 await update($, jobSpentAtom, m => Object.fromEntries(Object.entries(m).filter(([k]) => listed.has(k.slice(0, k.lastIndexOf('@'))))))
499 const jobs = Object.fromEntries(ownJobs.flatMap(k => (jobSpent[k] ? [[k, spentText(jobSpent[k])]] : [])))
500 // published for the other sessions' All tab: when it changes, and as a heartbeat
501 const text = JSON.stringify([own, jobs])
502 if (text !== published || now - publishedAt > PUBLISH_S) {
503 const pub: Published = { session: selfId, label, updated: now, rows: own, jobs }
504 await $.store.set(`chat:${selfId}`, pub).catch(() => {})
505 published = text
506 publishedAt = now
507 }
508 const demoFile = (await $.fs.list(dir).catch(() => [])).find(f => f.name === 'demo.json')
509 demo = demoFile ? await fresh<Demo>($, lastDemo, 'demo', `${dir}/demo.json`, demoFile.mtimeMs) : undefined
510 // another chat's ⟳ Refresh on this chat's tab: a request newer than the one last seen (the first read only notes it)
511 const asked = Number((await $.store.get(`sync:${selfId}`).catch(() => 0)) ?? 0)
512 if (syncSeen !== undefined && asked !== syncSeen) await syncSelf($)
513 syncSeen = asked
514 // the groups: this session (its shells and its file jobs), each live session that publishes, the rest by project
515 const self: Group = {
516 id: selfId,
517 label: demo?.label ?? label,
518 isSelf: true,
519 rows: demo ? [] : [...own],
520 }
521 const byId = new Map<string, Group>([[selfId, self]])
522 // the other chats: the store's entries (a demo's made-up chats come from files, sessions/demo-*.json)
523 const chats: [string, Published][] = []
524 if (!demo) {
525 const keys = await $.store.keys().catch(() => [] as string[])
526 // once a minute: a file job's mark goes with its file, any mark a day old (its chat ended with the row on)
527 if (now - sweptAt > 60) {
528 sweptAt = now
529 for (const k of keys.filter(k => k.startsWith('seen:'))) {
530 const key = k.slice(5)
531 const v = (await $.store.get(k).catch(() => undefined)) as { at?: number } | undefined
532 const isGone = !key.startsWith('bg-') && !listed.has(key.slice(0, key.lastIndexOf('@')))
533 if (isGone || now - (v?.at ?? now) > 86_400) await forget($, key)
534 }
535 }
536 for (const k of keys.filter(k => k.startsWith('chat:')).sort()) {
537 const sid = k.slice(5)
538 const p = sid === selfId ? undefined : ((await $.store.get(k).catch(() => undefined)) as Published | undefined)
539 if (!p) continue
540 if (now - p.updated > FORGET_S) {
541 await $.store.delete(k).catch(() => {})
542 await $.store.delete(`sync:${sid}`).catch(() => {})
543 } else if (now - p.updated <= ALIVE_S) chats.push([sid, p])
544 }
545 } else {
546 for (const f of (await $.fs.list(`${dir}/sessions`).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name))) {
547 const sid = f.name.slice(0, -5)
548 if (!f.name.endsWith('.json') || !sid.startsWith('demo-') || now - f.mtimeMs / 1000 > ALIVE_S) continue
549 const p = await fresh<Published>($, lastPub, sid, `${dir}/sessions/${f.name}`, f.mtimeMs)
550 if (p) chats.push([sid, p])
551 }
552 }
553 for (const [sid, p] of chats) {
554 if (demo && sid === 'demo-self') {
555 self.rows.push(...p.rows.map(r => (r.spent || isOver(r) ? r : { ...r, spent: spentText(demoSpent(r.id)) })))
556 continue
557 }
558 byId.set(sid, { id: sid, label: p.label, rows: p.rows.filter(r => !gone.has(`${sid}:${r.id}`)) })
559 }
560 // a file job's spend: this chat's own, or what the chat that launched it publishes
561 const theirs = Object.assign({}, ...chats.map(([, p]) => p.jobs ?? {})) as Record<string, string>
562 for (const { id, job, row: bare } of files) {
563 if (demo && !id.startsWith('demo-')) continue
564 const spent = jobs[`${id}@${job.started}`] ?? theirs[`${id}@${job.started}`]
565 const row = spent ? { ...bare, spent } : bare
566 if (isMine(job, selfId, root)) {
567 self.rows.push(row)
568 continue
569 }
570 const gid = job.session ?? `project:${job.project ?? ''}`
571 const name = job.session ? `chat ${job.session.slice(0, 8)}` : (job.project ?? '').split(/[\\/]/).filter(Boolean).pop() ?? 'jobs'
572 if (!byId.has(gid)) byId.set(gid, { id: gid, label: name, rows: [] })
573 if (!gone.has(`${gid}:${row.id}`)) byId.get(gid)?.rows.push(row)
574 }
575 // what still runs first: the band scrolls past its room, and the finished rows are the ones that can wait
576 const next = [...byId.values()]
577 .filter(g => g.isSelf || g.rows.length > 0)
578 .map(g => ({ ...g, rows: [...g.rows].sort((a, b) => Number(isOver(a)) - Number(isOver(b))) }))
579 // the session's figures for the minimized band's top row (free: no breakdown asked)
580 const u = demo ? demoUsage(Math.floor(now / 60) * 60) : await $.session.usage().catch(() => undefined)
581 const isNewUsage = JSON.stringify(u) !== JSON.stringify(usage)
582 usage = u
583 if (JSON.stringify(next) !== JSON.stringify(groups) || isNewUsage) {
584 groups = next
585 $.ui.invalidate('ui.render')
586 }
587}
588
589// Demo mode, for screenshots: ~/.claude/progress/demo.json { scene, label } turns it on. Made-up figures, this chat
590// named `label` with the rows of sessions/demo-self.json (none of its own), only the made-up chats and job files (named
591// demo-*), and `scene` picks what shows: all, self, mini, info
592// (pinned), tools (the open tab's), name (the first cut name's card), spent (the first row's). No file: off.
593type Demo = { scene?: string; label?: string }
594let demo: Demo | undefined
595const lastDemo = new Map<string, { mtime: number; v: Demo }>()
596const demoUsage = (now: number): SessionUsage => ({
597 startedAt: (now - 7_200) * 1000,
598 context: { tokens: 46_000, window: 200_000, percent: 23 },
599 rateLimits: [
600 { kind: 'five_hour', percentUsed: 41, resetsAt: new Date((now + 6_300) * 1000).toISOString() },
601 { kind: 'seven_day', percentUsed: 73, resetsAt: new Date((now + 280_000) * 1000).toISOString() },
602 ],
603 cost: { usd: 3.32 },
604})
605const demoTokens = (now: number) => ({ since: (now - 7_200) * 1000, in: 1_987_000, cacheRead: 1_700_000, cacheWrite: 120_000, out: 19_870 })
606// a row's made-up spend, the same for the same row
607const demoSpent = (id: string): Spent => {
608 const k = [...id].reduce((n, c) => n + c.charCodeAt(0), 0)
609 return { tokensIn: 120_000 + (k % 7) * 61_000, out: 900 + (k % 5) * 700, usd: 0.08 + (k % 9) * 0.07, steps: 1 + (k % 4) }
610}
611
612// The session's figures: the minimized band's top row and the tabs' ⓘ Info card. Context, rate limits (a ring
613// filled to the share used: blue, amber from 70 %, red from 90 %), tokens, cost; the details in a card.
614let usage: SessionUsage | undefined
615let isInfoPinned = false // ⓘ Info pressed: its card stays open
616const LEVEL = [
617 [90, '#E5534B', 'error'],
618 [70, '#E0A030', 'warning'],
619 [0, '#4A8FE7', 'suggestion'],
620] as const
621const levelOf = (pct: number) => LEVEL.find(([from]) => pct >= from) ?? LEVEL[2]
622const tokensAtom = atom({ plugin: 'detaly', key: 'tokens' } as const, { since: 0, in: 0, cacheRead: 0, cacheWrite: 0, out: 0 })
623const jobSpentAtom = atom({ plugin: 'detaly', key: 'jobSpent' } as const, {} as Record<string, Spent>)
624let ownJobs: string[] = [] // this chat's file jobs, "<id>@<started>", as the last tick listed them
625const big = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`)
626const LIMIT = { five_hour: ['5h', '5-hour limit'], seven_day: ['week', 'Weekly limit'], spend_limit: ['spend', 'Spend limit'] } as const
627type Stat = { text: string; f?: number }
628function statsOf(now: number, t: { since: number; in: number; cacheRead: number; cacheWrite: number; out: number }) {
629 const line: Stat[] = []
630 const card: string[] = []
631 const ctx = usage?.context
632 if (ctx?.percent !== undefined) {
633 line.push({ text: `ctx ${ctx.percent}%` })
634 card.push(`Context ${big(ctx.tokens ?? 0)} of ${big(ctx.window)} tokens (${ctx.percent} %)`)
635 }
636 for (const r of usage?.rateLimits ?? []) {
637 const [short, long] = LIMIT[r.kind as keyof typeof LIMIT] ?? [r.kind, r.kind]
638 line.push({ text: `${short} ${r.percentUsed}%`, f: r.percentUsed / 100 })
639 const at = r.resetsAt ? Date.parse(r.resetsAt) / 1000 : NaN
640 card.push(`${long} ${r.percentUsed} %${Number.isNaN(at) ? '' : ` · resets ${clockAt(now, at - now)}`}`)
641 }
642 const input = t.in + t.cacheRead + t.cacheWrite
643 if (input + t.out > 0) {
644 line.push({ text: `${big(input)} in · ${big(t.out)} out` })
645 card.push(`Tokens since ${clockAt(now, t.since / 1000 - now)}: in ${big(input)} (cache read ${big(t.cacheRead)}, written ${big(t.cacheWrite)}) · out ${big(t.out)}`)
646 }
647 if (usage?.cost) {
648 line.push({ text: `$${usage.cost.usd.toFixed(2)}` })
649 card.push(`Cost $${usage.cost.usd.toFixed(2)} · session ${coarse(now - usage.startedAt / 1000)}`)
650 }
651 return { line, card }
652}
653
654async function closeTab($: EngineInterface, id: string, isActive: boolean) {
655 await update($, closedAtom, c => [...c, id])
656 if (isActive) await update($, tabAtom, () => '')
657}
658
659// "<folder> · <start of the first message>", the folder alone before one
660const labelOf = (dirName: string, text: string) => {
661 const t = promptOf(text).replace(/\s+/g, ' ').slice(0, 40)
662 return t ? `${dirName} · ${t}` : dirName
663}
664// A user row's own words: tagged blocks (reminders, command echoes) cut whole; the rows the engine itself puts in a
665// transcript (worktree notes, caveats, continuations) count as none.
666const promptOf = (text: string) => {
667 const t = text
668 .replace(/<([\w-]+)[^>]*>[\s\S]*?<\/\1>/g, ' ')
669 .replace(/<[^>]*>/g, ' ')
670 .trim()
671 return /^(You are operating in a git worktree|Caveat:|\[SYSTEM|This session is being continued)/.test(t) ? '' : t
672}
673
674// Theme keys for the text, the same hues as hex for the desktop's SVG bar (an image: no theme there).
675const COLOR = { run: 'claude', stale: 'warning', done: 'success', fail: 'error', stop: 'inactive' } as const
676const HEX = { run: '#D97757', stale: '#E0A030', done: '#4EBA65', fail: '#E5534B', stop: '#999999' } as const
677const ICON = { run: '●', stale: '◌', done: '✓', fail: '✕', stop: '■' } as const
678// ponytail: the desktop draws cells ~7.85 px wide (measured on Windows 11 at 100 % zoom); an SVG is sized in px, so
679// a bar that fills its column is cells * this. Another zoom leaves the bar a little short or long of its column.
680const CELL_PX = 7.85
681const ICON_W = 3 // the icon cell, in cells: room for a desktop's native button when the × replaces the icon
682// ponytail: a desktop draws a name in a proportional bold face, so whether it is cut is a guess at its width by letter
683// class (fitted to names seen cut or whole in a 17-cell column); a wrong guess shows the whole-name card on a name that
684// fits, or none on one cut by a letter. The terminal counts cells.
685const namePx = (t: string) =>
686 [...t].reduce((n, c) => n + (/[iljtfrI.,:;'|!()\s]/.test(c) ? 3.9 : /[mwMW]/.test(c) ? 11.1 : /[A-Z]/.test(c) ? 8.5 : 7), 0)
687
688const BAR_H = 20 // a bar's height in px: its numbers inside, in a row two lines tall
689const THIN_H = 12 // a thin bar's, in a row one line tall (~19 px)
690const PAGE_JOBS = 10 // job rows on a page at most
691const TRACK = '#4d4d4d' // solid, so white numbers read on it in a dark and a light theme alike
692// a hover card: a desktop draws a placed Box with no ground of its own, so the rows under it showed through
693const CARD_BG = '#2a2a2a'
694const CARD_TEXT = '#ececec'
695const CARD_EDGE = '#3a3a3a' // a soft light rounded edge, as a desktop's own popovers have
696const padOf = (h: number) => (h >= BAR_H ? 4 : 2) // px of air above and below a bar, so the rows do not touch
697
698// w, h: the bar's size in px; the picture is h + 2 padOf(h) tall
699const bar = (w: number, h: number, body: string) =>
700 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h + 2 * padOf(h)}" viewBox="0 ${-padOf(h)} ${w} ${h + 2 * padOf(h)}">${body}</svg>`
701const pill = (w: number, h: number, fill = TRACK, more = '') => `<rect width="${w}" height="${h}" rx="${h / 2}" fill="${fill}"${more}/>`
702const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
703
704// A counted bar with its numbers inside, centred: light on the track, black where the fill runs under them. Segoe UI
705// (the image sees system fonts only; the variable face drops the weight there), bold, tabular figures so the numbers
706// do not jitter as they count. The baseline is set by hand: dominant-baseline is drawn low in an image.
707const svgCounted = (w: number, h: number, f: number, state: State, pct: string, counts: string) => {
708 const fw = f > 0 ? Math.max(h, Math.round(f * w)) : 0
709 const text = (color: string, soft: number) =>
710 barText(w, h, color, `<tspan font-weight="700">${esc(pct)}</tspan>` + (counts ? `<tspan dx="10" font-weight="600" fill-opacity="${soft}">${esc(counts)}</tspan>` : ''))
711 return bar(w, h, pill(w, h) + pill(fw, h, HEX[state]) + split(w, h, fw, text('#ececec', 0.65), text('#141414', 0.9)))
712}
713// text centred in a bar
714const barText = (w: number, h: number, color: string, spans: string) =>
715 `<text x="${w / 2}" y="${h / 2 + (h >= BAR_H ? 4.3 : 3.4)}" text-anchor="middle" fill="${color}" ` +
716 `font-family="'Segoe UI',system-ui,sans-serif" font-size="${h >= BAR_H ? 12 : 9.5}" style="font-variant-numeric:tabular-nums">${spans}</text>`
717// each colour only where its ground is: the light text off the fill (fw px), the dark one on it (drawn over each
718// other, the light one's anti-aliased edges ringed the dark one in white)
719const split = (w: number, h: number, fw: number, light: string, dark: string) => {
720 const r = h / 2
721 const fill = fw > 0 ? `M${r} 0H${fw - r}A${r} ${r} 0 0 1 ${fw - r} ${h}H${r}A${r} ${r} 0 0 1 ${r} 0Z` : ''
722 return (
723 `<defs><clipPath id="f"><path d="${fill}"/></clipPath>` +
724 `<clipPath id="t"><path clip-rule="evenodd" d="M0 0H${w}V${h}H0Z${fill}"/></clipPath></defs>` +
725 `<g clip-path="url(#t)">${light}</g>` +
726 (fw > 0 ? `<g clip-path="url(#f)">${dark}</g>` : '')
727 )
728}
729
730// An animation's start in the past, so it runs on the wall clock: a desktop draws each Svg as a new image whenever the
731// band is drawn again (every tick while a job counts), and an image's animation starts from its beginning; begun
732// `t mod dur` ago, the new image picks up where the old one was.
733const since = (t: number, dur: number) => `begin="-${(t % dur).toFixed(2)}s"`
734
735// A timer's bar, animated so a wait does not look stalled (SMIL in a plain image: no frame to reload and flash):
736// filled to f with stripes running through it, or without f a soft segment sweeping the track; "waiting" inside, so
737// it does not read as work counted. t: now, unix s.
738const svgWait = (w: number, h: number, f: number | undefined, t: number) => {
739 const c = HEX.run
740 const body =
741 f === undefined
742 ? `<defs><linearGradient id="g"><stop offset="0" stop-color="${c}" stop-opacity="0"/><stop offset="0.5" stop-color="${c}"/>` +
743 `<stop offset="1" stop-color="${c}" stop-opacity="0"/></linearGradient></defs>${pill(w, h)}` +
744 `<rect width="80" height="${h}" fill="url(#g)"><animate attributeName="x" values="-80;${w};-80" dur="${(3 * w) / 160}s" ${since(t, (3 * w) / 160)} repeatCount="indefinite"/></rect>`
745 : `<defs><pattern id="s" width="${h}" height="${h}" patternUnits="userSpaceOnUse">` +
746 `<path d="M0 ${h}L${h / 2} 0H${h}L${h / 2} ${h}Z" fill="#fff" fill-opacity="0.2"/>` +
747 `<animateTransform attributeName="patternTransform" type="translate" from="0 0" to="${h} 0" dur="1s" ${since(t, 1)} repeatCount="indefinite"/></pattern></defs>` +
748 pill(w, h) +
749 pill(Math.max(h, Math.round(f * w)), h, c, ' fill-opacity="0.85"') +
750 pill(Math.max(h, Math.round(f * w)), h, 'url(#s)')
751 const label = (color: string, soft: number) =>
752 barText(w, h, color, `<tspan font-weight="600" fill-opacity="${soft}">${f === undefined ? 'waiting · no estimate' : 'waiting'}</tspan>`)
753 const fw = f === undefined ? 0 : Math.max(h, Math.round(f * w))
754 return bar(w, h, `<clipPath id="r">${pill(w, h)}</clipPath><g clip-path="url(#r)">${body}</g>` + split(w, h, fw, label('#ececec', 0.7), label('#141414', 0.9)))
755}
756
757// A timer's ring: filled clockwise to f, or an arc spinning without f (SMIL).
758const RING = 2 * Math.PI * 5.5
759const svgRing = (f: number | undefined, hex: string, t = 0) => {
760 const arc = `cx="7" cy="7" r="5.5" fill="none" stroke-width="2"`
761 const fill =
762 f === undefined
763 ? `<circle ${arc} stroke="${hex}" stroke-linecap="round" stroke-dasharray="9 ${RING}">` +
764 `<animateTransform attributeName="transform" type="rotate" from="0 7 7" to="360 7 7" dur="1s" ${since(t, 1)} repeatCount="indefinite"/></circle>`
765 : `<circle ${arc} stroke="${hex}" stroke-dasharray="${(f * RING).toFixed(1)} ${RING}" transform="rotate(-90 7 7)"/>`
766 return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><circle ${arc} stroke="#888" stroke-opacity="0.35"/>${fill}</svg>`
767}
768
769export const register: Register = on => {
770 // a chat that ends takes its entry out of the store (the others would forget it after FORGET_S anyway)
771 on('session.end', async ($, e, next) => {
772 await $.store.delete(`chat:${selfId}`).catch(() => {})
773 return next(e)
774 })
775
776 on('session.start', async ($, e, next) => {
777 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
778 const dir = `${home}/.claude/progress`
779 progressDir = dir
780 isWindows = (await $.env.get('OS')) === 'Windows_NT'
781 selfId = await $.session.id()
782 // a worktree session belongs to its repository: <repo>/.claude/worktrees/<name>
783 const root = (await $.session.root()).replace(/[\\/]\.claude[\\/]worktrees[\\/][^\\/]+$/, '')
784 folder = root.split(/[\\/]/).filter(Boolean).pop() ?? 'chat'
785 const first = (await $.session.messages().catch(() => [])).find(m => m.role === 'user' && promptOf(m.text))
786 label = labelOf(folder, first?.text ?? '')
787 await $.env.set('CLAUDE_JOB_SESSION', selfId) // the helpers tag what this session launches
788 for (const [id, t] of await read($, tasks)) bg.set(id, t) // what ran before this load of the mod
789 await $.command.register({ name: 'detaly', description: "Detaly: open the band on the All tab (every chat's jobs)" })
790 let isBusy = false
791 const run = async () => {
792 if (isBusy) return
793 isBusy = true
794 try {
795 await tick($, dir, root)
796 } finally {
797 isBusy = false
798 }
799 }
800 runNow = run
801 $.clock.every(TICK_MS, run)
802 void run()
803
804 return next(e)
805 })
806
807 on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
808 const ran = await next(e)
809 if (e.run_in_background) {
810 const id = launched((await $.clock.now()) / 1000, e.description, e.command, ran.text)
811 const t = id ? bg.get(id) : undefined
812 if (id && launchedInStep) launchedInStep.push(id)
813 else if (t && launchShare) t.spent = plus(t.spent, launchShare)
814 await save($)
815 }
816 return ran
817 })
818
819 // A stopped shell sends no notification: its end is the stop.
820 on('tool.call', { tool: ['TaskStop', 'KillShell'] }, async ($, e, next) => {
821 const ran = await next(e)
822 const t = bg.get(String((e as { task_id?: unknown; shell_id?: unknown }).task_id ?? (e as { shell_id?: unknown }).shell_id))
823 if (t && t.endedAt === undefined && !('deny' in ran) && !ran.isError) {
824 Object.assign(t, { endedAt: (await $.clock.now()) / 1000, status: 'stopped' })
825 await save($)
826 }
827 return ran
828 })
829
830 // Its end: the notification row (<task-id>, <status>) as the session keeps it; one after a turn's end found the
831 // shell gone still names how it ended.
832 on('session.append', async ($, e, next) => {
833 const text = JSON.stringify(e.message.content ?? '')
834 if (text.includes('<task-id>')) {
835 const now = (await $.clock.now()) / 1000
836 for (const [, id = '', status] of text.matchAll(/<task-id>([^<]+)<\/task-id>[\s\S]*?<status>([^<]+)<\/status>/g)) {
837 const t = bg.get(id)
838 if (t && t.status === undefined) Object.assign(t, { endedAt: t.endedAt ?? now, status })
839 if (t && !isTurn) woke.push(id)
840 }
841 await save($)
842 }
843 return next(e)
844 })
845
846 // At a turn's end the engine lists what still runs: adds shells the mod did not see start (a reload), ends the rest.
847 on('classic.Stop', async ($, e, next) => {
848 const live = e.background_tasks
849 if (live) {
850 lastLive = live
851 const now = (await $.clock.now()) / 1000
852 for (const t of live) {
853 if (t.type === 'shell' && !bg.has(t.id) && !gone.has(`bg-${t.id}`)) bg.set(t.id, trackOf(now, t.description, { isLate: true }))
854 }
855 for (const [id, t] of bg) if (t.endedAt === undefined && !live.some(l => l.id === id)) t.endedAt = now
856 await save($)
857 }
858 return next(e)
859 })
860
861 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
862 const drawnAt = (await $.clock.now()) / 1000 // the animations' phase
863 if (e.props.hasSurvey) return next(e)
864 const stored = await read($, tabAtom)
865 const closed = await read($, closedAtom)
866 const scene = demo?.scene
867 const isMini = scene ? scene === 'mini' : await read($, miniAtom)
868 const tabs = groups.filter(g => g.isSelf || !closed.includes(g.id))
869 const self = tabs.find(g => g.isSelf)
870 const active = scene
871 ? ['all', 'info', 'tools'].includes(scene) ? 'all' : selfId
872 : !isMini && (stored === 'all' || tabs.some(g => g.id === stored && !g.isSelf)) ? stored : selfId
873 // this chat's tab with nothing in it: the band stays away (/detaly, or a job, brings it); minimized, its top row
874 // stays, a strip of the session's figures and ▴ Tabs
875 if (!isMini && active === selfId && !self?.rows.length) return next(e)
876 const shown = active === 'all' ? tabs.filter(g => g.rows.length > 0) : tabs.filter(g => g.id === active)
877 const all = shown.flatMap(g => g.rows)
878 const shownSpent = all.find(r => r.spent)?.id
879 // A card shown for good, or revealed while its keyed Box is hovered (the engine refuses a hover that reveals a card
880 // already shown).
881 const shownOr = (isShown: boolean) => (isShown ? { display: 'flex' as const } : { display: 'none' as const, hover: { display: 'flex' as const } })
882 let shownCut: string | undefined // set once the name column's width is known
883 // A card the desktop lifts out of the band while its keyed Box is hovered (scope-less, hidden, placed): drawn in the
884 // band it was cut at the band's edge (the band clips); lifted, it sits above the Box (under it without room there)
885 // in the desktop's own card, theme colours, its place and box props dropped. A terminal draws it in place.
886 const lifted = { position: 'absolute' as const, top: 1, left: 0, flexDirection: 'column' as const, paddingX: 1, borderStyle: 'round', ...shownOr(false) }
887
888 const { Box, Text, Button } = $.ui.resolve(e)
889 const ui = $.ui.resolve(e)
890 const Client = 'Client' in ui ? ui.Client : undefined // the pages' column, a surface module (desktop, terminal)
891 // by the surface: the element table answers any name ('Svg' in it is true on the terminal too)
892 const Svg = e.surface === 'desktop' || e.surface === 'mobile' ? $.ui.resolve(e).Svg : undefined
893 // Columns: icon, name (under a fifth of the band; one line, the whole on hover), bar (the rest; a counted row's
894 // numbers inside it), time (right-aligned, as wide as its longest text, 12 to 28). Every row is two lines tall, or one
895 // when they overflow (below).
896 const tailW = Math.max(12, Math.min(28, Math.max(0, ...all.map(r => r.tail.length))))
897 const nameW = Math.max(12, Math.min(22, Math.floor(e.props.bodyColumns * 0.18)))
898 const isCut = (t: string) => (Svg ? namePx(t) > nameW * CELL_PX : t.length > nameW)
899 shownCut = all.find(r => isCut(r.title))?.id
900 // The header (the tab row and its gap; minimized, ▴ Tabs) and the items under it: as tall as they are, up to the
901 // band's rows (and half the screen) less one: a desktop's rows run a few pixels taller than the engine counts
902 // (the tabs' native buttons), and a band past its room in pixels scrolls under the wheel. Past the room, pages,
903 // their numbers at the right (three cells off the bar). Every row has an explicit height: a desktop counts a margin
904 // in cells across (~8 px), not rows (~19 px).
905 const head = isMini ? 1 : 2
906 const half = e.viewport?.rows ? Math.floor(e.viewport.rows / 2) : 99
907 const room = Math.max(2, Math.min(e.props.maxRows || 99, half) - head - 1)
908 // A job row is two lines tall, one (a thin bar) when the two-line rows overflow the room. Pages hold at most
909 // PAGE_JOBS rows and the room's lines; a page that opens inside a chat's rows repeats its heading (in All), and a
910 // heading never ends a page.
911 const isAll = active === 'all'
912 const thin = shown.reduce((n, g) => n + 2 * g.rows.length + (isAll ? 1 : 0), 0) > room
913 const rowH = thin ? 1 : 2
914 type Slot = { g: Group; r?: Row } // a chat's heading (no r) or one of its rows
915 const pages: Slot[][] = []
916 let cur: Slot[] = []
917 let used = 0
918 let jobs = 0
919 for (const g of shown) {
920 g.rows.forEach((r, i) => {
921 const lead = isAll && (i === 0 || !cur.length) ? 1 : 0 // a heading before a chat's first row, atop a page
922 if (cur.length && (used + lead + rowH > room || jobs === PAGE_JOBS)) {
923 pages.push(cur)
924 cur = []
925 used = 0
926 jobs = 0
927 }
928 if (isAll && (i === 0 || !cur.length)) {
929 cur.push({ g })
930 used += 1
931 }
932 cur.push({ g, r })
933 used += rowH
934 jobs++
935 })
936 }
937 if (cur.length) pages.push(cur)
938 const hasMore = pages.length > 1
939 const barW = Math.max(16, e.props.bodyColumns - ICON_W - nameW - tailW - 2 * 3 - (hasMore ? 3 : 0))
940 const px = Math.floor(barW * CELL_PX) - 2
941 const icon = (r: Row) => {
942 if (!r.isTimer || r.state !== 'run') return <Text color={COLOR[r.state]}>{ICON[r.state]}</Text>
943 if (Svg) {
944 const alt = r.f === undefined ? 'running' : `${Math.floor(r.f * 100)} %`
945 return <Svg source={svgRing(r.f, HEX[r.state], drawnAt)} alt={alt} width={14} height={14} />
946 }
947 const glyph = r.f === undefined ? '◌' : '○◔◑◕●'[Math.min(4, Math.round(r.f * 4))]
948 return <Text color={COLOR[r.state]}>{glyph}</Text>
949 }
950 // The bar: a running timer's animated wait, or a counted bar with its percent and counts inside it.
951 const middle = (r: Row) => {
952 const barH = thin ? THIN_H : BAR_H // not 'h': that is the JSX factory
953 if (r.isTimer && r.state === 'run') {
954 const alt = r.f === undefined ? 'waiting' : `${Math.floor(r.f * 100)} % of the wait`
955 if (Svg) return <Svg source={svgWait(px, barH, r.f, drawnAt)} alt={alt} width={px} height={barH + 2 * padOf(barH)} />
956 if (r.f !== undefined) return <Text color={COLOR.run} dimColor>{'━'.repeat(Math.floor(r.f * barW)).padEnd(barW, '─')}</Text>
957 return <Text color="subtle">{'┄'.repeat(barW)}</Text>
958 }
959 const f = r.f
960 if (f === undefined) return <Text>{''}</Text>
961 const pct = `${Math.floor(f * 100)} %`
962 const counts = r.isTimer ? '' : r.counts
963 if (Svg) {
964 const label = r.isTimer ? '' : pct // a finished timer: a plain full bar
965 return <Svg source={svgCounted(px, barH, f, r.state, label, counts)} alt={[pct, counts].filter(Boolean).join(' ')} width={px} height={barH + 2 * padOf(barH)} />
966 }
967 const textW = r.isTimer ? 0 : pct.length + (counts ? counts.length + 2 : 0) + 2 // a space each side
968 const label = (
969 <Box width={textW} justifyContent="center">
970 <Text>
971 <Text bold dimColor={r.state === 'done'}>{pct}</Text>
972 <Text dimColor>{counts ? ` ${counts}` : ''}</Text>
973 </Text>
974 </Box>
975 )
976 const n = Math.floor(f * barW)
977 const leftW = Math.max(0, Math.floor((barW - textW) / 2))
978 const line = (from: number, len: number) => {
979 const k = Math.min(len, Math.max(0, n - from))
980 return (
981 <Text>
982 <Text color={COLOR[r.state]}>{'━'.repeat(k)}</Text>
983 <Text color="subtle">{'─'.repeat(len - k)}</Text>
984 </Text>
985 )
986 }
987 return (
988 <Box flexDirection="row">
989 {line(0, leftW)}
990 {textW ? label : null}
991 {line(leftW + textW, Math.max(0, barW - leftW - textW))}
992 </Box>
993 )
994 }
995 // this chat's rows keep their own ids as keys; another chat's are prefixed with it
996 const keyOf = (g: Group, r: Row) => (g.isSelf ? r.id : `${g.id}:${r.id}`)
997 const rowView = (g: Group, r: Row) => (
998 <Box key={keyOf(g, r)} flexDirection="row" alignItems="center" gap={2} height={rowH}>
999 {/* hovered (the row's Box is the scope), a ✖ takes the icon's place: the cell clips, and the hidden ✖
1000 before the icon, as wide as the cell, is revealed and pushes the icon out past the edge. This chat's
1001 finished row leaves at once, its running one after "stop tracking?"; another chat's is hidden here. */}
1002 <Box width={ICON_W} flexShrink={0} flexDirection="row" alignItems="center" overflow="hidden">
1003 <Box width={ICON_W} flexShrink={0} justifyContent="center" display="none" hover={{ display: 'flex' }}>
1004 {/* a desktop draws its own button, child colours ignored: the label's colour goes through hover */}
1005 <Button
1006 key={`x-${keyOf(g, r)}`}
1007 plain
1008 label="✖"
1009 hover={{ color: 'error', bold: true }}
1010 onPress={() => (g.isSelf && !isOver(r) ? ask($, r.id) : dismiss($, g.id, r.id))}
1011 />
1012 </Box>
1013 <Box width={ICON_W} flexShrink={0} alignItems="center" justifyContent="center">
1014 {icon(r)}
1015 </Box>
1016 </Box>
1017 {/* hovered, a cut name shows whole, and the bar the job's run and spend, in cards the desktop lifts above them
1018 (the band clips what it draws) */}
1019 <Box key={`n-${keyOf(g, r)}`} width={nameW} alignItems="center">
1020 <Text bold dimColor={r.state === 'done'} wrap="truncate-end">
1021 {r.title}
1022 </Text>
1023 {isCut(r.title) && !(scene === 'name' && r.id === shownCut) ? (
1024 <Box {...lifted}>
1025 <Text bold>{r.title}</Text>
1026 </Box>
1027 ) : null}
1028 </Box>
1029 <Box key={`b-${keyOf(g, r)}`} width={barW}>
1030 {g.isSelf && asking === r.id ? (
1031 <Box flexDirection="row" alignItems="center" gap={1}>
1032 <Text>Stop tracking this job? It keeps running.</Text>
1033 <Button key={`yes-${r.id}`} variant="primary" onPress={() => dismiss($, g.id, r.id)}>
1034 Yes
1035 </Button>
1036 <Button key={`no-${r.id}`} onPress={() => ask($, undefined)}>
1037 No
1038 </Button>
1039 </Box>
1040 ) : (
1041 middle(r)
1042 )}
1043 {(r.spent || r.info) && !(g.isSelf && asking === r.id) && !(scene === 'spent' && r.id === shownSpent) ? (
1044 <Box {...lifted}>
1045 {[r.info, r.spent].filter(Boolean).map(l => <Text key={l}>{l}</Text>)}
1046 </Box>
1047 ) : null}
1048 </Box>
1049 <Box width={tailW} justifyContent="flex-end">
1050 <Text color={r.state === 'run' ? undefined : COLOR[r.state]} dimColor={r.state === 'run'} wrap="truncate-end">
1051 {r.tail}
1052 </Text>
1053 </Box>
1054 </Box>
1055 )
1056 // A demo scene's card shown for good (the whole name, what Claude spent), drawn last in the band at the row's place,
1057 // so nothing drawn after paints over it.
1058 const rowCards = (r: Row, top: number) => [
1059 isCut(r.title) && scene === 'name' && r.id === shownCut ? (
1060 <Box position="absolute" top={top} left={ICON_W + 2} width={Math.ceil((1.25 * namePx(r.title)) / CELL_PX) + 2} paddingX={1} backgroundColor={CARD_BG} borderStyle="round" borderColor={CARD_EDGE}>
1061 <Text bold color={CARD_TEXT}>
1062 {r.title}
1063 </Text>
1064 </Box>
1065 ) : null,
1066 r.spent && scene === 'spent' && r.id === shownSpent ? (
1067 <Box position="absolute" top={top} right={hasMore ? 3 : 0} width={r.spent.length + 2} paddingX={1} backgroundColor={CARD_BG} borderStyle="round" borderColor={CARD_EDGE}>
1068 <Text color={CARD_TEXT}>{r.spent}</Text>
1069 </Box>
1070 ) : null,
1071 ]
1072 // the open page's rows
1073 if (pageOf !== active) page = 0
1074 pageOf = active
1075 page = Math.min(page, Math.max(0, pages.length - 1))
1076 type Item = { rows: number; view: ReturnType<typeof rowView> }
1077 const list: Item[] = (pages[page] ?? []).map(({ g, r }) =>
1078 r
1079 ? { rows: rowH, view: rowView(g, r) }
1080 : { rows: 1, view: <Box key={`head-${g.id}`} height={1}><Text bold dimColor>{g.label}</Text></Box> },
1081 )
1082 void markSeen($, (pages[page] ?? []).map(sl => sl.r), (await $.clock.now()) / 1000)
1083 const cards: ReturnType<typeof rowCards> = []
1084 let rowTop = head
1085 for (const { g, r } of pages[page] ?? []) {
1086 if (r) cards.push(...rowCards(r, rowTop))
1087 rowTop += r ? rowH : 1
1088 }
1089 if (active === 'all' && all.length === 0)
1090 list.push({ rows: 1, view: <Box key="none" height={1}><Text dimColor>No jobs in any chat.</Text></Box> })
1091 // All, and any tab with pages, keeps one height whatever page is open
1092 const shownRows = list.reduce((n, it) => n + it.rows, 0)
1093 if ((pages.length > 1 || active === 'all') && shownRows < room) list.push({ rows: room - shownRows, view: <Box key="pad" height={room - shownRows} /> })
1094 const pager =
1095 pages.length > 1 && Client ? (
1096 <Box position="absolute" top={head} right={0} width={3} height={room}>
1097 <Client key="pager" module="./pager.tsx" props={{ page, pages: pages.length, rows: room }} width={3} height={room} />
1098 </Box>
1099 ) : null
1100
1101 // Minimized: this chat's rows alone, the session's figures over them, ⟳ Refresh (this chat's); ▴ Tabs, where
1102 // ▾ Minimize was, brings the tabs back.
1103 // The figures in a row, a limit with its ring; the details in a card under it.
1104 const nowS = (await $.clock.now()) / 1000
1105 const stats = statsOf(nowS, demo ? demoTokens(nowS) : await read($, tokensAtom))
1106 const figures = (
1107 <Box flexDirection="row" alignItems="center" gap={1} flexShrink={1} overflow="hidden">
1108 {stats.line.map((st, i) => {
1109 const [, hex, theme] = levelOf((st.f ?? 0) * 100)
1110 return (
1111 <Box key={`stat-${i}`} flexDirection="row" alignItems="center" gap={1} flexShrink={0}>
1112 {i ? <Text dimColor>·</Text> : null}
1113 {st.f === undefined ? null : Svg ? (
1114 <Svg source={svgRing(Math.min(1, Math.max(0.02, st.f)), hex)} alt={st.text} width={14} height={14} />
1115 ) : (
1116 <Text color={theme}>{'○◔◑◕●'[Math.min(4, Math.round(st.f * 4))]}</Text>
1117 )}
1118 <Text dimColor>{st.text}</Text>
1119 </Box>
1120 )
1121 })}
1122 </Box>
1123 )
1124 const details = (color?: string) => stats.card.map(l => (
1125 <Text key={l} color={color}>
1126 {l}
1127 </Text>
1128 ))
1129
1130 if (isMini) {
1131 return (
1132 <Box flexDirection="column">
1133 <Box flexDirection="row" alignItems="center" height={1} gap={2}>
1134 <Box key="stat-figures" flexGrow={1} flexShrink={1}>
1135 {figures}
1136 {stats.card.length ? <Box {...lifted}>{details()}</Box> : null}
1137 </Box>
1138 <Button key="refresh-mini" plain label="⟳ Refresh" onPress={() => refreshTab($, selfId)} />
1139 <Button key="expand" plain label="▴ Tabs" onPress={() => update($, miniAtom, () => false)} />
1140 </Box>
1141 {list.map(it => it.view)}
1142 {pager}
1143 {cards}
1144 </Box>
1145 )
1146 }
1147
1148 // The tab row, as a browser's: All, this chat, the other chats with jobs; the open tab is the primary button.
1149 // Hovered, a tab shows its tools in a card under it, in the row between the tabs and the bars: ⟳ Refresh (its sync,
1150 // and its finished rows leave), and on another chat's tab ✖ Close (All's Refresh brings closed tabs back).
1151 const short = (t: string) => (t.length > 24 ? `${t.slice(0, 23)}…` : t)
1152 const running = (g: Group) => g.rows.filter(r => !isOver(r)).length
1153 const tab = (id: string, text: string, count: number, isClosable: boolean) => (
1154 <Box key={`tab-${id}`} flexDirection="row" alignItems="center">
1155 <Button key={`tab:${id}`} variant={id === active ? 'primary' : undefined} onPress={() => update($, tabAtom, () => id)}>
1156 {text}
1157 <Text dimColor>{count ? ` ${count}` : ''}</Text>
1158 </Button>
1159 <Box position="absolute" top={1} left={0} flexDirection="row" gap={1} paddingX={1} backgroundColor={CARD_BG} borderStyle="round" borderColor={CARD_EDGE} {...shownOr(scene === 'tools' && id === active)}>
1160 <Button key={`refresh:${id}`} plain label="⟳ Refresh" onPress={() => refreshTab($, id)} />
1161 {isClosable ? (
1162 <Button key={`close:${id}`} plain label="✖ Close" hover={{ color: 'error', bold: true }} onPress={() => closeTab($, id, id === active)} />
1163 ) : null}
1164 </Box>
1165 </Box>
1166 )
1167 const others = tabs.filter(g => !g.isSelf)
1168
1169 return (
1170 <Box flexDirection="column">
1171 <Box height={2} flexDirection="column">
1172 <Box flexDirection="row" alignItems="center" gap={1} height={1}>
1173 {tab('all', 'All', tabs.reduce((n, g) => n + running(g), 0), false)}
1174 {self ? tab(selfId, short(self.label), running(self), false) : null}
1175 {others.map(g => tab(g.id, short(g.label), running(g), true))}
1176 <Box flexGrow={1} />
1177 {/* ⓘ Info: the minimized row's figures and details in a card, while hovered or once pressed */}
1178 <Box key="info" flexDirection="row">
1179 <Button
1180 key="info-btn"
1181 plain
1182 label="ⓘ Info"
1183 onPress={() => {
1184 isInfoPinned = !isInfoPinned
1185 $.ui.invalidate('ui.render')
1186 }}
1187 />
1188 {isInfoPinned || scene === 'info' ? null : (
1189 <Box {...lifted}>
1190 {figures}
1191 {details()}
1192 </Box>
1193 )}
1194 </Box>
1195 <Button key="mini" plain label="▾ Minimize" onPress={() => update($, miniAtom, () => true)} />
1196 </Box>
1197 </Box>
1198 {list.map(it => it.view)}
1199 {pager}
1200 {cards}hooks/pager.tsx 42 lines1import type { ClientModule } from 'claude-code'
2
3// The band's pages, drawn on the surface (it takes the pointer): a column of numbered cells at the region's right (the
4// cell left of them is grip), the open page lit. Pressed, or swept with the button held (a desktop sends moves with
5// it), the cell under the pointer lights at once and its page goes to the hooks module ({ page }). `rows` comes from
6// the hooks module: a desktop reports no region size (surface.rows stays 0).
7type Props = { page: number; pages: number; rows: number }
8type State = { page: number; base: number } // a page pressed here, and the props' page then
9
10const Pager: ClientModule<Props, State> = (p, s) => {
11 const { Box, Text } = s.elements
12 // the cells share the rows to the last one: the bottom ones a row taller when they do not divide evenly
13 const n = Math.max(1, Math.min(p.pages, p.rows))
14 const base = Math.floor(p.rows / n)
15 const cells = Array.from({ length: n }, (_, i) => base + (i >= n - (p.rows % n) ? 1 : 0))
16 const tops = cells.map((_, i) => cells.slice(0, i).reduce((a, b) => a + b, 0))
17 // the page pressed here lights at once, until the hooks module's next props bring it (or another)
18 const open = s.state && s.state.base === p.page ? s.state.page : p.page
19 s.onPointer(e => {
20 if (e.type !== 'down' && !(e.type === 'move' && e.button !== undefined)) return
21 const page = Math.max(0, tops.findLastIndex(top => top <= e.y))
22 if (page === open) return
23 s.setState({ page, base: p.page })
24 s.post({ page })
25 })
26 return (
27 <Box flexDirection="row" width={3} height={p.rows}>
28 <Box width={1} />
29 <Box flexDirection="column" width={2}>
30 {cells.map((cell, i) => (
31 <Box key={`p${i}`} width={2} height={cell} alignItems="center" justifyContent="center" backgroundColor={i === open ? '#8a8a8a' : i % 2 ? '#333333' : '#3d3d3d'}>
32 <Text bold={i === open} color={i === open ? '#141414' : '#bdbdbd'}>
33 {String(i + 1)}
34 </Text>
35 </Box>
36 ))}
37 </Box>
38 </Box>
39 )
40}
41export default Pager
42types/index.d.ts 38 lines1// The Detaly mod's values in $.state: they outlive a reload of the mod within a session.
2
3// A background shell the session started (Bash / PowerShell with run_in_background), seen from its tool call.
4// `expectS`: its expected length from the description (a duration, or until a clock time: `isUntil`, exact even
5// when the start is unknown); `isLate`: first seen at a turn's end, start unknown; `isCounted`: its output showed
6// progress, so it ends with a full bar; `spent`: what Claude's responses about it cost (the one that launched it, the
7// ones that read its output or named it, every one of a turn its end woke).
8export type Spent = { tokensIn: number; out: number; usd: number; steps: number }
9export type JobProgressTask = {
10 title: string
11 started: number
12 expectS?: number
13 isUntil?: boolean
14 output?: string
15 isLate?: boolean
16 endedAt?: number
17 status?: string
18 isCounted?: boolean
19 spent?: Spent
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 // tab: the band's open tab, "all" or a session's id ("" = this session); closed: tabs the person closed;
25 // mini: the band minimized to this chat's rows, no tabs; tokens: the session's model responses summed since `since`
26 // (ms); jobSpent: what Claude's responses about this chat's file jobs cost, by "<id>@<started>" (those whose tool
27 // calls name the id)
28 detaly: {
29 tasks: [string, JobProgressTask][]
30 tab: string
31 closed: string[]
32 mini: boolean
33 tokens: { since: number; in: number; cacheRead: number; cacheWrite: number; out: number }
34 jobSpent: Record<string, Spent>
35 }
36 }
37}
38