SLOPSHOPPER

session-cards

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

newspinnerrowsguardprompttimer
★ 1v0.1.0no licenseupdated 2026-10-25Doofess/claude-setup/mods/session-cards
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-cards
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │  You prompted · 08:53 │ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⏺ Read(src/auth.ts) ⎿ Read 6 lines Edited src/auth.ts ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 8:53 AM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Turn line
✻ Worked for 42s · done 8:53 AM
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │  You prompted · 08:53 │ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Tool row
Edited src/auth.ts
Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │  / undefined │ │ ⟨Claude Code's own drawing⟩ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

claude-setup

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

The status bar: repo, branch, model and effort, context meter, 5-hour and 7-day plan meters, cost, session time

What you see

The status bar

The line along the bottom of Claude Code, read left to right:

  • Where you are: the repo (GitHub in violet, GitLab in its orange with its own icon, read from the origin remote) and the branch (Claude Code shows the PR badge itself; on the hint line the mod draws its # yellow and the number white and underlined, still a link to the PR).
  • Who is working: the model and the effort level. Each model and each effort level has its own colour, so you can tell at a glance whether you're on Opus (Claude orange) or Sonnet, at high or medium. Fable's name is a gold gradient, and a running Fable subagent's row pulses.
  • How full the context is: a meter that reaches 100% at the auto-compact point (about 267k of the 300k window), not at the end of the window. When it's full, it's time to /clear.
  • How much plan is left: the 5-hour and 7-day usage meters, each with the time until it resets.
  • What the session has cost, in green.
  • How long the session has run, beside a clock, both in the repo name's violet.

The meters change colour as they fill: green below 50%, yellow from 50%, orange from 70% and red from 90%. The same session late in a busy day:

The same bar with the context meter orange at 75%, the 5-hour meter red at 93% and the 7-day meter yellow at 57%

When the window is narrow, the least important pieces drop first: session time, cost, then reset times and the plan meters. Repo, branch, model with its effort, and context always stay; on a very narrow window the repo loses its owner, then letters of its name. Once the line fits, any dropped piece that still fits comes back, so no room is wasted:

<img src="docs/images/statusbar-narrow.png" width="380" alt="The bar at 60 columns: the repo cut to claude-s…, then branch, model and context">

Dropped pieces aren't lost. The session-cards mod (below) moves them onto Claude Code's hint line, beside the permissions text:

  • A reset time whose meter stays on the top row sits right under the meter's first block, without its →.
  • A meter that moves takes its reset time with it (7D ████ 32% → 4d21h), its first block under the last meter left on the top row. Where it can't line up, it goes to the right.
  • The rest (cost, session time) is right-aligned at the margin, or, when a moved meter holds the margin, in the free space before it.
  • Where pieces collide, the least important one drops, in the bar's own order; the hint always stays.
  • While background work shows on the hint line (1 shell, monitors, agents) and Claude is idle, the row keeps this layout and the pill is the mod's own: a white block that opens the background-tasks list when clicked. While Claude works, Claude Code's own line stays instead, since a mod's /tasks waits for the turn to end and Claude Code's pill opens at once; the pieces then follow it dim, packed left after a · , a meter as its percentage alone, since dim blocks can't show how full it is.

At 104 columns the bar keeps repo, branch, model, effort, context and the 5-hour meter; the 7-day meter moves onto the hint line with its reset time, right-aligned

The icons come from a Nerd Font. On a machine without one, the installer switches the bar to short words instead (ctx, 5H, 7D), so it reads fine in any monospace font. To see the bar without starting Claude Code, run python statusbar.py --demo.

Session cards

mods/session-cards is a Claude Code mod that makes a long session easier to scan:

  • Your prompts become a coloured card headed with the time you sent them, so you can find your own messages again.
  • Slash-command output gets a violet card of its own. A command row the engine hands to the mod (one queued while Claude works, such as /compact) is a violet You ran /compact card rather than a prompt card. Only output Claude Code keeps as a command result gets a card (/context, /color, /reload-plugins, /memory, /skills, /usage, /rename). Some commands get none, since Claude Code hands the mod nothing to draw:
  • Pickers and dialogs (/model, /effort, /mcp, /plugin, /theme, /login, /advisor) keep their result line as a plain row that no mod hook sees.
  • /clear prints nothing.
  • Skills print no output row.
  • In fullscreen, a dialog closed with Esc leaves no row.
  • The end of each turn is a coloured line with how long it took and when it finished (✻ Baked for 1s · done 4:20 PM). While background shells run, every such line ends in a dim · 1 shell still running, which goes away when the shell's notification arrives. While a background agent runs, Claude Code's own line stays, since only it knows when the agent ends.
  • Tool calls shrink to one short row each: an icon and a few words. Runs of reads, searches and shell commands keep Claude Code's own one-line summary ("Searched for 1 pattern, read 1 file…"), as in the picture below.
  • Agents get a row showing the agent type, its model and effort (in the same colours as the status bar), what it's doing right now, how long it has been running and how many tokens it has used.

A real session: the prompt card with its time, Claude Code's summary of three tool calls, and an Explore agent row showing Haiku, low effort, 4 s and 7k tokens

Behind the scenes

Nothing to look at here, but it shapes every session:

  • global/CLAUDE.md: global rules for how Claude plans, delegates, verifies and uses git.
  • Two agents: Explore (Haiku, read-only search, replaces the built-in one) and worker (Sonnet at medium effort, for implementation briefs).
  • Plugins and connectors: a short always-on list, with everything project-specific kept per project (see Plugin and MCP policy).
  • advisor-switch: turns Claude Code's advisor on (Opus, or Fable when Opus is stuck) and off for one project, in its .claude/settings.local.json. Claude runs it itself before and after a plan with workers; the installer removes a user-level advisorModel, so the advisor is never on in every project at once.
  • codex: hands a task to OpenAI's Codex CLI in the background (codex exec, read-only unless it works in a worktree), on the OpenAI subscription: questions about Codex, independent research, second opinions. Its sibling setup is codex-setup.
  • underwater-photoshop: a personal skill that edits underwater and macro photos in the local Photoshop.

Set up a new machine (Windows)

  1. Install Claude Code, Git for Windows (it brings Git Bash), GitHub CLI, Node 26+ and Python 3.10+. With winget:
   winget install Anthropic.ClaudeCode Git.Git GitHub.cli OpenJS.NodeJS Python.Python.3.14
   npm i -g pnpm
   winget install --id astral-sh.ruff -e

Then run npm install at the repo root (installs the pinned Biome for the mod lint hook).

  1. Run claude once and sign in, then gh auth login.
  2. Clone and install:
   git clone https://github.com/Doofess/claude-setup C:\dev\claude-setup
   cd C:\dev\claude-setup
   .\install.cmd

install.cmd runs install.ps1 under PowerShell 7 when it is installed, else Windows PowerShell, past 5.1's blocked-scripts policy. From Git Bash, run ./install.sh, which calls it.

  1. Restart Claude Code and open a new terminal tab.
  2. Only for photo work: python -m venv ~/.claude/skills/underwater-photoshop/.venv, then pip install -r skills/underwater-photoshop/requirements.txt into it. Needs Photoshop 2026 (COM Photoshop.Application).

The installer installs JetBrainsMono Nerd Font through winget if the machine has no Nerd Font, sets Windows Terminal's font and colour scheme, and removes hooks this repo has retired. Options:

FlagEffect
-KeepFontkeep the machine's own terminal font
-KeepSchemekeep the machine's own colour scheme
-SkipFontskip the font install and the font change
-SkipPluginsskip installing and updating plugins (faster when only config changed)

It backs up the previous files to ~/.claude/backups/claude-setup-<timestamp>/ on every run.

Check a machine

After installing, or on a new PC, run /setup-check in Claude Code. It finds the clone through ~/.claude/claude-setup-state.json, checks the clone is up to date, runs shell_matrix.py to compare every shell, repairs any difference with install.cmd, and runs the matrix again. It is done when the matrix exits 0. It asks before pulling, and it never edits the repo. What the installer can't do (a group-policy pin, a shell winget couldn't install, signing in to claude and gh) it hands to you with the exact command.

Same screen on every PC

After installing, the installer prints a Same experience table (name expected actual ok|FAIL) and fails if any row is wrong. It checks everything that decides what the screen looks like: the Nerd Font and Windows Terminal's font face and cellHeight, the Campbell colour scheme, Claude Code's theme (custom:clear with its file), the status line command, the session-cards mod being loaded, and Claude Code 2.1.308+, Python 3.10+ and Git Bash. The shell rows cover the rest: Terminal profile overrides (no profile sets its own font or colour scheme; -KeepFont / -SkipFont excuse font overrides and -KeepScheme colour scheme ones, and the row shows kept), Terminal default profile (PowerShell 7), Git Bash profile (present in Windows Terminal), PowerShell 7 (pwsh found), Windows PowerShell policy (RemoteSigned or looser; a group-policy pin that is stricter is a FAIL and named), PowerShell profiles (the claude-setup block in both profile files) and VS Code terminal (font, Campbell colours and contrast set; no VS Code shows kept). These rows only read, so they run under -Check too. It also runs the status bar on sample data and prints it, so a broken bar or a mangled meter glyph stops the install. Rows covered by -KeepFont, -KeepScheme or -SkipFont show kept. The install records the keep flags it ran with in ~/.claude/claude-setup-state.json (flags); /setup-check and shell_matrix.py pass them to the check and the repair, so a font or scheme you kept is never overwritten.

Changing something

Edit the files here, run .\install.ps1, commit and push. On the other machine: git pull, then .\install.ps1.

From Claude Code's ! prompt (Git Bash, so use forward slashes):

git -C C:/dev/claude-setup pull && powershell -ExecutionPolicy Bypass -File C:/dev/claude-setup/install.ps1

Daily habits

  • /clear when the context meter nears 100% (the auto-compact point), or when /context shows usage near 267k.
  • /usage weekly; /context when a session feels heavy; /skill-doctor monthly to spot skills that load but never run.
  • Start sessions from the repo folder, not C:\Windows\System32. Windows Terminal opens in C:\dev, and the PowerShell 7 profile moves a shell started in System32 (Start menu, taskbar) to C:\dev.

Reference

What goes where

In this repoOn the machine
global/CLAUDE.md~/.claude/CLAUDE.md
settings.jsonmerged into ~/.claude/settings.json (plugins, model and effort, skill overrides; see the merge rules below)
agents/*.md~/.claude/agents/
themes/*.json~/.claude/themes/; custom:clear is Claude Code's dark with userMessageBackground set to Campbell's background #0c0c0c, so the sticky prompt line at the top (the last prompt, once it scrolls away) has no grey bar, and background set to Campbell's bright white #F2F2F2, so background work (the 1 shell pill under the prompt, the background-tasks dialog) is white instead of cyan. A typed /command's echo row and the selected row in the background-tasks list lose their grey too. Its text stays the dim subtle grey and it has no border: Claude Code draws that one-row line itself, with no mod hook
statusbar.py~/.claude/statusbar.py, run by statusLine in settings.json every 60 s so the reset countdowns move
skills/<name>/~/.claude/skills/<name>/
mods/session-cards/stays in the repo; install.ps1 adds its folder to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json and checks it with claude plugin validate
terminal/profile.jsonnot copied; its font and colorScheme are written into Windows Terminal's profiles.defaults, and a colorScheme on the default profile itself is removed (see terminal/README.md)
mcp_off.pynot copied; the installer runs it to turn the claude.ai Adobe connector off in each known project in ~/.claude.json
mcp-presets/not installed; copy one into a repo as .mcp.json (see its README)

Not versioned on purpose: ~/.claude.json (login state, user-scope MCP servers), .credentials.json, history.jsonl, ~/.remember/. Synced plugins and claude.ai connectors follow the account, so they need no setup here.

Status bar details

  • The context meter's end is the auto-compact window minus 33k: 13k that Claude Code keeps free plus 20k reserved for the reply.
  • Each meter has 8 cells: the filled part in the bright level colour, the rest in a dark shade of the same colour; its icon and numbers take that colour too.
  • Separators, the other icons, the branch name and reset times are sent as exact white rather than the terminal's default foreground (light grey in most schemes), so nothing on the line is grey. The branch icon is the meters' yellow.
  • Effort runs green, yellow, the bypass red, then xhigh and max as the /effort picker draws them, held still: xhigh in the violet of accept edits with its middle letter lit, max a rainbow that starts at the bolt.
  • The pieces dropped on a narrow terminal are written to ~/.claude/statusbar-overflow/<session_id>.json, as text with their colours, with where each one goes and the width of the mode label in front of the hint (read from the session transcript); files older than a day are removed. The session-cards mod reads them for the hint line. Beside the plain hints (? for shortcuts, (shift+tab to cycle)), and beside a hint of only the background pill and the PR badge while Claude is idle, it draws the line itself, the pill as its own Button that runs /tasks; beside any other hint it hands the pieces to Claude Code's tail, since a rewritten hint turns the engine's pills into dead text. In a GitHub repo the file also holds pull, the PR address the mod links the badge's number to.
  • Reset times and the session time leave out a zero hour count past a day: 5d, not 5d0h.
  • Most of the bar's and the mod's colours are the colour scheme's own 16; Claude orange, the repo's violet and high effort's red (the red of bypass permissions on) are Claude Code's exact theme colours. The icons depend on the Nerd Font's glyph widths, so a different scheme or font gives a different screen.

Settings merge rules

settings.json is merged into the machine's ~/.claude/settings.json: enabledPlugins and env entry by entry (the repo wins a conflict, the machine's other entries stay), permissions with arrays unioned, hooks per event and per command, and every other key replaced by the repo's value. A key removed from the repo stays on the machine, so retiring one needs an explicit step in install.ps1.

env.CLAUDE_CODE_PLUGIN_DIRS (;-separated) is the one env entry that is unioned rather than replaced: the machine's own folders stay, ours (<repo>/mods/session-cards, with <repo> being this clone in forward slashes) is added once, and an existing entry ending in /mods/session-cards (from an older clone or worktree) is replaced by it. Entries are compared ignoring slash style, trailing slash and case.

Plugin and MCP policy

  • Always on (user scope): superpowers, frontend-design, context7, playwright, and the typescript, pyright and rust-analyzer LSPs. claude-md-management and the synced design and engineering plugins are pinned off. The synced docs/docx/pdf/pptx/xlsx skills and the browser and computer-use skills load by name only (skillOverrides).
  • Superpowers' brainstorming, diagnosing-superpowers, dispatching-parallel-agents, receiving-code-review, requesting-code-review, verification-before-completion and writing-skills are off (skillOverrides): CLAUDE.md asks design questions through AskUserQuestion, reviews with the built-in /code-review, and keeps "run the check before saying done" as a rule of its own. good-css is declared in extraKnownMarketplaces (vojtaholik/good-css); enabling it is left to each machine.
  • Anything tied to one project (databases, deploy target, observability, Kubernetes) is project scope: mcp-presets/.
  • The GitHub MCP server stays off; gh does the same job with far less context.
  • deniedMcpServers blocks every claude.ai connector except Adobe, Supabase and Cloudflare (Slack, Linear, Atlassian, Notion, Figma, Intercom, Claude Docs, Vercel, Canva, Spotify, Google Docs/Sheets/Calendar/Drive, Gmail, and desktop-commander). BigQuery is retired; the installer removes its user-scope server and hook.
  • Adobe: the "Adobe for creativity" claude.ai connector is the only route to Adobe tools from the CLI (the plugin copies return 403). It costs about 5k tokens a turn, so the installer turns it off in each project it knows (mcp_off.py); /mcp turns it back on for one project, and that choice survives re-runs. A deny-list entry wouldn't work here: deny lists union across settings sources, so --settings can't lift one for a session. A project first opened after the last install starts with Adobe on. Photo edits themselves run in local Photoshop via the underwater-photoshop skill.

History

The first visual setup (a custom theme, the readable-session mod, a Windows Terminal "Readable" scheme and an older status line) was retired on 2026-10-23 and rebuilt as the status bar and session-cards mod above. Tag visual-v1 holds the old version, and the installer removes it from machines that still have it.

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