A sidebar and a status line for Claude Code: usage limits, context, model, cost, git and session stats in a pane beside the transcript, or under the prompt

Language: English · 繁體中文
A sidebar and a status line for Claude Code. Usage limits, context, model, cost, git and session stats sit in a pane beside the transcript, so they no longer crowd the space under your prompt. Or, if you would rather have them under the prompt, in Claude Code's status line, laid out the way you pick.

| Row | What it is |
|---|---|
| Pet | A pixel-art pet that keeps you company: see The pet |
| Model, Effort | The session's model, and the last request's effort level: green for low through red for max |
| Mode | The permission mode: auto, plan, accept edits, … |
| Context, Session, Weekly | How full the context window is, and your 5-hour and weekly plan limits with their reset countdowns. Bars turn green, yellow, peach, then red at 50%, 70% and 90% |
| Cost | What the session has cost so far |
| Uptime | How long the session has run, and how many prompts you sent |
| Cache | How much of the last request the prompt cache served |
| Compact | How many times the conversation was compacted |
| Tasks | Subagents running now, and the shells, monitors and workflows still running in the background when Claude last replied, a count per type: 2 agents|1 shell |
| Git, Path | The branch with its added and removed lines, and the working directory |
| Claude, ccdrawu | Your Claude Code version, with the newest release when one is out, and this drawer's version |
These start off; turn them on in ccdrawu:
| Row | What it is |
|---|---|
| Style | The output style set in /config |
| ID | The start of this session's id |
| Window | Tokens in the context window, out of its size: 420k / 1M |
| Spend | Where you go through a gateway with a spend limit: how much of it is used, and when it resets |
| Rate | What the session costs an hour so far |
| Tokens, Speed | Input, output and cache tokens of the conversation since the drawer loaded, and the last reply's output tokens a second |
| Skills | Skills run in this session, and the last one |
| Sync, Files, Commit, Repo | Commits ahead of and behind the upstream, files staged, changed and new, the short hash of HEAD, and origin's owner/repo with the worktree when you are in one; no upstream, no commits or no origin when there is none, and not a repo outside one |
| Directory | The working directory's own name, without the path to it: ccdrawu |
| Status | status.claude.com: all clear in green, an incident in peach or red |
A row you turned on is always there: it reads - until its value comes, or where there is none, as Spend without a spend limit.
The status line can show any of them but the pet: see The status line.
/tui fullscreen once. Without it the pane sits above the prompt instead of beside the transcript (see Troubleshooting). The status line needs neither this nor a wide terminalccdrawu command (which choice is picked, which items are ticked) are its icons. Without one, see Troubleshootingccdrawu command, to pick what the drawer and the status line show. Claude Code runs it for the status line, so it has to be on your PATHFirst the ccdrawu command:
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/vulcanshen/ccdrawu/main/install.sh | sh
# Homebrew (macOS / Linux)
brew install vulcanshen/tap/ccdrawu
# Windows PowerShell
irm https://raw.githubusercontent.com/vulcanshen/ccdrawu/main/install.ps1 | iex
# Scoop (Windows)
scoop bucket add vulcanshen https://github.com/vulcanshen/scoop-bucket
scoop install ccdrawu
Then add the drawer to Claude Code:
ccdrawu install claude
It installs the ccdrawu plugin released with this ccdrawu, for your user, so every session has the drawer. Sessions already open take it with /reload-plugins. Then run ccdrawu to pick what the drawer shows, or to turn on the status line.
Install and update the plugin with ccdrawu install claude, or ccdrawu update, which runs it, only, not with /plugin in Claude Code or claude plugin marketplace. Added from the marketplace by hand, the plugin is whatever the repository's main branch holds, which can be ahead of your ccdrawu and read its settings differently; ccdrawu install claude takes the plugin of the same version as the command.
ccdrawu, turns it off in that place alone. Off on the inline page, a terminal narrowed below the sidebar's width hides the drawer instead of moving it above the prompt, and widening it again brings the drawer back beside the transcript. Turned back on while the terminal is narrow, the drawer does not come back above the prompt by itself: see Limitations. Off on both, sessions start without it. Open sessions follow within a second./ccdrawu opens or closes it for the session you type it in, even while Claude is working. Opened, it shows beside the transcript and above the prompt alike, even where Drawer is off; closed, it stays closed as the terminal widens or narrows. Changing Drawer in ccdrawu afterwards takes over again, and new sessions start as Drawer has it.× at its top right to close it, or press Ctrl+X Tab to focus it and then Ctrl+X X.Ctrl+X ← and Ctrl+X → make it wider or narrower while it has focus.Run ccdrawu, pick sidebar (beside the transcript) or inline (above the prompt) under Drawer and press Enter. The right side shows Items, the ones on, and Order, the same in the order the drawer shows them. Press Enter on Items for the list of every item: Space checks or unchecks one, Enter applies, Esc leaves things as they were, and / filters as you type. An item you turn on goes after the one before it in that list. Press Enter on Order to change the order: D picks an item up, j and k move it, Enter puts it down, and Enter again saves the order (Esc puts things back). Open sessions follow within a second.

ccdrawu can also be Claude Code's status line, the row under the prompt: with the drawer, or on its own with Drawer set to off.
Run ccdrawu, pick statusline under Statusline and press Enter, then set Statusline to on. ccdrawu sets ccdrawu statusline as Claude Code's status line in ~/.claude/settings.json. If you already have one, such as ccstatusline, it asks before replacing it and keeps it: setting Statusline back to off, or ccdrawu uninstall claude, puts yours back. Open sessions change at once.
Set it to on when narrow instead to have it only while the terminal is too narrow for the sidebar, under about 144 columns, and the drawer is not beside the transcript. Otherwise the row under the prompt goes away. With Drawer off on the inline page too, a narrow terminal shows the status line in place of the sidebar.
Model claude-opus-5-5 | Context 42% | Session 18% · 2h31m | Weekly 7% · 3d4h | Cost $1.20 | Git main +12 -3
label (Context 42%), short (ctx 42%) or value (42%).highlighted, each value in the drawer's colors, or dimmed, all of it in Claude Code's dim color.| unless you set another.A line too long for the terminal is cut at the end with ….
A pixel-art pet lives at the top of the drawer, a cat unless you pick another. Above the prompt it takes the leftmost column, without its lines or the ball.
/ccdrawu pat pats it: it stops, and a heart shows over it./ccdrawu ball throws it a ball, which it runs after and plays with.What it says comes from a fixed set of lines, so it costs no tokens. To pick the animal (cat, dog, rabbit, duck or penguin), run ccdrawu and set Pet in preferences under Settings; open sessions follow within a second. Its coat is picked at random each session. To hide it, uncheck Pet in the list of items.
Run ccdrawu, move to preferences under Settings and press Enter, then Enter on Alias and type a name such as ccd. Within a second /ccd works as well as /ccdrawu, in every open session. A name you change or remove keeps working in a session until you restart it.
The alias is kept in ~/.config/ccdrawu/config.json as "alias": "ccd", so you can also set it there.
The drawer's background comes from your Claude Code theme, not from the plugin. To make it the same color as your terminal, run ccdrawu and, in preferences under Settings:
dark-ansi, dark, …).x on it goes back to the theme's own.ccdrawu writes its own theme, that built-in one with your background (~/.claude/themes/ccdrawu.json), and selects it in Claude Code. Setting Claude theme back to off, or ccdrawu uninstall claude, selects the theme you had before. Open Claude Code sessions change at once.
Inside tmux, Claude Code limits itself to 256 colors, so a color like #11111b comes out as a different one. If your tmux passes truecolor through, add "CLAUDE_CODE_TMUX_TRUECOLOR": "1" to the env block of ~/.claude/settings.json and restart Claude Code.
~/.config/ccdrawu/config.json: what you picked in ccdrawu: the items, the alias, the bar, the pet, whether the drawer opens by itself, and the status line's settings. CCDRAWU__CONFIG names another folder for it, and so does XDG_CONFIG_HOME.~/.ccdrawu/: what the plugin writes: the list of items ccdrawu offers (catalog.json) and, while the status line is on, each session's values for it (sessions/; a session's file is removed after a week without use). CCDRAWU__DATA names another folder for it.On Windows, ~ is your user folder (%USERPROFILE%).
Every 6 hours the drawer asks the npm registry for Claude Code's newest version. With Status on, it also asks status.claude.com every 5 minutes; it makes no other requests.
Claude Code decides where the drawer goes, not ccdrawu: beside the transcript only in fullscreen rendering and in a terminal about 110–144 columns wide or more, above the prompt otherwise.
/tui fullscreen. Claude Code restarts, picks your conversation up where it was, and keeps the choice for later sessions. /tui default goes back./tui fullscreen says Claude Code already uses it but the drawer still sits above the prompt, quit Claude Code and start it again: claude -c continues the conversation.�Or the bars run into each other. The terminal's font is not a Nerd Font, so the terminal borrows those icons from another font, or can't draw them at all. Install one from nerdfonts.com and pick it as your terminal's font; in Windows Terminal that is Settings → your profile → Appearance → Font face.
A Nerd Font can also lack a few of these icons. Maple Mono NF CN has no mark for a choice that is not picked, so in the ccdrawu command those show as � while the picked one shows. JetBrains Mono Nerd Font has been tried and draws them all.
Without a Nerd Font, set Bar to line in preferences under Settings: each choice there shows its own bar, so you can see which one your terminal draws. The marks in the ccdrawu command have no such setting; there, only the color tells which choice is picked.
The sidebar's rows with bars wrap and push the rest out of place, and the ccdrawu command leaves parts of earlier screens behind. Seen in Termius's local terminal on Windows: use Windows Terminal there.
And its text is hard to read. Claude Code paints the sidebar from its theme, a light theme paints it light gray, and ccdrawu's colors are made for a dark background. Pick a dark theme with /theme, or set Claude theme in preferences under Settings, which can also give the sidebar your terminal's background: see Match the background.
See Match the background.
Claude Code runs ccdrawu statusline, and the ccdrawu it finds on your PATH is an older one, from before the status line. ccdrawu --version says which one runs; update it the way you installed it, or remove the older one.
The ccdrawu plugin is not running in that session, so the status line shows what Claude Code hands it. Install the plugin with ccdrawu install claude; a session started before it takes it with /reload-plugins.
/ccdrawu, or when the terminal narrows while it sits beside the transcript. So if you turn Drawer on the inline page back on while the terminal is too narrow for the sidebar, the drawer stays out of sight: open it with /ccdrawu, or widen the terminal and narrow it again. Open sessions say so in a toast when it happens.shift+tab, Mode changes once Claude finishes the tools of the step it is on; while Claude is idle, it changes when you send the next prompt.ccdrawu update
It looks up the latest release and updates the ccdrawu command the way you installed it: through Homebrew, or, installed with the install script, by putting the release's executable in place of its own, checked against the release's checksums. Then it updates the plugin in Claude Code to the same release, where Claude Code has it; the command and the plugin go together, and Claude Code does not update the plugin by itself. In sessions already open, run /reload-plugins. New sessions have the new plugin from the start.
scoop update ccdrawu, then ccdrawu install claude: Scoop does not update ccdrawu while it runs.ccdrawu is in, such as /usr/local/bin after the install script ran as root, it says so and stops: run the install script again, or ccdrawu update as the folder's owner.ccdrawu update: update the command the way you installed it (brew upgrade ccdrawu, scoop update ccdrawu, or the install script again), then run ccdrawu install claude, which updates the plugin to the version of the command.First take the drawer out of Claude Code:
ccdrawu uninstall claude
This also puts back the theme and the status line you had before ccdrawu's.
Then remove the ccdrawu command:
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/vulcanshen/ccdrawu/main/uninstall.sh | sh
# Windows PowerShell
irm https://raw.githubusercontent.com/vulcanshen/ccdrawu/main/uninstall.ps1 | iex
Both ask before removing ~/.config/ccdrawu/ and ~/.ccdrawu/. With Homebrew or Scoop, brew uninstall ccdrawu or scoop uninstall ccdrawu.
hooks/register.tsx 817 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type {
5 Background,
6 CacheInfo,
7 Config,
8 GitInfo,
9 Kit,
10 ModelInfo,
11 Modes,
12 ServiceStatus,
13 SessionInfo,
14 SkillsInfo,
15 TokenInfo,
16 Usage,
17 VersionInfo,
18} from '../types'
19import {
20 BALL,
21 BALL_INK,
22 BALL_LEFT,
23 drawnCells,
24 KIT_HEIGHT,
25 KIT_WIDTH,
26 kitCells,
27 kitsFor,
28 kitsTick,
29 LIFT_ROOM,
30 PET_HEIGHT,
31 PET_START,
32 PET_TICK,
33 petCells,
34 petCoat,
35 petEvent,
36 petLook,
37 petSpecies,
38 petTick,
39 TRACK,
40 type PetEvent,
41 type Run,
42} from './pets'
43import { registerCommands } from './commands'
44import {
45 bar,
46 countBackground,
47 parseShortstat,
48 parseStatusV2,
49 percentText,
50 repoFromRemote,
51 shortPath,
52 usageColor,
53} from './format'
54import { C, ITEMS, view, type Seg, type Snapshot, type View } from './items'
55import {
56 chunk,
57 COLUMN_GAP,
58 columnRows,
59 drawerShows,
60 drawerWanted,
61 enabledItems,
62 inlineValue,
63 type Manual,
64 parseConfig,
65 type Placement,
66 segWidth,
67 showsAnywhere,
68} from './layout'
69import { locations } from './paths'
70import { statuslineFile } from './statusline'
71
72const PANE = 'ccdrawu'
73const PANE_OPEN = { id: PANE, title: 'Status', columns: 34 } as const
74const LABEL = 9 // key column width beside the transcript: keys of 8 characters, and a space; wider while a longer one shows
75
76const usage = atom({ plugin: 'ccdrawu', key: 'usage' } as const, null as Usage | null)
77const model = atom({ plugin: 'ccdrawu', key: 'model' } as const, null as ModelInfo | null)
78const git = atom({ plugin: 'ccdrawu', key: 'git' } as const, null as GitInfo | null)
79const path = atom({ plugin: 'ccdrawu', key: 'path' } as const, '')
80const turns = atom({ plugin: 'ccdrawu', key: 'turns' } as const, 0)
81const cache = atom({ plugin: 'ccdrawu', key: 'cache' } as const, null as CacheInfo | null)
82const tokens = atom({ plugin: 'ccdrawu', key: 'tokens' } as const, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as TokenInfo)
83const sessionInfo = atom({ plugin: 'ccdrawu', key: 'session' } as const, {} as SessionInfo)
84const service = atom({ plugin: 'ccdrawu', key: 'status' } as const, null as ServiceStatus | null)
85const skills = atom({ plugin: 'ccdrawu', key: 'skills' } as const, { count: 0 } as SkillsInfo)
86const compacts = atom({ plugin: 'ccdrawu', key: 'compacts' } as const, 0)
87const agents = atom({ plugin: 'ccdrawu', key: 'agents' } as const, 0)
88const background = atom({ plugin: 'ccdrawu', key: 'background' } as const, {} as Background)
89const version = atom({ plugin: 'ccdrawu', key: 'version' } as const, null as VersionInfo | null)
90const drawer = atom({ plugin: 'ccdrawu', key: 'drawer' } as const, null as string | null)
91const modes = atom({ plugin: 'ccdrawu', key: 'modes' } as const, {} as Modes)
92const now = atom({ plugin: 'ccdrawu', key: 'now' } as const, 0)
93const pet = atom({ plugin: 'ccdrawu', key: 'pet' } as const, PET_START)
94const kits = atom({ plugin: 'ccdrawu', key: 'kits' } as const, [] as Kit[])
95const config = atom({ plugin: 'ccdrawu', key: 'config' } as const, {} as Config)
96// Kept by the host, so a reload keeps what /ccdrawu set
97const manual = atom({ plugin: 'ccdrawu', key: 'manual' } as const, null as Manual)
98
99async function refreshUsage($: Engine) {
100 const u = await $.session.usage()
101 await update($, usage, () => ({
102 startedAt: u.startedAt,
103 contextPercent: u.context.percent,
104 contextTokens: u.context.tokens,
105 contextWindow: u.context.window,
106 limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
107 costUsd: u.cost?.usd,
108 }))
109}
110
111async function tick($: Engine) {
112 const t = await $.clock.now()
113 await update($, now, () => t)
114}
115
116async function refreshTurns($: Engine) {
117 const n = await $.session.turns()
118 await update($, turns, () => n)
119}
120
121// Before a resumed session sends its first request, take the effort /model saved in settings;
122// turn.step's real value replaces it once a request goes out
123async function refreshModelName($: Engine) {
124 const name = await $.session.model()
125 const settings = (await $.settings.read()) as unknown as {
126 effortLevel?: string
127 modelSettings?: Record<string, { effortLevel?: string } | undefined>
128 }
129 const configured = settings.modelSettings?.[name]?.effortLevel ?? settings.effortLevel
130 await update($, model, m => ({ name, effort: m?.effort ?? configured }))
131}
132
133let home: string | undefined
134let dataDir: string | undefined
135let configFile: string | undefined
136
137async function locateFiles($: Engine) {
138 // Windows has USERPROFILE, and often no HOME; the TUI's os.UserHomeDir reads them in this order too
139 home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
140 if (!home) return
141 const found = locations({
142 home,
143 data: await $.env.get('CCDRAWU__DATA'),
144 config: await $.env.get('CCDRAWU__CONFIG'),
145 xdgConfig: await $.env.get('XDG_CONFIG_HOME'),
146 })
147 dataDir = found.data
148 configFile = found.configFile
149}
150
151// Write `text` to <data>/<name> unless it already holds it
152async function keepFile($: Engine, name: string, text: string) {
153 if (dataDir === undefined) return
154 const target = `${dataDir}/${name}`
155 const current = await $.fs.read(target).catch(() => undefined)
156 if (current !== text) await $.fs.write(target, text)
157}
158
159// The TUI lists what this version of the mod can show from catalog.json, so it needs no update of its own
160async function installCatalog($: Engine) {
161 await keepFile($, 'catalog.json', JSON.stringify({ version: 1, items: ITEMS }, null, 2) + '\n')
162}
163
164// The TUI writes ~/.config/ccdrawu/config.json; every open session looks at it each second and takes
165// a change at once. A file not there means the catalog's defaults
166let configStamp: number | undefined
167let statusWasShown = false // whether the config read last had the Status row on
168// Where the config read last let the drawer show, before the first read
169let drawerWas: { dock: boolean; inline: boolean } | undefined
170// The command names this session has registered: /ccdrawu, and each alias the config has named
171const commands = new Set<string>()
172
173async function refreshConfig($: Engine) {
174 const file = configFile
175 if (file === undefined) return
176 const stamp = await $.fs.stat(file).then(
177 s => s.mtimeMs,
178 () => 0,
179 )
180 if (stamp === configStamp) return
181 const parsed = stamp === 0 ? {} : parseConfig(await $.fs.read(file))
182 // Half written: keep what shows and read it again next second
183 if (parsed === undefined) return
184 configStamp = stamp
185 await update($, config, () => parsed)
186 await registerCommands(spec => $.command.register(spec), commands, parsed.alias)
187 // The Status row just turned on, or on from the start: its reading now, not at the next 5 minutes
188 const shown = showsAnywhere(parsed, 'status')
189 if (shown && !statusWasShown) quietly(refreshStatus($))
190 statusWasShown = shown
191 // The drawer just turned on or off in a place: what /ccdrawu set goes, and open sessions open it, close
192 // it, or take it out of a place it is off in. The first read leaves it as session.start has it
193 const shows = { dock: drawerShows(parsed, 'dock'), inline: drawerShows(parsed, 'inline') }
194 if (drawerWas !== undefined && (shows.dock !== drawerWas.dock || shows.inline !== drawerWas.inline)) {
195 await update($, manual, () => null)
196 if (!shows.dock && !shows.inline) await closePane($)
197 else if (!(await $.ui.panes()).some(p => p.id === PANE)) void $.ui.open(PANE_OPEN)
198 else await keepPlace($)
199 if (shows.inline && !drawerWas.inline) quietly(hintInline($))
200 }
201 drawerWas = shows
202}
203
204// Turned on above the prompt in a terminal too narrow for the sidebar, the drawer stays out of sight:
205// the engine holds a pane opened unasked until the terminal is wide enough to dock it, and draws one
206// above the prompt only when the person opens it. Once the open has settled, say what to do (the README
207// says why)
208const INLINE_HINT = 'Type /ccdrawu to show the drawer above the prompt'
209
210async function hintInline($: Engine) {
211 await $.clock.sleep(1_000)
212 const pane = (await $.ui.panes()).find(p => p.id === PANE)
213 if (pane !== undefined && !pane.isPlaced) $.ui.toast(INLINE_HINT, { timeoutMs: 8_000 })
214}
215
216// Whether the terminal docks panes beside the transcript, as the pane's last drawing says
217let fullscreen = false
218
219async function closePane($: Engine) {
220 placedAs = undefined
221 await $.ui.close({ id: PANE })
222}
223
224// The drawer out of a place it does not belong in (drawerWanted). Above the prompt, in a terminal that
225// docks panes, it opens again unasked: the engine holds it undrawn until the terminal is wide enough to
226// dock it
227async function keepPlace($: Engine) {
228 const place = placedAs
229 if (place !== 'dock' && place !== 'inline') return
230 const pane = (await $.ui.panes()).find(p => p.id === PANE)
231 const conf = await read($, config)
232 const set = await read($, manual)
233 if (pane?.isPlaced !== true || drawerWanted(conf, place, set)) return
234 await closePane($)
235 if (place === 'inline' && fullscreen && drawerWanted(conf, 'dock', set)) void $.ui.open(PANE_OPEN)
236}
237
238// The pet on either side of the prompt: it has no need of a tick, or of events, when it shows nowhere
239async function petShown($: Engine) {
240 return showsAnywhere(await read($, config), 'pet')
241}
242
243// A tick of the pet and its kits: written only when what they show changed, worked out again on the
244// value there is then, so an event that came in between is kept
245async function stepPet($: Engine) {
246 if (!(await petShown($))) return
247 const t = await $.clock.now()
248 const ks = await read($, kits)
249 const movedKits = kitsTick(ks, t)
250 if (movedKits !== ks) await update($, kits, current => (current === ks ? movedKits : kitsTick(current, t)))
251 const s = await read($, pet)
252 const next = petTick(s, t)
253 if (next === s) return
254 await update($, pet, current => (current === s ? next : petTick(current, t)))
255}
256
257async function petHears($: Engine, event: PetEvent) {
258 if (!(await petShown($))) return
259 const t = await $.clock.now()
260 const species = petSpecies((await read($, config)).pet?.species)
261 await update($, pet, s => petEvent(s, event, t, species, Math.random))
262}
263
264// No API reports the permission mode; Claude Code's own hook events carry permission_mode,
265// so remember the main conversation's from them
266async function rememberMode($: Engine, e: { permission_mode?: string; agent_id?: string }) {
267 const mode = e.permission_mode
268 if (e.agent_id !== undefined || mode === undefined) return
269 // Read before each tool call too: leave the drawing alone when nothing changed
270 if ((await read($, modes)).permission === mode) return
271 await update($, modes, m => ({ ...m, permission: mode }))
272}
273
274async function refreshGit($: Engine) {
275 const cwd = await $.session.cwd()
276 await update($, path, () => shortPath(cwd, home))
277
278 const head = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 3_000 })
279 if (head.exitCode !== 0) {
280 await update($, git, () => null)
281 return
282 }
283 // Before the first commit there is no HEAD: compare with the index instead
284 let diff = await $.process.run(['git', 'diff', 'HEAD', '--shortstat'], { timeoutMs: 3_000 })
285 if (diff.exitCode !== 0) diff = await $.process.run(['git', 'diff', '--shortstat'], { timeoutMs: 3_000 })
286 const branch = head.stdout.trim()
287 const status = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 3_000 })
288 const place = await repoPlace($, cwd)
289 await update($, git, () => ({
290 branch,
291 ...parseShortstat(diff.stdout),
292 ...(status.exitCode === 0 ? parseStatusV2(status.stdout) : {}),
293 ...place,
294 }))
295}
296
297// origin's owner/repo, and the worktree's folder when the session is in a linked one; they change with the
298// working directory alone, so they are looked up again only when it moves
299let placeOf: { cwd: string; repo?: string; worktree?: string } | undefined
300
301async function repoPlace($: Engine, cwd: string): Promise<{ repo?: string; worktree?: string }> {
302 if (placeOf?.cwd !== cwd) {
303 const remote = await $.process.run(['git', 'remote', 'get-url', 'origin'], { timeoutMs: 3_000 })
304 const dirs = await $.process.run(
305 ['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'],
306 { timeoutMs: 3_000 },
307 )
308 const [gitDir, commonDir, top] = dirs.stdout.trim().split('\n')
309 placeOf = {
310 cwd,
311 repo: remote.exitCode === 0 ? repoFromRemote(remote.stdout) : undefined,
312 worktree: dirs.exitCode === 0 && gitDir !== commonDir ? top?.split(/[\\/]/).pop() : undefined,
313 }
314 }
315 return { repo: placeOf.repo, worktree: placeOf.worktree }
316}
317
318// The session's id and the output style
319async function refreshSession($: Engine) {
320 const id = (await $.session.id()).replace(/\.jsonl$/, '')
321 const style = (await $.config.list()).find(r => r.key === 'outputStyle')?.value
322 await update($, sessionInfo, () => ({
323 id,
324 outputStyle: typeof style === 'string' ? style : undefined,
325 }))
326}
327
328// status.claude.com's overall status, from its public status page API
329// Claude's status, asked of status.claude.com only while the Status row shows somewhere: a request out
330// that the many who leave the row off never make
331async function refreshStatus($: Engine) {
332 if (!showsAnywhere(await read($, config), 'status')) return
333 const res = await $.http.fetch('https://status.claude.com/api/v2/status.json')
334 if (!res.ok) return
335 const s = (JSON.parse(res.text) as { status?: { indicator?: string; description?: string } }).status
336 if (s?.indicator !== undefined && s.description !== undefined) {
337 const status = { indicator: s.indicator, description: s.description }
338 await update($, service, () => status)
339 }
340}
341
342async function refreshAgents($: Engine) {
343 const list = await $.agent.list()
344 const running = list.filter(a => a.status === 'pending' || a.status === 'running' || a.status === 'waiting')
345 await update($, agents, () => running.length)
346 const ids = running.map(a => a.id)
347 const species = petSpecies((await read($, config)).pet?.species)
348 const coat = (await read($, pet)).coat
349 await update($, kits, ks => kitsFor(ks, ids, species, coat, Math.random))
350}
351
352async function refreshVersion($: Engine) {
353 const { base, version: full } = await $.session.version()
354 const current = base ?? full
355 await update($, version, v => ({ current, latest: v?.latest }))
356 const res = await $.http.fetch('https://registry.npmjs.org/@anthropic-ai/claude-code/latest')
357 if (!res.ok) return
358 const latest = (JSON.parse(res.text) as { version?: string }).version
359 await update($, version, () => ({ current, latest }))
360}
361
362// $.plugin has no version: read this plugin's own plugin.json
363async function refreshDrawer($: Engine) {
364 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }
365 await update($, drawer, () => manifest.version ?? null)
366}
367
368// Everything the items may show, as the drawer holds it now
369async function readSnapshot($: Engine): Promise<Snapshot> {
370 return {
371 usage: await read($, usage),
372 model: await read($, model),
373 git: await read($, git),
374 path: await read($, path),
375 turns: await read($, turns),
376 cache: await read($, cache),
377 tokens: await read($, tokens),
378 session: await read($, sessionInfo),
379 status: await read($, service),
380 skills: await read($, skills),
381 compacts: await read($, compacts),
382 agents: await read($, agents),
383 background: await read($, background),
384 version: await read($, version),
385 drawer: await read($, drawer),
386 modes: await read($, modes),
387 now: await read($, now),
388 }
389}
390
391// While the config has the statusline on, keep what it shows in <data>/sessions/<session id>.json for
392// `ccdrawu statusline` to lay out, written only when it changed. The id is asked each time: after /clear
393// the session goes on under a new one, and Claude Code hands the command that one
394let statuslineWritten: { file: string; text: string } | undefined
395// Where the pane was drawn last: dock, beside the transcript, or inline, above the prompt; undefined
396// once ccdrawu closes it
397let placedAs: Placement | undefined
398
399async function writeStatusline($: Engine) {
400 if (dataDir === undefined || !(await read($, config)).statusline?.on) return
401 const id = (await $.session.id()).replace(/\.jsonl$/, '')
402 const file = `${dataDir}/sessions/${id}.json`
403 // Beside the transcript only while the pane is open and drawn there: not closed, nor waiting for a
404 // wider terminal, nor above the prompt
405 const pane = (await $.ui.panes()).find(p => p.id === PANE)
406 const docked = pane?.isPlaced === true && placedAs === 'dock'
407 const text = statuslineFile(await readSnapshot($), docked)
408 if (statuslineWritten?.file === file && statuslineWritten.text === text) return
409 await $.fs.write(file, text)
410 statuslineWritten = { file, text }
411}
412
413// A failed background refresh (git timing out, offline for the version check) affects its row alone
414function quietly(work: Promise<unknown>) {
415 void work.catch(() => undefined)
416}
417
418export const register: Register = on => {
419 on('session.start', async ($, e, next) => {
420 // The alias follows once the config is read
421 await registerCommands(spec => $.command.register(spec), commands, undefined)
422
423 await tick($)
424 // The pet's coat, once a session (a reload keeps it), and it starts awake. A reload keeps the state
425 // an older module wrote too: fields it did not have yet take their defaults
426 const started = await $.clock.now()
427 await update($, pet, p => ({
428 ...PET_START,
429 ...p,
430 coat: p.coat < 0 ? Math.floor(Math.random() * 1000) : p.coat,
431 active: started,
432 }))
433 await locateFiles($).catch(() => undefined)
434 quietly(installCatalog($))
435 // Read before the drawer opens, which it does only while the config has it on
436 await refreshConfig($).catch(() => undefined)
437 quietly(refreshUsage($))
438 quietly(refreshModelName($))
439 quietly(refreshGit($))
440 quietly(refreshAgents($))
441 quietly(refreshVersion($))
442 quietly(refreshDrawer($))
443 quietly(refreshTurns($))
444 quietly(refreshSession($))
445
446 // Countdowns and uptime every 30 seconds; git, path and subagents every 5; Claude's status every 5
447 // minutes while its row shows (first when the config turns it on); the latest version every 6 hours;
448 // the config, and the statusline's file while it is on, every second
449 $.clock.every(30_000, () => quietly(tick($)))
450 $.clock.every(5_000, () => {
451 quietly(refreshGit($))
452 quietly(refreshAgents($))
453 })
454 $.clock.every(5 * 60_000, () => quietly(refreshStatus($)))
455 $.clock.every(6 * 60 * 60_000, () => quietly(refreshVersion($)))
456 $.clock.every(1_000, () => quietly(refreshConfig($)))
457 $.clock.every(1_000, () => quietly(writeStatusline($)))
458 $.clock.every(PET_TICK, () => quietly(stepPet($)))
459
460 // In a terminal too narrow for a sidebar the engine holds the pane until it widens. With the drawer
461 // off in both places the session starts without it, and /ccdrawu opens it; off in one, the pane
462 // drawn there is taken out of it (keepPlace). A reload keeps what /ccdrawu set
463 const conf = await read($, config)
464 const set = await read($, manual)
465 if (drawerWanted(conf, 'dock', set) || drawerWanted(conf, 'inline', set)) void $.ui.open(PANE_OPEN)
466
467 return next(e)
468 })
469
470 // Every slash command passes through here, so a failure must leave other commands alone
471 on('command.run', async ($, e, next) => {
472 if (!commands.has(e.command)) return next(e)
473 // /ccdrawu pat pats the pet, /ccdrawu ball throws it a ball; anything else opens or closes the drawer
474 const word = e.args.trim().toLowerCase()
475 if (word === 'pat' || word === 'ball') {
476 await petHears($, word)
477 return {}
478 }
479 // The drawer on or off for this session, wherever it sits, whatever the config has (drawerWanted). A
480 // pane waiting undrawn for a wider terminal reads as closed: opened again, asked, it is drawn
481 const isShown = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
482 await update($, manual, () => (isShown ? 'off' : 'on'))
483 if (isShown) await closePane($)
484 else await $.ui.open(PANE_OPEN)
485 return {}
486 }).catch(($, e, next) => (commands.has(e.command) ? {} : next(e)))
487
488 // Usage, context and cost changed (after each turn, or a rate limit moved)
489 on('session.measure', async ($, e, next) => {
490 const result = await next(e)
491 quietly(refreshUsage($))
492 return result
493 })
494
495 // Each request of the main conversation: its effort, how much the prompt cache served, the tokens it
496 // took, and its output tokens a second (the time from asking to the last token, waiting included)
497 on('turn.step', async function* ($, e, next) {
498 if (e.agentId === undefined) quietly(petHears($, 'busy'))
499 const start = await $.clock.now()
500 const result = yield* next(e)
501 if (e.agentId === undefined) {
502 const effort = e.effort === undefined ? undefined : String(e.effort)
503 await update($, model, m => ({ name: m?.name ?? e.model, effort }))
504 const u = result.usage
505 if (u) {
506 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
507 await update($, cache, () => ({ read: u.cache_read_input_tokens, total }))
508 const seconds = ((await $.clock.now()) - start) / 1000
509 await update($, tokens, t => ({
510 input: t.input + u.input_tokens,
511 output: t.output + u.output_tokens,
512 cacheRead: t.cacheRead + u.cache_read_input_tokens,
513 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
514 speed: seconds > 0 && u.output_tokens > 0 ? u.output_tokens / seconds : t.speed,
515 }))
516 }
517 }
518 return result
519 })
520
521 on('turn.complete', async ($, e, next) => {
522 const result = await next(e)
523 if (e.agentId === undefined) {
524 quietly(petHears($, e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stop' : 'failed'))
525 quietly(refreshTurns($))
526 quietly(refreshModelName($))
527 // The output style may have changed in /config meanwhile
528 quietly(refreshSession($))
529 }
530 quietly(refreshAgents($))
531 return result
532 })
533
534 // The events below hold back what the engine does; if the drawer fails, .catch lets it go on
535 // (when the hook already called next, that call's result is reused)
536 on('agent.spawn', async ($, e, next) => {
537 const result = await next(e)
538 quietly(refreshAgents($))
539 return result
540 }).catch(($, e, next) => next(e))
541
542 // A skill run in this session: count it, and keep its name
543 on('skill.prompt', async ($, e, next) => {
544 const result = await next(e)
545 quietly(update($, skills, s => ({ count: s.count + 1, last: e.skill })))
546 return result
547 }).catch(($, e, next) => next(e))
548
549 on('session.compact', async ($, e, next) => {
550 const result = await next(e)
551 if (e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in result && result.skip)) {
552 quietly(update($, compacts, n => n + 1))
553 quietly(petHears($, 'compact'))
554 }
555 return result
556 }).catch(($, e, next) => next(e))
557
558 // A tool of Claude's own failed, not one the user stopped: the pet jumps
559 on('classic.PostToolUseFailure', async ($, e, next) => {
560 const result = await next(e)
561 if (e.agent_id === undefined && e.is_interrupt !== true) quietly(petHears($, 'error'))
562 return result
563 }).catch(($, e, next) => next(e))
564
565 // A compaction starts the context over, and no turn ends to measure it: empty it here, as the status
566 // line's figures are, until the next response measures it. Read again, it is the fill from before:
567 // $.session.usage() still answers that here and empties a moment later (Claude Code 2.1.300)
568 on('classic.PostCompact', async ($, e, next) => {
569 const result = await next(e)
570 if (e.agent_id === undefined) {
571 quietly(update($, usage, u => (u === null ? u : { ...u, contextPercent: undefined, contextTokens: undefined })))
572 }
573 return result
574 }).catch(($, e, next) => next(e))
575
576 // Permission mode: recorded at session start, on each prompt, after each step's tool calls and
577 // when a reply ends (after shift+tab it shows once the next of these comes). classic.PreToolUse
578 // carries no permission_mode; PostToolBatch comes once a step, where PostToolUse comes per tool
579 on('classic.PostToolBatch', async ($, e, next) => {
580 const result = await next(e)
581 quietly(rememberMode($, e))
582 return result
583 }).catch(($, e, next) => next(e))
584
585 on('classic.SessionStart', async ($, e, next) => {
586 const result = await next(e)
587 quietly(rememberMode($, e))
588 return result
589 }).catch(($, e, next) => next(e))
590
591 on('classic.UserPromptSubmit', async ($, e, next) => {
592 const result = await next(e)
593 quietly(rememberMode($, e))
594 return result
595 }).catch(($, e, next) => next(e))
596
597 on('classic.Stop', async ($, e, next) => {
598 const result = await next(e)
599 quietly(rememberMode($, e))
600 if (e.agent_id === undefined) quietly(update($, background, () => countBackground(e.background_tasks ?? [])))
601 return result
602 }).catch(($, e, next) => next(e))
603
604 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
605 const { Box, Text } = $.ui.resolve(e)
606 // Leave out the paddingX on either side, or a row runs past the width and wraps
607 const inner = Math.max(22, e.props.bodyColumns - 2)
608 const placement = e.props.placement
609 fullscreen = e.viewport?.isFullscreen === true
610 // Drawn somewhere new: out of it if the drawer does not belong there
611 if (placement !== placedAs) {
612 placedAs = placement
613 quietly(keepPlace($))
614 }
615
616 const s = await readSnapshot($)
617 const conf = await read($, config)
618 const enabled = enabledItems(conf, placement)
619 // The pet is not a row: it is drawn on its own, over the rows or left of the columns
620 const shown = enabled.flatMap(item => {
621 const v = view(item.id, s)
622 return v ? [{ item, view: v }] : []
623 })
624
625 // No Fragments here: this surface lays a Fragment out as a column, so siblings go in a row Box
626 const segs = (list: readonly Seg[]) =>
627 list.map(g => (
628 <Text color={g.color} bold={g.bold === true}>
629 {g.text}
630 </Text>
631 ))
632
633 // The pet, in this session's coat of the animal the config names
634 const hasPet = enabled.some(item => item.id === 'pet')
635 const p = await read($, pet)
636 const species = petSpecies(conf.pet?.species)
637 const look = petLook(p)
638 // Columns from the left of the strip for a place along the walk, spread over the width there is
639 const along = (pos: number) => Math.round((pos / TRACK) * Math.max(0, inner - 12))
640 const ball = p.ball === undefined ? undefined : { pos: p.ballPos, lift: p.ballLift }
641 // A pet's box keeps its size, each row one high: a row of nothing but gaps (a sleeping pet's top
642 // row with no snore yet) would otherwise fold to nothing and the rows under it jump up. Beside the
643 // transcript it is taller than the pet, room for it to jump into; above the prompt there is none
644 const cellRows = (rows: Run[][]) =>
645 rows.map(row => (
646 <Box flexDirection="row" height={1} flexShrink={0}>
647 {row.map(r =>
648 'gap' in r ? (
649 <Box width={r.gap} flexShrink={0} />
650 ) : (
651 <Text color={r.fg} backgroundColor={r.bg}>
652 {r.ch}
653 </Text>
654 ),
655 )}
656 </Box>
657 ))
658 const drawPet = (pixels: number) => (
659 <Box key="pet" flexDirection="column" width={12} height={pixels / 2} flexShrink={0}>
660 {cellRows(
661 petCells(species, look.pose, petCoat(species, p.coat), p.faceLeft, look.over, pixels, pixels > PET_HEIGHT ? look.lift : 0),
662 )}
663 </Box>
664 )
665
666 // Above the prompt: text alone, in columns side by side
667 if (placement === 'inline') {
668 // The pet takes the leftmost columns, 12 and 2 to spare, and walks on the spot
669 const petWidth = hasPet ? 14 : 0
670 const cells = shown.map(({ item, view: v }) => {
671 const value = inlineValue(v)
672 return { label: item.label, value, width: segWidth(value) }
673 })
674 const rows = columnRows(
675 cells.map(c => ({ label: c.label.length, value: c.width })),
676 inner - petWidth,
677 )
678 const columns = chunk(cells, rows).map(list => ({
679 list,
680 label: Math.max(...list.map(c => c.label.length)) + 1,
681 value: Math.max(...list.map(c => c.width)),
682 }))
683 const lines = Array.from({ length: Math.min(rows, cells.length) }, (_, r) => (
684 <Box flexDirection="row">
685 {columns.flatMap((col, i) => {
686 const cell = col.list[r]
687 if (cell === undefined) return []
688 const isLast = i === columns.length - 1
689 return [
690 <Text color={C.blue}>{cell.label.padEnd(col.label)}</Text>,
691 ...segs(cell.value),
692 ...(isLast ? [] : [<Text>{' '.repeat(col.value - cell.width)}</Text>, <Text color={C.surface}>{COLUMN_GAP}</Text>]),
693 ]
694 })}
695 </Box>
696 ))
697 return (
698 <Box flexDirection="row" paddingX={1}>
699 {hasPet ? (
700 <Box flexDirection="column" flexShrink={0} marginRight={2}>
701 {drawPet(PET_HEIGHT)}
702 </Box>
703 ) : null}
704 <Box flexDirection="column">{lines}</Box>
705 </Box>
706 )
707 }
708
709 // Beside the transcript: a key column and bars for meters, one row after another
710 const keyWidth = Math.max(LABEL, ...shown.map(({ item }) => item.label.length + 1))
711 const barWidth = Math.max(4, inner - keyWidth - 5)
712 // The key keeps its width: a value too long for the row (a path) would squeeze it, and its padding
713 // would wrap to an empty second row
714 const label = (text: string) => (
715 <Box flexShrink={0}>
716 <Text color={C.blue}>{text.padEnd(keyWidth)}</Text>
717 </Box>
718 )
719 const dockRow = (key: string, v: View) => {
720 if (v.meter !== undefined) {
721 const b = bar(v.meter, barWidth, conf.bar)
722 return (
723 <Box flexDirection="row">
724 {label(key)}
725 <Text color={usageColor(v.meter)}>{b.filled}</Text>
726 <Text color={C.surface}>{b.empty}</Text>
727 <Text color={C.text}>{percentText(v.meter).padStart(5)}</Text>
728 </Box>
729 )
730 }
731 return (
732 <Box flexDirection="row">
733 {label(key)}
734 {v.cutStart ? (
735 <Text color={C.text} wrap="truncate-start">
736 {v.value.map(g => g.text).join('')}
737 </Text>
738 ) : (
739 segs(v.value)
740 )}
741 </Box>
742 )
743 }
744
745 const rows = []
746 // Read here, not above the prompt, where no kit is drawn: a kit's step redraws only the sidebar
747 const ks = hasPet ? await read($, kits) : []
748 // The pet over the rows: what it says, then the strip it walks along, its place along its walk spread
749 // over the width there is
750 if (hasPet) {
751 rows.push(
752 <Box flexDirection="row" height={1} flexShrink={0}>
753 {p.line === undefined ? null : <Text color={C.text}>{p.line.slice(0, inner)}</Text>}
754 </Box>,
755 <Box flexDirection="row" height={(PET_HEIGHT + LIFT_ROOM) / 2} flexShrink={0}>
756 {/* The kits, out of the flow and first, so the pet goes over them: on the ground, along a
757 walk as long as the pet's, spread over the width there is less their own */}
758 {ks.map(k => (
759 <Box
760 key={'kit-' + k.id}
761 position="absolute"
762 top={(PET_HEIGHT + LIFT_ROOM - KIT_HEIGHT) / 2}
763 left={Math.round((k.x / TRACK) * Math.max(0, inner - KIT_WIDTH))}
764 width={KIT_WIDTH}
765 height={KIT_HEIGHT / 2}
766 flexDirection="column"
767 >
768 {cellRows(kitCells(species, k))}
769 </Box>
770 ))}
771 <Box width={along(p.x)} flexShrink={0} />
772 {drawPet(PET_HEIGHT + LIFT_ROOM)}
773 {/* A ball over the strip, out of the flow: where a pet at its place would have its middle */}
774 {ball === undefined ? null : (
775 <Box
776 key="ball"
777 position="absolute"
778 top={0}
779 left={along(ball.pos) + BALL_LEFT}
780 width={4}
781 height={(PET_HEIGHT + LIFT_ROOM) / 2}
782 flexDirection="column"
783 >
784 {cellRows(drawnCells(BALL, BALL_INK, PET_HEIGHT + LIFT_ROOM, ball.lift))}
785 </Box>
786 )}
787 </Box>,
788 )
789 }
790 for (const { item, view: v } of shown) {
791 rows.push(dockRow(item.label, v))
792 if (v.below !== undefined) {
793 rows.push(
794 <Box flexDirection="row">
795 {label('')}
796 {segs(v.below)}
797 </Box>,
798 )
799 }
800 if (v.resets !== undefined) {
801 rows.push(
802 <Box flexDirection="row">
803 {label('')}
804 <Text color={C.flamingo}>{'resets '}</Text>
805 <Text color={C.text}>{v.resets}</Text>
806 </Box>,
807 )
808 }
809 }
810 return (
811 <Box flexDirection="column" paddingX={1}>
812 {rows}
813 </Box>
814 )
815 })
816}
817hooks/pets.ts 872 lines1// The pets: five animals, 12 x 8 pixels each, drawn two pixels to a cell (▀ ▄), so 12 columns by 4 rows.
2// Each faces right, its feet on the bottom row; a pet facing left is drawn the other way about. A
3// jump is no drawing of its own: the pet as it strides, lifted off the ground (user, 2026-10-15: drawn
4// apart it looked squashed). The marks are inks a coat colors: '#' the fur, 's' the second color
5// (stripes, an ear), 'w' the light one (chest, belly), 'x' the more white a black-and-white cat
6// has, 'e' the eye, 'n' the nose, 'p' pink, 'b' a bill and feet, and 'q' the limbs: legs,
7// paws and feet together, the duck's wing and the penguin's flipper, in every pose, a color each coat
8// names apart from the fur, as such an animal's would be (white socks, cream legs), so they stand out
9// from the body and a paw a swipe raises does not run into the head (user, 2026-10-15: the limbs are
10// colored from the first, all of them). A session picks one coat of its pet at random.
11
12import type { Kit, PetBall, PetMode, PetState } from '../types'
13
14export const SPECIES = ['cat', 'dog', 'rabbit', 'duck', 'penguin'] as const
15export type Species = (typeof SPECIES)[number]
16export const POSES = ['walk1', 'walk2', 'sit', 'sleep', 'lie', 'swipe1', 'swipe2'] as const
17export type Pose = (typeof POSES)[number]
18export type Coat = Record<string, string>
19
20type Pet = { frames: Record<Pose, string[]>; coats: Coat[] }
21
22export const PETS: Record<Species, Pet> = {
23 // locku's cat; its sitting drawn lower, to fit in 8
24 cat: {
25 frames: {
26 walk1: [
27 'x.......#..#',
28 '#.......####',
29 '.s......##e#',
30 '.##s#s#s##xx',
31 '.##s#s#s#www',
32 '..#xxxxxxww.',
33 '..q.q...q.q.',
34 '..q.q...q.q.',
35 ],
36 walk2: [
37 'x.......#..#',
38 '#.......####',
39 '.s......##e#',
40 '.##s#s#s##xx',
41 '.##s#s#s#www',
42 '..#xxxxxxww.',
43 '.q..q..q..q.',
44 'q...q..q...q',
45 ],
46 sit: [
47 '.....#..#...',
48 '.....####...',
49 '.....##e#...',
50 '.....##xx...',
51 '...s#sww....',
52 '..#s#sxx....',
53 'x.#s#s#qq...',
54 '.qq####qq...',
55 ],
56 sleep: [
57 '............',
58 '............',
59 '............',
60 '.......#..#.',
61 '......####..',
62 '..#s#s##xx..',
63 '.##s#s#s##ww',
64 'x#xxxxxxx#qq',
65 ],
66 lie: [
67 '............',
68 '............',
69 '........#..#',
70 '........####',
71 '........##e#',
72 '..#s#s#s##xx',
73 '.##s#s#s#www',
74 'x#xxxxxxx.qq',
75 ],
76 swipe1: [
77 '.....#..#...',
78 '.....####...',
79 '.....##e#...',
80 '.....##xx...',
81 '...s#swwqq..',
82 '..#s#sxx....',
83 'x.#s#s#qq...',
84 '.qq####q....',
85 ],
86 swipe2: [
87 '.....#..#...',
88 '.....####.q.',
89 '.....##e#q..',
90 '.....##xxq..',
91 '...s#sww....',
92 '..#s#sxx....',
93 'x.#s#s#qq...',
94 '.qq####q....',
95 ],
96 },
97 // locku's five: an orange tabby, an amber, a white, a grey-blue, a black-and-white
98 coats: [
99 { q: '#f4f1ea', '#': '#f6b06a', s: '#a8460c', w: '#f6b06a', x: '#f6b06a', e: '#7ed957' },
100 { q: '#9a958e', '#': '#7a4a2a', s: '#e8b830', w: '#9a958e', x: '#7a4a2a', e: '#7ed957' },
101 { q: '#c4c0b8', '#': '#f4f1ea', s: '#8c8a86', w: '#f4f1ea', x: '#f4f1ea', e: '#5fa8ff' },
102 { q: '#f4f1ea', '#': '#9fb2c8', s: '#4e5d70', w: '#9fb2c8', x: '#9fb2c8', e: '#7ed957' },
103 { q: '#f4f1ea', '#': '#55504c', s: '#55504c', w: '#f4f1ea', x: '#f4f1ea', e: '#7ed957' },
104 ],
105 },
106 dog: {
107 frames: {
108 walk1: [
109 '........###.',
110 '#......s##e#',
111 '.#.....s###n',
112 '.#########w.',
113 '.########ww.',
114 '..########..',
115 '..q.q...q.q.',
116 '..q.q...q.q.',
117 ],
118 walk2: [
119 '........###.',
120 '#......s##e#',
121 '.#.....s###n',
122 '.#########w.',
123 '.########ww.',
124 '..########..',
125 '.q..q..q..q.',
126 'q...q..q...q',
127 ],
128 sit: [
129 '......###...',
130 '.....s##e#..',
131 '.....s###n..',
132 '......##w...',
133 '#....###ww..',
134 '.#..####ww..',
135 '..######w...',
136 '..q###.qq...',
137 ],
138 sleep: [
139 '............',
140 '............',
141 '............',
142 '........###.',
143 '......s####n',
144 '..######s##.',
145 '.##########.',
146 '#qq######qq.',
147 ],
148 lie: [
149 '............',
150 '............',
151 '........###.',
152 '.......s##e#',
153 '#......s###n',
154 '.#########w.',
155 '.##########.',
156 '.q########qq',
157 ],
158 swipe1: [
159 '......###...',
160 '.....s##e#..',
161 '.....s###n..',
162 '......##w...',
163 '#....###wqqq',
164 '.#..####w...',
165 '..######w...',
166 '..q###.q....',
167 ],
168 swipe2: [
169 '......###...',
170 '.....s##e#q.',
171 '.....s###nq.',
172 '......##wq..',
173 '#....###w...',
174 '.#..####w...',
175 '..######w...',
176 '..q###.q....',
177 ],
178 },
179 // a shiba, a black-and-tan, a white, a black-and-white
180 coats: [
181 { q: '#f4ead8', '#': '#d98b4a', s: '#a8602c', w: '#f4ead8', e: '#3a2a20', n: '#2b2b2b' },
182 { q: '#d9a066', '#': '#6b625a', s: '#3e3934', w: '#d9a066', e: '#f0c060', n: '#2b2b2b' },
183 { q: '#d8c8b0', '#': '#eee8dc', s: '#d8c8b0', w: '#ffffff', e: '#3a2a20', n: '#2b2b2b' },
184 { q: '#f4f1ea', '#': '#35302e', s: '#35302e', w: '#f4f1ea', e: '#e8c060', n: '#c08a7a' },
185 ],
186 },
187 rabbit: {
188 frames: {
189 walk1: [
190 '........#.#.',
191 '........p.p.',
192 '........#.#.',
193 '.......##e#.',
194 '..#########p',
195 'w#########..',
196 '.########...',
197 '..qq...qq...',
198 ],
199 walk2: [
200 '........#.#.',
201 '........p.p.',
202 '........#.#.',
203 '.......##e#.',
204 '..#########p',
205 'w#########..',
206 '.#######....',
207 '.qq.....qq..',
208 ],
209 sit: [
210 '......#.#...',
211 '......p.p...',
212 '......#.#...',
213 '.....##e#...',
214 '.....####p..',
215 '...#####....',
216 'w.######....',
217 '..qq.qq.....',
218 ],
219 sleep: [
220 '............',
221 '............',
222 '............',
223 '............',
224 '.....####...',
225 '...##pp####.',
226 'w##########p',
227 '.qq######qq.',
228 ],
229 lie: [
230 '............',
231 '........#.#.',
232 '........p.p.',
233 '........#.#.',
234 '.......##e#.',
235 '..#########p',
236 'w##########.',
237 '.qq######qq.',
238 ],
239 swipe1: [
240 '......#.#...',
241 '......p.p...',
242 '......#.#...',
243 '.....##e#...',
244 '.....####p..',
245 '...#####....',
246 'wqq.####....',
247 '.....qq.....',
248 ],
249 swipe2: [
250 '......#.#...',
251 '......p.p...',
252 '......#.#...',
253 '.....##e#...',
254 '.....####p..',
255 '...#####....',
256 'w.######....',
257 '.qqq.qq.....',
258 ],
259 },
260 // a grey, a white with red eyes, a brown, a ginger
261 coats: [
262 { q: '#f4f1ea', '#': '#b8b4ac', p: '#f2a7b5', w: '#f4f1ea', e: '#3a2a40' },
263 { q: '#c4c0b8', '#': '#f0ece4', p: '#f2a7b5', w: '#ffffff', e: '#d9534f' },
264 { q: '#f4f1ea', '#': '#9a6a44', p: '#e8a0a8', w: '#f4f1ea', e: '#2a1a10' },
265 { q: '#f4ead8', '#': '#d9894a', p: '#f2a7b5', w: '#f4ead8', e: '#2a1a10' },
266 ],
267 },
268 duck: {
269 frames: {
270 walk1: [
271 '.......###..',
272 '......##e#..',
273 '......####bb',
274 '.......##...',
275 '#.....####..',
276 '##qq######..',
277 '.########...',
278 '....b..b....',
279 ],
280 walk2: [
281 '.......###..',
282 '......##e#..',
283 '......####bb',
284 '.......##...',
285 '#.....####..',
286 '##qq######..',
287 '.########...',
288 '...b....b...',
289 ],
290 sit: [
291 '............',
292 '.......###..',
293 '......##e#..',
294 '......####bb',
295 '.......##...',
296 '#.....####..',
297 '##qq######..',
298 '.########...',
299 ],
300 sleep: [
301 '............',
302 '............',
303 '............',
304 '............',
305 '......###...',
306 '#....#####..',
307 '##qq#######.',
308 '.#########..',
309 ],
310 lie: [
311 '............',
312 '............',
313 '............',
314 '........###.',
315 '.......##e#.',
316 '#.....####bb',
317 '##qq#######.',
318 '.#########..',
319 ],
320 swipe1: [
321 '...q...###..',
322 '...qq.##e#..',
323 '....qq####bb',
324 '.......##...',
325 '#.....####..',
326 '##########..',
327 '.########...',
328 '....b..b....',
329 ],
330 swipe2: [
331 '.......###..',
332 '......##e#..',
333 '......####bb',
334 '.qq....##...',
335 '#.qqq.####..',
336 '##########..',
337 '.########...',
338 '....b..b....',
339 ],
340 },
341 // a yellow duckling, a white duck, a brown one, a grey one
342 coats: [
343 { q: '#c99a2e', '#': '#f2d24b', b: '#f08a24', e: '#2b2b2b' },
344 { q: '#c4c0b8', '#': '#f2efe8', b: '#f0a830', e: '#2b2b2b' },
345 { q: '#6a5038', '#': '#a88a64', b: '#e8c040', e: '#2b2b2b' },
346 { q: '#7a7f88', '#': '#b8bcc4', b: '#f0a830', e: '#2b2b2b' },
347 ],
348 },
349 penguin: {
350 frames: {
351 walk1: [
352 '.....####...',
353 '....###e#bb.',
354 '....####w...',
355 '...q###ww...',
356 '..qq###www..',
357 '...####www..',
358 '...####ww...',
359 '.....b..b...',
360 ],
361 walk2: [
362 '.....####...',
363 '....###e#bb.',
364 '....####w...',
365 '...q###ww...',
366 '...q####www.',
367 '...####www..',
368 '...####ww...',
369 '....b...b...',
370 ],
371 sit: [
372 '.....####...',
373 '....###e#bb.',
374 '....####w...',
375 '...q###ww...',
376 '...q###www..',
377 '...####www..',
378 '...####ww...',
379 '....bb.bb...',
380 ],
381 sleep: [
382 '............',
383 '............',
384 '............',
385 '............',
386 '........###.',
387 '..########bb',
388 '.###qq####w.',
389 '..wwwwwwww..',
390 ],
391 lie: [
392 '............',
393 '............',
394 '............',
395 '.......####.',
396 '......###e#b',
397 '.###qq####w.',
398 '.##########.',
399 '..wwwwwwww..',
400 ],
401 swipe1: [
402 '.....####...',
403 '.q..###e#bb.',
404 '..q.####w...',
405 '...#####w...',
406 '...####www..',
407 '...####www..',
408 '...####ww...',
409 '.....b..b...',
410 ],
411 swipe2: [
412 '.....####...',
413 '....###e#bb.',
414 '....####w...',
415 'qqq####ww...',
416 '...####www..',
417 '...####www..',
418 '...####ww...',
419 '.....b..b...',
420 ],
421 },
422 // a dark grey one, a little blue, a gentoo, a chinstrap
423 coats: [
424 { q: '#8a93a6', '#': '#5a6170', w: '#f4f1ea', b: '#f0a830', e: '#f4f1ea' },
425 { q: '#4a6a94', '#': '#6f8fb8', w: '#f4f1ea', b: '#3a3f4b', e: '#f4f1ea' },
426 { q: '#3a414d', '#': '#4a5260', w: '#f4f1ea', b: '#f06a30', e: '#f4f1ea' },
427 { q: '#6a7280', '#': '#8c94a2', w: '#ffffff', b: '#2b2b2b', e: '#2b2b2b' },
428 ],
429 },
430}
431
432// Kits: a small one of the pet's kind, 6 x 4 pixels, so 6 columns by 2 rows, the rabbit two pixels
433// taller for its ears, in the same inks; they only walk (see kitsFor)
434export type KitPose = 'walk1' | 'walk2'
435export const KIT_WIDTH = 6
436export const KIT_HEIGHT = 6 // the box a kit stands in, as high as the rabbit
437export const KITS: Record<Species, Record<KitPose, string[]>> = {
438 cat: {
439 walk1: ['#..#.#', '#..#e#', '.#s#sx', '.q..q.'],
440 walk2: ['#..#.#', '#..#e#', '.#s#sx', 'q.q..q'],
441 },
442 dog: {
443 walk1: ['#..##.', '.#s#e#', '.####n', '.q..q.'],
444 walk2: ['#..##.', '.#s#e#', '.####n', 'q.q..q'],
445 },
446 rabbit: {
447 walk1: ['...#.#', '...p.p', '..##ep', 'w####.', '.####.', '.qq.q.'],
448 walk2: ['...#.#', '...p.p', '..##ep', 'w####.', '.####.', 'qq..qq'],
449 },
450 duck: {
451 walk1: ['...##.', '...#eb', '#q###.', '.b..b.'],
452 walk2: ['...##.', '...#eb', '#q###.', 'b...b.'],
453 },
454 penguin: {
455 walk1: ['..##..', '.##eb.', 'q#ww..', '.b.b..'],
456 walk2: ['..##..', '.##eb.', 'q#ww..', 'b...b.'],
457 },
458}
459
460// A run of one look along a row of cells: a gap to leave, or characters in their colors
461export type Run = { gap: number } | { ch: string; fg?: string; bg?: string }
462type Cell = { ch: string; fg?: string; bg?: string; text?: boolean } | undefined
463
464// Over a pet's head, in text, not pixels, in the top row of cells at the side it faces: a sleeping
465// pet snores, z, zz, zzZ and round again, in the room every sleeping pose leaves there; a pet patted
466// sits, and a heart goes up in the corner every sitting pose leaves free. Beside the transcript the
467// box is taller than the pet, and the text goes in the room over it
468const SNORES = ['z', 'zz', 'zzZ']
469export const SNORE_COLOR = '#f9e2af'
470export const HEART = '♥'
471export const HEART_COLOR = '#f38ba8'
472export type Over = { text: string; color: string }
473export const PET_HEIGHT = 8 // a pet's pixels, high
474export const LIFT_ROOM = 2 // the pixels over it, beside the transcript, for it to jump into
475
476// The snore n steps along, from 1
477export function snore(n: number): Over {
478 return { text: SNORES[(n - 1) % SNORES.length] ?? 'z', color: SNORE_COLOR }
479}
480
481// Coat n of the animal, counting round: a session keeps its number when the config changes animal
482export function petCoat(species: Species, n: number): Coat {
483 const coats = PETS[species].coats
484 return coats[Math.max(0, n) % coats.length] ?? {}
485}
486
487// The cells of a pose, facing left drawn the other way about, over over the head; see drawnCells
488export function petCells(
489 species: Species,
490 pose: Pose,
491 coat: Coat,
492 faceLeft = false,
493 over?: Over,
494 height = PET_HEIGHT,
495 lift = 0,
496): Run[][] {
497 const drawn = PETS[species].frames[pose].map(r => (faceLeft ? [...r].reverse().join('') : r))
498 return drawnCells(drawn, coat, height, lift, over, faceLeft)
499}
500
501// The cells of a drawing, a row of runs for each two rows of pixels: ▀ in the upper pixel's color on
502// the lower one's, ▄ when only the lower one is there, a gap when neither is. height is the box's, in
503// pixels (even, at least the drawing's); the drawing stands on its bottom, lift pixels up it when it
504// jumps. over goes in the top row, at the right, or at the left when overLeft.
505export function drawnCells(drawn: string[], coat: Coat, height = PET_HEIGHT, lift = 0, over?: Over, overLeft = false): Run[][] {
506 const faceLeft = overLeft
507 const width = drawn[0]?.length ?? 0
508 const above = Math.max(0, height - drawn.length - lift)
509 const empty = '.'.repeat(width)
510 const frame = [...Array<string>(above).fill(empty), ...drawn, ...Array<string>(Math.max(0, height - above - drawn.length)).fill(empty)]
511 const at = (y: number, x: number) => coat[frame[y]?.[x] ?? '.']
512 const grid: Cell[][] = []
513 for (let y = 0; y < frame.length; y += 2) {
514 grid.push(
515 Array.from({ length: width }, (_, x) => {
516 const top = at(y, x)
517 const bottom = at(y + 1, x)
518 if (top === undefined && bottom === undefined) return undefined
519 return top === undefined ? { ch: '▄', fg: bottom } : { ch: '▀', fg: top, bg: bottom }
520 }),
521 )
522 }
523 const top = grid[0]
524 if (over !== undefined && top !== undefined) {
525 // From the right edge leftwards facing right, from the left edge facing left
526 const text = faceLeft ? [...over.text].reverse().join('') : over.text
527 const from = faceLeft ? 0 : width - text.length
528 ;[...text].forEach((ch, i) => (top[from + i] = { ch, fg: over.color, text: true }))
529 }
530 return grid.map(row => {
531 const runs: Run[] = []
532 for (const cell of row) {
533 const last = runs[runs.length - 1]
534 if (cell === undefined) {
535 if (last && 'gap' in last) last.gap++
536 else runs.push({ gap: 1 })
537 } else if (cell.text && last && 'ch' in last && last.fg === cell.fg && last.bg === undefined) {
538 last.ch += cell.ch
539 } else runs.push({ ch: cell.ch, fg: cell.fg, bg: cell.bg })
540 }
541 return runs
542 })
543}
544
545// What a pet does, beside Claude. While Claude works it wanders: it picks a place along its walk
546// and goes there, running now and then when it is far, and there it sits a while, hops, or goes
547// straight on to the next, or now and then swipes at the air (user, 2026-10-14: to and fro alone was
548// dull). It climbed the ends of its walk for a while; every way of drawing that looked odd, and it went
549// (user, 2026-10-15). When Claude is done it stops and sits; idle it winds down, sitting, lying down
550// after two minutes and asleep after ten, now and then strolling a little, looking the other way or
551// swiping, and wakes the moment anything goes on. A tool failing makes it jump; a reply done, a
552// compaction or a pat gets a line out of it; a pat sits it down a moment, a heart over its head, and
553// wakes it. A ball thrown in (/ccdrawu ball) comes from its far side, bounces and rolls to a stop;
554// it runs over, paws at it a while, and has it (user, 2026-10-15, after vscode-pets). None of it
555// touches `$`: the hooks feed it events, a tick and a random source.
556
557export const PET_TICK = 200 // ms between ticks
558export const TRACK = 20 // steps from one end of its walk to the other
559const LINE_MS = 8_000
560const HEART_MS = 3_000
561const JUMP_MS = 600
562const LIE_AFTER = 2 * 60_000
563const SLEEP_AFTER = 10 * 60_000
564const SNORE_MS = 800
565const SWIPE_MS = 1_200 // a swipe at the air
566const NEAREST = 4 // the next place is at least this far from where it is
567const IDLE_CHANCE = 1 / 150 // a tick's chance, idle, of a stroll, a look round or a swipe: about every 30 s
568const ROLL_MS = 1_200 // a ball rolls this long to a stop
569const PLAY_MS = 1_600 // the pet paws at it this long
570const BALL_MAX_MS = 20_000 // a ball not had by then goes
571export const BALL_REACH = 5 // how far from the ball the pet stands to paw at it, along its walk
572export const BALL_LEFT = 4 // the ball's columns from the left of the box a pet at its place would have
573
574// The ball, 4 x 4 pixels on the ground: red, a shine on it
575export const BALL = ['.rr.', 'rhrr', 'rrrr', '.rr.']
576export const BALL_INK: Coat = { r: '#f38ba8', h: '#fde2e7' }
577const BOUNCE = [2, 1, 0, 1, 0] // its pixels off the ground, a tick at a time, as it comes in
578
579export const PET_START: PetState = {
580 coat: -1,
581 busy: false,
582 active: 0,
583 mode: 'sit',
584 x: 0,
585 target: 0,
586 run: false,
587 stride: 0,
588 restUntil: 0,
589 lift: 0,
590 faceLeft: false,
591 snore: 0,
592 heart: false,
593 lineUntil: 0,
594 heartUntil: 0,
595 jumpFrom: 0,
596 jumpUntil: 0,
597 ballPos: 0,
598 ballLift: 0,
599 playUntil: 0,
600}
601
602// The next place to go: a few tries for one far enough, else the far end
603function nextTarget(x: number, rand: () => number): number {
604 for (let i = 0; i < 4; i++) {
605 const t = Math.round(rand() * TRACK)
606 if (Math.abs(t - x) >= NEAREST) return t
607 }
608 return x < TRACK / 2 ? TRACK : 0
609}
610
611// busy: Claude is at work; done, stop, failed: its turn ended with an answer, stopped by the user, or
612// on an error; error: a tool failed while it works on; pat, ball: the user's
613export type PetEvent = 'busy' | 'done' | 'stop' | 'failed' | 'error' | 'compact' | 'pat' | 'ball'
614
615// Lines are plain ASCII: a character two cells wide would push the row past the pane
616const LINES: Record<'done' | 'error' | 'compact', string[]> = {
617 done: ['All done!', 'Nice work.', 'Ta-da!', 'Finished!', 'That went well.'],
618 error: ['Uh oh...', 'Yikes!', 'Oops!', 'That broke.', 'Hmm...'],
619 compact: ['Tidied up!', 'Squeezed it in.', 'Packed it up.', 'Lighter now.'],
620}
621export const PAT_LINES: Record<Species, string[]> = {
622 cat: ['Purr...', 'Mrrp!', 'Meow.'],
623 dog: ['Woof!', '*wag wag*', 'Arf!'],
624 rabbit: ['*happy hop*', '*nose wiggle*', 'Eep!'],
625 duck: ['Quack!', 'Quack quack!', '*waddle*'],
626 penguin: ['*flap flap*', 'Squawk!', '*happy slide*'],
627}
628export const BALL_LINES: Record<Species, string[]> = {
629 cat: ['*pounce*', 'Got it!', 'Mine!'],
630 dog: ['Fetch!', 'Got it!', '*tail wags*'],
631 rabbit: ['*boing*', 'Got it!', 'Hop hop!'],
632 duck: ['Quack! Got it!', 'Mine!', '*waddle waddle*'],
633 penguin: ['*belly slide*', 'Got it!', 'Squawk!'],
634}
635
636// Where a ball is along the walk, and how far off the ground, as it rolls in to a stop
637export function ballAt(b: PetBall, now: number): { pos: number; lift: number } {
638 const t = Math.min(1, Math.max(0, (now - b.from) / ROLL_MS))
639 // Slowing as it goes
640 const pos = Math.round(b.start + (b.rest - b.start) * (1 - (1 - t) ** 2))
641 const lift = BOUNCE[Math.floor((now - b.from) / PET_TICK)] ?? 0
642 return { pos, lift }
643}
644
645const pick = (lines: readonly string[], rand: () => number) => lines[Math.floor(rand() * lines.length) % lines.length] ?? ''
646
647// A jump from now
648const jumps = (now: number) => ({ jumpFrom: now, jumpUntil: now + JUMP_MS })
649
650// What an event does to the pet; the tick after it shows it
651export function petEvent(s: PetState, event: PetEvent, now: number, species: Species, rand: () => number): PetState {
652 const said = (line: string) => ({ line, lineUntil: now + LINE_MS })
653 // A turn over, or a pat: it stops where it is
654 const stops = { target: s.x, restUntil: 0 }
655 switch (event) {
656 case 'busy':
657 return { ...s, busy: true, active: now }
658 case 'done':
659 return { ...s, ...stops, busy: false, active: now, ...said(pick(LINES.done, rand)) }
660 case 'stop':
661 return { ...s, ...stops, busy: false, active: now }
662 case 'failed':
663 return { ...s, ...stops, busy: false, active: now, ...jumps(now), ...said(pick(LINES.error, rand)) }
664 case 'error':
665 return { ...s, active: now, ...jumps(now), ...said(pick(LINES.error, rand)) }
666 case 'compact':
667 return { ...s, active: now, ...said(pick(LINES.compact, rand)) }
668 case 'pat':
669 return { ...s, ...stops, active: now, heartUntil: now + HEART_MS, ...said(pick(PAT_LINES[species], rand)) }
670 case 'ball': {
671 // From its far side, to a stop in the middle stretch; it will stand on the nearer side of it
672 const start = s.x < TRACK / 2 ? TRACK : 0
673 const rest = BALL_REACH + Math.floor(rand() * (TRACK - 2 * BALL_REACH + 1))
674 const sides = [rest - BALL_REACH, rest + BALL_REACH]
675 const goal = sides.reduce((a, b) => (Math.abs(b - s.x) < Math.abs(a - s.x) ? b : a))
676 const line = pick(BALL_LINES[species], rand)
677 return { ...s, ...stops, active: now, heartUntil: 0, playUntil: 0, ball: { from: now, start, rest, goal, line } }
678 }
679 }
680}
681
682// The pet a tick later. The same object back when nothing in it changed, so a pet sitting still
683// costs no redraw
684export function petTick(s: PetState, now: number, rand: () => number = Math.random): PetState {
685 const heart = now < s.heartUntil
686 const asleep = !s.busy && now - s.active >= SLEEP_AFTER
687 let { x, target, run, stride, restUntil, jumpFrom, jumpUntil, faceLeft, ball, playUntil, line, lineUntil } = s
688 let moving = false
689 // A ball: it runs to it, paws at it once it has stopped rolling, and has it; one not had in time goes
690 if (ball !== undefined && now - ball.from > BALL_MAX_MS) [ball, playUntil] = [undefined, 0]
691 if (ball !== undefined && !heart) {
692 if (playUntil > 0 && now >= playUntil) {
693 ;[line, lineUntil] = [ball.line, now + LINE_MS]
694 ;[ball, playUntil] = [undefined, 0]
695 } else if (playUntil === 0 && x === ball.goal && now >= ball.from + ROLL_MS) {
696 playUntil = now + PLAY_MS
697 faceLeft = ball.goal > ball.rest
698 } else if (playUntil === 0) {
699 ;[target, run, restUntil] = [ball.goal, true, 0]
700 }
701 }
702 const playing = now < playUntil
703 if (playing && !heart) stride++
704 if (!heart && !asleep && !playing && now >= jumpUntil && now >= restUntil) {
705 if (x !== target) {
706 // On its way: a step, two when it runs, facing where it goes
707 const step = Math.min(run ? 2 : 1, Math.abs(target - x))
708 faceLeft = target < x
709 x += faceLeft ? -step : step
710 stride++
711 moving = true
712 } else if (ball !== undefined) {
713 // At the ball's place before it has stopped: it waits for it
714 } else if (s.busy) {
715 // There: it swipes at the air, sits a while, hops, or goes straight on; the next place, and a run
716 // if it is far
717 const r = rand()
718 if (r < 0.15) playUntil = now + SWIPE_MS
719 else if (r < 0.45) restUntil = now + 1_000 + Math.floor(rand() * 2_000)
720 else if (r < 0.65) ({ jumpFrom, jumpUntil } = jumps(now))
721 target = nextTarget(x, rand)
722 run = Math.abs(target - x) > TRACK / 2 && rand() < 0.4
723 } else if (rand() < IDLE_CHANCE) {
724 // Idle: now and then a stroll, a look the other way, or a swipe
725 const r = rand()
726 if (r < 0.4) [target, run] = [nextTarget(x, rand), false]
727 else if (r < 0.7) faceLeft = !faceLeft
728 else playUntil = now + SWIPE_MS
729 }
730 }
731 // A swipe begun this tick is pawing at once
732 const pawing = playing || (!heart && now < playUntil)
733 if (pawing && !playing) stride++
734 const lying = !s.busy && now - s.active >= LIE_AFTER
735 const mode: PetMode =
736 now < jumpUntil
737 ? 'jump'
738 : heart
739 ? 'sit'
740 : pawing
741 ? 'play'
742 : asleep
743 ? 'sleep'
744 : moving
745 ? 'walk'
746 : lying
747 ? 'lie'
748 : 'sit'
749 // Off the ground in a jump: up and down a pixel a tick, 1, 2, 1
750 const lift =
751 mode === 'jump'
752 ? Math.min(LIFT_ROOM, Math.floor((now - jumpFrom) / PET_TICK) + 1, Math.ceil((jumpUntil - now) / PET_TICK))
753 : 0
754 const snoreAt = mode === 'sleep' ? (Math.floor(now / SNORE_MS) % 3) + 1 : 0
755 if (line !== undefined && now >= lineUntil) line = undefined
756 const rolled = ball === undefined ? { pos: 0, lift: 0 } : ballAt(ball, now)
757 const next: PetState = {
758 ...s,
759 mode,
760 x,
761 target,
762 run,
763 stride,
764 restUntil,
765 jumpFrom,
766 jumpUntil,
767 lift,
768 faceLeft,
769 snore: snoreAt,
770 heart,
771 line,
772 lineUntil,
773 ball,
774 ballPos: rolled.pos,
775 ballLift: rolled.lift,
776 playUntil,
777 }
778 return (Object.keys(next) as (keyof PetState)[]).every(k => next[k] === s[k]) ? s : next
779}
780
781// How to draw it: the pose (its legs take turns as it goes), what goes over its head, and how far off
782// the ground it is
783export function petLook(s: PetState): { pose: Pose; over?: Over; lift: number } {
784 switch (s.mode) {
785 case 'walk':
786 return { pose: s.stride % 2 === 0 ? 'walk1' : 'walk2', lift: 0 }
787 case 'jump':
788 // Its legs apart, as in a stride
789 return { pose: 'walk2', lift: s.lift }
790 case 'play':
791 // Pawing at the ball, or at the air, one way and the other
792 return { pose: s.stride % 2 === 0 ? 'swipe1' : 'swipe2', lift: 0 }
793 case 'lie':
794 return { pose: 'lie', lift: 0 }
795 case 'sleep':
796 return { pose: 'sleep', over: s.snore > 0 ? snore(s.snore) : undefined, lift: 0 }
797 case 'sit':
798 return { pose: 'sit', over: s.heart ? { text: HEART, color: HEART_COLOR } : undefined, lift: 0 }
799 }
800}
801
802// Kits (user, 2026-10-16): the pet sits while Claude waits on its subagents, though work goes on. For
803// each subagent running, up to three, a kit walks behind the pet beside the transcript, drawn under
804// it; the pet goes on as before. A kit is in a coat of its kind's own colors, none the pet's and none
805// another kit's, so each reads apart (user: in the pet's colors it would not; in grey, or muted, they
806// ran together and looked dead). A kit comes in at an end of the walk as its subagent starts, wanders
807// while it runs, and walks off the nearer end once it is done.
808export const KIT_MAX = 3
809
810// The kits for the subagents running now: one comes in for each new one while there are fewer than
811// KIT_MAX, in a coat neither the pet (coat petCoat of species) nor another kit has, and one whose
812// subagent is gone heads for the nearer end. The same array back when none changed
813export function kitsFor(kits: Kit[], running: readonly string[], species: Species, petCoat: number, rand: () => number): Kit[] {
814 const coats = PETS[species].coats.length
815 const worn = (n: number) => Math.max(0, n) % coats
816 let changed = false
817 const next = kits.map(k => {
818 if (k.leaving || running.includes(k.id)) return k
819 changed = true
820 return { ...k, leaving: true, target: k.x < TRACK / 2 ? 0 : TRACK, restUntil: 0 }
821 })
822 for (const id of running) {
823 if (next.length >= KIT_MAX) break
824 if (next.some(k => k.id === id)) continue
825 const x = rand() < 0.5 ? 0 : TRACK
826 const target = nextTarget(x, rand)
827 const free = Array.from({ length: coats }, (_, n) => n).filter(n => n !== worn(petCoat) && !next.some(k => worn(k.coat) === n))
828 const coat = free[Math.floor(rand() * free.length)] ?? Math.floor(rand() * coats)
829 next.push({ id, coat, x, target, stride: 0, faceLeft: target < x, restUntil: 0, leaving: false })
830 changed = true
831 }
832 return changed ? next : kits
833}
834
835// The kits a tick later: a step towards where each goes, and there a rest now and then and the next
836// place; one leaving goes once it is at the end. The same array back when none moved
837export function kitsTick(kits: Kit[], now: number, rand: () => number = Math.random): Kit[] {
838 let changed = false
839 const next: Kit[] = []
840 for (const k of kits) {
841 if (k.leaving && k.x === k.target) {
842 changed = true
843 continue
844 }
845 if (now < k.restUntil) {
846 next.push(k)
847 continue
848 }
849 if (k.x !== k.target) {
850 const faceLeft = k.target < k.x
851 next.push({ ...k, x: k.x + (faceLeft ? -1 : 1), faceLeft, stride: k.stride + 1 })
852 } else {
853 const restUntil = rand() < 0.4 ? now + 1_000 + Math.floor(rand() * 2_000) : 0
854 next.push({ ...k, target: nextTarget(k.x, rand), restUntil })
855 }
856 changed = true
857 }
858 return changed ? next : kits
859}
860
861// The cells of a kit, standing on the bottom of its box, its legs taking turns as it goes
862export function kitCells(species: Species, k: Kit): Run[][] {
863 const pose: KitPose = k.stride % 2 === 0 ? 'walk1' : 'walk2'
864 const drawn = KITS[species][pose].map(r => (k.faceLeft ? [...r].reverse().join('') : r))
865 return drawnCells(drawn, petCoat(species, k.coat), KIT_HEIGHT)
866}
867
868// The pet the config names, cat when it names none or one there is not
869export function petSpecies(name: unknown): Species {
870 return SPECIES.find(s => s === name) ?? 'cat'
871}
872hooks/commands.ts 23 lines1import type { CommandSpec } from 'claude-code'
2
3import { commandNames } from './format'
4
5// Registers /ccdrawu and the alias the config names, each once, through $.command.register. The engine
6// cannot take a command back, so a name stays for the rest of the session: an alias changed or cleared
7// still works until then
8export async function registerCommands(
9 register: (spec: CommandSpec) => Promise<unknown>,
10 registered: Set<string>,
11 alias: unknown,
12) {
13 for (const name of commandNames(alias)) {
14 if (registered.has(name)) continue
15 const description =
16 name === 'ccdrawu'
17 ? 'Toggle the status drawer; pat or ball to play with the pet'
18 : 'Toggle the status drawer; pat or ball to play with the pet (alias of /ccdrawu)'
19 await register({ name, description, immediate: true })
20 registered.add(name)
21 }
22}
23hooks/format.ts 208 lines1// Pure helpers for the drawer: times, bars, paths, git stats, versions. None touches `$`, so tests can call them.
2
3const MINUTE = 60_000
4const HOUR = 60 * MINUTE
5const DAY = 24 * HOUR
6
7// Time left: 3d4h, 4h31m, 12m; under a minute (or past) reads "now"
8export function countdown(ms: number): string {
9 if (ms < MINUTE) return 'now'
10 const d = Math.floor(ms / DAY)
11 const h = Math.floor((ms % DAY) / HOUR)
12 const m = Math.floor((ms % HOUR) / MINUTE)
13 if (d > 0) return `${d}d${h}h`
14 if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
15 return `${m}m`
16}
17
18// Time elapsed, shaped like countdown; under a minute reads "0m"
19export function duration(ms: number): string {
20 return ms < MINUTE ? '0m' : countdown(ms)
21}
22
23// A bar's filled and empty parts, `width` cells in all, in the style picked in ccdrawu (the config's
24// "bar"): Nerd Font squares (nf-oct-square_fill, nf-oct-square), from the font itself rather than one
25// the terminal borrows, apart from each other and from the rows above and below; or "line", for a
26// terminal with no Nerd Font, a box-drawing line every monospace font has, its parts told apart by
27// color
28export const SQUARE_FILL = '\u{F445}'
29export const SQUARE = '\u{F51D}'
30export const LINE = '━'
31export function bar(percent: number, width: number, style?: string): { filled: string; empty: string } {
32 const [full, none] = style === 'line' ? [LINE, LINE] : [SQUARE_FILL, SQUARE]
33 const n = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
34 return { filled: full.repeat(n), empty: none.repeat(width - n) }
35}
36
37export function percentText(percent: number): string {
38 return `${Math.round(percent)}%`
39}
40
41// Usage color (Catppuccin Mocha): green under 50%, yellow under 70%, peach under 90%, red from 90%
42export function usageColor(percent: number): string {
43 if (percent < 50) return '#a6e3a1'
44 if (percent < 70) return '#f9e2af'
45 if (percent < 90) return '#fab387'
46 return '#f38ba8'
47}
48
49// A count kept short: 950, 1.5k, 152k, 1.2M
50export function compact(n: number): string {
51 const short = (v: number) => (v < 10 ? v.toFixed(1).replace(/\.0$/, '') : String(Math.round(v)))
52 if (n < 1000) return String(Math.round(n))
53 if (n < 999_500) return short(n / 1e3) + 'k'
54 return short(n / 1e6) + 'M'
55}
56
57// What `git status --porcelain=v2 --branch` says: the commit, how far it is from its upstream (left out
58// without one), and the files staged, unstaged and untracked
59export function parseStatusV2(text: string): {
60 sha?: string
61 ahead?: number
62 behind?: number
63 staged: number
64 unstaged: number
65 untracked: number
66} {
67 const out: ReturnType<typeof parseStatusV2> = { staged: 0, unstaged: 0, untracked: 0 }
68 for (const line of text.split('\n')) {
69 if (line.startsWith('# branch.oid ')) {
70 const oid = line.slice('# branch.oid '.length).trim()
71 if (oid !== '(initial)') out.sha = oid.slice(0, 7)
72 } else if (line.startsWith('# branch.ab ')) {
73 const m = /\+(\d+) -(\d+)/.exec(line)
74 if (m) {
75 out.ahead = Number(m[1])
76 out.behind = Number(m[2])
77 }
78 } else if (/^[12u] /.test(line)) {
79 // XY: the index's change, then the working tree's; . is none
80 if (line[2] !== '.') out.staged++
81 if (line[3] !== '.') out.unstaged++
82 } else if (line.startsWith('? ')) {
83 out.untracked++
84 }
85 }
86 return out
87}
88
89// owner/repo from a remote URL: git@host:owner/repo.git, https://host/owner/repo, ssh://git@host/owner/repo
90export function repoFromRemote(url: string): string | undefined {
91 return /[:/]([^/:]+\/[^/]+?)(?:\.git)?\/?$/.exec(url.trim())?.[1]
92}
93
94// The files a working tree has, by kind: "2 staged · 3 changed · 1 new"; "clean" when there are none
95export function filesText(staged: number, unstaged: number, untracked: number): string {
96 const parts = [
97 [staged, 'staged'],
98 [unstaged, 'changed'],
99 [untracked, 'new'],
100 ] as const
101 const shown = parts.filter(([n]) => n > 0).map(([n, what]) => `${n} ${what}`)
102 return shown.length > 0 ? shown.join(' · ') : 'clean'
103}
104
105// How far a branch is from its upstream: "↑2 ↓3", "↑2", or "up to date"
106export function syncText(ahead: number, behind: number): string {
107 const parts = [...(ahead > 0 ? [`↑${ahead}`] : []), ...(behind > 0 ? [`↓${behind}`] : [])]
108 return parts.length > 0 ? parts.join(' ') : 'up to date'
109}
110
111// The background work a reply's end reports, counted by type; subagents are left to $.agent.list(),
112// which follows them live
113export function countBackground(tasks: readonly { type: string }[]): Record<string, number> {
114 const counts: Record<string, number> = {}
115 for (const t of tasks) if (t.type !== 'subagent') counts[t.type] = (counts[t.type] ?? 0) + 1
116 return counts
117}
118
119const TASK_ORDER = ['shell', 'monitor', 'workflow']
120
121// What runs in the background, a count per type: "2 agents|1 shell"; "none" when nothing does.
122// The statusline parts its items with |, so it parts the counts with `sep` ", "
123export function tasksText(agents: number, background: Readonly<Record<string, number>>, sep = '|'): string {
124 const rank = (type: string) => (TASK_ORDER.includes(type) ? TASK_ORDER.indexOf(type) : TASK_ORDER.length)
125 const types = Object.keys(background).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
126 const parts = [['agent', agents] as const, ...types.map(t => [t, background[t] ?? 0] as const)]
127 .filter(([, n]) => n > 0)
128 .map(([type, n]) => `${n} ${type}${n === 1 ? '' : 's'}`)
129 return parts.length > 0 ? parts.join(sep) : 'none'
130}
131
132// Effort color (Catppuccin Mocha), low to max: green, yellow, peach, maroon, red; anything else (an
133// integer budget, a level added later) sky
134export function effortColor(effort: string): string {
135 switch (effort) {
136 case 'low':
137 return '#a6e3a1'
138 case 'medium':
139 return '#f9e2af'
140 case 'high':
141 return '#fab387'
142 case 'xhigh':
143 return '#eba0ac'
144 case 'max':
145 return '#f38ba8'
146 }
147 return '#89dceb'
148}
149
150const MODE_LABELS: Record<string, string> = {
151 default: 'default',
152 acceptEdits: 'accept edits',
153 bypassPermissions: 'bypass',
154 plan: 'plan',
155 dontAsk: "don't ask",
156 auto: 'auto',
157}
158
159// A permission mode's display name; one it does not know is shown as it is
160export function modeLabel(mode: string): string {
161 return MODE_LABELS[mode] ?? mode
162}
163
164// A path under home starts with ~; on Windows it goes on with \
165export function shortPath(path: string, home: string | undefined): string {
166 if (home && (path === home || path.startsWith(home + '/') || path.startsWith(home + '\\'))) {
167 return '~' + path.slice(home.length)
168 }
169 return path
170}
171
172// A path's last directory: ~/sideproj/ccdrawu is ccdrawu; a root, with none, is itself
173export function directoryName(path: string): string {
174 return path.split(/[\\/]/).filter(Boolean).pop() ?? path
175}
176
177// A text cut to `max` characters keeping its end: …roj/ccdrawu
178export function truncateStart(text: string, max: number): string {
179 const chars = [...text]
180 return chars.length <= max ? text : '…' + chars.slice(chars.length - max + 1).join('')
181}
182
183// `git diff --shortstat` prints " 3 files changed, 12 insertions(+), 3 deletions(-)"
184export function parseShortstat(text: string): { added: number; removed: number } {
185 const added = /(\d+) insertions?\(\+\)/.exec(text)
186 const removed = /(\d+) deletions?\(-\)/.exec(text)
187 return { added: added ? Number(added[1]) : 0, removed: removed ? Number(removed[1]) : 0 }
188}
189
190// Whether `latest` is newer than `current`, comparing numeric parts: 2.1.299 < 2.1.300
191export function isNewer(latest: string, current: string): boolean {
192 const a = latest.split('.').map(n => parseInt(n, 10) || 0)
193 const b = current.split('.').map(n => parseInt(n, 10) || 0)
194 for (let i = 0; i < Math.max(a.length, b.length); i++) {
195 const x = a[i] ?? 0
196 const y = b[i] ?? 0
197 if (x !== y) return x > y
198 }
199 return false
200}
201
202// The command is always /ccdrawu; the alias setting adds one more name (a leading slash is dropped)
203export function commandNames(alias: unknown): string[] {
204 const extra = typeof alias === 'string' ? alias.trim().replace(/^\/+/, '') : ''
205 if (extra === '' || extra === 'ccdrawu' || !/^[a-z0-9][a-z0-9-]*$/i.test(extra)) return ['ccdrawu']
206 return ['ccdrawu', extra]
207}
208hooks/items.ts 215 lines1// The drawer's items: the catalog (written to ~/.ccdrawu/catalog.json for the TUI to list)
2// and what each one shows. None touches `$`, so tests can call them.
3
4import type { Background, CacheInfo, GitInfo, Limit, ModelInfo, Modes, ServiceStatus, SessionInfo, SkillsInfo, TokenInfo, Usage, VersionInfo } from '../types'
5import { compact, countdown, directoryName, duration, effortColor, filesText, isNewer, modeLabel, percentText, syncText, tasksText, usageColor } from './format'
6
7// Catppuccin Mocha: keys in blue, values in text.
8// The pane's background is not drawn here: it is the theme's composerSidebarBackground.
9export const C = {
10 text: '#cdd6f4',
11 blue: '#89b4fa',
12 lavender: '#b4befe',
13 flamingo: '#f2cdcd',
14 surface: '#45475a',
15 green: '#a6e3a1',
16 red: '#f38ba8',
17 peach: '#fab387',
18}
19
20export type Item = {
21 id: string
22 label: string // the key column in the pane: 9 characters at most, and 8 for all but Directory
23 short: string // the label in the statusline when its format is short: 4 characters at most
24 name: string // what the TUI lists
25 group: string // the TUI lists the items under these, in catalog order
26 description: string
27 dock: boolean // on by default beside the transcript
28 inline: boolean // on by default above the prompt
29 statusline: boolean // on by default in `ccdrawu statusline`
30}
31
32// The order here is the order on screen
33export const ITEMS: readonly Item[] = [
34 // Drawn by the pane itself (pets.ts), over the rows beside the transcript and left of the columns above the prompt
35 { id: 'pet', label: 'Pet', short: 'pet', name: 'Pet', group: 'Pet', description: 'A pixel pet: it walks while Claude works, sits when Claude is done, sleeps when nothing goes on; /ccdrawu pat pats it', dock: true, inline: true, statusline: false },
36 { id: 'model', label: 'Model', short: 'mdl', name: 'Model', group: 'Model', description: 'The model answering in this session', dock: true, inline: true, statusline: true },
37 { id: 'effort', label: 'Effort', short: 'eff', name: 'Effort level', group: 'Model', description: "The last request's effort level, green for low to red for max", dock: true, inline: true, statusline: false },
38 { id: 'mode', label: 'Mode', short: 'mode', name: 'Permission mode', group: 'Model', description: 'auto, plan, accept edits, ...; after shift+tab it shows once Claude finishes the tools of a step, or with the next prompt', dock: true, inline: true, statusline: false },
39 { id: 'style', label: 'Style', short: 'sty', name: 'Output style', group: 'Model', description: 'The output style set in /config', dock: false, inline: false, statusline: false },
40 { id: 'id', label: 'ID', short: 'id', name: 'Session ID', group: 'Session', description: "The start of this session's id", dock: false, inline: false, statusline: false },
41 { id: 'context', label: 'Context', short: 'ctx', name: 'Context', group: 'Usage', description: 'How full the context window is', dock: true, inline: true, statusline: true },
42 { id: 'window', label: 'Window', short: 'win', name: 'Context size', group: 'Usage', description: 'Tokens in the context window, out of its size', dock: false, inline: false, statusline: false },
43 { id: 'session', label: 'Session', short: '5h', name: 'Session limit', group: 'Usage', description: 'The 5-hour usage limit, and when it resets', dock: true, inline: true, statusline: true },
44 { id: 'weekly', label: 'Weekly', short: '7d', name: 'Weekly limit', group: 'Usage', description: 'The 7-day usage limit, and when it resets', dock: true, inline: true, statusline: true },
45 { id: 'spend', label: 'Spend', short: 'spnd', name: 'Spend limit', group: 'Usage', description: "A gateway's spend limit, where there is one", dock: false, inline: false, statusline: false },
46 { id: 'cost', label: 'Cost', short: 'cost', name: 'Session cost', group: 'Usage', description: 'What this session has cost so far, as /cost totals it', dock: true, inline: true, statusline: true },
47 { id: 'rate', label: 'Rate', short: 'rate', name: 'Cost rate', group: 'Usage', description: 'What the session costs an hour, so far', dock: false, inline: false, statusline: false },
48 { id: 'tokens', label: 'Tokens', short: 'tok', name: 'Tokens', group: 'Usage', description: 'Input, output and cache tokens of the main conversation since the drawer loaded', dock: false, inline: false, statusline: false },
49 { id: 'speed', label: 'Speed', short: 'spd', name: 'Token speed', group: 'Usage', description: 'Output tokens a second in the last reply', dock: false, inline: false, statusline: false },
50 { id: 'uptime', label: 'Uptime', short: 'up', name: 'Uptime and turns', group: 'Activity', description: 'How long the session has run, and how many turns', dock: true, inline: true, statusline: false },
51 { id: 'cache', label: 'Cache', short: 'hit', name: 'Cache hit rate', group: 'Activity', description: 'How much of the last request the prompt cache served', dock: true, inline: true, statusline: false },
52 { id: 'compact', label: 'Compact', short: 'cmp', name: 'Compactions', group: 'Activity', description: 'How many times the context was compacted', dock: true, inline: true, statusline: false },
53 { id: 'tasks', label: 'Tasks', short: 'bg', name: 'Background tasks', group: 'Activity', description: "Subagents running now, and shells, monitors and workflows in the background as of Claude's last reply", dock: true, inline: true, statusline: false },
54 { id: 'skills', label: 'Skills', short: 'skl', name: 'Skills', group: 'Activity', description: 'Skills run in this session, and the last one', dock: false, inline: false, statusline: false },
55 { id: 'git', label: 'Git', short: 'git', name: 'Git branch and changes', group: 'Workspace', description: 'The branch, and lines added and removed since HEAD', dock: true, inline: true, statusline: true },
56 { id: 'sync', label: 'Sync', short: 'sync', name: 'Ahead and behind', group: 'Workspace', description: 'Commits ahead of and behind the upstream', dock: false, inline: false, statusline: false },
57 { id: 'files', label: 'Files', short: 'chg', name: 'Changed files', group: 'Workspace', description: 'Files staged, changed and new', dock: false, inline: false, statusline: false },
58 { id: 'commit', label: 'Commit', short: 'sha', name: 'Commit', group: 'Workspace', description: 'The short hash of HEAD', dock: false, inline: false, statusline: false },
59 { id: 'repo', label: 'Repo', short: 'repo', name: 'Repository', group: 'Workspace', description: "origin's owner/repo, and the worktree when the session is in one", dock: false, inline: false, statusline: false },
60 { id: 'path', label: 'Path', short: 'path', name: 'Working directory', group: 'Workspace', description: "The session's working directory", dock: true, inline: true, statusline: false },
61 { id: 'directory', label: 'Directory', short: 'dir', name: 'Directory name', group: 'Workspace', description: "The working directory's own name, without the path to it", dock: false, inline: false, statusline: false },
62 { id: 'version', label: 'Claude', short: 'cc', name: 'Claude Code version', group: 'About', description: 'This version, and the latest when a newer one is out', dock: true, inline: true, statusline: false },
63 { id: 'ccdrawu', label: 'ccdrawu', short: 'drw', name: 'ccdrawu version', group: 'About', description: "This drawer's own version", dock: true, inline: true, statusline: false },
64 { id: 'status', label: 'Status', short: 'stat', name: 'Claude status', group: 'About', description: 'status.claude.com: all clear in green, an incident in peach or red', dock: false, inline: false, statusline: false },
65]
66
67// Everything an item may show, as the drawer holds it
68export type Snapshot = {
69 usage: Usage | null
70 model: ModelInfo | null
71 git: GitInfo | null
72 path: string
73 turns: number
74 cache: CacheInfo | null
75 tokens: TokenInfo
76 session: SessionInfo
77 status: ServiceStatus | null
78 skills: SkillsInfo
79 compacts: number
80 agents: number
81 background: Background // by type, as of Claude's last reply
82 version: VersionInfo | null
83 drawer: string | null // ccdrawu's own version, from its plugin.json
84 modes: Modes
85 now: number
86}
87
88export type Seg = { text: string; color: string; bold?: boolean }
89
90// What an item shows. Beside the transcript a meter draws a bar, `resets` and `below` take a row of
91// their own (so a long value wraps whole) and `cutStart` keeps the end of a value too long for the row;
92// above the prompt all is text on one row
93export type View = { value: Seg[]; meter?: number; resets?: string; below?: Seg[]; cutStart?: boolean }
94
95// What an item shows before it has a value, or where it has none: its row is there all the same, so the
96// rows after it do not move when the value comes
97export const NO_VALUE = '-'
98
99// A limit's bar and countdown; its resets row is there before them too
100function limitView(limit: Limit | undefined, now: number): View {
101 if (limit === undefined) return { value: [{ text: NO_VALUE, color: C.text }], resets: NO_VALUE }
102 return {
103 value: [{ text: percentText(limit.percentUsed), color: usageColor(limit.percentUsed) }],
104 meter: limit.percentUsed,
105 resets: limit.resetsAt === undefined ? NO_VALUE : countdown(Date.parse(limit.resetsAt) - now),
106 }
107}
108
109// An item's view; undefined only for the pet, which the pane draws itself, and an id the catalog does not have
110export function view(id: string, s: Snapshot): View | undefined {
111 if (id === 'pet' || !ITEMS.some(item => item.id === id)) return undefined
112 return held(id, s) ?? { value: [{ text: NO_VALUE, color: C.text }] }
113}
114
115// What an item holds, or undefined while it holds nothing
116function held(id: string, s: Snapshot): View | undefined {
117 const text = (value: string, color: string = C.text): View => ({ value: [{ text: value, color }] })
118 const limit = (kind: string) => limitView(s.usage?.limits.find(l => l.kind === kind), s.now)
119
120 switch (id) {
121 case 'model':
122 return s.model === null ? undefined : { value: [{ text: s.model.name, color: C.lavender, bold: true }] }
123 case 'effort':
124 return s.model?.effort === undefined ? undefined : text(s.model.effort, effortColor(s.model.effort))
125 case 'mode':
126 return s.modes.permission === undefined ? undefined : text(modeLabel(s.modes.permission))
127 case 'context': {
128 const percent = s.usage?.contextPercent ?? 0
129 return { value: [{ text: percentText(percent), color: usageColor(percent) }], meter: percent }
130 }
131 case 'session':
132 return limit('five_hour')
133 case 'weekly':
134 return limit('seven_day')
135 case 'spend':
136 return limit('spend_limit')
137 case 'cost':
138 return s.usage?.costUsd === undefined ? undefined : text('$' + s.usage.costUsd.toFixed(2))
139 case 'uptime':
140 return text((s.usage ? duration(s.now - s.usage.startedAt) : NO_VALUE) + ` · ${s.turns} turns`)
141 case 'cache':
142 return s.cache && s.cache.total > 0 ? text(percentText((s.cache.read / s.cache.total) * 100)) : undefined
143 case 'compact':
144 return text(String(s.compacts))
145 case 'tasks':
146 return text(tasksText(s.agents, s.background))
147 case 'git':
148 if (!s.git) return text('not a repo')
149 return {
150 value: [{ text: s.git.branch, color: C.text }],
151 below: [
152 { text: '+' + s.git.added, color: C.green },
153 { text: ' -' + s.git.removed, color: C.red },
154 ],
155 }
156 case 'path':
157 return { ...text(s.path), cutStart: true }
158 case 'directory':
159 return text(directoryName(s.path))
160 case 'version': {
161 const v = s.version
162 if (!v) return undefined
163 const newer = v.latest !== undefined && isNewer(v.latest, v.current)
164 return { value: [{ text: v.current, color: C.text }, ...(newer ? [{ text: ' ↑ ' + v.latest, color: C.peach }] : [])] }
165 }
166 case 'ccdrawu':
167 return s.drawer ? text(s.drawer) : undefined
168 case 'style':
169 return s.session.outputStyle ? text(s.session.outputStyle) : undefined
170 case 'id':
171 return s.session.id ? text(s.session.id.slice(0, 8)) : undefined
172 case 'window': {
173 const u = s.usage
174 if (u?.contextWindow === undefined) return undefined
175 return text(`${compact(u.contextTokens ?? 0)} / ${compact(u.contextWindow)}`)
176 }
177 case 'rate': {
178 // From a minute on: before that one request makes the hourly figure jump about
179 const hours = s.usage ? (s.now - s.usage.startedAt) / 3_600_000 : 0
180 if (s.usage?.costUsd === undefined || hours < 1 / 60) return undefined
181 return text(`$${(s.usage.costUsd / hours).toFixed(2)}/h`)
182 }
183 case 'tokens': {
184 const t = s.tokens
185 if (t.input + t.output + t.cacheRead + t.cacheWrite === 0) return undefined
186 return text(`in ${compact(t.input)} · out ${compact(t.output)} · cache ${compact(t.cacheRead + t.cacheWrite)}`)
187 }
188 case 'speed':
189 return s.tokens.speed === undefined ? undefined : text(`${Math.round(s.tokens.speed)} tok/s`)
190 case 'skills':
191 return text(s.skills.count === 0 ? 'none' : `${s.skills.count}` + (s.skills.last ? ` · last ${s.skills.last}` : ''))
192 case 'sync':
193 if (!s.git) return text('not a repo')
194 return text(s.git.ahead === undefined ? 'no upstream' : syncText(s.git.ahead, s.git.behind ?? 0))
195 case 'files':
196 if (!s.git) return text('not a repo')
197 return s.git.staged === undefined ? undefined : text(filesText(s.git.staged, s.git.unstaged ?? 0, s.git.untracked ?? 0))
198 case 'commit':
199 if (!s.git) return text('not a repo')
200 return text(s.git.sha ?? 'no commits')
201 case 'repo': {
202 const g = s.git
203 if (!g) return text('not a repo')
204 if (!g.repo && !g.worktree) return text('no origin')
205 return text([g.repo, g.worktree].filter(Boolean).join(' · '))
206 }
207 case 'status': {
208 if (!s.status) return undefined
209 const color = s.status.indicator === 'none' ? C.green : s.status.indicator === 'minor' ? C.peach : C.red
210 return text(s.status.description, color)
211 }
212 }
213 return undefined
214}
215hooks/layout.ts 131 lines1// Which items show where, from the config the TUI writes, and how they fall into columns above the prompt.
2// None touches `$`, so tests can call them.
3
4import type { Config } from '../types'
5import { truncateStart } from './format'
6import { C, ITEMS, NO_VALUE, type Item, type Seg, type View } from './items'
7
8export type Placement = 'dock' | 'inline'
9
10// ~/.config/ccdrawu/config.json: { "dock": [ids], "inline": [ids], "alias": name, "pet": { "species" },
11// "bar": style, "drawer": { "sidebarOff", "inlineOff" }, "statusline": { "on", ... } }. Undefined when it is not JSON, as when caught half written:
12// read it again on the next tick
13export function parseConfig(text: string): Config | undefined {
14 let raw: unknown
15 try {
16 raw = JSON.parse(text)
17 } catch {
18 return undefined
19 }
20 const config: Config = {}
21 if (typeof raw !== 'object' || raw === null) return config
22 for (const placement of ['dock', 'inline'] as const) {
23 const ids = (raw as Record<string, unknown>)[placement]
24 if (Array.isArray(ids)) config[placement] = ids.filter((id): id is string => typeof id === 'string')
25 }
26 const alias = (raw as Record<string, unknown>).alias
27 if (typeof alias === 'string') config.alias = alias
28 const style = (raw as Record<string, unknown>).bar
29 if (typeof style === 'string') config.bar = style
30 const pet = (raw as Record<string, unknown>).pet
31 if (typeof pet === 'object' && pet !== null) {
32 const { species } = pet as Record<string, unknown>
33 config.pet = typeof species === 'string' ? { species } : {}
34 }
35 const drawer = (raw as Record<string, unknown>).drawer
36 if (typeof drawer === 'object' && drawer !== null) {
37 const d = drawer as Record<string, unknown>
38 // 0.1.1's off turned the whole drawer off
39 const off = d.off === true
40 config.drawer = { sidebarOff: off || d.sidebarOff === true, inlineOff: off || d.inlineOff === true }
41 }
42 const statusline = (raw as Record<string, unknown>).statusline
43 if (typeof statusline === 'object' && statusline !== null) {
44 config.statusline = { on: (statusline as Record<string, unknown>).on === true }
45 }
46 return config
47}
48
49// In the order the config lists them (set in ccdrawu), the ones the catalog knows, each once; a placement
50// the config leaves out takes the catalog's defaults, in its order
51export function enabledItems(config: Config, placement: Placement): Item[] {
52 const ids = config[placement]
53 if (ids === undefined) return ITEMS.filter(item => item[placement])
54 return [...new Set(ids)].flatMap(id => ITEMS.find(item => item.id === id) ?? [])
55}
56
57// Whether the config lets the drawer show beside the transcript (dock) or above the prompt (inline)
58export function drawerShows(config: Config, placement: Placement): boolean {
59 return placement === 'dock' ? config.drawer?.sidebarOff !== true : config.drawer?.inlineOff !== true
60}
61
62// /ccdrawu turns the drawer on or off for the session, wherever it sits, over the config; null until it
63// does, and again once the config's Drawer changes (the last of the two wins)
64export type Manual = 'on' | 'off' | null
65
66// Whether the drawer belongs in a place: as /ccdrawu last set it for the session, else as the config has it
67export function drawerWanted(config: Config, placement: Placement, manual: Manual): boolean {
68 return manual === null ? drawerShows(config, placement) : manual === 'on'
69}
70
71// Whether an item shows beside the transcript or above the prompt
72export function showsAnywhere(config: Config, id: string): boolean {
73 return (['dock', 'inline'] as const).some(p => enabledItems(config, p).some(item => item.id === id))
74}
75
76const INLINE_PATH = 32 // above the prompt a path keeps its last 32 characters
77export const COLUMN_GAP = ' │ '
78
79// Above the prompt: no bars, what the sidebar puts below after the value, the reset countdown after
80// the percentage, but not one not known: on one row it holds no row in place
81export function inlineValue(v: View): Seg[] {
82 const cut = v.cutStart ? v.value.map(s => ({ ...s, text: truncateStart(s.text, INLINE_PATH) })) : v.value
83 const value = v.below === undefined ? cut : [...cut, { text: ' ', color: C.text }, ...v.below]
84 if (v.resets === undefined || v.resets === NO_VALUE) return value
85 return [...value, { text: ' · ', color: C.surface }, { text: v.resets, color: C.text }]
86}
87
88// Cells a text takes on the terminal: East Asian wide and fullwidth characters (CJK, the | between
89// task counts) take two, the rest one
90export function cellWidth(text: string): number {
91 let n = 0
92 for (const ch of text) {
93 const c = ch.codePointAt(0) ?? 0
94 const wide =
95 (c >= 0x1100 && c <= 0x115f) ||
96 (c >= 0x2e80 && c <= 0xa4cf) ||
97 (c >= 0xac00 && c <= 0xd7a3) ||
98 (c >= 0xf900 && c <= 0xfaff) ||
99 (c >= 0xfe30 && c <= 0xfe4f) ||
100 (c >= 0xff00 && c <= 0xff60) ||
101 (c >= 0xffe0 && c <= 0xffe6) ||
102 (c >= 0x20000 && c <= 0x3fffd)
103 n += wide ? 2 : 1
104 }
105 return n
106}
107
108export function segWidth(segs: readonly Seg[]): number {
109 return segs.reduce((n, s) => n + cellWidth(s.text), 0)
110}
111
112// Above the prompt the items fill columns top to bottom, side by side: the fewest rows whose columns
113// fit in `width`, each column a key (its longest plus a space) and a value; one column when none fit
114export function columnRows(cells: readonly { label: number; value: number }[], width: number): number {
115 for (let rows = 1; rows < cells.length; rows++) {
116 let total = -COLUMN_GAP.length
117 for (let i = 0; i < cells.length; i += rows) {
118 const column = cells.slice(i, i + rows)
119 total += COLUMN_GAP.length + Math.max(...column.map(c => c.label)) + 1 + Math.max(...column.map(c => c.value))
120 }
121 if (total <= width) return rows
122 }
123 return Math.max(1, cells.length)
124}
125
126export function chunk<T>(list: readonly T[], size: number): T[][] {
127 const out: T[][] = []
128 for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size))
129 return out
130}
131hooks/paths.ts 14 lines1// Where ccdrawu keeps its files (terminu design E3), the same places the ccdrawu TUI reads and writes.
2// None touches `$`, so tests can call it.
3
4export type Env = { home: string; data?: string; config?: string; xdgConfig?: string }
5
6// data: CCDRAWU__DATA, else ~/.ccdrawu (the catalog);
7// configFile: config.json in CCDRAWU__CONFIG, else $XDG_CONFIG_HOME/ccdrawu, else ~/.config/ccdrawu.
8// A variable set but empty counts as unset
9export function locations(env: Env): { data: string; configFile: string } {
10 const data = env.data || `${env.home}/.ccdrawu`
11 const config = env.config || (env.xdgConfig ? `${env.xdgConfig}/ccdrawu` : `${env.home}/.config/ccdrawu`)
12 return { data, configFile: `${config}/config.json` }
13}
14hooks/statusline.ts 27 lines1// What `ccdrawu statusline` lays out: each item's value on one row, as it shows above the prompt, kept in
2// <data>/sessions/<session id>.json while the config has the statusline on. The command picks the items,
3// their order, the labels and the separator; the values and their colors are worked out here alone.
4// None touches `$`, so tests can call them.
5
6import { tasksText } from './format'
7import { C, ITEMS, view, type Seg, type Snapshot } from './items'
8import { inlineValue } from './layout'
9
10// Every item, the pet aside: it draws nothing on one row of text. Tasks part
11// their counts with ", " here, as the statusline parts its items with |
12export function statuslineItems(s: Snapshot): Record<string, Seg[]> {
13 const items: Record<string, Seg[]> = {}
14 for (const item of ITEMS) {
15 if (item.id === 'pet') continue
16 const v = item.id === 'tasks' ? { value: [{ text: tasksText(s.agents, s.background, ', '), color: C.text }] } : view(item.id, s)
17 if (v) items[item.id] = inlineValue(v)
18 }
19 return items
20}
21
22// The file's text, with no time in it, so a session whose figures stand still leaves the file alone.
23// docked: whether the drawer shows beside the transcript now, for a statusline on only when it does not
24export function statuslineFile(s: Snapshot, docked: boolean): string {
25 return JSON.stringify({ version: 1, docked, items: statuslineItems(s) }) + '\n'
26}
27types/index.d.ts 139 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3// contextTokens over contextWindow is contextPercent, in tokens
4export type Usage = {
5 startedAt: number
6 contextPercent?: number
7 contextTokens?: number
8 contextWindow?: number
9 limits: Limit[]
10 costUsd?: number
11}
12
13export type ModelInfo = { name: string; effort?: string }
14
15// The branch with its line counts; then, from `git status --porcelain=v2`, the commit, how far it is
16// from its upstream (none without one) and the files staged, unstaged and untracked; repo is origin's
17// owner/name, worktree the linked worktree's folder when the session is in one
18export type GitInfo = {
19 branch: string
20 added: number
21 removed: number
22 sha?: string
23 ahead?: number
24 behind?: number
25 staged?: number
26 unstaged?: number
27 untracked?: number
28 repo?: string
29 worktree?: string
30}
31
32// The main conversation's last request: tokens read from the prompt cache, and its whole input
33export type CacheInfo = { read: number; total: number }
34
35// The main conversation's tokens since the drawer loaded, and its last reply's output tokens a second
36export type TokenInfo = { input: number; output: number; cacheRead: number; cacheWrite: number; speed?: number }
37
38// This session: its id, the output style
39export type SessionInfo = { id?: string; outputStyle?: string }
40
41// status.claude.com's overall status: indicator none, minor, major or critical
42export type ServiceStatus = { indicator: string; description: string }
43
44// Skills run in this session, and the last one's name
45export type SkillsInfo = { count: number; last?: string }
46
47export type VersionInfo = { current: string; latest?: string }
48
49// permission: the permission mode (auto, plan, ...)
50export type Modes = { permission?: string }
51
52// The background work in flight as of Claude's last reply, counted by type (shell, monitor, workflow, ...);
53// subagents are counted live from $.agent.list() instead
54export type Background = Record<string, number>
55
56// The items on in each placement, as the TUI wrote them; one left out takes the catalog's defaults.
57// alias: one more name for /ccdrawu; pet: which animal; drawer: whether it shows beside the transcript
58// and above the prompt; statusline: whether `ccdrawu statusline` is Claude Code's status line (the rest
59// of its block is the TUI's and the command's)
60export type Config = {
61 dock?: string[]
62 inline?: string[]
63 alias?: string
64 pet?: { species?: string }
65 bar?: string
66 drawer?: { sidebarOff: boolean; inlineOff: boolean }
67 statusline?: { on: boolean }
68}
69
70// The pet (hooks/pets.ts): what it is doing, where along its walk, and what it says, a session's
71export type PetMode = 'walk' | 'sit' | 'lie' | 'sleep' | 'jump' | 'play'
72// A ball thrown in (/ccdrawu ball): it rolls from start to rest, from the time thrown on; the pet
73// stands at goal to play with it, and says line when it has it
74export type PetBall = { from: number; start: number; rest: number; goal: number; line: string }
75export type PetState = {
76 coat: number // which coat, picked at random once a session; -1 until then
77 busy: boolean // Claude is working
78 active: number // when Claude or the user last did something
79 mode: PetMode
80 x: number // 0 to TRACK, how far along its walk
81 target: number // where along it it is going
82 run: boolean // running there, two steps a tick
83 stride: number // its steps, for its legs to take turns
84 restUntil: number // sitting a while where it got to, before it goes on
85 lift: number // how many pixels off the ground it is, in a jump
86 faceLeft: boolean
87 snore: number // how far along its snore, while asleep
88 heart: boolean
89 line?: string
90 lineUntil: number
91 heartUntil: number
92 jumpFrom: number // in the air from then until jumpUntil
93 jumpUntil: number
94 ball?: PetBall
95 ballPos: number // where the ball is along the walk, and how far off the ground, a tick's
96 ballLift: number
97 playUntil: number // pawing at the ball until then
98}
99
100// A kit (hooks/pets.ts): a small one of the pet's kind for a subagent running, walking behind the pet
101export type Kit = {
102 id: string // the subagent's, as $.agent.list() names it
103 coat: number // of its kind's coats, neither the pet's nor another kit's
104 x: number // 0 to TRACK along the walk, as the pet's
105 target: number
106 stride: number
107 faceLeft: boolean
108 restUntil: number
109 leaving: boolean // its subagent is done: it heads for the nearer end, and goes there
110}
111
112declare module 'claude-code' {
113 interface PluginState {
114 ccdrawu: {
115 usage: Usage | null
116 model: ModelInfo | null
117 git: GitInfo | null
118 path: string
119 turns: number
120 cache: CacheInfo | null
121 tokens: TokenInfo
122 session: SessionInfo
123 status: ServiceStatus | null
124 skills: SkillsInfo
125 compacts: number
126 agents: number
127 background: Background
128 version: VersionInfo | null
129 drawer: string | null
130 modes: Modes
131 now: number
132 config: Config
133 pet: PetState
134 kits: Kit[]
135 manual: 'on' | 'off' | null
136 }
137 }
138}
139