SLOPSHOPPER

detaly

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…

newbandguardcommandpromptprocess
v0.2.3MITupdated 2026-10-15qr1x55/Detaly-Claude-Mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · detaly
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /detaly ⎿ detaly: Detaly: All tab. [ All ] [ app · fix the failing a… ] ⓘ Info ▾ Minimize No jobs in any chat. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ All ] [ app · fix the failing a… ] ⓘ Info ▾ Minimize No jobs in any chat.
README

Detaly Claude Mod

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:

where it lives: the band above the prompt, the rest of the window blurred

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

the band minimized: the session's figures over three jobs of this chat

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

All: every open chat's jobs, a heading each, pages at the right

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

What it shows

  • The chat's own background tasks, with no setup. Every background 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.
  • Any other job that writes a small JSON file (protocol below), from Python, a log tail or anything else.
  • Every other open chat's jobs, in tabs (below).

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.

Row types

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.

counted

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).

timer

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

no estimate

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

stalled

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

done

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

failed

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.

The band

Tabs

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:

a hovered tab's tools

  • ⟳ Refresh — the tab's finished rows leave, and what runs is put right. This chat: running rows you dismissed come back, background shells the band missed are followed again, and each running shell's kind (counted or timer) is read again from its output. Another chat: its running rows you hid come back, and that chat is asked to do the same. All: both, for every chat, and closed tabs come back.
  • ✖ Close (another chat's tab) — the tab leaves; All's Refresh brings it back.

Rows

  • Hover a row: its icon turns into a red ✖. On a finished row it removes the row at once; on a running one it asks Stop tracking this job? It keeps running. — Yes drops it from the band (the job goes on), No keeps it. Another chat's row is hidden here only.
  • Hover a cut name to see it whole.

a cut name, whole in a card

  • Hover a row's bar for a card above it with what Claude spent on the job: for a background task, the response that launched it, the ones that read its output or named it, and every response of a turn its end woke; for a progress file job, the responses whose tool calls name its id (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.

what Claude spent on a task

  • Rows are two lines tall; when they do not fit the band, one line each, with a thin bar.
  • More than fit (or more than 10), they come in pages: a numbered column at the right; press a number, or sweep the column with the button held. A page that opens inside a chat's rows repeats its heading.

Minimize and the session's figures

▾ Minimize (top right) keeps this chat's rows only, under one line of the session's figures:

minimized: this chat's rows under the session's figures

  • ctx — how full the context window is.
  • 5h / week — the rate-limit windows, each with a ring filled to its share (blue, amber from 70 %, red from 90 %).
  • in / out — tokens of every model response in this session (subagents' too), counted since the mod was loaded.
  • $ — what the session has cost, as /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.

ⓘ Info: the session's figures and details

Requirements

  • Claude Code 2.1.286 or newer with mods (function hooks). In the desktop app: a local Code tab session (not WSL, not cloud); /status shows the version.
  • Tested on Windows 11 only: the Claude desktop app's Code tab with its bundled Claude Code 2.1.295, at 100 % zoom. The code is meant to be cross-platform (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).

What it keeps, and where

  • Nothing leaves your machine. The mod makes no network calls and costs no tokens: it draws, and reads and writes local files.
  • Claude Code's shared mod store ($.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.
  • The same store also holds the presence clock (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).
  • The session's state (Claude Code's own store for mods): the chat's background tasks, the open tab, minimized or not, and the token counts.
  • One small PowerShell process (Windows), started by one of the open chats: every 10 s it asks Windows how long ago the keyboard or mouse was touched and which app's window is in front, for the presence clock. About 70 MB of memory and 30 ms of CPU a minute (measured); it ends with that chat, and another open chat starts its own.

Install

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.

  1. git clone https://github.com/qr1x55/Detaly-Claude-Mod "%USERPROFILE%\.claude\mods\detaly"
  2. In %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.

Jobs outside Claude: the protocol

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):

  • Python 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.
  • Any log, no code change 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.

Limits

  • The band does not scroll. The app caps its height (about 12 lines on a desktop: the tabs and 4–5 two-line rows, or 9 thin ones), and a desktop scrolls a taller band itself before a mod can keep its tabs in place (anthropics/claude-code#99449), so rows past the room come in pages and the wheel over the band scrolls the chat.
  • Split view shows the band in the left window only. With two chats side by side, the desktop asks only the left one to draw a band above its prompt; the right one gets none, jobs or not. The left band's All tab shows both chats' jobs.
  • Background tasks are kept in the session's state, so an update of the mod keeps them. Tasks started before the mod was loaded at all show up at the chat's next turn end, with a count from then (≥ 0:02); a clock time in their description (at ~18:10) still gives the exact countdown.
  • Tokens are counted from the model's responses while the mod runs; nothing before it was loaded.
  • Every open chat reads ~/.claude/progress every 2 s (files untouched for an hour are skipped), and a file again only when it changed.
  • The bars fill the band's width; on the desktop they are sized from cells measured at ~7.85 px (Windows 11, 100 % zoom), so at another zoom a bar may end a little short of or past its column. Whether a name is cut is a guess at its width there.

Develop

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.

Issues and license

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.

Source 3 files
hooks/register.tsx 1290 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 lines
1import 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
42
types/index.d.ts 38 lines
1// 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