Draws your prompts as an accent card headed with the time you handed them over.

My Claude Code setup, kept in git so every PC I use looks and behaves the same. One script, install.ps1, sets up a fresh Windows machine: global rules, agents, plugins, a custom status bar, a mod that tidies the conversation view, and the terminal's font and colours.

The line along the bottom of Claude Code, read left to right:
# yellow and the number white and underlined, still a link to the PR)./clear.The meters change colour as they fill: green below 50%, yellow from 50%, orange from 70% and red from 90%. The same session late in a busy day:

When the window is narrow, the least important pieces drop first: session time, cost, then reset times and the plan meters. Repo, branch, model with its effort, and context always stay; on a very narrow window the repo loses its owner, then letters of its name. Once the line fits, any dropped piece that still fits comes back, so no room is wasted:
<img src="docs/images/statusbar-narrow.png" width="380" alt="The bar at 60 columns: the repo cut to claude-s…, then branch, model and context">
Dropped pieces aren't lost. The session-cards mod (below) moves them onto Claude Code's hint line, beside the permissions text:
→.7D ████ 32% → 4d21h), its first block under the last meter left on the top row. Where it can't line up, it goes to the right.1 shell, monitors, agents) and Claude is idle, the row keeps this layout and the pill is the mod's own: a white block that opens the background-tasks list when clicked. While Claude works, Claude Code's own line stays instead, since a mod's /tasks waits for the turn to end and Claude Code's pill opens at once; the pieces then follow it dim, packed left after a · , a meter as its percentage alone, since dim blocks can't show how full it is.
The icons come from a Nerd Font. On a machine without one, the installer switches the bar to short words instead (ctx, 5H, 7D), so it reads fine in any monospace font. To see the bar without starting Claude Code, run python statusbar.py --demo.
mods/session-cards is a Claude Code mod that makes a long session easier to scan:
/compact) is a violet You ran /compact card rather than a prompt card. Only output Claude Code keeps as a command result gets a card (/context, /color, /reload-plugins, /memory, /skills, /usage, /rename). Some commands get none, since Claude Code hands the mod nothing to draw:/model, /effort, /mcp, /plugin, /theme, /login, /advisor) keep their result line as a plain row that no mod hook sees./clear prints nothing.✻ Baked for 1s · done 4:20 PM). While background shells run, every such line ends in a dim · 1 shell still running, which goes away when the shell's notification arrives. While a background agent runs, Claude Code's own line stays, since only it knows when the agent ends.
Nothing to look at here, but it shapes every session:
global/CLAUDE.md: global rules for how Claude plans, delegates, verifies and uses git.Explore (Haiku, read-only search, replaces the built-in one) and worker (Sonnet at medium effort, for implementation briefs).advisor-switch: turns Claude Code's advisor on (Opus, or Fable when Opus is stuck) and off for one project, in its .claude/settings.local.json. Claude runs it itself before and after a plan with workers; the installer removes a user-level advisorModel, so the advisor is never on in every project at once.codex: hands a task to OpenAI's Codex CLI in the background (codex exec, read-only unless it works in a worktree), on the OpenAI subscription: questions about Codex, independent research, second opinions. Its sibling setup is codex-setup.underwater-photoshop: a personal skill that edits underwater and macro photos in the local Photoshop. winget install Anthropic.ClaudeCode Git.Git GitHub.cli OpenJS.NodeJS Python.Python.3.14
npm i -g pnpm
winget install --id astral-sh.ruff -e
Then run npm install at the repo root (installs the pinned Biome for the mod lint hook).
claude once and sign in, then gh auth login. git clone https://github.com/Doofess/claude-setup C:\dev\claude-setup
cd C:\dev\claude-setup
.\install.cmd
install.cmd runs install.ps1 under PowerShell 7 when it is installed, else Windows PowerShell, past 5.1's blocked-scripts policy. From Git Bash, run ./install.sh, which calls it.
python -m venv ~/.claude/skills/underwater-photoshop/.venv, then pip install -r skills/underwater-photoshop/requirements.txt into it. Needs Photoshop 2026 (COM Photoshop.Application).The installer installs JetBrainsMono Nerd Font through winget if the machine has no Nerd Font, sets Windows Terminal's font and colour scheme, and removes hooks this repo has retired. Options:
| Flag | Effect |
|---|---|
-KeepFont | keep the machine's own terminal font |
-KeepScheme | keep the machine's own colour scheme |
-SkipFont | skip the font install and the font change |
-SkipPlugins | skip installing and updating plugins (faster when only config changed) |
It backs up the previous files to ~/.claude/backups/claude-setup-<timestamp>/ on every run.
After installing, or on a new PC, run /setup-check in Claude Code. It finds the clone through ~/.claude/claude-setup-state.json, checks the clone is up to date, runs shell_matrix.py to compare every shell, repairs any difference with install.cmd, and runs the matrix again. It is done when the matrix exits 0. It asks before pulling, and it never edits the repo. What the installer can't do (a group-policy pin, a shell winget couldn't install, signing in to claude and gh) it hands to you with the exact command.
After installing, the installer prints a Same experience table (name expected actual ok|FAIL) and fails if any row is wrong. It checks everything that decides what the screen looks like: the Nerd Font and Windows Terminal's font face and cellHeight, the Campbell colour scheme, Claude Code's theme (custom:clear with its file), the status line command, the session-cards mod being loaded, and Claude Code 2.1.308+, Python 3.10+ and Git Bash. The shell rows cover the rest: Terminal profile overrides (no profile sets its own font or colour scheme; -KeepFont / -SkipFont excuse font overrides and -KeepScheme colour scheme ones, and the row shows kept), Terminal default profile (PowerShell 7), Git Bash profile (present in Windows Terminal), PowerShell 7 (pwsh found), Windows PowerShell policy (RemoteSigned or looser; a group-policy pin that is stricter is a FAIL and named), PowerShell profiles (the claude-setup block in both profile files) and VS Code terminal (font, Campbell colours and contrast set; no VS Code shows kept). These rows only read, so they run under -Check too. It also runs the status bar on sample data and prints it, so a broken bar or a mangled meter glyph stops the install. Rows covered by -KeepFont, -KeepScheme or -SkipFont show kept. The install records the keep flags it ran with in ~/.claude/claude-setup-state.json (flags); /setup-check and shell_matrix.py pass them to the check and the repair, so a font or scheme you kept is never overwritten.
Edit the files here, run .\install.ps1, commit and push. On the other machine: git pull, then .\install.ps1.
From Claude Code's ! prompt (Git Bash, so use forward slashes):
git -C C:/dev/claude-setup pull && powershell -ExecutionPolicy Bypass -File C:/dev/claude-setup/install.ps1
/clear when the context meter nears 100% (the auto-compact point), or when /context shows usage near 267k./usage weekly; /context when a session feels heavy; /skill-doctor monthly to spot skills that load but never run.C:\Windows\System32. Windows Terminal opens in C:\dev, and the PowerShell 7 profile moves a shell started in System32 (Start menu, taskbar) to C:\dev.| In this repo | On the machine |
|---|---|
global/CLAUDE.md | ~/.claude/CLAUDE.md |
settings.json | merged into ~/.claude/settings.json (plugins, model and effort, skill overrides; see the merge rules below) |
agents/*.md | ~/.claude/agents/ |
themes/*.json | ~/.claude/themes/; custom:clear is Claude Code's dark with userMessageBackground set to Campbell's background #0c0c0c, so the sticky prompt line at the top (the last prompt, once it scrolls away) has no grey bar, and background set to Campbell's bright white #F2F2F2, so background work (the 1 shell pill under the prompt, the background-tasks dialog) is white instead of cyan. A typed /command's echo row and the selected row in the background-tasks list lose their grey too. Its text stays the dim subtle grey and it has no border: Claude Code draws that one-row line itself, with no mod hook |
statusbar.py | ~/.claude/statusbar.py, run by statusLine in settings.json every 60 s so the reset countdowns move |
skills/<name>/ | ~/.claude/skills/<name>/ |
mods/session-cards/ | stays in the repo; install.ps1 adds its folder to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json and checks it with claude plugin validate |
terminal/profile.json | not copied; its font and colorScheme are written into Windows Terminal's profiles.defaults, and a colorScheme on the default profile itself is removed (see terminal/README.md) |
mcp_off.py | not copied; the installer runs it to turn the claude.ai Adobe connector off in each known project in ~/.claude.json |
mcp-presets/ | not installed; copy one into a repo as .mcp.json (see its README) |
Not versioned on purpose: ~/.claude.json (login state, user-scope MCP servers), .credentials.json, history.jsonl, ~/.remember/. Synced plugins and claude.ai connectors follow the account, so they need no setup here.
/effort picker draws them, held still: xhigh in the violet of accept edits with its middle letter lit, max a rainbow that starts at the bolt.~/.claude/statusbar-overflow/<session_id>.json, as text with their colours, with where each one goes and the width of the mode label in front of the hint (read from the session transcript); files older than a day are removed. The session-cards mod reads them for the hint line. Beside the plain hints (? for shortcuts, (shift+tab to cycle)), and beside a hint of only the background pill and the PR badge while Claude is idle, it draws the line itself, the pill as its own Button that runs /tasks; beside any other hint it hands the pieces to Claude Code's tail, since a rewritten hint turns the engine's pills into dead text. In a GitHub repo the file also holds pull, the PR address the mod links the badge's number to.5d, not 5d0h.bypass permissions on) are Claude Code's exact theme colours. The icons depend on the Nerd Font's glyph widths, so a different scheme or font gives a different screen.settings.json is merged into the machine's ~/.claude/settings.json: enabledPlugins and env entry by entry (the repo wins a conflict, the machine's other entries stay), permissions with arrays unioned, hooks per event and per command, and every other key replaced by the repo's value. A key removed from the repo stays on the machine, so retiring one needs an explicit step in install.ps1.
env.CLAUDE_CODE_PLUGIN_DIRS (;-separated) is the one env entry that is unioned rather than replaced: the machine's own folders stay, ours (<repo>/mods/session-cards, with <repo> being this clone in forward slashes) is added once, and an existing entry ending in /mods/session-cards (from an older clone or worktree) is replaced by it. Entries are compared ignoring slash style, trailing slash and case.
skillOverrides).skillOverrides): CLAUDE.md asks design questions through AskUserQuestion, reviews with the built-in /code-review, and keeps "run the check before saying done" as a rule of its own. good-css is declared in extraKnownMarketplaces (vojtaholik/good-css); enabling it is left to each machine.mcp-presets/.gh does the same job with far less context.deniedMcpServers blocks every claude.ai connector except Adobe, Supabase and Cloudflare (Slack, Linear, Atlassian, Notion, Figma, Intercom, Claude Docs, Vercel, Canva, Spotify, Google Docs/Sheets/Calendar/Drive, Gmail, and desktop-commander). BigQuery is retired; the installer removes its user-scope server and hook.mcp_off.py); /mcp turns it back on for one project, and that choice survives re-runs. A deny-list entry wouldn't work here: deny lists union across settings sources, so --settings can't lift one for a session. A project first opened after the last install starts with Adobe on. Photo edits themselves run in local Photoshop via the underwater-photoshop skill.The first visual setup (a custom theme, the readable-session mod, a Windows Terminal "Readable" scheme and an older status line) was retired on 2026-10-23 and rebuilt as the status bar and session-cards mod above. Tag visual-v1 holds the old version, and the installer removes it from machines that still have it.
hooks/register.tsx 1110 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { TurnRecord } from '../types'
5import { neon, NEON_MS } from './neon'
6
7export type { TurnRecord }
8
9
10/** Claude Code's own wrapper lines around a pasted block: `<pasted_content …>` and `</pasted_content …>`. */
11const WRAPPER = /^[ \t]*<\/?pasted_content\b[^>\n]*>[ \t]*(?:\r?\n|$)/gm
12
13/** The prompt as typed, with the paste wrapper lines taken out. */
14export function promptText(text: string): string {
15 return text.replace(WRAPPER, '')
16}
17
18
19/** `HH:MM`, 24-hour, in the machine's local time. */
20export function clock(ms: number): string {
21 const at = new Date(ms)
22 const two = (n: number) => String(n).padStart(2, '0')
23
24 return `${two(at.getHours())}:${two(at.getMinutes())}`
25}
26
27type Described = { icon: string; verb: [string, string]; detail: string }
28type RowState = 'running' | 'done' | 'failed' | 'stopped'
29
30const asString = (value: unknown): string => (typeof value === 'string' ? value : '')
31
32const field = (input: unknown, key: string): unknown =>
33 typeof input === 'object' && input !== null ? (input as Record<string, unknown>)[key] : undefined
34
35/** Forward slashes, relative to `cwd` when under it, else as given. */
36function relative(path: string, cwd: string): string {
37 const slashed = path.replace(/\\/g, '/')
38 const root = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
39 const drive = /^[a-z]:/i.test(slashed)
40 const same = (a: string, b: string) => (drive ? a.toLowerCase() === b.toLowerCase() : a === b)
41
42 if (root !== '' && slashed.length > root.length && slashed[root.length] === '/' && same(slashed.slice(0, root.length), root)) {
43 return slashed.slice(root.length + 1)
44 }
45
46 return slashed
47}
48
49/** The cut to `width` cells: the end becomes `…` when anything was dropped. */
50function cut(text: string, width: number): string {
51 if (text.length <= width) {
52 return text
53 }
54
55 return width <= 0 ? '' : `${text.slice(0, width - 1)}…`
56}
57
58const firstLine = (text: string): string => (text.split(/\r?\n/)[0] ?? '').trim()
59
60/** An icon, a verb pair (present, past) and a detail for one tool call, in plain words. */
61export function describeTool(tool: string, input: unknown, cwd: string): Described {
62 const path = (value: unknown) => relative(asString(value), cwd)
63
64 switch (tool) {
65 case 'Bash':
66 case 'PowerShell': {
67 const description = firstLine(asString(field(input, 'description')))
68
69 return { icon: '', verb: ['Running', 'Ran'], detail: description !== '' ? description : firstLine(asString(field(input, 'command'))) }
70 }
71 case 'Edit':
72 case 'MultiEdit':
73 return { icon: '', verb: ['Editing', 'Edited'], detail: path(field(input, 'file_path')) }
74 case 'Write':
75 return { icon: '', verb: ['Writing', 'Wrote'], detail: path(field(input, 'file_path')) }
76 case 'NotebookEdit':
77 return { icon: '', verb: ['Editing', 'Edited'], detail: path(field(input, 'notebook_path')) }
78 case 'Read':
79 return { icon: '', verb: ['Reading', 'Read'], detail: path(field(input, 'file_path')) }
80 case 'Glob':
81 case 'Grep': {
82 const where = path(field(input, 'path'))
83
84 return { icon: '', verb: ['Searching', 'Searched'], detail: `${asString(field(input, 'pattern'))}${where === '' ? '' : ` in ${where}`}` }
85 }
86 case 'Skill':
87 return { icon: '', verb: ['Using skill', 'Used skill'], detail: asString(field(input, 'skill')) }
88 case 'AskUserQuestion': {
89 const questions = field(input, 'questions')
90
91 return { icon: '', verb: ['Asking you', 'Asked you'], detail: Array.isArray(questions) ? asString(field(questions[0], 'question')) : '' }
92 }
93 case 'WebFetch':
94 return { icon: '', verb: ['Fetching', 'Fetched'], detail: asString(field(input, 'url')) }
95 case 'WebSearch':
96 return { icon: '', verb: ['Searching', 'Searched'], detail: asString(field(input, 'query')) }
97 }
98
99 if (tool.startsWith('mcp__')) {
100 const split = tool.indexOf('__', 5)
101 const detail = split < 0 ? tool.slice(5) : `${tool.slice(5, split)} ${tool.slice(split + 2)}`
102
103 return { icon: '', verb: ['Calling', 'Called'], detail }
104 }
105
106 const values = typeof input === 'object' && input !== null ? Object.values(input) : []
107 const first = values.find((value): value is string => typeof value === 'string' && value !== '' && value.length < 60)
108
109 return { icon: '', verb: ['Using', 'Used'], detail: first === undefined ? tool : `${tool} ${first}` }
110}
111
112const MARK = { running: '', done: '', failed: ' ✗', stopped: ' (stopped)' } as const
113
114/** The row as plain text: `<icon> <Verb> <detail>` plus the mark, cut to `width`. */
115export function rowText(d: Described, state: RowState, width: number): string {
116 const verb = state === 'running' ? d.verb[0] : d.verb[1]
117
118 return cut(`${d.icon} ${verb} ${d.detail}${MARK[state]}`, width)
119}
120
121const FAMILIES = ['opus', 'sonnet', 'haiku', 'fable', 'mythos'] as const
122const FAMILY_COLOURS: Record<(typeof FAMILIES)[number], string> = {
123 opus: 'claude',
124 sonnet: 'blue',
125 haiku: 'cyan',
126 fable: 'yellowBright',
127 mythos: 'greenBright',
128}
129const EFFORT_COLOURS: Record<string, string> = {
130 low: 'green',
131 medium: 'yellow',
132 high: 'error', // the red of `bypass permissions on`, as the status bar draws it
133 // xhigh and max are still frames of the /effort picker's animations, as the status bar holds them: xhigh's
134 // autoAccept shimmer lit on its middle letter, max's rainbow starting at the bolt (red) and running on through the word.
135 xhigh: 'autoAccept',
136 max: '#eb5f57',
137}
138/** The status bar's model icon (md-robot), drawn before an agent's model in the model's colour. */
139const ROBOT = '\u{F06A9}'
140const SHIMMER = '#d0b4ff'
141const RAINBOW = ['#f58b57', '#fac35f', '#91c882'] // orange, yellow, green: the steps after the bolt's red
142
143const familyOf = (model: string) => FAMILIES.find(family => model.toLowerCase().includes(family))
144
145/** The colour name for a model name or id (as the status bar colours it), undefined when unknown. */
146export function modelColour(model: string): string | undefined {
147 const family = familyOf(model)
148
149 return family === undefined ? undefined : FAMILY_COLOURS[family]
150}
151
152/** The colour name for an effort level, undefined when unknown. */
153export function effortColour(level: string): string | undefined {
154 return EFFORT_COLOURS[level]
155}
156
157/** An effort level as coloured runs: xhigh and max letter by letter, as the /effort picker draws them, held still. */
158export function effortLetters(level: string): { text: string; color: string }[] {
159 const letters = [...level]
160
161 if (level === 'xhigh') {
162 return letters.map((text, i) => ({ text, color: i === Math.floor(letters.length / 2) ? SHIMMER : 'autoAccept' }))
163 }
164
165 if (level === 'max') {
166 return letters.map((text, i) => ({ text, color: RAINBOW[i % RAINBOW.length] as string }))
167 }
168
169 return [{ text: level, color: effortColour(level) ?? 'text' }]
170}
171
172/** The family capitalised (`Sonnet`), or the id as given when the family is unknown. */
173function modelName(model: string): string {
174 const family = familyOf(model)
175
176 return family === undefined ? model : family.charAt(0).toUpperCase() + family.slice(1)
177}
178
179/** `0s`, `48s`, `1m 0s`, `12m 5s`, `1h 2m`. */
180export function fmtElapsed(ms: number): string {
181 const seconds = Math.max(0, Math.floor(ms / 1000))
182
183 if (seconds < 60) {
184 return `${seconds}s`
185 }
186
187 const minutes = Math.floor(seconds / 60)
188
189 return minutes < 60 ? `${minutes}m ${seconds % 60}s` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
190}
191
192/** `950`, `72k`, `1.2M`, rounded as the status bar does. */
193export function fmtTokens(n: number): string {
194 if (n < 1000) {
195 return String(n)
196 }
197
198 if (Math.floor(n / 1000 + 0.5) < 1000) {
199 return `${Math.floor(n / 1000 + 0.5)}k`
200 }
201
202 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
203}
204
205/** What the mod knows of one subagent. */
206export type AgentRecord = {
207 type: string
208 description: string
209 model?: string
210 effort?: string
211 background: boolean
212 startedAt: number
213 finishedAt?: number
214 tokens: number
215 action?: string
216}
217
218/** The words of an agent row: `finishedAt` set means finished. */
219export function agentRow(a: AgentRecord, now: number) {
220 const finished = a.finishedAt !== undefined
221
222 return {
223 verb: finished ? 'Ran' : 'Running',
224 type: a.type,
225 model: a.model === undefined ? undefined : modelName(a.model),
226 effort: a.effort,
227 action: finished ? (a.action === 'remote' ? 'remote' : 'done') : a.action ?? a.description,
228 elapsed: fmtElapsed((a.finishedAt ?? now) - a.startedAt),
229 tokens: fmtTokens(a.tokens),
230 }
231}
232
233/** The words of a turn-end line: the duration and the time it ended as `12:34 PM`. */
234export function turnLine(r: TurnRecord): { duration: string; done: string } {
235 return {
236 duration: fmtElapsed(r.durationMs),
237 // Newer ICU puts a narrow no-break space before PM; the engine's line has a plain one.
238 done: new Date(r.endedAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }).replace(/ /g, ' '),
239 }
240}
241
242/** Bumped on every change to an agent record and once a second while one runs. */
243const agentTick = atom({ plugin: 'session-cards', key: 'agentTick' } as const, 0)
244
245/** One run of the status bar's overflow: `colour` is a colour name, a hex string, or null for the default. */
246type Span = { text: string; colour: string | null; bold: boolean; underline?: boolean }
247
248/** Bumped every NEON_MS while a Fable subagent runs: the frame of chalk-animation's neon on its row. */
249const neonTick = atom({ plugin: 'session-cards', key: 'neonTick' } as const, 0)
250
251/** The ids of the background shells that run now: the turn-end lines read it, so they redraw as one starts or ends. */
252const runningShells = atom({ plugin: 'session-cards', key: 'runningShells' } as const, [] as string[])
253
254/** Bumped every minute so the hint line reads the overflow file again. */
255const overflowTick = atom({ plugin: 'session-cards', key: 'overflowTick' } as const, 0)
256
257const SEPARATOR = ' │ '
258
259/** Screen column where the engine starts the mode label, or the hint when there is none (measured on 2.1.310). */
260const HINT_X = 2
261
262/**
263 * Cells of the mode label the engine draws in front of the hint on the same row, the widest one with its
264 * separator (`⏵⏵ bypass permissions on · `), for a record from an older statusbar.py that does not say the
265 * label's width. The hint text leaves the label out and the mode is not on the event, so it is reserved in front
266 * of every hint but the default mode's own (`? for shortcuts`), which has no label: a background shell's hint is
267 * `1 shell` in any mode.
268 */
269const MODE_LABEL = 27
270
271/**
272 * The permissions shield (md-shield_key_outline) that starts the hint behind a mode label. The engine draws the
273 * label's ` · ` itself, outside this hook, so the shield follows the dot: `⏵⏵ bypass permissions on · <shield> (…)`.
274 * The mode is not on the event, so it is dim like the hint rather than in the mode's colour.
275 */
276const SHIELD = '\u{F0BC5}'
277
278/**
279 * The hints the engine draws as plain text, which the hook may rewrite. Any other hint may hold live pills (`1 shell`,
280 * monitors, agents) that the user clicks to see what runs; a rewritten hint or a tree of the hook's own turns them
281 * into dead text, so beside those the engine keeps its line and the pieces go in its dim `tail`.
282 */
283const isPlainHint = (hint: string) => hint.startsWith('? for shortcuts') || hint.startsWith('(shift+tab to cycle)')
284
285/** The background-tasks pill as the engine labels it (2.1.310): shells and monitors, agents, or a count of tasks. */
286const PILL = /^(?:\d+ shells?(?:, \d+ monitors?)?|\d+ (?:monitors?|local agents?|MCP tasks?|background tasks?))$/
287
288/** The PR badge as the hint line reads it: `PR #12`, or GitLab's `MR !12`. */
289const PR_BADGE = /^([PM]R) ([#!])(\d+)$/
290
291/** The PR badge's mark in the status bar's meter yellow, its number in the bar's white. */
292const BADGE_MARK = '#EBC83C'
293const BADGE_NUMBER = '#FFFFFF'
294
295/** One part of a hint the hook draws: dim text, the PR badge, or the background-tasks pill. */
296export type HintPiece =
297 | { kind: 'text'; text: string }
298 | { kind: 'badge'; word: string; mark: string; number: string }
299 | { kind: 'pill'; text: string }
300
301/** The hint's ` · `-separated parts as pieces, with the separators in the text pieces around them. */
302export function hintPieces(hint: string): HintPiece[] {
303 const pieces: HintPiece[] = []
304 const text = (more: string) => {
305 const last = pieces[pieces.length - 1]
306
307 if (last?.kind === 'text') {
308 last.text += more
309 } else if (more !== '') {
310 pieces.push({ kind: 'text', text: more })
311 }
312 }
313
314 hint.split(' · ').forEach((part, i) => {
315 text(i === 0 ? '' : ' · ')
316 const badge = PR_BADGE.exec(part)
317
318 if (PILL.test(part)) {
319 pieces.push({ kind: 'pill', text: part })
320 } else if (badge !== null) {
321 pieces.push({ kind: 'badge', word: badge[1], mark: badge[2], number: badge[3] })
322 } else {
323 text(part)
324 }
325 })
326
327 return pieces
328}
329
330function isSpan(value: unknown): value is Span {
331 return typeof field(value, 'text') === 'string'
332}
333
334/** A moved piece of the status bar: `col` is a screen column, `rank` its drop priority (higher drops first). */
335type Placed = { col: number; spans: Span[] }
336type Ranked = { rank: number; spans: Span[] }
337
338/**
339 * The status bar's overflow file: `pieces` is the flat list every statusbar.py writes; a newer one also says
340 * where each piece goes (`under` its bar, moved `bars`, the `right` group) and the mode label's width.
341 */
342export type Overflow = {
343 pieces: Span[][]
344 label?: number
345 under?: (Placed & Ranked)[]
346 bars?: (Placed & Ranked)[]
347 right?: Ranked[]
348 /** The address a PR number goes after (`https://github.com/<owner>/<name>/pull/`), in a GitHub repo. */
349 pull?: string
350}
351
352const spansOf = (value: unknown): Span[] | null => {
353 const spans = field(value, 'spans')
354 return Array.isArray(spans) ? spans.filter(isSpan) : null
355}
356
357const numberOf = (value: unknown, key: string): number | null => {
358 const n = field(value, key)
359 return typeof n === 'number' && Number.isFinite(n) ? n : null
360}
361
362/** The entries of `key` that have a span list and every number in `numbers`, or undefined when there is no list. */
363function entries<T>(parsed: unknown, key: string, numbers: string[]): T[] | undefined {
364 const list = field(parsed, key)
365
366 if (!Array.isArray(list)) {
367 return undefined
368 }
369
370 return list.flatMap(item => {
371 const spans = spansOf(item)
372 const values = numbers.map(n => numberOf(item, n))
373 return spans === null || values.includes(null) ? [] : [{ ...Object.fromEntries(numbers.map((n, i) => [n, values[i]])), spans } as T]
374 })
375}
376
377/** The status bar's overflow file, or null when it is missing, unreadable or not the shape. */
378function parseOverflow(text: string): Overflow | null {
379 let parsed: unknown
380
381 try {
382 parsed = JSON.parse(text)
383 } catch {
384 return null
385 }
386
387 const pieces = field(parsed, 'pieces')
388 const pull = field(parsed, 'pull')
389
390 if (!Array.isArray(pieces)) {
391 return null
392 }
393
394 return {
395 pieces: pieces.map(piece => (Array.isArray(piece) ? piece.filter(isSpan) : [])),
396 label: numberOf(parsed, 'label') ?? undefined,
397 under: entries<Placed & Ranked>(parsed, 'under', ['col', 'rank']),
398 bars: entries<Placed & Ranked>(parsed, 'bars', ['col', 'rank']),
399 right: entries<Ranked>(parsed, 'right', ['rank']),
400 pull: typeof pull === 'string' && pull.startsWith('https://') ? pull : undefined,
401 }
402}
403
404// By code point: a Nerd Font icon past U+FFFF (the clock, the robot) is two UTF-16 units but one cell.
405const widthOf = (piece: Span[]): number => piece.reduce((sum, span) => sum + [...span.text].length, 0)
406
407const separatorSpan: Span = { text: SEPARATOR, colour: 'text', bold: false }
408
409/** The pieces joined by the separator, as one run of spans. */
410const joined = (pieces: Span[][]): Span[] => pieces.flatMap((piece, i) => (i === 0 ? piece : [separatorSpan, ...piece]))
411
412/**
413 * Where the second row's pieces go, as offsets inside the hook's tree, which starts after the mode label at screen
414 * column `HINT_X + record.label` and opens with `hintCells` of hint and two spaces. Reset times go under their bar
415 * (one the hint reaches is not drawn); moved bars go where the record lines them up, or join the right group when
416 * one would start inside the hint or run past the margin; the right group ends at the margin of `columns - 4`, or,
417 * when a piece there blocks it and it holds no moved bar, in the rightmost gap that fits, a separator before a piece.
418 * While the right group reaches the hint or a piece before it, the highest rank among those pieces drops, so a
419 * moved bar outranks a reset time it collides with. Null when nothing is left to draw.
420 */
421export function placeRow(record: Overflow, hintCells: number, columns: number): Placed[] | null {
422 const origin = HINT_X + (record.label ?? 0)
423 const end = columns - 4 - origin
424 const left = hintCells + 2
425 const endOf = (piece: Placed) => piece.col + widthOf(piece.spans)
426 const inRow = (piece: Placed) => piece.col >= left && endOf(piece) <= end
427 let fixed: (Placed & Ranked)[] = []
428
429 for (const piece of record.under ?? []) {
430 const at = { ...piece, col: piece.col - origin }
431 const before = fixed[fixed.length - 1]
432
433 if (inRow(at) && (!before || at.col >= endOf(before))) {
434 fixed.push(at)
435 }
436 }
437
438 const bars = (record.bars ?? []).map(bar => ({ ...bar, col: bar.col - origin }))
439 const clear = (bar: Placed) => fixed.every(piece => endOf(piece) <= bar.col || endOf(bar) <= piece.col)
440 let right: Ranked[] = record.right ?? []
441 const barsLinedUp = bars.every(bar => inRow(bar) && clear(bar))
442
443 if (barsLinedUp) {
444 fixed = [...fixed, ...bars]
445 } else {
446 right = [...bars.map(({ rank, spans }) => ({ rank, spans })), ...right]
447 }
448
449 let group: Placed | null = null
450
451 while (right.length > 0) {
452 const spans = joined(right.map(piece => piece.spans))
453 const width = widthOf(spans)
454 const start = end - width
455 // Two spaces between a piece before and the right group, as after the hint.
456 const blocking = fixed.filter(piece => endOf(piece) + 2 > start)
457
458 if (start >= left && blocking.length === 0) {
459 group = { col: start, spans }
460 break
461 }
462
463 // With the margin taken, a group of no moved bar (which would leave the meters' order) goes in the rightmost
464 // free gap, ending a separator before the piece after it.
465 const fits = (col: number) =>
466 col >= left && fixed.every(piece => endOf(piece) + 2 <= col || col + width + SEPARATOR.length <= piece.col)
467 const gap = barsLinedUp ? Math.max(...fixed.map(piece => piece.col - SEPARATOR.length - width).filter(fits)) : -Infinity
468
469 if (gap > -Infinity) {
470 group = { col: gap, spans }
471 break
472 }
473
474 const contenders: Ranked[] = start < left ? right : [...right, ...blocking]
475 const worst = contenders.reduce((a, b) => (b.rank > a.rank ? b : a))
476 right = right.filter(piece => piece !== worst)
477 fixed = fixed.filter(piece => piece !== worst)
478 }
479
480 const placed: Placed[] = []
481
482 for (const piece of [...fixed, ...(group ? [group] : [])].sort((a, b) => a.col - b.col)) {
483 const before = placed[placed.length - 1]
484
485 // A piece a separator after the one before it is drawn with the separator, as on the top row.
486 if (before && endOf(before) + SEPARATOR.length === piece.col) {
487 before.spans = [...before.spans, separatorSpan, ...piece.spans]
488 } else {
489 placed.push({ col: piece.col, spans: piece.spans })
490 }
491 }
492
493 return placed.length === 0 ? null : placed
494}
495
496/** The leading pieces whose line (`hint`, two spaces, the pieces joined) fits in `room` cells. */
497export function fitPieces(pieces: Span[][], hintWidth: number, room: number): Span[][] {
498 const fits: Span[][] = []
499 let used = hintWidth + 2
500
501 for (const piece of pieces) {
502 const next = used + (fits.length === 0 ? 0 : SEPARATOR.length) + widthOf(piece)
503
504 if (next > room) {
505 break
506 }
507
508 fits.push(piece)
509 used = next
510 }
511
512 return fits
513}
514
515export const register: Register = on => {
516 on('session.start', async ($, e, next) => {
517 // The status bar rewrites the overflow file about once a minute; the hint
518 // line only redraws on its own events, so a tick makes it read the file again.
519 $.clock.every(60_000, () => update($, overflowTick, n => n + 1))
520
521 // Agent rows show elapsed time: redraw them each second while a subagent runs.
522 $.clock.every(1000, () => {
523 if ([...agents.values()].some(a => a.finishedAt === undefined)) {
524 return update($, agentTick, n => n + 1)
525 }
526 })
527
528 // A running Fable agent's row pulses (neon): one tick per frame.
529 $.clock.every(NEON_MS, () => {
530 if ([...agents.values()].some(a => a.finishedAt === undefined && familyOf(a.model ?? '') === 'fable')) {
531 return update($, neonTick, n => n + 1)
532 }
533 })
534
535 return next(e)
536 })
537
538 // What the mod knows of each subagent, by the Agent call's tool_use_id, and
539 // the subagent id its loop's events carry, mapped back to that tool_use_id.
540 // A hot reload empties both; rows are then the unknown kind until the next spawn.
541 const agents = new Map<string, AgentRecord>()
542 const byAgentId = new Map<string, string>()
543
544 const known = (agentId: string | undefined) => {
545 const toolUseId = agentId === undefined ? undefined : byAgentId.get(agentId)
546
547 return toolUseId === undefined ? undefined : agents.get(toolUseId)
548 }
549
550 // The Agent call wraps the agent's whole run: it returns at completion, or at
551 // once when the agent was backgrounded (from the start or by ctrl+b mid-run).
552 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
553 const answer = await next(e)
554 const record = agents.get(e.tool_use_id)
555 const status = answer.result === undefined ? undefined : (answer.result as { status?: string }).status
556
557 if (record !== undefined && status !== undefined) {
558 const now = await $.clock.now()
559
560 if (status === 'async_launched' || status === 'remote_launched') {
561 record.background = true
562 }
563
564 // A remote agent has no local loop: no event will ever end its row.
565 if (status === 'remote_launched') {
566 record.action = 'remote'
567 record.finishedAt ??= now
568 }
569
570 if (status === 'completed') {
571 record.finishedAt ??= now
572 }
573
574 await update($, agentTick, n => n + 1)
575 }
576
577 return answer
578 })
579
580 on('agent.spawn', async ($, e, next) => {
581 const result = await next(e)
582 const now = await $.clock.now()
583
584 agents.set(e.tool_use_id, {
585 type: e.subagentType,
586 description: e.description,
587 model: result.model,
588 background: e.background,
589 startedAt: now,
590 tokens: 0,
591 })
592
593 if (result.agentId !== undefined) {
594 byAgentId.set(result.agentId, e.tool_use_id)
595 }
596
597 await update($, agentTick, n => n + 1)
598
599 return result
600 })
601
602 on('turn.step', async function* ($, e, next) {
603 const record = known(e.agentId)
604
605 if (record !== undefined && e.effort !== undefined) {
606 record.effort = String(e.effort)
607 await update($, agentTick, n => n + 1)
608 }
609
610 const result = yield* next(e)
611
612 if (record !== undefined && result.usage !== null) {
613 const u = result.usage
614 // The latest step's counts: each call reads the whole context again, so a sum would count it once per step.
615 record.tokens = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
616 await update($, agentTick, n => n + 1)
617 }
618
619 // A subagent runs nested inside this mod's own tool.call frame for the Agent
620 // call, so its tool.call never reaches a hook: the step's tool uses say what it does.
621 const last = record === undefined ? undefined : result.toolUses.at(-1)
622
623 if (record !== undefined && last !== undefined) {
624 const d = describeTool(last.name, last.input as Record<string, unknown>, await $.session.cwd())
625 record.action = `${d.verb[0]} ${d.detail}`
626 await update($, agentTick, n => n + 1)
627 }
628
629 return result
630 })
631
632 on('turn.complete', async ($, e, next) => {
633 // A subagent's turn is not the main loop's: no turn-end line, so no record.
634 if (e.agentId === undefined) {
635 const key = { plugin: 'session-cards', key: 'turns' } as const
636 const { value = [] } = await $.state.get(key)
637 const seq = (value.at(-1)?.seq ?? -1) + 1
638 const all = [...value, { seq, durationMs: e.durationMs, endedAt: await $.clock.now() }]
639 await $.state.set(key, all.slice(-TURNS_KEPT))
640 }
641
642 const record = known(e.agentId)
643
644 if (record !== undefined) {
645 record.finishedAt = await $.clock.now()
646 await update($, agentTick, n => n + 1)
647 }
648
649 return next(e)
650 })
651
652 // Background shells that run now, by the id the Bash/PowerShell result gave.
653 // Event hooks publish the set to `runningShells`, which the turn-end lines
654 // read, so they redraw as shells start and end. A ui.render hook may not
655 // write $.state but may write the module, so the task-notification row can
656 // end one too; the lines count from here and show it on their next redraw.
657 const shells = new Set<string>()
658 const all = () => [...shells]
659
660 // A call that went to the background (run_in_background, ctrl+b, a timeout) answers with the task's id.
661 const backgrounded = (answer: { result?: unknown }) => {
662 const id = answer.result === undefined ? undefined : (answer.result as { backgroundTaskId?: string }).backgroundTaskId
663
664 if (id !== undefined && id !== '') {
665 shells.add(id)
666 }
667
668 return id !== undefined && id !== ''
669 }
670
671 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
672 const answer = await next(e)
673
674 if (backgrounded(answer)) {
675 await update($, runningShells, all)
676 }
677
678 return answer
679 })
680
681 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
682 const answer = await next(e)
683
684 if (backgrounded(answer)) {
685 await update($, runningShells, all)
686 }
687
688 return answer
689 })
690
691 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
692 const answer = await next(e)
693 const id = answer.result === undefined ? undefined : (answer.result as { task_id?: string }).task_id
694
695 if (id !== undefined && shells.delete(id)) {
696 await update($, runningShells, all)
697 }
698
699 return answer
700 })
701
702 // Rows of the turn-end line pair with `turns` the way prompts pair with their times:
703 // `claimedTurns` pins a row (e.requestId) to its record's `seq`, so each row keeps
704 // its own record on every redraw, and as the cap drops the oldest.
705 const TURNS_KEPT = 200
706 const claimedTurns = new Map<string, number>()
707
708 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
709 const { word, durationMs, isExpanded, waitingFor } = e.props
710
711 if (e.surface !== 'terminal' || isExpanded === true || waitingFor !== undefined) {
712 return next(e)
713 }
714
715 const { value = [] } = await $.state.get({ plugin: 'session-cards', key: 'turns' })
716 let at = claimedTurns.get(e.requestId)
717
718 if (at === undefined) {
719 const used = new Set(claimedTurns.values())
720 const found = value.find(r => r.durationMs === durationMs && !used.has(r.seq))
721
722 if (found === undefined) {
723 return next(e)
724 }
725
726 at = found.seq
727 claimedTurns.set(e.requestId, at)
728 }
729
730 const record = value.find(r => r.seq === at)
731
732 // A background agent still ran when the turn ended: the engine's line says so
733 // (`Waiting for 1 background agent to finish`), and only it knows when one ends.
734 const waiting = (r: TurnRecord) => [...agents.values()].some(a => a.background && a.finishedAt === undefined && a.startedAt <= r.endedAt)
735
736 if (record === undefined || waiting(record)) {
737 return next(e)
738 }
739
740 // As the engine's line does, every turn-end line says while a background shell runs.
741 // Reading the state redraws the line when a shell starts or ends; the count is the module's.
742 await read($, runningShells)
743 const running = shells.size
744
745 const line = turnLine(record)
746 const { Box, Text } = $.ui.resolve(e)
747
748 // The engine's line keeps a blank line above it; the row it replaces is that whole line.
749 return (
750 <Box flexDirection="row" width="100%" marginTop={1}>
751 <Box minWidth={2}>
752 <Text color="claude">✻</Text>
753 </Box>
754 <Text dimColor>{`${word} for`}</Text>
755 <Text>{' '}</Text>
756 <Text bold color="text">
757 {line.duration}
758 </Text>
759 <Text color="success">{` · done ${line.done}`}</Text>
760 {running > 0 && <Text dimColor>{` · ${running} ${running === 1 ? 'shell' : 'shells'} still running`}</Text>}
761 </Box>
762 )
763 })
764
765 // A row's time is settled at its first draw and kept for every redraw. A
766 // ui.render hook may not write $.state, so the claims live here, in the
767 // module: `claimed` maps a row (e.requestId, the message id) to its time or
768 // null, and `taken` counts the times each text has handed out, which is the
769 // cursor into promptTimes[text]. So two identical prompts show their own
770 // times, the earlier row the earlier time. A hot reload empties both; rows
771 // are then claimed again in draw order, which is their order in the log.
772 const claimed = new Map<string, number | null>()
773 const taken = new Map<string, number>()
774
775 on('prompt.submit', async ($, e, next) => {
776 const now = await $.clock.now()
777 const { value = {} } = await $.state.get({ plugin: 'session-cards', key: 'promptTimes' })
778 await $.state.set({ plugin: 'session-cards', key: 'promptTimes' }, { ...value, [e.text]: [...(value[e.text] ?? []), now] })
779
780 // A finished background task reaches the session as a notification naming it.
781 const ended = e.origin.kind === 'task-notification' ? /<task-id>([^<]+)<\/task-id>/.exec(e.text)?.[1] : undefined
782
783 if (ended !== undefined && shells.delete(ended)) {
784 await update($, runningShells, all)
785 }
786
787 return next(e)
788 })
789
790 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
791 // The engine reports a finished background task as a notification row: its id ends the shell.
792 const ended = e.props.origin.kind === 'task-notification' ? e.props.task?.id : undefined
793
794 if (ended !== undefined) {
795 shells.delete(ended)
796 }
797
798 if (e.surface !== 'terminal' || e.props.origin.kind !== 'composer') {
799 return next(e)
800 }
801
802 const { Box, Text } = $.ui.resolve(e)
803 const { text } = e.props
804 // A slash command's row (the one /compact keeps reaches here; a typed one is the engine's echo) is a card in the
805 // permission blue of the command output cards. A prompt that starts with a path (`/tmp is full`) names no command.
806 const name = /^\/(\S+)/.exec(text)?.[1]
807
808 if (name !== undefined && (await $.command.list()).some(c => c.name === name)) {
809 return (
810 <Box borderStyle="round" borderColor="permission" paddingX={1}>
811 <Text color="permission" bold>
812 {` You ran ${text}`}
813 </Text>
814 </Box>
815 )
816 }
817
818 let at = claimed.get(e.requestId)
819
820 if (at === undefined) {
821 const { value = {} } = await $.state.get({ plugin: 'session-cards', key: 'promptTimes' })
822 const used = taken.get(text) ?? 0
823 at = value[text]?.[used] ?? null
824 claimed.set(e.requestId, at)
825
826 if (at !== null) {
827 taken.set(text, used + 1)
828 }
829 }
830
831 const header = ` You prompted${at === null ? '' : ` · ${clock(at)}`}`
832
833 return (
834 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
835 <Text color="claude" bold>
836 {header}
837 </Text>
838 <Text>{promptText(text)}</Text>
839 </Box>
840 )
841 })
842
843 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
844 if (e.surface !== 'terminal') {
845 return next(e)
846 }
847
848 const inner = await next(e)
849
850 // A row the engine drew empty (a Box with no children) stays empty.
851 if (inner.type === 'Box' && !inner.children?.length) {
852 return inner
853 }
854
855 const { Box, Text } = $.ui.resolve(e)
856 const { command, args, isErrored } = e.props
857 const colour = isErrored ? 'error' : 'permission'
858
859 return (
860 <Box flexDirection="column" borderStyle="round" borderColor={colour} paddingX={1}>
861 <Text color={colour} bold>
862 {` /${command}${args === '' ? '' : ` ${args}`}`}
863 </Text>
864 {inner}
865 </Box>
866 )
867 })
868
869 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
870 const { tool, input, isRunning, isErrored, isInterrupted, isExpanded } = e.props
871
872 // Expanded rows (ctrl+o, --verbose, an unfolded group) and tasks are the engine's.
873 if (e.surface !== 'terminal' || isExpanded === true || tool === 'Task') {
874 return next(e)
875 }
876
877 const { Box, Text } = $.ui.resolve(e)
878
879 if (tool === 'Agent') {
880 await read($, agentTick)
881
882 const columns = e.viewport?.columns ?? 120
883 const running = isRunning === true
884 const record = agents.get(e.props.tool_use_id)
885
886 // A spawn the mod never saw: the call's description and nothing else.
887 if (record === undefined) {
888 const type = asString(field(input, 'subagent_type')) || 'general-purpose'
889 const head = ` ${running ? 'Running' : 'Ran'} ${type} `
890
891 return (
892 <Box flexDirection="row">
893 <Text color="text">{' '}</Text>
894 <Text color="text" bold>
895 {running ? 'Running' : 'Ran'}
896 </Text>
897 <Text color="text">{` ${type} ${cut(asString(field(input, 'description')), columns - head.length)}`}</Text>
898 </Box>
899 )
900 }
901
902 const now = await $.clock.now()
903 // Last resort: a foreground call whose row stopped running without the Agent
904 // call's result reaching the mod is over all the same, and the time is kept.
905 // A background call's row stops running at launch; its turn.complete ends it.
906 if (!running && !record.background && record.finishedAt === undefined) {
907 record.finishedAt = now
908 }
909
910 const row = agentRow(record, now)
911 const pulsing = row.verb === 'Running' && familyOf(row.model ?? '') === 'fable'
912 const frame = pulsing ? await read($, neonTick) : 0
913 const modelStyle = pulsing ? neon(frame) : familyOf(row.model ?? '') === 'fable' ? { color: 'yellowBright', bold: true } : {}
914 const mark = isInterrupted ? ' (stopped)' : isErrored ? ' ✗' : ''
915 const tail = ` ${row.elapsed} · ${row.tokens}`
916 const fixed =
917 ` ${row.verb} ${row.type}`.length +
918 (row.model === undefined ? 0 : [...` · ${ROBOT} ${row.model}`].length) +
919 (row.effort === undefined ? 0 : ` ${row.effort}`.length) +
920 2 +
921 mark.length +
922 tail.length
923
924 return (
925 <Box flexDirection="row">
926 <Text color="text">{' '}</Text>
927 <Text color="text" bold>
928 {row.verb}
929 </Text>
930 <Text color="text">{` ${row.type}`}</Text>
931 {row.model !== undefined && <Text color="text">{' · '}</Text>}
932 {row.model !== undefined && <Text color={modelColour(row.model) ?? 'text'}>{`${ROBOT} `}</Text>}
933 {row.model !== undefined && <Text color={modelColour(row.model) ?? 'text'} {...modelStyle}>
934 {row.model}
935 </Text>}
936 {/* The bolt between model and effort takes the level's colour, as on the status bar. */}
937 {row.effort !== undefined && <Text color={effortColour(row.effort) ?? 'text'}>{' '}</Text>}
938 {row.effort !== undefined && effortLetters(row.effort).map(run => <Text color={run.color}>{run.text}</Text>)}
939 <Text color="text">{` ${cut(row.action, columns - fixed)}`}</Text>
940 {mark === ' ✗' && <Text color="error">{mark}</Text>}
941 {mark === ' (stopped)' && <Text color="text">{mark}</Text>}
942 <Text color="text">{tail}</Text>
943 </Box>
944 )
945 }
946
947 const d = describeTool(tool, input, await $.session.cwd())
948 const state: RowState = isInterrupted ? 'stopped' : isErrored ? 'failed' : isRunning ? 'running' : 'done'
949 const verb = state === 'running' ? d.verb[0] : d.verb[1]
950 const mark = MARK[state]
951 const room = (e.viewport?.columns ?? 120) - `${d.icon} ${verb} `.length - mark.length
952
953 return (
954 <Box flexDirection="row">
955 <Text color="text">{`${d.icon} `}</Text>
956 <Text color="text" bold>
957 {verb}
958 </Text>
959 <Text color="text">{` ${cut(d.detail, room)}`}</Text>
960 {state === 'failed' && <Text color="error">{mark}</Text>}
961 {state === 'stopped' && <Text color="text">{mark}</Text>}
962 </Box>
963 )
964 })
965
966 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
967 if (e.surface !== 'terminal') {
968 return next(e)
969 }
970
971 await read($, overflowTick)
972
973 const { hint } = e.props
974 const defaultHint = hint.startsWith('? for shortcuts')
975 const plain = isPlainHint(hint)
976 const withShield = (label: number) => (label === 0 || !plain ? hint : `${SHIELD} ${hint}`)
977 const pass = (shown: string) => next(shown === hint ? e : { ...e, props: { ...e.props, hint: shown } })
978
979 // statusbar.py writes under Python's expanduser("~"), which on Windows is USERPROFILE.
980 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
981 const guess = withShield(defaultHint ? 0 : MODE_LABEL)
982
983 if (!home) {
984 return pass(guess)
985 }
986
987 let record: Overflow | null
988
989 try {
990 record = parseOverflow(await $.fs.read(`${home}/.claude/statusbar-overflow/${await $.session.id()}.json`))
991 } catch {
992 return pass(guess)
993 }
994
995 if (record === null) {
996 return pass(guess)
997 }
998
999 const columns = e.viewport?.columns ?? 80
1000 const placedRecord = record.under !== undefined || record.bars !== undefined || record.right !== undefined
1001 // The record's label is the transcript's mode; the default mode's own hint never has one in front.
1002 const label = defaultHint ? 0 : placedRecord ? (record.label ?? MODE_LABEL) : MODE_LABEL
1003 const shown = withShield(label)
1004 const cells = [...shown].length
1005
1006 const overflow: Overflow = record // narrowed, for the closures below
1007 const withTail = () => {
1008 // The engine draws its own ` · ` before the tail and squeezes its spaces, so the pieces cannot line up under
1009 // their bars: the flat list packs left, the ` · ` counted as the hint's two spaces and one more cell. The tail is
1010 // one dim grey, so a meter's full and empty blocks look alike: they are left out and its percentage stays.
1011 const pieces = overflow.pieces.map(piece => piece.map(span => ({ ...span, text: span.text.replace(/█+/g, '') })))
1012 const fits = fitPieces(pieces, label + cells + 1, columns - 4)
1013 const tail = joined(fits)
1014 .map(span => span.text)
1015 .join('')
1016 .replace(/ {2,}/g, ' ')
1017 return fits.length === 0 ? next(e) : next({ ...e, props: { ...e.props, tail } })
1018 }
1019
1020 // Idle, a hint of the pill and the PR badge alone is drawn here like a plain one, the pill as the hook's own
1021 // Button. While a turn runs the engine's pill stays: a plugin's /tasks waits for the turn's end, the engine's opens
1022 // at once. Any other part may be live too (a cloud session, the bridge), so such a hint keeps the engine's line.
1023 const parts = hintPieces(shown)
1024 const hasPill = parts.some(part => part.kind === 'pill')
1025 const ownPill =
1026 !e.props.isWorking && hasPill && parts.every(part => part.kind !== 'text' || /^(?: · )+$/.test(part.text))
1027
1028 if (!plain && !ownPill) {
1029 return withTail()
1030 }
1031
1032 let placed: Placed[] | null
1033
1034 if (placedRecord) {
1035 placed = placeRow({ ...record, label }, cells, columns)
1036 } else {
1037 // An older statusbar.py: its pieces packed left after the hint, the label's cells reserved in front.
1038 const fits = fitPieces(record.pieces, label + cells, columns - 4)
1039 placed = fits.length === 0 ? null : [{ col: cells + 2, spans: joined(fits) }]
1040 }
1041
1042 if (placed === null) {
1043 return plain ? pass(shown) : withTail()
1044 }
1045
1046 const { Box, Text, Link, Button } = $.ui.resolve(e)
1047 let at = cells
1048
1049 const rest = placed.map(piece => {
1050 const gap = piece.col - at
1051 at = piece.col + widthOf(piece.spans)
1052 return [
1053 <Text>{' '.repeat(gap)}</Text>,
1054 ...piece.spans.map(span => (
1055 <Text color={span.colour ?? undefined} bold={span.bold === true} underline={span.underline === true}>
1056 {span.text}
1057 </Text>
1058 )),
1059 ]
1060 })
1061
1062 // The hint dim, but the PR badge: its mark yellow and its number white and underlined, a link to the PR when the
1063 // status bar knows the repo (the engine's badge is one, and a rewritten line would lose it).
1064 const run = (part: Exclude<HintPiece, { kind: 'pill' }>) => {
1065 if (part.kind === 'text') {
1066 return <Text dimColor>{part.text}</Text>
1067 }
1068
1069 const number = [
1070 <Text color={BADGE_MARK}>{part.mark}</Text>,
1071 <Text color={BADGE_NUMBER} underline>
1072 {part.number}
1073 </Text>,
1074 ]
1075 return [
1076 <Text dimColor>{`${part.word} `}</Text>,
1077 overflow.pull !== undefined && part.mark === '#' ? <Link href={`${overflow.pull}${part.number}`}>{number}</Link> : number,
1078 ]
1079 }
1080
1081 if (!hasPill) {
1082 // One Text, so a row narrower than the estimate cuts its end instead of wrapping onto a second line.
1083 return (
1084 <Text wrap="truncate-end">
1085 {parts.map(part => (part.kind === 'pill' ? null : run(part)))}
1086 {rest}
1087 </Text>
1088 )
1089 }
1090
1091 // The pill inverted at rest, as the engine's is under the pointer; a press opens the background tasks list.
1092 return (
1093 <Box flexDirection="row">
1094 {parts.map(part =>
1095 part.kind === 'pill' ? (
1096 <Button key="tasks" plain onPress={() => void $.command.run({ command: 'tasks' })}>
1097 <Text color="background" inverse>
1098 {part.text}
1099 </Text>
1100 </Button>
1101 ) : (
1102 <Text>{run(part)}</Text>
1103 ),
1104 )}
1105 <Text wrap="truncate-end">{rest}</Text>
1106 </Box>
1107 )
1108 })
1109}
1110hooks/neon.ts 15 lines1/**
2 * The `neon` effect of chalk-animation 2.0.3 (MIT, https://github.com/bokub/chalk-animation), ported because a
3 * mod cannot import npm packages and the library prints ANSI on its own timer.
4 *
5 * Its algorithm: frame `n` every 500 ms; even frames are dim, odd frames bold. Its own colours (rgb 88,80,85 and
6 * 213,70,242) are replaced by Fable's gold: both frames are `yellowBright`.
7 */
8export const NEON_MS = 500
9
10export function neon(frame: number): { color: string; bold: boolean; dimColor: boolean } {
11 return frame % 2 === 0
12 ? { color: 'yellowBright', bold: false, dimColor: true }
13 : { color: 'yellowBright', bold: true, dimColor: false }
14}
15types/index.d.ts 24 lines1/** Prompt text -> the times (ms since the epoch) prompt.submit saw it, oldest first. */
2export type PromptTimes = Record<string, number[]>
3
4/** One finished main-loop turn: its number (counting from the session's first), how long it ran and when it ended (ms since the epoch). */
5export type TurnRecord = { seq: number; durationMs: number; endedAt: number }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'session-cards': {
10 promptTimes: PromptTimes
11 /** Bumped every minute so the hint line reads the status bar's overflow file again. */
12 overflowTick: number
13 /** Bumped when an agent record changes and each second while a subagent runs, so Agent rows redraw. */
14 agentTick: number
15 /** Bumped every 500 ms while a Fable subagent runs: the frame of neon on its row. */
16 neonTick: number
17 /** The main loop's finished turns, oldest first, the last 200: what the turn-end line draws. */
18 turns: TurnRecord[]
19 /** The ids of the background shells that run now, so the turn-end lines redraw as one starts or ends. */
20 runningShells: string[]
21 }
22 }
23}
24