SLOPSHOPPER

ccdrawu

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

newpanecommandtoastpromptprocess
★ 2v0.1.4GPL-3.0updated 2026-10-16vulcanshen/ccdrawu
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ccdrawu
│ ┃ ccdrawu ✕ › fix the failing auth test and add an audit log call │ ┃ All done! │ ┃ ⏺ Read(src/auth.ts) │ ┃ ▀▄▄▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▄▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▄▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ Model claude-opus-5-5 ⎿ 3 pass, 1 fail │ ┃ Effort - │ ┃ Mode - ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Context  │ ┃ Session  ✻ Worked for 42s · done 4:20 PM │ ┃ resets NaNm │ ┃ Weekly - › /ccdrawu │ ┃ resets - │ ┃ Cost $0.42 │ ┃ Uptime 30m · 1 turns │ ┃ Cache - │ ┃ Compact 0 │ ┃ Tasks none │ ┃ Git feat/auth-refresh │ ┃ +14 -3 │ ┃ Path /work/app │ ┃ Claude 2.1.289 │ ┃ ccdrawu - │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ccdrawu
All done! ▀▄▄▀ ▀▀▀▀ ▄▀▀▀▀▀ ▀▄▀▀▀▀▀▀▀ Model claude-opus-5-5 Effort - Mode - Context  49% Session  31% resets NaNm Weekly - resets - Cost $0.42 Uptime 30m · 1 turns Cache - Compact 0 Tasks none Git feat/auth-refresh +14 -3 Path /work/app Claude 2.1.289 ccdrawu -
README

ccdrawu

GitHub Release Go Version License

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.

demo

What it shows

RowWhat it is
PetA pixel-art pet that keeps you company: see The pet
Model, EffortThe session's model, and the last request's effort level: green for low through red for max
ModeThe permission mode: auto, plan, accept edits, …
Context, Session, WeeklyHow 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%
CostWhat the session has cost so far
UptimeHow long the session has run, and how many prompts you sent
CacheHow much of the last request the prompt cache served
CompactHow many times the conversation was compacted
TasksSubagents 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, PathThe branch with its added and removed lines, and the working directory
Claude, ccdrawuYour Claude Code version, with the newest release when one is out, and this drawer's version

These start off; turn them on in ccdrawu:

RowWhat it is
StyleThe output style set in /config
IDThe start of this session's id
WindowTokens in the context window, out of its size: 420k / 1M
SpendWhere you go through a gateway with a spend limit: how much of it is used, and when it resets
RateWhat the session costs an hour so far
Tokens, SpeedInput, output and cache tokens of the conversation since the drawer loaded, and the last reply's output tokens a second
SkillsSkills run in this session, and the last one
Sync, Files, Commit, RepoCommits 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
DirectoryThe working directory's own name, without the path to it: ccdrawu
Statusstatus.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.

Requirements

  • Claude Code 2.1.287 or later
  • Fullscreen rendering, for the drawer: run /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 terminal
  • A terminal wide enough for a sidebar: a pane opened on its own waits until the terminal is about 110–144 columns wide
  • A Nerd Font as your terminal's font: the drawer's bars and the marks in the ccdrawu command (which choice is picked, which items are ticked) are its icons. Without one, see Troubleshooting
  • The ccdrawu 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 PATH
  • macOS, Linux or Windows (on Windows, tried in Windows Terminal: see Limitations)

Install

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

Usage

  • The drawer opens by itself when a session starts.
  • Drawer, atop the sidebar page and the inline page in 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.
  • Click the × 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.

Pick what it shows

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.

settings

The status line

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
  • Items and Order: what it shows, and in which order, picked as for the drawer. Model, Context, Session, Weekly, Cost and Git to start with; the pet stays in the drawer.
  • Format: how each item reads: label (Context 42%), short (ctx 42%) or value (42%).
  • Color: highlighted, each value in the drawer's colors, or dimmed, all of it in Claude Code's dim color.
  • Separator: what stands between the items, | unless you set another.
  • Refresh: how many seconds Claude Code waits before running it again while nothing happens, 10 unless you set another; it also runs it as the conversation moves on.

A line too long for the terminal is cut at the end with ….

The pet

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.

  • While Claude works, it wanders about: it stops, hops, runs and swipes at the air.
  • When Claude is done, it sits down and says so. If you stop Claude it just sits; if the reply ends in an error, or a tool fails, it jumps. It has something to say after a compaction too.
  • Left alone, it lies down after 2 minutes and falls asleep after 10, and now and then it strolls, looks around or swipes.
  • /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.
  • While subagents run, a small one of its kind walks behind it for each, up to three, beside the transcript only, each in a coat of its own, never the pet's. It comes in at an end as its subagent starts and walks off when it is done.

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.

A shorter command

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.

Match the background

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:

  • Claude theme: pick the built-in Claude Code theme to stand on (dark-ansi, dark, …).
  • Drawer background: pick your terminal's background color; 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.

Where it keeps things

  • ~/.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.

Troubleshooting

The drawer sits above the prompt

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.

  • Run /tui fullscreen. Claude Code restarts, picks your conversation up where it was, and keeps the choice for later sessions. /tui default goes back.
  • If /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.
  • Widen the terminal.

Bars or marks show as boxes or �

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.

Rows wrap, with pieces at the left edge of the screen

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.

The sidebar is light gray

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.

The sidebar's background is not the terminal's

See Match the background.

The status line is an empty row

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 status line shows only Model, Context and Cost

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.

Limitations

  • With fullscreen rendering, Claude Code puts the drawer above the prompt only when you open it with /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.
  • After you switch the permission mode with 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.
  • The status line can be a few seconds behind the drawer: Claude Code runs it again as the conversation moves on, or after Refresh seconds.
  • The colors are Catppuccin Mocha, made for a dark background, and cannot be changed yet: on a light terminal the text is hard to read. Under a light Claude Code theme, see Troubleshooting.
  • Where an organization manages Claude Code, Mode, Compact and Skills may stay empty, Context catches up with a compaction only after the next reply, and the pet does not jump when a tool fails or speak after a compaction: those events skip plugins the user installed.
  • Claude Code's mod interface is in early access and may change between releases.
  • On Windows, ccdrawu has been tried in Windows Terminal. Termius's local terminal draws it out of place (see Troubleshooting), and other Windows terminals have not been tried yet; reports are welcome.

Update

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.

  • Installed with Scoop, it tells you to run scoop update ccdrawu, then ccdrawu install claude: Scoop does not update ccdrawu while it runs.
  • Where it cannot write in the folder 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.
  • Before 0.1.3 there is no 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.

Uninstall

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.

More

Source 9 files
hooks/register.tsx 817 lines
1import { 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}
817
hooks/pets.ts 872 lines
1// 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}
872
hooks/commands.ts 23 lines
1import 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}
23
hooks/format.ts 208 lines
1// 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}
208
hooks/items.ts 215 lines
1// 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}
215
hooks/layout.ts 131 lines
1// 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}
131
hooks/paths.ts 14 lines
1// 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}
14
hooks/statusline.ts 27 lines
1// 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}
27
types/index.d.ts 139 lines
1export 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