SLOPSHOPPER

moddeck

A side bar with a live Session box, a Side Question panel (/btw), every mod in grouped cards (a switch is the mod's name, green on and red off; click it to…

newpanebandguardcommandmodel
★ 1v0.10.6MITupdated 2026-10-15samuelbfernandes/moddeck/mods/moddeck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · moddeck
│ ┃ Moddeck ✕ › fix the failing auth test and add an audit log call │ ┃ Moddeck Expand the panel │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ Session │ ⏺ Update(src/auth.ts) │ ┃ │ 30m │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Context ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k │ ⏺ Bash(bun test) │ ┃ │ 5h ▰▰▰▱▱▱▱▱▱▱ 31% │ ⎿ 3 pass, 1 fail │ ┃ │ $0.42 │ │ ┃ │ in 2.1k · out 1.5k · cache 91k · 35 tok… │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Side Question Ask here ⏎ ask │ › /moddeck │ ┃ ╰──────────────────────────────────────────╯ ⎿ moddeck: Moddeck · open │ ┃ │ ┃ fold Model controls fold Model controls │ ┃ ╭─────────────────────────────────────────── │ ┃ │ advisor switch, off ● off ▾ Claude consult │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ fold Guards fold Guards │ ┃ ╭─────────────────────────────────────────── │ ┃ │ tool:scope-watch option:scope-watch:/scope │ ┃ ╰─────────────────────────────────────────── │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Moddeck
Moddeck Expand the panel ╭──────────────────────────────────────────────────────────╮ │ Session │ │ 30m │ │ Context ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k │ │ 5h ▰▰▰▱▱▱▱▱▱▱ 31% │ │ $0.42 │ │ in 2.1k · out 1.5k · cache 91k · 35 tok/s │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Side Question Ask here ⏎ ask │ ╰──────────────────────────────────────────────────────────╯ fold Model controls fold Model controls ╭─────────────────────────────────────────────────────────── │ advisor switch, off ● off ▾ Claude consults a stronger mod ╰─────────────────────────────────────────────────────────── fold Guards fold Guards ╭─────────────────────────────────────────────────────────── │ tool:scope-watch option:scope-watch:/scope-watch status op ╰─────────────────────────────────────────────────────────── fold Automation fold Automation ╭─────────────────────────────────────────────────────────── │ tool:ssh-connect option:ssh-connect:/ssh shell option:ssh- ╰─────────────────────────────────────────────────────────── fold Workspace utilities fold Workspace utilities ╭─────────────────────────────────────────────────────────── │ tool:checkpoint option:checkpoint:/checkpoint list option: │ ───────────────────────────────────────────────────────… │ tool:usage-meter Usage line and limits │ ───────────────────────────────────────────────────────… │ tool:filetree File tree beside the chat │ ───────────────────────────────────────────────────────… │ tool:edit-replay Step through the last edits │ ───────────────────────────────────────────────────────… │ tool:diagram Draws the last mermaid diagram │ ───────────────────────────────────────────────────────… │ tool:staged-diff What the next commit holds │ ───────────────────────────────────────────────────────… │ tool:decision-log option:decision-log:/decision docs/DECI │ ───────────────────────────────────────────────────────… │ tool:table-preview Preview a csv or tsv file ╰─────────────────────────────────────────────────────────── fold UI development fold UI development ╭─────────────────────────────────────────────────────────── │ tool:ui-preview option:ui-preview:/ui-preview start option │ ───────────────────────────────────────────────────────… │ tool:contrast-check Contrast and colour formats ╰─────────────────────────────────────────────────────────── fold Skills · 6 fold Skills · 6 ╭─────────────────────────────────────────────────────────── │ star dual-model-review skill:dual-model-review A second mo │ star agents-md-scaffold skill:agents-md-scaffold Sets up A │ star align skill:align Interviews you, writes a spec, hand │ star burst skill:burst Three graded versions of a text or │ star assess-plugin skill:assess-plugin Checks a plugin bef │ ───────────────────────────────────────────────────────… │ Search skills ⏎ use more lines ╰─────────────────────────────────────────────────────────── green on · red off · click a name · $ = spends model tokens
README

moddeck

Install (Claude Code 2.1.295 or later): /plugin install moddeck --marketplace samuelbfernandes/moddeck

A side bar beside the chat with a live Session box on top, then the toolkit's mods and skills.

The side bar in the desktop app: Session box, Side Question, and the Prompt helpers and Model controls cards

The lower part: Workspace utilities, UI development, Skills and Prompts

Dropdowns sit right after a name: tldr (off, highlight, summary), command-guard (off, low, medium, high) and advisor (off, opus, fable). A green dot is on, red is off, and the ☀ / ☾ buttons at the top set the pane's colours.

The Session box (top of the side bar)

Like a status line, but inside the pane: it needs no status line and never touches $.ui.status. It is drawn only from figures the engine gives; a figure that is missing is not drawn (never as zero), and with nothing known the box is simply absent and the side bar is exactly the old one. Nothing here sends a prompt or calls a model. Each row can be hidden.

rowwhat it showswhere it reads
modelmodel name, provider (Bedrock, Vertex, Foundry, or a custom base-URL host), effort (with a yellow ⚠ medium would do / ⚠ try high when model-picker's effort-watch says it does not fit the last task), session length (1h 12m)turn.step (model, effort), else $.config.list() / $.session.model(); $.env / $.settings.read().env; usage().startedAt
modethe configured permission mode in plain words, only when it is not the ordinary one (plan mode, auto mode, accepts edits, red permissions bypassed !). The mode Shift+Tab switches to mid-session is not readable by a mod, so this is the one in the settings (or a config row)$.settings.read(), $.config.list()
backuptime since the newest commit or checkpoint, and files unsaved: green when the tree is clean or the save is recent, amber from 2 h with files unsaved, red from 8 h (/rewind covers only Claude's own edits); none · 3 unsaved when nothing was ever savedread-only git log -1, git for-each-ref refs/moddeck/checkpoints, at start and after each turn
contextbar, percent, tokens/window; green, yellow from 70%, red from 85%; from 85% also the three biggest categoriessession.measure / $.session.usage(); the breakdown is breakdown: 'summary' (local estimate, never 'full')
usageone bar per rate-limit window (5h, 7d, other kinds by name) with percent and resets in 1h 30m; the 7d window only from usageSevenDayAt %rateLimits
usage pacea ▲ (amber, red) when, at the burn rate so far (percent used over the elapsed share of the window: 5h or 7d), the window reaches 90% (amber) or 100% (red) before it resets; under 10% used stays neutralhooks/pace.ts
costsession $x.xx, plus Today $x and Week $xusage().cost; atom usage-meter.ledger { today, week } (nothing drawn when absent)
cacheCache until 14:30 (a clock time: the pane does not repaint between turns), Cache expired, hit 87%atom cache-guard.cache { expiresAt, hitRate } (nothing drawn when absent)
gitbranch with * when dirty, ↑2 ↓1, !3 +1 ✘0 ?2 (modified, added, deleted, untracked), ⎇ name in a linked worktree; ahead turns to the warning colour from pushWarnread-only git status --porcelain=v2 --branch and rev-parse, at session start and after each turn; not a repository: no row
tokenssession in / out / cache totals and the last turn's output speedturn.complete usage and durationMs (speed is output over the whole turn's time, so tool time makes it read low)
activitythe last few tool calls (✓ Read ×3 · ◐ Edit auth.ts, running vs done), skills used, MCP servers used, Compactions: 2 (after the first)tool.call before and after next, session.compact
environmentcounts of CLAUDE.md files (project up to home, plus ~/.claude/CLAUDE.md), rules, MCP servers, hooks, plugins; shown when the total reaches envThreshold$.fs, $.tool.list(), $.settings.read()

/moddeck rows list | hide <id> | show <id> | compact | expanded changes what the box shows and keeps it between sessions (in the plugin's store, keys rows and layout); an unknown word prints a short help and changes nothing. /moddeck alone still shows or hides the pane. expanded is one row per line; compact joins rows with │ on as few lines as fit.

What is in it

Top to bottom, at every width: a top bar (title and an expand button that reopens the pane wider, and again narrower), the Session box, the Side Question panel, the mods in grouped cards, then Skills and Prompts in cards of their own. Every line is one row: text that does not fit the pane is cut with "…", never wrapped onto a second line (a Side Question answer is the one thing that wraps).

One line per mod: its name first, then its controls, then a short description, always last, which takes the room left. Inside a card the lines are parted by thin rules. The descriptions and groups are presentation only (hooks/catalog.ts); no command or state changed.

  • A switch is the mod's name: a pill, green while on and red while off. A click runs /<mod> on|off at once and shows the reply as a toast. Its action buttons (blue, [like this]) sit right after the name and show while it is on.
  • Prompt helpers: model-picker ([status], [explain]), $ reflect, $ tldr with its off|highlight|summary dropdown (summary is the default), and concise.
  • Model controls: command-guard, whose name is a switch (off, or back to the level it had; medium the first time) with its off|low|medium|high dropdown right after the name; review-cost, a plain switch (it has only on and off); $ advisor with its off|opus|fable dropdown.
  • Guards: secret-guard, data-guard, big-file-guard, repro-log ([recipe], [status] while on).
  • Automation: auto-test ([run], [status] while on), long-run-ping.
  • Workspace utilities: checkpoint, branch-janitor, cache-guard, usage-meter, progress, filetree, edit-replay, diagram. Their names are plain (not switches): a click runs the command (/checkpoint, /cache-guard, ...; the first line of the reply is a toast, and the engine prints the whole reply under the command). Commands that need your text or change something ([clean] for /branch-janitor clean, [$ warm] for /keepwarm 90m, [restore] for /checkpoint restore ) are put in the prompt box; nothing is sent until you press Enter.
  • One column: the cards stand one under the other, each the full width of the pane.
  • A card with no readable line is not drawn (a mod that is not installed has no line).
  • Skills and Prompts (prompt-library's saved prompts): name and description per line; a click puts the text in the prompt box. With more than skillRows items the card shows that many and gets a scroll bar on its right (the thumb shows where you are): a click on the track moves a page, and the mouse wheel over the list moves it a line (the wheel anywhere else scrolls the pane as usual). The card's title then shows the count (Skills · 5).
  • A $ marks what spends model tokens (reflect, the Side Question, /keepwarm).
  • In the desktop app every name is a pill, white on deep green or red for a switch and on gray for a command that runs on click (the same colours in every theme); the whole pill is the button. Every box is filled (dark, or light after /moddeck theme light) with its text, descriptions and clickable words coloured to match; skill and prompt names start with "/". On a light theme the app draws button text dark, so the light colours use pale pills of the same hues (and a light fill). A mod cannot see the app's own light or dark choice: click ☀ or ☾ at the top of the side bar, or type /moddeck theme light|dark (setting appTheme, default dark, kept for later sessions) picks it. Every box may shrink below its text and cuts it with "…", so a box keeps its four edges when the pane is dragged narrower and nothing overlaps. The pane has no title row there (the app's frame has the title, expand and close). The terminal draws the same content with its own top bar and coloured name text.

Use

It opens by itself at session start where the screen can dock it (fullscreen terminal, desktop app), once per session. /moddeck shows or hides it; /moddeck rows ... sets the Session box. Commands are always typed with their /; a bare word is an ordinary prompt.

Settings

autoOpen (default on), skillRows (5: lines a Skills or Prompts card shows before its scroll bar), showSession (true), sessionLayout (expanded or compact), sessionRows (comma list of row ids in display order, default model,context,usage,cost,cache,git,tokens,activity,environment; ids left out are hidden), usageSevenDayAt (80), toolsMaxVisible (4), pushWarn (5), envThreshold (0 = always). What /moddeck rows saved wins over sessionRows and sessionLayout.

Limits

  • The pane is a terminal/desktop pane (boxes, text, buttons, dropdowns), not HTML: the switch is a coloured pill button, the switch has no role="switch" (the pane has no such property; the button carries a label such as secret-guard switch, on), and keyboard focus is the engine's own. The scroll bar is drawn with ┃ and │ cells; the wheel finds the list by counting rows from the bottom of the pane, which holds because every line there is exactly one row. The expand button's resize is a close and reopen at another width; if there is no room it puts the pane back.
  • It reads the switches from the mods' own state; if Claude Code refuses that read, the switch is not drawn (the other rows still work).
  • Every Session box figure comes from the engine's typed API, but none was seen in a live session: the hooks are only observers (they let each event through first and keep their books in try/catch), and each failing source drops only its row.
  • The cost and cache rows need usage-meter and cache-guard to publish their atoms; without them those parts are not drawn.
  • The session name is not given to mods by the engine, so the environment row does not show one.
  • Never verified live: the pane, the dropdown, the scroll bar and the wheel, and filling the prompt box were tested against Claude Code's simulated engine only (it checks the tree, not how a surface paints it).

Side Question

A Side Question panel right under the Session box: a field and an ask button, with the last answers above it. Enter asks; /btw <question> does the same and expands the panel; /btw alone expands it. The answer is made over the main thread's cached context with every tool denied, shown only here and never added to the conversation, and costs a little credit (a cold cache costs like a full message). It needs a first reply in the session. If Claude Code's own built-in /btw takes priority over a mod's command of that name, use the field instead. Not verified live.

Updates

Every time the side bar is turned on (session start, or opened again), moddeck runs git ls-remote --tags against its GitHub repository (at most every 6 hours; updateCheck turns it off) and, if a newer release exists, asks Update now / Later / Skip this version. Update now runs the installer's own one-liner (pipx run --spec git+https://github.com/samuelbfernandes/moddeck@vX.Y.Z moddeck update --scope user -y, else uvx; -y because there is no terminal to answer questions); restart Claude Code afterwards. /moddeck update checks at once. Nothing is installed without your yes. Not verified live.

Source 9 files
hooks/register.tsx 1403 lines
1// moddeck: a side bar with the toolkit's mods and skills, one line each inside a card per group. A switch is the mod's name
2// (green on, red off) and flips at once by running the mod's own command; another name runs its command at once when
3// that is harmless, or puts it in the prompt box (`$.prompt.fill`) when it needs your text or changes something.
4// Nothing here spends tokens or sends a prompt by itself.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { BtwEntry } from '../types'
9import { INSTRUCTION, addEntry, buildPrompt, cutAnswer, digest, fallbackPrompt, footer, headline, isRunning, maxCharsOf, nextId, patchEntry, tokensOf, whyNot } from './btw'
10import { GROUPS, MAX_LIST_ROWS, MIN_LIST_ROWS, TOOLS, allSkills, listRowsOf, listSize, priced, rankTools, searchFirst, splitCommand, starFirst, toggleStar } from './catalog'
11import type { Action, InstalledSkill, Option, SavedPrompt, Tool } from './catalog'
12import { CARD_CHROME, barTarget, listAt, pillText, scrollBar, stepTop, trackCells } from './layout'
13import type { BottomList } from './layout'
14import { isDirty, linkedWorktree, parseStatusV2 } from './gitinfo'
15import type { GitInfo } from './gitinfo'
16import {
17  DEFAULT_BOX,
18  DEFAULT_ROWS,
19  EMPTY_ACTIVITY,
20  ROW_IDS,
21  buildRows,
22  finishRun,
23  hideRow,
24  layoutLines,
25  parseRows,
26  providerOf,
27  showRow,
28  startRun,
29  targetOf,
30} from './sessionrows'
31import type { Activity, BoxSettings, EnvCounts, Layout, Part, RowId, Snapshot, TokenTotals } from './sessionrows'
32
33import { ASK_HEADER, CHECK_EVERY_MS, LATER, RELEASE, REMOTE, SKIP, UPDATE_NOW, askText, installArgv, isNewer, latestRelease, manualCommand } from './update'
34
35type Dollar = EngineInterface
36
37const PANE = 'moddeck'
38const TITLE = 'Moddeck'
39const COLUMNS = 40
40/** Wide enough for the cards with short names to sit two to a line. */
41const WIDE_COLUMNS = 96
42const HINT = 'Side panel · use /tui fullscreen'
43const FAILED = 'Moddeck · could not open'
44const PANE_SURFACES = new Set(['terminal', 'desktop'])
45/**
46 * The app's colours, the "Deep solid" palette in a dark and a light version: the box fill, its text, its descriptions,
47 * its clickable words, and the name pills (on, off, a command). The app draws a button's text itself, in its own
48 * theme's colour (light on a dark theme, dark on a light one), so a pill's fill is chosen to read under that text:
49 * deep on a dark theme, a pale tint of the same hue on a light one.
50 */
51type Ink = { fill: string; text: string; desc: string; link: string; on: string; off: string; run: string; dotOn: string; dotOff: string }
52const INK: Record<'dark' | 'light', Ink> = {
53  dark: { fill: '#1F1F1E', text: '#ECEAE4', desc: '#9A9893', link: '#7FB2E5', on: '#166534', off: '#991B1B', run: '#374151', dotOn: '#22C55E', dotOff: '#EF4444' },
54  light: { fill: '#FAF9F5', text: '#1F1F1E', desc: '#6B6A65', link: '#185FA5', on: '#BBF7D0', off: '#FECACA', run: '#E5E7EB', dotOn: '#16A34A', dotOff: '#DC2626' },
55}
56type ThemePick = 'light' | 'dark'
57const THEMES: readonly ThemePick[] = ['light', 'dark']
58const isThemePick = (v: unknown): v is ThemePick => typeof v === 'string' && (THEMES as readonly string[]).includes(v)
59
60const autoOpen = atom({ plugin: 'moddeck', key: 'autoOpen' } as const, 'new')
61/** First row shown in each scrolling list (`skills`, `prompts`): a list shows a few rows at a time, with a scroll bar, when it has many. */
62const tops = atom({ plugin: 'moddeck', key: 'tops' } as const, {} as Record<string, number>)
63/** The updateCheck setting, set once by `register` (the open path in `toggle` reads it). */
64let updateCheckOn = true
65/** The Side Question panel: the last few side questions and answers, newest first (in memory only). */
66/** The Side Question panel: collapsed to one button until clicked (or until /btw is used). */
67const btwOpen = atom({ plugin: 'moddeck', key: 'btwOpen' } as const, false)
68const btwList = atom({ plugin: 'moddeck', key: 'btw' } as const, [] as BtwEntry[])
69/** What the Ask here field holds: kept as it is typed, emptied after asking and by ✕ close. */
70/** What each list's search field holds (`skills`, `prompts`); matching names move to the top. */
71const listQuery = atom({ plugin: 'moddeck', key: 'listQuery' } as const, {} as Record<string, string>)
72/** Cards folded down to their title by their ▾ button (`group:<name>`, `group:skills`, `group:prompts`); kept in the store as `folded`. null = not loaded. */
73const folded = atom({ plugin: 'moddeck', key: 'folded' } as const, null as string[] | null)
74const btwDraft = atom({ plugin: 'moddeck', key: 'btwDraft' } as const, '')
75/** A newer release the person has not updated to yet (null = none known); the side bar shows a one-line button for it. */
76const updateAvail = atom({ plugin: 'moddeck', key: 'updateAvail' } as const, null as string | null)
77/** The expand button: the pane is drawn wide (`WIDE_COLUMNS`) instead of `COLUMNS`. */
78const wide = atom({ plugin: 'moddeck', key: 'wide' } as const, false)
79const DEFAULT_SKILL_ROWS = 5
80/** A tool group with more tools than this gets stars and scrolls. */
81const GROUP_SCROLL_AT = 8
82/** Every skill the session has, as the engine lists it (null = not read yet: the toolkit's own are shown meanwhile). */
83const installed = atom({ plugin: 'moddeck', key: 'installedSkills' } as const, null as InstalledSkill[] | null)
84let skillsAsked = false
85/** Lines each list (`skills`, `prompts`) shows, as its more/fewer buttons set it; kept in the store as `listRows`. null = not loaded. */
86const listRows = atom({ plugin: 'moddeck', key: 'listRows' } as const, null as Record<string, number> | null)
87/** Starred skill ids, shown at the top of the Skills box; kept in the store as `starred`. null = not loaded. */
88const starred = atom({ plugin: 'moddeck', key: 'starred' } as const, null as string[] | null)
89
90/** The list sizes and the stars, loaded from the store the first time they are asked for. */
91async function rowsPicked($: Dollar): Promise<Record<string, number>> {
92  const now = await read($, listRows)
93  if (now !== null) return now
94  const kept = (await stored<Record<string, number>>($, 'listRows')) ?? {}
95  await safe(() => update($, listRows, () => kept))
96  return kept
97}
98async function starsPicked($: Dollar): Promise<string[]> {
99  const now = await read($, starred)
100  if (now !== null) return now
101  const kept = (await stored<string[]>($, 'starred')) ?? []
102  await safe(() => update($, starred, () => kept))
103  return kept
104}
105async function foldedPicked($: Dollar): Promise<string[]> {
106  const now = await read($, folded)
107  if (now !== null) return now
108  const kept = (await stored<string[]>($, 'folded')) ?? []
109  await safe(() => update($, folded, () => kept))
110  return kept
111}
112async function flipFold($: Dollar, key: string): Promise<void> {
113  const next = toggleStar(await foldedPicked($), key)
114  await update($, folded, () => next)
115  await safe(() => $.store.set('folded', next))
116}
117async function setRows($: Dollar, id: string, n: number): Promise<void> {
118  const next = { ...(await rowsPicked($)), [id]: n }
119  await update($, listRows, () => next)
120  await safe(() => $.store.set('listRows', next))
121}
122async function flipStar($: Dollar, id: string): Promise<void> {
123  const next = toggleStar(await starsPicked($), id)
124  await update($, starred, () => next)
125  await safe(() => $.store.set('starred', next))
126}
127
128/** The session's skills from the engine's own listing (local estimate, no model call), with a description from the matching slash command. */
129async function readSkills($: Dollar): Promise<void> {
130  skillsAsked = true
131  const u = await attempt(() => $.session.usage({ breakdown: 'summary' }))
132  const listed = u?.context.breakdown?.skills?.skillFrontmatter
133  if (!listed) return
134  const cmds = (await attempt(() => $.command.list())) ?? []
135  const descOf = new Map(cmds.map(c => [c.name, c.description]))
136  await safe(() => update($, installed, () => listed.map(k => ({ name: k.name, desc: descOf.get(k.name) }))))
137}
138/** Where the last drawing put the pane's window and its lists, for the wheel (`ui.scroll`); a fresh load starts empty. */
139let paneOffset = 0
140let bottomLists: BottomList[] = []
141let scrollables: Record<string, { total: number; shown: number }> = {}
142const clampTop = (top: number, total: number, shown: number): number => Math.max(0, Math.min(top, total - shown))
143/** What the person last picked in a dropdown here (`command-guard`, and `command-guard:last`, the level its switch goes back to): the pick shows at once, even where the other mod's state is not watched for redraws. */
144const picks = atom({ plugin: 'moddeck', key: 'picks' } as const, {} as Record<string, string>)
145
146// ---- the Session box: what is kept in memory (never in the transcript), and read by the pane ---------------------------------
147type MeasureAtom = {
148  context: { tokens?: number; window?: number; percent?: number }
149  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
150  cost?: number
151}
152type MetaAtom = { model?: string; effort?: string | number; provider?: string; startedAt?: number; breakdown?: { name: string; tokens: number }[] }
153/** The latest figures `session.measure` raised (or `session.usage()` gave at start). */
154const measure = atom({ plugin: 'moddeck', key: 'measure' } as const, null as MeasureAtom | null)
155const meta = atom({ plugin: 'moddeck', key: 'meta' } as const, {} as MetaAtom)
156/** Git state of the working directory, refreshed at session start and after each turn; null = not a repository. */
157const gitState = atom({ plugin: 'moddeck', key: 'git' } as const, null as GitInfo | null)
158/** The working directory, shown with the branch in the line above the prompt. */
159const cwdState = atom({ plugin: 'moddeck', key: 'cwd' } as const, '' as string)
160const tokenTotals = atom({ plugin: 'moddeck', key: 'tokens' } as const, null as TokenTotals | null)
161const activity = atom({ plugin: 'moddeck', key: 'activity' } as const, EMPTY_ACTIVITY as Activity)
162/** The newest commit or checkpoint, ms; null = none, undefined (never set) = not read. */
163const backupAt = atom({ plugin: 'moddeck', key: 'backupAt' } as const, undefined as number | null | undefined) // state is JSON data: only a number or null is ever written
164/** The configured permission mode. */
165const permMode = atom({ plugin: 'moddeck', key: 'permMode' } as const, undefined as string | undefined)
166const EFFORT_ADVICE = { plugin: 'model-picker', key: 'effortAdvice' } as const
167const ROUTED_TO = { plugin: 'model-picker', key: 'routedTo' } as const
168const envCounts = atom({ plugin: 'moddeck', key: 'env' } as const, null as EnvCounts | null)
169/** The rows shown (a comma list in display order) and the layout, as /moddeck rows set them; null = not set by the person (the settings apply). */
170const rowsList = atom({ plugin: 'moddeck', key: 'rows' } as const, null as string | null)
171const layoutPick = atom({ plugin: 'moddeck', key: 'layout' } as const, null as Layout | null)
172/** `/moddeck theme light|dark`, this session's pick (kept in the store too); null = the setting. */
173const themePick = atom({ plugin: 'moddeck', key: 'theme' } as const, null as ThemePick | null)
174
175const LEDGER = { plugin: 'usage-meter', key: 'ledger' } as const
176const CACHE = { plugin: 'cache-guard', key: 'cache' } as const
177const KEEPWARM = { plugin: 'cache-guard', key: 'keepwarm' } as const
178const JANITOR_OFF = { plugin: 'branch-janitor', key: 'isOff' } as const
179const BAND_HIDDEN = { plugin: 'progress', key: 'isHidden' } as const
180
181const HELP = [
182  'Moddeck · /moddeck shows or hides the side bar',
183  '/moddeck update checks for a newer release · /btw <question> asks in the side bar',
184  `/moddeck rows list · hide <id> · show <id> · compact · expanded`,
185  '/moddeck theme light · dark (the side bar colours in the desktop app)',
186  `ids: ${ROW_IDS.join(', ')}`,
187].join('\n')
188
189function num(v: unknown, fallback: number): number {
190  return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback
191}
192
193function defer($: Dollar, work: () => Promise<void>): void {
194  $.clock.after(1, () => {
195    work().catch(() => undefined)
196  })
197}
198
199// The other mods' state this side bar reads. The engine wants each reference written out here, so the list of what a
200// module reads can be checked; a mod that is not installed (or a read that is refused) reads as "cannot see".
201const SUGGEST_OFF = { plugin: 'model-picker', key: 'isOff' } as const
202const REFLECT_OFF = { plugin: 'reflect', key: 'isOff' } as const
203const SECRET_ON = { plugin: 'secret-guard', key: 'isOn' } as const
204const DATA_OFF = { plugin: 'data-guard', key: 'isOff' } as const
205const BIGFILE_OFF = { plugin: 'big-file-guard', key: 'isOff' } as const
206const REPRO_OFF = { plugin: 'repro-log', key: 'isOff' } as const
207const AUTOTEST_OFF = { plugin: 'auto-test', key: 'isOff' } as const
208const PING_OFF = { plugin: 'long-run-ping', key: 'isOff' } as const
209const PROMPTS = { plugin: 'prompt-library', key: 'items' } as const
210const BLAST_LEVEL = { plugin: 'command-guard', key: 'level' } as const
211const REVIEW_OFF = { plugin: 'review-cost', key: 'isOff' } as const
212const FORMAT_OFF = { plugin: 'format-on-edit', key: 'isOff' } as const
213const LEDGER_OFF = { plugin: 'test-ledger', key: 'isOff' } as const
214const INBOX_OFF = { plugin: 'error-inbox', key: 'isOff' } as const
215const CONCISE_OFF = { plugin: 'concise', key: 'isOff' } as const
216const TLDR_OFF = { plugin: 'tldr', key: 'isOff' } as const
217const TLDR_MODE = { plugin: 'tldr', key: 'mode' } as const
218
219async function valueOf(read: Promise<unknown>): Promise<unknown> {
220  try {
221    return ((await read) as { value?: unknown } | undefined)?.value
222  } catch {
223    return undefined
224  }
225}
226
227const firstLine = (text: string | undefined): string => (text ?? '').split('\n')[0]?.trim() ?? ''
228/** `$.ui.log` draws one transcript line, where a newline shows as a stray glyph: the lines are joined with " · " instead. */
229export const oneLine = (text: string): string =>
230  text
231    .split('\n')
232    .map(l => l.trim())
233    .filter(Boolean)
234    .join(' · ')
235
236/** Show a command's reply: its first line as a toast (the engine already prints the whole reply under the command). */
237function show($: Dollar, text: string | undefined, fallback: string): void {
238  $.ui.toast(firstLine((text ?? '').trim()) || fallback)
239}
240
241/** A switch click: run the mod's own `on` / `off` at once. */
242async function flip($: Dollar, s: Tool, isOn: boolean): Promise<void> {
243  try {
244    const out = (await $.command.run({ command: s.command ?? s.id, args: isOn ? 'off' : 'on' })) as { text?: string } | undefined
245    show($, out?.text, `${s.label} · ${isOn ? 'off' : 'on'}`)
246  } catch {
247    $.ui.toast(`Moddeck · could not switch ${s.label}`)
248  }
249}
250
251/** Run the command now, or (when it needs your text or changes something) put it in the prompt box; nothing is sent. */
252async function act($: Dollar, action: Action): Promise<void> {
253  const typed = action.text.trim()
254  try {
255    if (action.run) {
256      const { command, args } = splitCommand(action.text)
257      const out = (await $.command.run({ command, args })) as { text?: string } | undefined
258      show($, out?.text, `Moddeck · ${typed}`)
259      return
260    }
261    const out = (await $.prompt.fill({ text: action.text } as never)) as { isFilled?: boolean } | undefined
262    if (out?.isFilled === false) $.ui.toast(`Moddeck · type it yourself: ${typed}`)
263  } catch {
264    $.ui.toast(action.run ? `Moddeck · could not run ${typed}` : `Moddeck · type it yourself: ${typed}`)
265  }
266}
267
268/** A dropdown pick: remember it for the drawing, then run the mod's own command with it. */
269/** Asked when the advisor is picked while a variable keeps it off: what is in the way, and a way out. */
270async function offerAdvisorFix($: Dollar, blockedBy: string, model: string): Promise<void> {
271  const isTelemetry = blockedBy === 'DISABLE_TELEMETRY'
272  const YES = 'Yes, go ahead'
273  const NO = 'No'
274  const text = isTelemetry
275    ? `Using the advisor turns Claude Code's telemetry back on (DISABLE_TELEMETRY is removed from your settings), so usage data is sent to Anthropic again. Error reports stay off. OK to proceed?`
276    : `Using the advisor removes CLAUDE_CODE_DISABLE_ADVISOR_TOOL from your settings. OK to proceed?`
277  let answer: string
278  try {
279    answer = await $.ui.ask(text, { options: [YES, NO], header: 'Advisor' })
280  } catch {
281    return // Esc: nothing changes
282  }
283  if (answer !== YES) return
284  const request = isTelemetry
285    ? `Remove DISABLE_TELEMETRY from the env block of ~/.claude/settings.json, and if ~/.claude/moddeck/state/user.json lists "telemetry-off" under "selected", take it out so a moddeck update does not put it back. Back up both files first. Then tell me to restart and run /advisor ${model}.`
286    : `Remove CLAUDE_CODE_DISABLE_ADVISOR_TOOL from the env block of ~/.claude/settings.json (back the file up first). Then tell me to restart and run /advisor ${model}.`
287  await act($, { text: request, run: false })
288  $.ui.toast('Press Enter to make the change')
289}
290
291async function pick($: Dollar, id: string, command: string, value: string, fillsPrompt = false, blockedBy?: string): Promise<void> {
292  if (fillsPrompt && blockedBy !== undefined && value !== 'off') return offerAdvisorFix($, blockedBy, value)
293  if (fillsPrompt) {
294    // Claude Code's own command: a mod cannot run it, so it goes in the prompt box; the row follows the setting once sent.
295    await act($, { text: `/${command} ${value}`, run: false })
296    $.ui.toast(`Press Enter to run /${command} ${value}`)
297    return
298  }
299  await safe(() => update($, picks, p => ({ ...p, [id]: value })))
300  try {
301    const out = (await $.command.run({ command, args: value })) as { text?: string } | undefined
302    $.ui.toast(firstLine(out?.text) || `${command} · ${value}`)
303  } catch {
304    $.ui.toast(`Moddeck · could not set ${command} to ${value}`)
305  }
306}
307
308/** command-guard's name is a switch too: off, or back to the level it had (medium the first time). */
309async function flipLevel($: Dollar, t: Tool, level: string, chosen: Record<string, string>, blockedBy?: string): Promise<void> {
310  const back = chosen[`${t.id}:last`] ?? t.defaultLevel ?? (t.levels?.includes('medium') ? 'medium' : t.levels?.find(l => l !== 'off')) ?? 'on'
311  if (level !== 'off') await safe(() => update($, picks, p => ({ ...p, [`${t.id}:last`]: level })))
312  await pick($, t.id, t.command ?? t.id, level === 'off' ? back : 'off', t.fillsPrompt === true, blockedBy)
313}
314
315async function safe(f: () => Promise<unknown>): Promise<void> {
316  try {
317    await f()
318  } catch {
319    // a pick is still run; only its early redraw is lost
320  }
321}
322
323async function isPaneOpen($: Dollar): Promise<boolean> {
324  return (await $.ui.panes()).some(p => p.id === PANE)
325}
326
327async function toggle($: Dollar): Promise<string> {
328  await update($, autoOpen, () => 'done') // the person has taken over: never open it for them after this
329  if (await isPaneOpen($)) {
330    await $.ui.close({ id: PANE })
331    return 'Moddeck · closed'
332  }
333  const opened = await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
334  if (opened.isPlaced && updateCheckOn) defer($, async () => void (await checkForUpdate($, false)))
335  return opened.isPlaced ? 'Moddeck · open' : 'Moddeck · waiting for room\nTry /tui fullscreen, or a wider window.'
336}
337
338// ---- Side Question: a side question answered over the cached context, shown in the side bar and never added to the chat -------
339/** Opens the side bar if it is not up (a /btw typed with it closed brings it), without taking over its auto-open. */
340async function ensurePane($: Dollar): Promise<boolean> {
341  if (await isPaneOpen($)) return true
342  await update($, autoOpen, () => 'done')
343  const opened = await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
344  return opened.isPlaced
345}
346
347async function askBtw($: Dollar, maxChars: number, text: string): Promise<string> {
348  const question = text.trim()
349  if (question === '') {
350    await safe(() => update($, btwOpen, () => true))
351    await ensurePane($)
352    return 'Side Question · type it in the box at the bottom of the side bar, or /btw <question>'
353  }
354  const now = await $.clock.now()
355  if (isRunning(await read($, btwList), now)) return 'Side Question · one is still thinking'
356  if (!(await ensurePane($))) return 'Side Question · no room for the side bar\nTry /tui fullscreen, or a wider window.'
357  await safe(() => update($, btwOpen, () => true)) // asking opens the panel
358  const id = nextId(await read($, btwList))
359  await update($, btwList, l => addEntry(l, { id, at: now, question, status: 'thinking', answer: '', isCut: false, tokens: null, note: '' }))
360  defer($, async () => {
361    let patch: Partial<BtwEntry>
362    try {
363      let r = await $.model.fork({ prompt: buildPrompt(question) })
364      if (!r.isAnswered && r.reason === 'nothing-to-fork') {
365        // A resumed or reloaded session has no request to fork until its next turn: ask over the transcript's text instead.
366        const text = digest((await attempt(() => $.session.messages())) ?? [])
367        const model = (await attempt(() => $.session.model())) ?? 'sonnet'
368        if (text !== '') r = await $.model.complete({ model: typeof model === 'string' && model !== '' ? model : 'sonnet', system: INSTRUCTION, prompt: fallbackPrompt(text, question), maxTokens: 1024 })
369      }
370      if (!r.isAnswered) patch = { status: 'failed', note: whyNot(r) }
371      else if (r.text.trim() === '') patch = { status: 'failed', note: whyNot({ reason: 'empty-reply' }) }
372      else {
373        const cut = cutAnswer(r.text, maxChars)
374        patch = { status: 'done', answer: cut.text, isCut: cut.isCut, tokens: tokensOf(r.usage) }
375      }
376    } catch {
377      patch = { status: 'failed', note: 'The request could not be sent' }
378    }
379    await update($, btwList, l => patchEntry(l, id, patch))
380  })
381  return 'Side Question · thinking · the answer is in the side bar'
382}
383
384// ---- update check: `git ls-remote --tags` for the newest release, asked once per window; nothing is installed without a yes ----
385async function stored<T>($: Dollar, key: string): Promise<T | undefined> {
386  try {
387    return (await $.store.get(key)) as T | undefined
388  } catch {
389    return undefined
390  }
391}
392
393/** Looks the newest release tag up (at most every CHECK_EVERY_MS unless forced) and, when it is newer and not skipped, asks. */
394async function checkForUpdate($: Dollar, isForced: boolean): Promise<string> {
395  const now = await $.clock.now()
396  let latest = await stored<string>($, 'latest')
397  const at = (await stored<number>($, 'checkedAt')) ?? 0
398  if (isForced || latest === undefined || now - at >= CHECK_EVERY_MS || now < at) {
399    try {
400      const r = await $.process.run(['git', 'ls-remote', '--tags', '--refs', REMOTE], { timeoutMs: 20_000 })
401      if (r.exitCode !== 0) return 'Moddeck · could not check for updates'
402      latest = latestRelease(r.stdout)
403      await $.store.set('checkedAt', now)
404      if (latest !== undefined) await $.store.set('latest', latest)
405    } catch {
406      return 'Moddeck · could not check for updates' // offline or no git: stay quiet at start, say so on a forced check
407    }
408  }
409  if (latest === undefined || !isNewer(latest, RELEASE)) {
410    await update($, updateAvail, () => null)
411    return `Moddeck ${RELEASE} · up to date`
412  }
413  await update($, updateAvail, () => latest ?? null)
414  if (!isForced && (await stored<string>($, 'skipped')) === latest) return `Moddeck ${latest} is available (skipped)`
415  await offerUpdate($, latest)
416  return `Moddeck ${latest} is available`
417}
418
419async function offerUpdate($: Dollar, latest: string): Promise<void> {
420  let answer: string
421  try {
422    answer = await $.ui.ask(askText(latest, RELEASE), { options: [UPDATE_NOW, LATER, SKIP], header: ASK_HEADER })
423  } catch {
424    return // Esc: ask again next time
425  }
426  if (answer === SKIP) {
427    await $.store.set('skipped', latest)
428    await update($, updateAvail, () => null)
429  } else if (answer === UPDATE_NOW) await runUpdate($, latest)
430}
431
432/** The installer's documented one-liner through pipx (else uvx); the result is told in a toast and the log. */
433async function runUpdate($: Dollar, latest: string): Promise<void> {
434  const manual = `Moddeck · update by hand: ${manualCommand(latest)}`
435  try {
436    let runner: 'pipx' | 'uvx' | undefined
437    for (const r of ['pipx', 'uvx'] as const) {
438      try {
439        if ((await $.process.run([r, '--version'], { timeoutMs: 10_000 })).exitCode === 0) {
440          runner = r
441          break
442        }
443      } catch {
444        // not installed: try the next
445      }
446    }
447    if (runner === undefined) return void $.ui.toast(manual, { timeoutMs: 15_000 })
448    $.ui.toast(`Moddeck · updating to ${latest}…`)
449    const out = await $.process.run(installArgv(runner, latest), { timeoutMs: 300_000 })
450    if (out.exitCode === 0) {
451      await update($, updateAvail, () => null)
452      $.ui.toast(`Moddeck ${latest} installed · restart Claude Code to use it`, { timeoutMs: 15_000 })
453    } else {
454      $.ui.log(oneLine(`Moddeck update failed (exit ${out.exitCode})\n${(out.stderr || out.stdout).split('\n').slice(-8).join('\n')}`))
455      $.ui.toast(manual, { timeoutMs: 15_000 })
456    }
457  } catch {
458    $.ui.toast(manual, { timeoutMs: 15_000 })
459  }
460}
461
462/** Once per session, from a timer: open where there is room; whether it docked is settled by its first drawing. */
463async function autoOpenPane($: Dollar): Promise<void> {
464  if ((await read($, autoOpen)) !== 'new') return
465  await update($, autoOpen, () => 'done')
466  if (await isPaneOpen($)) return
467  const opened = await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
468  if (opened.isPlaced) await update($, autoOpen, () => 'checking')
469  else await $.ui.close({ id: PANE })
470}
471
472/** The expand button: close and reopen the pane at the other width; if there is no room for it, put it back as it was. */
473async function expand($: Dollar): Promise<void> {
474  try {
475    const isWide = await read($, wide)
476    await $.ui.close({ id: PANE })
477    const opened = await $.ui.open({ id: PANE, title: TITLE, columns: isWide ? COLUMNS : WIDE_COLUMNS })
478    if (opened.isPlaced) {
479      await update($, wide, () => !isWide)
480      return
481    }
482    await $.ui.close({ id: PANE })
483    await $.ui.open({ id: PANE, title: TITLE, columns: isWide ? WIDE_COLUMNS : COLUMNS })
484    $.ui.toast('Moddeck · no room to resize')
485  } catch {
486    $.ui.toast('Moddeck · could not resize')
487  }
488}
489
490async function settleAutoOpen($: Dollar, placement: 'dock' | 'inline'): Promise<void> {
491  if ((await read($, autoOpen)) !== 'checking') return
492  await update($, autoOpen, () => 'done')
493  if (placement !== 'dock') await $.ui.close({ id: PANE })
494}
495
496
497// ---- gathering the Session box's figures: every source is tried on its own and a failure only drops its row ----------------
498async function attempt<T>(f: () => Promise<T>): Promise<T | undefined> {
499  try {
500    return await f()
501  } catch {
502    return undefined
503  }
504}
505
506async function storedText($: Dollar, key: string): Promise<string | undefined> {
507  const v = await attempt(() => $.store.get(key))
508  return typeof v === 'string' ? v : undefined
509}
510
511async function nowOf($: Dollar): Promise<number> {
512  const t = await attempt(() => $.clock.now())
513  return typeof t === 'number' && Number.isFinite(t) ? t : Date.now()
514}
515
516function contextOf(c: { tokens?: number; window?: number; percent?: number } | undefined): MeasureAtom['context'] {
517  return { tokens: c?.tokens, window: c?.window, percent: c?.percent }
518}
519
520/** `git status` and `rev-parse`, read-only and short; not a repository (or no git) leaves the atom null. */
521async function refreshGit($: Dollar): Promise<void> {
522  let info: GitInfo | null = null
523  try {
524    const st = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 4000 })
525    if (st.exitCode === 0) info = parseStatusV2(st.stdout) ?? null
526    if (info) {
527      const rev = await attempt(() => $.process.run(['git', 'rev-parse', '--git-dir', '--git-common-dir'], { timeoutMs: 2000 }))
528      const worktree = rev && rev.exitCode === 0 ? linkedWorktree(rev.stdout) : undefined
529      if (worktree) info = { ...info, worktree }
530    }
531  } catch {
532    info = null
533  }
534  const dir = await attempt(() => $.session.cwd())
535  if (typeof dir === 'string') await safe(() => update($, cwdState, () => dir))
536  await safe(() => update($, gitState, () => info))
537  if (info === null) return // no repository: the backup row needs git anyway
538  // The newest of the last commit and the last checkpoint: what a dirty tree could be recovered from.
539  const stamp = async (argv: string[]) => {
540    const r = await attempt(() => $.process.run(argv, { timeoutMs: 3000 }))
541    const n = r && r.exitCode === 0 ? Number(r.stdout.trim().split('\n')[0]) : NaN
542    return Number.isFinite(n) && n > 0 ? n * 1000 : undefined
543  }
544  const times = [await stamp(['git', '--no-optional-locks', 'log', '-1', '--format=%ct']), await stamp(['git', '--no-optional-locks', 'for-each-ref', '--sort=-creatordate', '--count=1', '--format=%(creatordate:unix)', 'refs/moddeck/checkpoints'])].filter((t): t is number => t !== undefined)
545  await safe(() => update($, backupAt, () => (times.length > 0 ? Math.max(...times) : null)))
546}
547
548/**
549 * The app's colours: `/moddeck theme` (this session's, else saved), else the `appTheme` setting. A mod cannot see the
550 * app's own light or dark choice, so the person picks it.
551 */
552async function inkOf($: Dollar, setting: ThemePick): Promise<Ink> {
553  const stored = await storedText($, 'theme')
554  return INK[(await read($, themePick)) ?? (isThemePick(stored) ? stored : setting)]
555}
556
557/** `/moddeck theme light|dark`: the side bar's colours in the app, kept for later sessions. */
558async function themeCommand($: Dollar, words: string[]): Promise<string> {
559  const want = words[0]
560  if (words.length !== 1 || !isThemePick(want)) return 'Moddeck · /moddeck theme light or dark'
561  await setTheme($, want)
562  return `Moddeck · theme ${want}`
563}
564
565/** The light or dark colours, now and in later sessions: `/moddeck theme`, or the sun and moon at the top of the side bar. */
566async function setTheme($: Dollar, want: ThemePick): Promise<void> {
567  await update($, themePick, () => want)
568  await attempt(() => $.store.set('theme', want))
569}
570
571/** The permission mode set in the settings (or a config row, where the menu has one). The mode Shift+Tab switches to mid-session is not readable by a mod. */
572async function readPermissionMode($: Dollar): Promise<string | undefined> {
573  const ok = (v: unknown): v is string => typeof v === 'string' && v !== ''
574  const rows = await attempt(() => $.config.list())
575  const row = rows?.find(r => /permission.?mode|default.?mode/i.test(r.key))
576  if (row && ok(row.value)) return row.value
577  const settings = (await attempt(() => $.settings.read())) as { permissions?: { defaultMode?: unknown }; defaultMode?: unknown } | undefined
578  const fromSettings = settings?.permissions?.defaultMode ?? settings?.defaultMode
579  return ok(fromSettings) && fromSettings !== 'default' ? fromSettings : undefined // the ordinary mode is not worth a row
580}
581
582/** CLAUDE.md files (the project root and its parents up to home, plus ~/.claude/CLAUDE.md), rules, MCP servers, hooks, plugins. */
583async function countEnvironment($: Dollar): Promise<EnvCounts> {
584  const out: EnvCounts = {}
585  const home = await attempt(() => $.env.get('HOME'))
586  const found = await attempt(() => $.fs.ancestors({ names: ['CLAUDE.md'] }))
587  if (found) {
588    const inHome = found.filter(f => !home || f.dir === home || f.dir.startsWith(`${home}/`))
589    let n = inHome.length
590    if (home) {
591      const own = await attempt(() => $.fs.read(`${home}/.claude/CLAUDE.md`))
592      if (typeof own === 'string') n++
593    }
594    out.claudeMd = n
595  }
596  const rules = await attempt(() => $.fs.list('.claude/rules'))
597  if (rules) out.rules = rules.filter(r => r.kind === 'file' && r.name.endsWith('.md')).length
598  const settings = (await attempt(() => $.settings.read())) as Record<string, unknown> | undefined
599  const servers = new Set<string>()
600  const norm = (n: string) => n.replace(/[^a-zA-Z0-9]/g, '_')
601  const tools = await attempt(() => $.tool.list())
602  for (const t of tools ?? []) {
603    const name = t.mcp ? t.name.slice(5).split('__')[0] : undefined
604    if (name) servers.add(norm(name))
605  }
606  if (settings?.mcpServers && typeof settings.mcpServers === 'object') for (const k of Object.keys(settings.mcpServers)) servers.add(norm(k))
607  if (tools !== undefined || settings !== undefined) out.mcp = servers.size
608  if (settings) {
609    const hooks = settings.hooks
610    if (hooks && typeof hooks === 'object') {
611      out.hooks = Object.values(hooks as Record<string, unknown>).reduce<number>(
612        (n, entries) => n + (Array.isArray(entries) ? entries.reduce<number>((m, en) => m + (Array.isArray((en as { hooks?: unknown })?.hooks) ? (en as { hooks: unknown[] }).hooks.length : 1), 0) : 0),
613        0,
614      )
615    }
616    const plugins = settings.enabledPlugins
617    if (plugins && typeof plugins === 'object') out.plugins = Object.values(plugins as Record<string, unknown>).filter(v => v === true).length
618  }
619  return out
620}
621
622/** Once per session, from a timer: load what /moddeck rows saved, and read the model, provider, start time, environment and git. */
623async function primeSession($: Dollar): Promise<void> {
624  const rows = await storedText($, 'rows')
625  if (rows !== undefined) await safe(() => update($, rowsList, () => rows))
626  const layout = await storedText($, 'layout')
627  if (layout === 'compact' || layout === 'expanded') await safe(() => update($, layoutPick, () => layout))
628
629  const usage = await attempt(() => $.session.usage())
630  const model = (await attempt(async () => ((await $.config.list()).find(r => r.key === 'model')?.value))) ?? (await attempt(() => $.session.model()))
631  const env = async (name: string, f: () => Promise<string | undefined>) => (await attempt(f)) ?? ((await attempt(() => $.settings.read())) as { env?: Record<string, string> } | undefined)?.env?.[name]
632  const provider = providerOf({
633    bedrock: await env('CLAUDE_CODE_USE_BEDROCK', () => $.env.get('CLAUDE_CODE_USE_BEDROCK')),
634    vertex: await env('CLAUDE_CODE_USE_VERTEX', () => $.env.get('CLAUDE_CODE_USE_VERTEX')),
635    foundry: await env('CLAUDE_CODE_USE_FOUNDRY', () => $.env.get('CLAUDE_CODE_USE_FOUNDRY')),
636    baseUrl: await env('ANTHROPIC_BASE_URL', () => $.env.get('ANTHROPIC_BASE_URL')),
637  })
638  await safe(() =>
639    update($, meta, m => ({
640      ...m,
641      model: m.model ?? (typeof model === 'string' && model !== '' ? model : undefined),
642      provider,
643      startedAt: typeof usage?.startedAt === 'number' ? usage.startedAt : m.startedAt,
644    })),
645  )
646  if (usage) await safe(() => update($, measure, m => m ?? { context: contextOf(usage.context), rateLimits: usage.rateLimits ?? [], cost: usage.cost?.usd }))
647  const counts = await attempt(() => countEnvironment($))
648  if (counts) await safe(() => update($, envCounts, () => counts))
649  const mode = await readPermissionMode($)
650  if (mode !== undefined) await safe(() => update($, permMode, () => mode))
651  await refreshGit($)
652}
653
654/** The biggest used categories of the window, from the engine's own local estimate (`summary`: no model or token-count call). */
655async function breakdownOf($: Dollar): Promise<{ name: string; tokens: number }[] | undefined> {
656  const u = await attempt(() => $.session.usage({ breakdown: 'summary' }))
657  const cats = u?.context.breakdown?.categories
658  if (!cats) return undefined
659  return cats
660    .filter(c => c.kind === 'used' && c.tokens > 0)
661    .sort((a, b) => b.tokens - a.tokens)
662    .slice(0, 3)
663    .map(c => ({ name: c.name, tokens: c.tokens }))
664}
665
666async function observeMeasure($: Dollar, e: { context: MeasureAtom['context']; rateLimits: MeasureAtom['rateLimits']; cost?: { usd: number }; changed: readonly string[] }): Promise<void> {
667  await update($, measure, () => ({ context: contextOf(e.context), rateLimits: e.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })), cost: e.cost?.usd }))
668  const percent = e.context.percent ?? 0
669  if (percent >= 85 && e.changed.includes('context')) {
670    const breakdown = await breakdownOf($)
671    await update($, meta, m => ({ ...m, breakdown }))
672  } else if (percent < 85) {
673    const held = (await read($, meta)).breakdown
674    if (held !== undefined) await update($, meta, m => ({ ...m, breakdown: undefined }))
675  }
676}
677
678/** The settings the Session box reads, settled once at load. */
679type Conf = { showSession: boolean; layout: Layout; rows: string; theme: ThemePick; box: Omit<BoxSettings, 'width'> }
680
681/** What is shown now: the person's /moddeck rows choice (this session's, else saved), else the settings. */
682async function currentRows($: Dollar, c: Conf): Promise<RowId[]> {
683  const picked = (await read($, rowsList)) ?? (await storedText($, 'rows')) ?? c.rows
684  return parseRows(picked)
685}
686async function currentLayout($: Dollar, c: Conf): Promise<Layout> {
687  const picked = (await read($, layoutPick)) ?? (await storedText($, 'layout'))
688  return picked === 'compact' || picked === 'expanded' ? picked : c.layout
689}
690
691/** `/moddeck rows ...`: change the Session box and keep it (in this plugin's store). Unknown words change nothing. */
692async function rowsCommand($: Dollar, c: Conf, words: string[]): Promise<string> {
693  const [verb, id] = [words[0] ?? 'list', words[1]?.toLowerCase()]
694  const shownNow = await currentRows($, c)
695  const describe = async () => {
696    const shown = await currentRows($, c)
697    const hidden = ROW_IDS.filter(r => !shown.includes(r))
698    return `Rows: ${shown.join(', ') || 'none'}${hidden.length > 0 ? `\nHidden: ${hidden.join(', ')}` : ''}\nLayout: ${await currentLayout($, c)}`
699  }
700  if (verb === 'list' && words.length <= 1) return describe()
701  if ((verb === 'compact' || verb === 'expanded') && words.length === 1) {
702    await update($, layoutPick, () => verb)
703    await attempt(() => $.store.set('layout', verb))
704    return `Moddeck · rows ${verb}`
705  }
706  if ((verb === 'hide' || verb === 'show') && words.length === 2 && id !== undefined && (ROW_IDS as readonly string[]).includes(id)) {
707    const next = (verb === 'hide' ? hideRow(shownNow, id as RowId) : showRow(shownNow, id as RowId)).join(',')
708    await update($, rowsList, () => next)
709    await attempt(() => $.store.set('rows', next))
710    return `Moddeck · ${verb === 'hide' ? 'hid' : 'showing'} ${id}\n${await describe()}`
711  }
712  return HELP
713}
714
715
716/** Everything the Session box could show right now; a source that fails or is absent leaves its field out. */
717async function snapshotOf($: Dollar): Promise<Snapshot> {
718  const now = await nowOf($)
719  let m = await read($, measure)
720  const held = await read($, meta)
721  let startedAt = held.startedAt
722  if (m === null || startedAt === undefined) {
723    const u = await attempt(() => $.session.usage())
724    if (u) {
725      startedAt = typeof u.startedAt === 'number' ? u.startedAt : startedAt
726      m = m ?? { context: contextOf(u.context), rateLimits: u.rateLimits ?? [], cost: u.cost?.usd }
727    }
728  }
729  const ledger = await valueOf($.state.get(LEDGER as never))
730  const cache = await valueOf($.state.get(CACHE as never))
731  const obj = (v: unknown) => (v && typeof v === 'object' ? (v as Record<string, unknown>) : undefined)
732  const l = obj(ledger)
733  const c = obj(cache)
734  const nn = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : v === null ? null : undefined)
735  return {
736    now,
737    model: held.model,
738    provider: held.provider,
739    effort: held.effort,
740    startedAt,
741    context: m?.context,
742    breakdown: held.breakdown,
743    rateLimits: m?.rateLimits,
744    cost: m?.cost,
745    ledger: l ? { today: nn(l.today) ?? undefined, week: nn(l.week) } : undefined,
746    cache: c ? { expiresAt: nn(c.expiresAt), hitRate: nn(c.hitRate) } : undefined,
747    git: await read($, gitState),
748    permissionMode: await read($, permMode),
749    backupAt: await read($, backupAt),
750    routedTo: ((v: unknown) => (typeof v === 'string' && v !== '' ? v : undefined))(await valueOf($.state.get(ROUTED_TO as never))),
751    effortAdvice: ((v: unknown) => (typeof v === 'string' && v !== '' ? v : undefined))(await valueOf($.state.get(EFFORT_ADVICE as never))),
752    tokens: await read($, tokenTotals),
753    activity: await read($, activity),
754    env: await read($, envCounts),
755  }
756}
757
758
759export const register: Register = (on, options) => {
760  const isAutoOpen = options.autoOpen !== false
761  const rows = typeof options.skillRows === 'number' && options.skillRows >= 1 ? Math.floor(options.skillRows) : DEFAULT_SKILL_ROWS
762  const conf: Conf = {
763    showSession: options.showSession !== false,
764    layout: options.sessionLayout === 'compact' ? 'compact' : 'expanded',
765    rows: typeof options.sessionRows === 'string' ? options.sessionRows : DEFAULT_ROWS,
766    theme: isThemePick(options.appTheme) ? options.appTheme : 'dark',
767    box: {
768      sevenDayAt: num(options.usageSevenDayAt, DEFAULT_BOX.sevenDayAt),
769      toolsMaxVisible: Math.max(1, Math.floor(num(options.toolsMaxVisible, DEFAULT_BOX.toolsMaxVisible))),
770      pushWarn: Math.max(1, num(options.pushWarn, DEFAULT_BOX.pushWarn)),
771      envThreshold: num(options.envThreshold, DEFAULT_BOX.envThreshold),
772    },
773  }
774  const showSession = conf.showSession
775  updateCheckOn = options.updateCheck !== false
776  const btwMax = maxCharsOf(options.btwMaxChars)
777
778  on('session.start', async ($, e, next) => {
779    try {
780      await $.command.register({ name: 'moddeck', description: 'Show or hide the moddeck side bar; "rows" sets what its Session box shows' })
781    } catch {
782      // the side bar can still be opened another way
783    }
784    try {
785      await $.command.register({ name: 'btw', description: 'Ask a side question in the moddeck side bar; the answer is not added to the conversation: /btw <question>', argumentHint: '<question>', immediate: true })
786    } catch {
787      // without it the box at the bottom of the side bar still takes questions
788    }
789    if (updateCheckOn) defer($, async () => void (await checkForUpdate($, false))) // every time it is turned on: a quiet look at the newest release
790    if (showSession) defer($, () => primeSession($)) // reading only: never blocks the start
791    defer($, () => readSkills($)) // the Skills box lists every installed skill
792    if (isAutoOpen && e.isInteractive && e.surface !== null && PANE_SURFACES.has(e.surface)) defer($, () => autoOpenPane($))
793    return next(e)
794  })
795
796  on('command.run', { command: 'moddeck' }, async ($, e) => {
797    const words = String((e as { args?: unknown }).args ?? '').trim().split(/\s+/).filter(Boolean)
798    if (words.length === 0) return { text: await toggle($) }
799    if (words[0]!.toLowerCase() === 'update') return { text: await checkForUpdate($, true) }
800    if (words[0]!.toLowerCase() === 'theme') return { text: await themeCommand($, words.slice(1).map(w => w.toLowerCase())) }
801    if (words[0]!.toLowerCase() === 'rows') return { text: await rowsCommand($, conf, words.slice(1).map(w => w.toLowerCase())) }
802    return { text: HELP }
803  }).catch(() => ({ text: FAILED }))
804
805  on('command.run', { command: 'btw' }, async ($, e) => ({ text: await askBtw($, btwMax, String((e as { args?: unknown }).args ?? '')) })).catch(() => ({ text: 'Side Question · could not ask' }))
806
807  // ---- observers for the Session box: each lets the event through first (or, for a step, before it), and only keeps books ----
808  on('session.measure', async ($, e, next) => {
809    const out = await next(e)
810    try {
811      await observeMeasure($, e as never)
812    } catch {
813      // the box shows the last figures it had
814    }
815    return out
816  })
817
818  // A step streams, so this hook is a generator that forwards the stream untouched; the books are kept from a timer, off its path.
819  on('turn.step', async function* ($, e, next) {
820    try {
821      defer($, async () => {
822        if (e.agentId !== undefined) return // a subagent's model is not the session's
823        const held = await read($, meta)
824        if (held.model !== e.model || held.effort !== e.effort) await update($, meta, m => ({ ...m, model: e.model, effort: e.effort }))
825      })
826    } catch {
827      // the model row keeps what it had
828    }
829    return yield* next(e)
830  })
831
832  on('turn.complete', async ($, e, next) => {
833    const out = await next(e)
834    try {
835      if (e.agentId === undefined) {
836        const u = e.usage
837        if (u) {
838          const tps = e.durationMs >= 500 && u.output_tokens > 0 ? u.output_tokens / (e.durationMs / 1000) : undefined
839          await update($, tokenTotals, t => ({
840            input: (t?.input ?? 0) + u.input_tokens,
841            output: (t?.output ?? 0) + u.output_tokens,
842            cacheRead: (t?.cacheRead ?? 0) + u.cache_read_input_tokens,
843            cacheWrite: (t?.cacheWrite ?? 0) + u.cache_creation_input_tokens,
844            tokensPerSecond: tps ?? t?.tokensPerSecond,
845          }))
846        }
847        if (showSession) defer($, () => refreshGit($))
848      }
849    } catch {
850      // the totals keep their last value
851    }
852    return out
853  })
854
855  on('tool.call', async ($, e, next) => {
856    const id = String((e as { tool_use_id?: unknown }).tool_use_id ?? '')
857    const name = String(e.tool)
858    const input = e as unknown as Record<string, unknown>
859    const target = name === 'Skill' && typeof input.skill === 'string' ? input.skill : targetOf(input)
860    try {
861      void update($, activity, a => startRun(a, { id, name, target, state: 'running' })).catch(() => undefined)
862    } catch {
863      // nothing is kept for this call
864    }
865    const out = await next(e)
866    try {
867      const failed = (out as { isError?: boolean; deny?: unknown } | undefined)?.isError === true || (out as { deny?: unknown } | undefined)?.deny !== undefined
868      await update($, activity, a => finishRun(a, id, failed ? 'error' : 'done'))
869    } catch {
870      // a call may stay marked running
871    }
872    return out
873  }).catch(($, e, next) => next(e)) // a failed book-keeping never refuses the call
874
875  on('session.compact', async ($, e, next) => {
876    const out = await next(e)
877    try {
878      const done = e.agentId === undefined && e.trigger !== 'precompute' && typeof (out as { skip?: unknown } | undefined)?.skip !== 'string'
879      if (done) await update($, activity, a => ({ ...a, compactions: a.compactions + 1 }))
880    } catch {
881      // the count keeps its last value
882    }
883    return out
884  }).catch(($, e, next) => next(e))
885
886  // The mouse wheel over a long list (Skills, Prompts) scrolls that list; anywhere else it scrolls the pane as usual.
887  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
888    const hit = e.pointer === undefined ? undefined : listAt(bottomLists, e.contentRows, paneOffset + e.pointer.row)
889    const list = hit === undefined ? undefined : scrollables[hit.id]
890    if (hit === undefined || list === undefined || e.by === 0) return next(e)
891    await update($, tops, t => ({ ...t, [hit.id]: clampTop((t[hit.id] ?? 0) + e.by, list.total, list.shown) }))
892    return {}
893  }).catch(($, e, next) => next(e)) // a failed list scroll still scrolls the pane
894
895  // The line above the prompt: the folder and the branch, like the path line in RStudio's console.
896  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
897    const [dir, g] = [await read($, cwdState), await read($, gitState)]
898    if (dir === '' && !g) return next(e)
899    const { Box, Text } = $.ui.resolve(e)
900    const home = dir.replace(/^\/(Users|home)\/[^/]+/, '~')
901    const line = (
902      <Box key="moddeck-where" columnGap={1}>
903        {g && <Text bold color={isDirty(g) ? 'warning' : 'success'}>{`⎇ ${g.branch}${isDirty(g) ? '*' : ''}`}</Text>}
904        {home !== '' && <Text dimColor wrap="truncate-start">{home}</Text>}
905      </Box>
906    )
907    let below: unknown
908    try {
909      below = await next(e)
910    } catch {
911      below = undefined
912    }
913    return below !== null && typeof below === 'object' ? (
914      <Box key="moddeck-where-band" flexDirection="column">
915        {line}
916        {below as never}
917      </Box>
918    ) : (
919      line
920    )
921  }).catch(($, e, next) => next(e))
922
923  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
924    const R = $.ui.resolve(e) as ReturnType<typeof $.ui.resolve> & { Input?: (p: Record<string, unknown>) => unknown }
925    const { Box, Button, Select, Text } = R
926    const Input = R.Input
927    if ((await read($, autoOpen)) === 'checking') defer($, () => settleAutoOpen($, e.props.placement))
928    const isInline = e.props.placement === 'inline' || e.viewport?.isFullscreen === false
929    if (e.surface === 'terminal' && isInline) return <Text dimColor>{HINT}</Text>
930    paneOffset = e.props.scroll?.offset ?? 0
931    // The desktop app lays boxes out like a web page: a box never shrinks below its text unless told it may (minWidth 0),
932    // and text past its box is clipped (with "…") only when the box hides overflow. It also draws a Button's `label`
933    // instead of its children, so there a Button gets no label and a switch's colour is the box around its name.
934    // The terminal's tree is left exactly as it was.
935    const desk = e.surface === 'desktop'
936    const fit = desk ? ({ minWidth: 0, overflow: 'hidden' } as const) : {}
937    // The app's boxes are filled and their text coloured from INK; the terminal keeps its theme's colours.
938    const ink = desk ? await inkOf($, conf.theme) : undefined
939    const fill = ink ? { backgroundColor: ink.fill } : {}
940    const inked = ink ? { color: ink.text } : {}
941    const dim = ink ? { color: ink.desc } : { dimColor: true }
942    const link = ink?.link ?? 'suggestion'
943    const cols = Math.max(20, e.props.bodyColumns)
944    const room = Math.max(10, cols - CARD_CHROME)
945
946    // Every line is one row: coloured parts inside one Text, so a pane narrower than the line cuts it with "…".
947    const partsLine = (key: string, parts: readonly Part[]) => (
948      <Text key={key} wrap="truncate-end">
949        {parts.map((p, i) => (
950          <Text key={`${key}:${i}`} color={p.color ?? (ink ? (p.dim ? ink.desc : ink.text) : undefined)} dimColor={ink ? undefined : p.dim} bold={p.bold}>
951            {p.text}
952          </Text>
953        ))}
954      </Text>
955    )
956    // A card: a bold title, then a round box whose rows are parted by thin rules (the look of the Session box).
957    const rule = (key: string) => (
958      <Text key={key} {...dim} wrap="truncate-end">
959        {'─'.repeat(300)}
960      </Text>
961    )
962    // A card: its title with a ▾ that folds the card down to the title alone (▸ opens it again), then its bordered box.
963    const foldedNow = await foldedPicked($)
964    const card = (key: string, title: string, rowsOf: unknown[], opts: { width?: number | string; rules?: boolean; count?: number } = {}) => {
965      const isFolded = foldedNow.includes(key)
966      const shownTitle = isFolded && opts.count !== undefined && !title.includes(' · ') ? `${title} · ${opts.count}` : title
967      return (
968        <Box key={key} flexDirection="column" width={opts.width} flexShrink={1} {...fit}>
969          <Box columnGap={1} {...fit}>
970            <Button key={`title:${key}`} plain label={desk ? undefined : isFolded ? `open ${title}` : `fold ${title}`} onPress={() => void flipFold($, key)}>
971              <Text bold wrap="truncate-end">
972                {shownTitle}
973              </Text>
974            </Button>
975            <Button key={`fold:${key}`} plain label={desk ? undefined : isFolded ? `open ${title}` : `fold ${title}`} onPress={() => void flipFold($, key)}>
976              <Text color={link}>{isFolded ? '▸' : '▾'}</Text>
977            </Button>
978          </Box>
979          {!isFolded && (
980            <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1} {...fill} {...fit}>
981              {(opts.rules === false ? rowsOf : rowsOf.flatMap((r, i) => (i === 0 ? [r] : [rule(`${key}:rule:${i}`), r]))) as never}
982            </Box>
983          )}
984        </Box>
985      )
986    }
987
988    // The Session box at the top: live figures from the engine, each row hidden when its figure is missing.
989    let sessionBox: unknown = null
990    if (showSession) {
991      try {
992        const settings: BoxSettings = { ...conf.box, width: room }
993        const built = buildRows(await snapshotOf($), settings)
994        const shown = (await currentRows($, conf)).map(id => built[id]).filter((r): r is NonNullable<typeof r> => r !== undefined)
995        const lines = layoutLines(shown, await currentLayout($, conf), settings.width)
996        if (lines.length > 0) {
997          sessionBox = (
998            <Box key="session" flexDirection="column" borderStyle="round" paddingX={1} {...fill} {...fit}>
999              <Text bold {...inked}>
1000                Session
1001              </Text>
1002              {lines.map(l => (
1003                <Box key={`session:row:${l.key}`} {...fit}>
1004                  {partsLine(`session:line:${l.key}`, l.parts)}
1005                </Box>
1006              ))}
1007            </Box>
1008          )
1009        }
1010      } catch {
1011        sessionBox = null // the side bar still draws without it
1012      }
1013    }
1014
1015    // The Side Question panel, right under the Session box: one line until clicked, then the last answers and a field.
1016    const btws = await read($, btwList)
1017    const isBtwOpen = await read($, btwOpen)
1018    // The Side Question box: its title (hover it for what it does), an "Ask here" field beside it, and the last answers below.
1019    const thinking = btws.some(b => b.status === 'thinking')
1020    const draft = await read($, btwDraft)
1021    const ask = (v: string) => {
1022      void update($, btwDraft, () => '')
1023      void askBtw($, btwMax, v).then(t => $.ui.toast(t.split('\n')[0] ?? t))
1024    }
1025    // Hovering the title lights the "btw-tip" group, which reveals the tip card laid over the box just below the title row.
1026    const btwTitle = (
1027      <Box key="btw:title" flexShrink={0} hover={{ scope: 'btw-tip' }}>
1028        <Text bold {...inked}>{`Side Question${thinking ? ' …' : ''}`}</Text>
1029      </Box>
1030    )
1031    const btwTip = (
1032      <Box position="absolute" top={1} left={0} right={0} display="none" hover={{ scope: 'btw-tip', display: 'flex' }} borderStyle="round" paddingX={1} {...fill}>
1033        <Text {...dim} wrap="wrap">
1034          Ask about this session without adding anything to the chat. The answer shows here only. $ spends model tokens.
1035        </Text>
1036      </Box>
1037    )
1038    const btwHead = (
1039      <Box key="btw:head" columnGap={1} alignItems="center" {...fit}>
1040        {btws.length > 0 && (
1041          <Button key="btw:toggle" plain label={desk ? undefined : isBtwOpen ? 'hide answers' : 'show answers'} onPress={() => void update($, btwOpen, o => !o)}>
1042            <Text {...inked}>{isBtwOpen ? "▾" : `▸ ${btws.length}`}</Text>
1043          </Button>
1044        )}
1045        {btwTitle}
1046        <Box flexGrow={1} flexShrink={1} {...fit}>
1047          {Input !== undefined ? (
1048            <Input key="btw:input" placeholder="Ask here" submitLabel="ask" value={draft} onInput={(v: string) => void update($, btwDraft, () => v)} onSubmit={(v: string) => ask(v)} />
1049          ) : (
1050            <Text {...dim} wrap="truncate-end">
1051              type /btw your question
1052            </Text>
1053          )}
1054        </Box>
1055      </Box>
1056    )
1057    const btwBox = (
1058      <Box key="btw" flexDirection="column" borderStyle="round" paddingX={1} {...fill} {...fit}>
1059        {btwHead}
1060        {btwTip}
1061        {isBtwOpen &&
1062          btws.slice(0, 2).map(b => (
1063            <Box key={`btw:${b.id}`} flexDirection="column">
1064              <Text color={link} wrap="truncate-end">{`› ${headline(b.question, room)}`}</Text>
1065              {b.answer !== '' && (
1066                <Text {...inked} wrap="wrap">
1067                  {b.answer}
1068                </Text>
1069              )}
1070              <Text {...dim} wrap="truncate-end">
1071                {footer(b)}
1072              </Text>
1073              {b.status !== 'thinking' && (
1074                <Button key={`btw:close:${b.id}`} plain onPress={() => {
1075                    void update($, btwList, l => l.filter(a => a.id !== b.id))
1076                    void update($, btwDraft, () => '') // a fresh field for the next question
1077                  }}>
1078                  <Text color={link}>✕ close</Text>
1079                </Button>
1080              )}
1081            </Box>
1082          ))}
1083      </Box>
1084    )
1085
1086    // Switches: only the ones whose state this side bar can read. Each switch reads its own mod's state; a mod that is not
1087    // installed (or not readable) is simply not drawn.
1088    const flag = (v: unknown, isOff: boolean): boolean | undefined => (typeof v === 'boolean' ? (isOff ? !v : v) : undefined)
1089    const ons: Record<string, boolean | undefined> = {
1090      'model-picker': flag(await valueOf($.state.get(SUGGEST_OFF as never)), true),
1091      reflect: flag(await valueOf($.state.get(REFLECT_OFF as never)), true),
1092      'secret-guard': flag(await valueOf($.state.get(SECRET_ON as never)), false),
1093      'data-guard': flag(await valueOf($.state.get(DATA_OFF as never)), true),
1094      'big-file-guard': flag(await valueOf($.state.get(BIGFILE_OFF as never)), true),
1095      'repro-log': flag(await valueOf($.state.get(REPRO_OFF as never)), true),
1096      'auto-test': flag(await valueOf($.state.get(AUTOTEST_OFF as never)), true),
1097      'long-run-ping': flag(await valueOf($.state.get(PING_OFF as never)), true),
1098      'review-cost': flag(await valueOf($.state.get(REVIEW_OFF as never)), true),
1099      'format-on-edit': flag(await valueOf($.state.get(FORMAT_OFF as never)), true),
1100      'test-ledger': flag(await valueOf($.state.get(LEDGER_OFF as never)), true),
1101      'error-inbox': flag(await valueOf($.state.get(INBOX_OFF as never)), true),
1102      concise: flag(await valueOf($.state.get(CONCISE_OFF as never)), true),
1103      tldr: flag(await valueOf($.state.get(TLDR_OFF as never)), true),
1104      'branch-janitor': flag(await valueOf($.state.get(JANITOR_OFF as never)), true), // null outside a repository: not drawn
1105      'progress': flag(await valueOf($.state.get(BAND_HIDDEN as never)), true),
1106      // cache-guard's switch is keep-warm: on while /keepwarm runs (its state holds the run), off when it is null.
1107      'cache-guard': await (async () => { const kw = await valueOf($.state.get(KEEPWARM as never)); return kw === undefined ? undefined : kw !== null })(),
1108    }
1109    const blast = await valueOf($.state.get(BLAST_LEVEL as never))
1110    const chosen = await read($, picks)
1111    // The level the dropdown shows: the person's last pick, else the mod's own state; undefined = the mod is not readable.
1112    // The advisor is Claude Code's own (/advisor): its model is the advisorModel setting; two variables keep it off.
1113    const settingsNow = (await attempt(() => $.settings.read())) as { advisorModel?: unknown; env?: Record<string, string> } | undefined
1114    const savedAdvisor = typeof settingsNow?.advisorModel === 'string' && settingsNow.advisorModel !== '' ? settingsNow.advisorModel : 'off'
1115    const envOf = (v: string | undefined, fromSettings: string | undefined) => (v !== undefined && v !== "" ? v : fromSettings)
1116    const noTelemetry = envOf(await attempt(() => $.env.get('DISABLE_TELEMETRY')), settingsNow?.env?.DISABLE_TELEMETRY)
1117    const noAdvisor = envOf(await attempt(() => $.env.get('CLAUDE_CODE_DISABLE_ADVISOR_TOOL')), settingsNow?.env?.CLAUDE_CODE_DISABLE_ADVISOR_TOOL)
1118    const advisorBlock = noAdvisor === '1' ? 'CLAUDE_CODE_DISABLE_ADVISOR_TOOL' : noTelemetry === '1' ? 'DISABLE_TELEMETRY' : undefined
1119    // tldr's level: off, or the mode it is in (highlight or summary); undefined while the mod is not readable.
1120    const tldrModeNow = await valueOf($.state.get(TLDR_MODE as never))
1121    const levelOf: Record<string, string | undefined> = {
1122      'command-guard': chosen['command-guard'] ?? (typeof blast === 'string' ? blast : undefined),
1123      advisor: savedAdvisor, // the setting: /advisor writes it, a pick here only fills the prompt box
1124      tldr: ons.tldr === undefined ? undefined : ons.tldr ? (tldrModeNow === 'highlight' ? 'highlight' : 'summary') : 'off',
1125    }
1126    // A level the list does not name (a saved "sonnet") is still shown as picked.
1127    const levelsOf = (t: Tool): string[] => (t.levels !== undefined && levelOf[t.id] !== undefined && !t.levels.includes(levelOf[t.id]!) ? [...t.levels, levelOf[t.id]!] : (t.levels ?? []))
1128    const descOf = (t: Tool): string | undefined =>
1129      t.id === 'advisor' && advisorBlock !== undefined ? `Kept off by ${advisorBlock}=1 · pick a model to see the fix` : t.desc
1130
1131    // One line per tool: its name (a green or red pill when it is a switch), its controls, then its description, which
1132    // takes what is left and is cut with "…". The descriptions, icons and groups are presentation only.
1133    const isOnOf = (t: Tool): boolean | undefined => (t.levels !== undefined ? (levelOf[t.id] === undefined ? undefined : levelOf[t.id] !== 'off') : ons[t.id])
1134    const visible = (t: Tool): boolean => (t.isSwitch || t.levels !== undefined ? isOnOf(t) !== undefined : true)
1135    const actionsShown = (t: Tool): Option[] => (t.isSwitch && !isOnOf(t) ? [] : (t.options ?? []))
1136    const action = (key: string, label: string, act1: Action, costs?: boolean) => (
1137      <Button key={key} plain onPress={() => act($, act1)}>
1138        <Text color={link}>{`[${priced(label, costs)}]`}</Text>
1139      </Button>
1140    )
1141    const nameOf = (t: Tool) => priced(t.label, t.costs)
1142    const line = (t: Tool, nameWidth: number) => {
1143      const isOn = isOnOf(t)
1144      const text = pillText(nameOf(t), nameWidth)
1145      const press = () => (t.levels !== undefined ? flipLevel($, t, levelOf[t.id]!, chosen, t.id === 'advisor' ? advisorBlock : undefined) : flip($, t, isOn!))
1146      const run = () => (t.click ? act($, t.click) : undefined)
1147      // In the app every name is a pill (INK): green or red for a switch, gray for a command that runs on click. The pill box
1148      // has no padding of its own, so the whole coloured area is the button (its text carries the air on each side).
1149      // The name in a small box drawn like the group cards, tight around the name (no padding), its line dimmed for a
1150      // lighter stroke. The box itself lights on hover, so the box and the hover highlight are one area.
1151      const pillOf = (key: string, color: string, onPress: () => unknown) => (
1152        <Box key={`pill:${t.id}`} borderStyle="round" borderColor="inactive" borderDimColor backgroundColor={color} hover={{ backgroundColor: ink?.run }} paddingX={0} paddingY={0} margin={0} flexShrink={0}>
1153          <Button key={key} plain onPress={onPress as () => void}>
1154            <Text bold color={ink?.text}>
1155              {nameOf(t)}
1156            </Text>
1157          </Button>
1158        </Box>
1159      )
1160      // Every name sits in the same neutral box; a switch says on or off only by the dot after it (green on, red off).
1161      const name = desk ? (
1162        pillOf(isOn === undefined ? `tool:${t.id}` : `switch:${t.id}`, ink!.fill, isOn === undefined ? run : press)
1163      ) : (
1164        <Button key={isOn === undefined ? `tool:${t.id}` : `switch:${t.id}`} plain label={isOn === undefined ? undefined : `${t.label} switch, ${isOn ? 'on' : 'off'}`} onPress={isOn === undefined ? run : press}>
1165          <Text bold inverse>
1166            {text}
1167          </Text>
1168        </Button>
1169      )
1170      const dot = isOn !== undefined && (
1171        <Box key={`dot:${t.id}`} flexShrink={0}>
1172          <Text color={isOn ? (ink?.dotOn ?? 'success') : (ink?.dotOff ?? 'error')}>●</Text>
1173        </Box>
1174      )
1175      const level = t.levels !== undefined && (
1176        <Select key={`level:${t.id}`} value={levelOf[t.id]!} options={levelsOf(t).map(v => ({ value: v }))} onSelect={(v: string) => pick($, t.id, t.command ?? t.id, v, t.fillsPrompt === true, t.id === 'advisor' ? advisorBlock : undefined)} />
1177      )
1178      return (
1179        <Box key={`row:${t.id}`} columnGap={1} {...fit}>
1180          <Box flexShrink={0} columnGap={1}>
1181            {name}
1182            {dot as never}
1183            {level as never}
1184            {actionsShown(t).map(o => action(`option:${t.id}:${o.text}`, o.label, o, o.costs))}
1185          </Box>
1186          <Box flexGrow={1} flexShrink={1} {...fit}>
1187            <Text {...dim} wrap="truncate-end">
1188              {descOf(t) ?? t.note ?? ''}
1189            </Text>
1190          </Box>
1191        </Box>
1192      )
1193    }
1194    const groupTools = GROUPS.map(g => ({ g, tools: TOOLS.filter(t => t.group === g && visible(t)) })).filter(x => x.tools.length > 0)
1195    // One column: every card on its own line, the full width of the pane.
1196    // A group with more than GROUP_SCROLL_AT tools gets stars: starred tools first, the card sized to fit them (at least
1197    // MIN_LIST_ROWS, GROUP_SCROLL_AT when none is starred), and ▲ ▼ to scroll the rest.
1198    const groupTops = await read($, tops)
1199    const groupStars = await starsPicked($)
1200    const starOf = (t: Tool) => `tool:${t.id}`
hooks/btw.ts 84 lines
1// The "by the way" panel: pure helpers. A side question is answered over the main thread's cached context and shown in the
2// side bar only; nothing is added to the conversation. No engine access here.
3
4import type { BtwEntry } from '../types'
5
6export const MAX_KEPT = 5
7export const DEFAULT_MAX_CHARS = 1500
8/** What the fork is told ahead of the question. The engine denies every tool for it; this is the polite half. */
9export const INSTRUCTION = 'Answer the side question briefly. Do not use tools. Do not change anything.'
10export const MAX_QUESTION_CHARS = 4000
11/** A question still "thinking" this long after it started is taken as lost and no longer blocks a new one. */
12export const STALE_MS = 3 * 60_000
13
14const clip = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max).trimEnd()}…` : text)
15
16export const buildPrompt = (question: string): string => `${INSTRUCTION}\n\nSide question: ${question.slice(0, MAX_QUESTION_CHARS)}`
17export const headline = (question: string, max = 120): string => clip(question.replace(/\s+/g, ' ').trim(), max)
18
19export function maxCharsOf(value: unknown): number {
20  const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : DEFAULT_MAX_CHARS
21  return n >= 200 ? n : DEFAULT_MAX_CHARS
22}
23
24export function cutAnswer(text: string, max: number): { text: string; isCut: boolean } {
25  const body = text.trim()
26  return body.length > max ? { text: `${body.slice(0, max).trimEnd()}…`, isCut: true } : { text: body, isCut: false }
27}
28
29/** Most of the transcript the fallback sends when there is nothing to fork (a resumed or reloaded session). */
30export const DIGEST_CHARS = 60_000
31const PER_MESSAGE = 4_000
32
33/** The newest user and assistant text, oldest first, as plain "User:"/"Claude:" lines, up to `max` characters. */
34export function digest(messages: readonly { role: 'user' | 'assistant'; text: string }[], max = DIGEST_CHARS): string {
35  const out: string[] = []
36  let used = 0
37  for (let i = messages.length - 1; i >= 0; i--) {
38    const m = messages[i]!
39    const body = m.text.trim()
40    if (body === '') continue
41    const line = `${m.role === 'user' ? 'User' : 'Claude'}: ${clip(body, PER_MESSAGE)}`
42    if (used + line.length > max) break
43    out.push(line)
44    used += line.length + 2
45  }
46  return out.reverse().join('\n\n')
47}
48
49export const fallbackPrompt = (transcript: string, question: string): string =>
50  `The conversation so far (text only, newest last):\n\n${transcript}\n\n---\n\nSide question: ${question.slice(0, MAX_QUESTION_CHARS)}`
51
52type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
53export const tokensOf = (u: Usage): number => u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
54export const fmtTokens = (n: number): string => (n < 1000 ? String(Math.round(n)) : `${Math.round(n / 1000)}k`)
55
56/** The dim line under an answer. */
57export const footer = (e: BtwEntry): string =>
58  e.status === 'thinking' ? 'Thinking…' : e.status === 'failed' ? e.note : `not added to the conversation${e.tokens === null || e.tokens <= 0 ? '' : ` · ~${fmtTokens(e.tokens)} tokens est.`}${e.isCut ? ' · cut' : ''}`
59
60/** Newest first, at most MAX_KEPT. */
61export const addEntry = (list: readonly BtwEntry[], entry: BtwEntry): BtwEntry[] => [entry, ...list].slice(0, MAX_KEPT)
62export const patchEntry = (list: readonly BtwEntry[], id: number, patch: Partial<BtwEntry>): BtwEntry[] => list.map(a => (a.id === id ? { ...a, ...patch } : a))
63export const nextId = (list: readonly BtwEntry[]): number => list.reduce((m, a) => Math.max(m, a.id), 0) + 1
64export const isRunning = (list: readonly BtwEntry[], now: number): boolean => list.some(a => a.status === 'thinking' && now - a.at < STALE_MS)
65
66type Miss =
67  | { reason: 'nothing-to-fork' }
68  | { reason: 'api-error'; status: number | null; error: string }
69  | { reason: 'empty-reply' }
70  | { reason: 'aborted' }
71
72export function whyNot(miss: Miss): string {
73  switch (miss.reason) {
74    case 'nothing-to-fork':
75      return 'Nothing to ask about yet · send a message first'
76    case 'api-error':
77      return `The request failed${miss.status === null ? '' : ` (${miss.status})`} · ${miss.error.replace(/_/g, ' ')}`
78    case 'aborted':
79      return 'Cut short'
80    default:
81      return 'The model sent no text'
82  }
83}
84
hooks/catalog.ts 324 lines
1// What the side bar lists: every mod, one line each inside its group's card (name, then its controls, then a short
2// description), then the skills. Commands are always written with their "/": the slash is required everywhere now.
3//
4//  - a switch (the mods that work by themselves) is its name, green when on and red when off; a click flips it, and its
5//    actions show only while it is on;
6//  - command-guard is a switch too (off, or back to its last level) with a dropdown of levels right after the name;
7//  - any other name runs its command at once (`run`), or puts it in the prompt box when it needs your text or changes
8//    something; a tool with a single task has just its name: no button repeats it.
9
10/** A command line as typed, with its slash: `/reflect status`. A text ending in a space waits for the person to finish it. */
11export type Action = { text: string; run: boolean }
12
13export type Option = { label: string; costs?: boolean } & Action
14
15export type Group = 'Prompt helpers' | 'Guards' | 'Automation' | 'Model controls' | 'Workspace utilities' | 'UI development'
16/** In drawing order, one card under the other. */
17export const GROUPS: Group[] = ['Prompt helpers', 'Model controls', 'Guards', 'Automation', 'Workspace utilities', 'UI development']
18
19export type Tool = {
20  id: string
21  label: string
22  /** Presentation only: the card the row sits in, a one-glyph icon and a short description. None of it changes behaviour. */
23  group: Group
24  icon: string
25  desc?: string
26  /** Spends model tokens: drawn with a "$". */
27  costs?: boolean
28  /** A switch: the command to run with `on` / `off` (`/model-picker on`). */
29  command?: string
30  isSwitch?: boolean
31  /** What a click does (rows that are not switches). */
32  click?: Action
33  /** A dropdown of levels, one of them `off`: the name is green while on, red while off. The pick runs `/<command> <level>` at once. */
34  levels?: string[]
35  /** The level a switch goes back to when it is turned on again (else the first level that is not off). */
36  defaultLevel?: string
37  /** A level is set by Claude Code's own command, which a mod cannot run: a pick puts `/<command> <level>` in the prompt box instead. */
38  fillsPrompt?: true
39  /** A row with nothing to type says when it appears instead. */
40  note?: string
41  /** Buttons right after the name (keep the labels short: the description gets what is left): only while a switch is on, always for the others. */
42  options?: Option[]
43}
44
45export const TOOLS: Tool[] = [
46  {
47    id: 'model-picker',
48    label: 'model-picker',
49    group: 'Prompt helpers',
50    icon: '✦',
51    desc: 'Picks the model for new conversations and subagents; flags a mismatch',
52    isSwitch: true,
53    command: 'model-picker',
54    options: [
55      { label: 'status', text: '/model-picker', run: true },
56      { label: 'explain', text: '/model-picker explain ', run: false },
57    ],
58  },
59  {
60    id: 'reflect',
61    label: 'reflect',
62    group: 'Prompt helpers',
63    icon: '◌',
64    desc: 'Turns corrections into rules',
65    costs: true,
66    isSwitch: true,
67    command: 'reflect',
68  },
69  { id: 'tldr', label: 'tldr', group: 'Prompt helpers', icon: '⚑', desc: 'Marks what you must not skip in long replies; summary adds a box above the prompt', costs: true, command: 'tldr', levels: ['off', 'highlight', 'summary'], defaultLevel: 'summary' },
70  { id: 'concise', label: 'concise', group: 'Prompt helpers', icon: '»', desc: 'Short, action-first replies', isSwitch: true, command: 'concise' },
71  // Each mod keeps its own on/off and answers /<command> on|off; the side bar only reads their state.
72  { id: 'secret-guard', label: 'secret-guard', group: 'Guards', icon: '🔒', desc: 'Keeps secrets out of the context', isSwitch: true, command: 'secret-guard' },
73  { id: 'data-guard', label: 'data-guard', group: 'Guards', icon: '▤', desc: 'Asks before data files are committed', isSwitch: true, command: 'data-guard' },
74  { id: 'big-file-guard', label: 'big-file-guard', group: 'Guards', icon: '▢', desc: 'Cuts large files to their first lines', isSwitch: true, command: 'big-file-guard' },
75  {
76    id: 'repro-log',
77    label: 'repro-log',
78    group: 'Guards',
79    icon: '☰',
80    desc: 'Records a reproducibility recipe',
81    isSwitch: true,
82    command: 'repro',
83    options: [
84      { label: 'recipe', text: '/repro', run: true },
85      { label: 'status', text: '/repro status', run: true },
86    ],
87  },
88  {
89    id: 'auto-test',
90    label: 'auto-test',
91    group: 'Automation',
92    icon: '✓',
93    desc: 'Runs the tests after edits',
94    isSwitch: true,
95    command: 'auto-test',
96    options: [
97      { label: 'run', text: '/auto-test run', run: true },
98      { label: 'status', text: '/auto-test status', run: true },
99    ],
100  },
101  { id: 'long-run-ping', label: 'long-run-ping', group: 'Automation', icon: '◷', desc: 'Pings when a long run ends', isSwitch: true, command: 'long-run-ping' },
102  {
103    id: 'format-on-edit',
104    label: 'format-on-edit',
105    group: 'Automation',
106    icon: '¶',
107    desc: 'Formats edited files (Air, Ruff, Prettier)',
108    isSwitch: true,
109    command: 'format-on-edit',
110    options: [{ label: 'status', text: '/format-on-edit status', run: true }],
111  },
112  {
113    id: 'test-ledger',
114    label: 'test-ledger',
115    group: 'Automation',
116    icon: '≡',
117    desc: 'Test, lint and build runs and results',
118    isSwitch: true,
119    command: 'test-ledger',
120    options: [{ label: 'open', text: '/test-ledger', run: true }],
121  },
122  {
123    id: 'scope-watch',
124    label: 'scope-watch',
125    group: 'Guards',
126    icon: '⌖',
127    desc: 'Flags edits outside your folder',
128    click: { text: '/scope-watch ', run: false },
129    options: [
130      { label: 'status', text: '/scope-watch status', run: true },
131      { label: 'off', text: '/scope-watch off', run: false },
132    ],
133  },
134  { id: 'command-guard', label: 'command-guard', group: 'Model controls', icon: '◎', desc: 'Asks before risky commands', command: 'command-guard', levels: ['off', 'low', 'medium', 'high'] },
135  {
136    id: 'advisor',
137    label: 'advisor',
138    group: 'Model controls',
139    icon: '⚖',
140    desc: 'Claude consults a stronger model at hard decisions (experimental, API only)',
141    costs: true,
142    command: 'advisor',
143    levels: ['off', 'opus', 'fable'],
144    fillsPrompt: true,
145  },
146  { id: 'review-cost', label: 'review-cost', group: 'Model controls', icon: '$', desc: 'Shows review cost before a push', isSwitch: true, command: 'review-cost' },
147  {
148    id: 'checkpoint',
149    label: 'checkpoint',
150    group: 'Workspace utilities',
151    icon: '⛁',
152    desc: 'Snapshots of the working tree',
153    click: { text: '/checkpoint', run: true },
154    options: [
155      { label: 'list', text: '/checkpoint list', run: true },
156      { label: 'restore', text: '/checkpoint restore ', run: false },
157    ],
158  },
159  {
160    id: 'ssh-connect',
161    label: 'ssh',
162    group: 'Automation',
163    icon: '⇄',
164    desc: 'Opens your terminal on a host from ~/.ssh/config; you type any 2FA code there',
165    click: { text: '/ssh connect', run: true },
166    options: [
167      { label: 'shell', text: '/ssh shell', run: true },
168      { label: 'status', text: '/ssh status', run: true },
169      { label: 'off', text: '/ssh disconnect', run: true },
170    ],
171  },
172  {
173    id: 'branch-janitor',
174    label: 'branch-janitor',
175    group: 'Workspace utilities',
176    icon: '⑂',
177    desc: 'On: watches this repository and offers to delete finished branches',
178    isSwitch: true,
179    command: 'branch-janitor',
180    options: [
181      { label: 'check', text: '/branch-janitor', run: true },
182      { label: 'clean', text: '/branch-janitor clean', run: false },
183    ],
184  },
185  {
186    id: 'cache-guard',
187    label: 'cache-guard',
188    group: 'Workspace utilities',
189    icon: '♨',
190    desc: 'On: keeps the prompt cache warm (cheap pings); always warns before a cold write',
191    costs: true,
192    isSwitch: true,
193    command: 'keepwarm',
194    options: [{ label: 'report', text: '/cache-guard', run: true }],
195  },
196  { id: 'usage-meter', label: 'usage-meter', group: 'Workspace utilities', icon: '▥', desc: 'Usage line and limits', click: { text: '/usage-meter', run: true } },
197  {
198    id: 'progress',
199    label: 'progress',
200    group: 'Workspace utilities',
201    icon: '▰',
202    desc: 'On: a progress line above the prompt while subagents or plan tasks run',
203    isSwitch: true,
204    command: 'progress',
205    options: [{ label: 'pane', text: '/progress', run: true }],
206  },
207  { id: 'filetree', label: 'filetree', group: 'Workspace utilities', icon: '⌥', desc: 'File tree beside the chat', click: { text: '/filetree', run: true } },
208  { id: 'edit-replay', label: 'edit-replay', group: 'Workspace utilities', icon: '▶', desc: 'Step through the last edits', click: { text: '/replay', run: true } },
209  { id: 'diagram', label: 'diagram', group: 'Workspace utilities', icon: '◇', desc: 'Draws the last mermaid diagram', click: { text: '/diagram', run: true } },
210  { id: 'staged-diff', label: 'staged-diff', group: 'Workspace utilities', icon: '±', desc: 'What the next commit holds', click: { text: '/staged-diff', run: true } },
211  {
212    id: 'error-inbox',
213    label: 'error-inbox',
214    group: 'Workspace utilities',
215    icon: '!',
216    desc: 'The last failed tool calls',
217    isSwitch: true,
218    command: 'error-inbox',
219    options: [{ label: 'open', text: '/error-inbox', run: true }],
220  },
221  {
222    id: 'decision-log',
223    label: 'decision-log',
224    group: 'Workspace utilities',
225    icon: '✎',
226    desc: 'docs/DECISIONS.md, one row per decision',
227    click: { text: '/decision-log', run: true },
228    options: [{ label: 'add', text: '/decision ', run: false }],
229  },
230  { id: 'table-preview', label: 'table-preview', group: 'Workspace utilities', icon: '▦', desc: 'Preview a csv or tsv file', click: { text: '/table-preview ', run: false } },
231  {
232    id: 'ui-preview',
233    label: 'ui-preview',
234    group: 'UI development',
235    icon: '▣',
236    desc: 'Run the app, list plumber routes, screenshot',
237    click: { text: '/ui-preview', run: true },
238    options: [
239      { label: 'start', text: '/ui-preview start', run: false },
240      { label: 'shot', text: '/ui-preview shot', run: true },
241    ],
242  },
243  { id: 'contrast-check', label: 'contrast-check', group: 'UI development', icon: '◐', desc: 'Contrast and colour formats', click: { text: '/contrast-check ', run: false } },
244]
245
246export type Skill = { id: string; label: string; desc: string; text: string }
247
248export const SKILLS: Skill[] = [
249  { id: 'dual-model-review', label: 'dual-model-review', desc: 'A second model tries to prove a change wrong', text: 'Use the dual-model-review skill to review my last change' },
250  { id: 'agents-md-scaffold', label: 'agents-md-scaffold', desc: 'Sets up AGENTS.md, a decisions log, a rubric', text: 'Use the agents-md-scaffold skill to set up AGENTS.md for this repo' },
251  { id: 'align', label: 'align', desc: 'Interviews you, writes a spec, hands off', text: 'Use the align skill: interview me before we build ' },
252  { id: 'burst', label: 'burst', desc: 'Three graded versions of a text or idea', text: 'Use the burst skill: three versions of ' },
253  { id: 'assess-plugin', label: 'assess-plugin', desc: 'Checks a plugin before you install it', text: 'Use the assess-plugin skill to assess ' },
254  { id: 'no-ai-text', label: 'no-ai-text', desc: 'Edits AI tells out of a draft, keeps the voice', text: 'Use the no-ai-text skill on this draft: ' },
255]
256
257/** Starred items first (in list order), then the rest; unknown stars are ignored. */
258export function starFirst<T>(items: readonly T[], starred: readonly string[], idOf: (item: T) => string): T[] {
259  const on = new Set(starred)
260  return [...items.filter(i => on.has(idOf(i))), ...items.filter(i => !on.has(idOf(i)))]
261}
262
263/** Fewest lines a list shows (when it has that many items). */
264export const MIN_LIST_ROWS = 5
265
266/** Lines a list shows: enough for every starred item, else the person's pick; at least MIN_LIST_ROWS, never more than it has. */
267export function listSize(starredCount: number, picked: number, total: number): number {
268  return Math.min(total, Math.max(MIN_LIST_ROWS, starredCount > 0 ? starredCount : picked))
269}
270
271/** Starred first, then switches that are on, then the rest; the catalogue order is kept inside each band. */
272export function rankTools<T>(items: readonly T[], isStarred: (item: T) => boolean, isOn: (item: T) => boolean): T[] {
273  const band = (i: T) => (isStarred(i) ? 0 : 2) + (isOn(i) ? 0 : 1)
274  return items.map((item, at) => ({ item, at })).sort((a, b) => band(a.item) - band(b.item) || a.at - b.at).map(x => x.item)
275}
276
277/** Items whose name matches `query` first (names that start with it, then names that contain it), then the rest; and how many match. */
278export function searchFirst<T>(items: readonly T[], query: string, nameOf: (item: T) => string): { items: T[]; matches: number } {
279  const q = query.trim().toLowerCase().replace(/^\//, '')
280  if (q === '') return { items: [...items], matches: 0 }
281  const name = (i: T) => nameOf(i).toLowerCase()
282  const starts = items.filter(i => name(i).startsWith(q))
283  const contains = items.filter(i => !name(i).startsWith(q) && name(i).includes(q))
284  const rest = items.filter(i => !name(i).includes(q))
285  return { items: [...starts, ...contains, ...rest], matches: starts.length + contains.length }
286}
287
288/** Toggles one id in the starred list. */
289export const toggleStar = (starred: readonly string[], id: string): string[] => (starred.includes(id) ? starred.filter(s => s !== id) : [...starred, id])
290
291/** Lines a list shows: the person's choice for it, else the default; at least MIN_LIST_ROWS, at most MAX_LIST_ROWS. */
292export const MAX_LIST_ROWS = 30
293export const listRowsOf = (picked: Record<string, number>, id: string, fallback: number): number => Math.max(MIN_LIST_ROWS, Math.min(MAX_LIST_ROWS, Math.floor(picked[id] ?? fallback)))
294
295/** One installed skill as the engine lists it: its name and, when a slash command carries it, that command's line. */
296export type InstalledSkill = { name: string; desc?: string }
297
298/** The toolkit's own skills first, then every other installed skill by name; a plugin skill `plug:name` keeps its prefix. */
299export function allSkills(installed: readonly InstalledSkill[]): Skill[] {
300  const own = new Set(SKILLS.map(k => k.id))
301  const seen = new Set<string>()
302  const rest: Skill[] = []
303  for (const k of installed) {
304    const name = k.name.trim()
305    if (name === '' || own.has(name) || own.has(name.split(':').pop() ?? '') || seen.has(name)) continue
306    seen.add(name)
307    rest.push({ id: name, label: name, desc: (k.desc ?? '').replace(/\s+/g, ' ').trim(), text: `Use the ${name} skill ` })
308  }
309  rest.sort((a, b) => a.label.localeCompare(b.label))
310  return [...SKILLS, ...rest]
311}
312
313export const priced = (label: string, costs: boolean | undefined): string => (costs ? `$ ${label}` : label)
314
315/** `/reflect status` -> { command: 'reflect', args: 'status' }. */
316export function splitCommand(text: string): { command: string; args: string } {
317  const body = text.replace(/^\//, '').trim()
318  const at = body.indexOf(' ')
319  return at === -1 ? { command: body, args: '' } : { command: body.slice(0, at), args: body.slice(at + 1).trim() }
320}
321
322/** One saved prompt, as prompt-library keeps it. */
323export type SavedPrompt = { name: string; text: string }
324
hooks/layout.ts 61 lines
1// The side bar's layout arithmetic, kept pure so it can be tested: the scroll bar of a long list, and which list a mouse
2// wheel tick is over. Every row the pane draws is one line (text that does not fit is cut with "…", never wrapped), so
3// heights are exact counts of rows.
4
5/** Cells a card's border and padding take: a round border (2) and one cell of padding on each side (2). */
6export const CARD_CHROME = 4
7
8/** Each cell of a list's scroll bar, top to bottom: the thumb (where the window is) or the track. */
9export function scrollBar(top: number, shown: number, total: number, cells = shown): ('thumb' | 'track')[] {
10  if (cells <= 0) return []
11  if (total <= shown) return Array.from({ length: cells }, () => 'thumb')
12  const size = Math.max(1, Math.round((cells * shown) / total))
13  const maxTop = total - shown
14  const at = Math.round((Math.max(0, Math.min(top, maxTop)) / maxTop) * (cells - size))
15  return Array.from({ length: cells }, (_, i) => (i >= at && i < at + size ? 'thumb' : 'track'))
16}
17
18/** The track a list's scroll bar draws between its ▲ and ▼ arrows (one cell each); a bar under 3 rows has no arrows. */
19export const trackCells = (shown: number): number => (shown >= 3 ? shown - 2 : shown)
20
21/** One line up or down, kept inside the list. */
22export const stepTop = (top: number, by: number, shown: number, total: number): number => Math.max(0, Math.min(Math.max(0, total - shown), top + by))
23
24/** Where a click on the scroll bar's cell `cell` moves the window: a page toward it; on the thumb, a page toward the half it is in. */
25export function barTarget(top: number, shown: number, total: number, cell: number, cells = shown): number {
26  const bar = scrollBar(top, shown, total, cells)
27  const first = bar.indexOf('thumb')
28  const last = bar.lastIndexOf('thumb')
29  const maxTop = Math.max(0, total - shown)
30  if (cell < first) return Math.max(0, top - shown)
31  if (cell > last || cell >= cells / 2) return Math.min(maxTop, top + shown)
32  return Math.max(0, top - shown)
33}
34
35/** A scrolling list near the bottom of the pane: its id and how many rows it shows now. */
36export type BottomList = { id: string; shown: number; under?: number; folded?: boolean }
37
38/**
39 * Which list a tree row is over. The lists are given bottom-up (the last one drawn first) and sit directly above
40 * `below` rows (the footer and the gap above it); each is a title row, a bordered box of `shown` rows, and a gap.
41 */
42export function listAt(lists: readonly BottomList[], contentRows: number, row: number, below = 2): { id: string; index: number } | undefined {
43  const fromBottom = contentRows - 1 - row
44  let cursor = below // the bottom border of the lowest list's box
45  for (const l of lists) {
46    if (l.folded) {
47      cursor += 2 // a folded list is its title row and the gap above it
48      continue
49    }
50    const under = l.under ?? 0 // rows inside the box below the list (the more/fewer row and the rule above it)
51    const lowest = cursor + 1 + under
52    const highest = lowest + l.shown - 1
53    if (fromBottom >= lowest && fromBottom <= highest) return { id: l.id, index: highest - fromBottom }
54    cursor += under + l.shown + 3 + 1 // two borders, the title, and the gap above it
55  }
56  return undefined
57}
58
59/** A name padded to `width` cells, with a cell of air each side: the text of a name pill. */
60export const pillText = (name: string, width: number): string => ` ${name.padEnd(width)} `
61
hooks/gitinfo.ts 71 lines
1// Git state for the Session box: pure parsers of `git status --porcelain=v2 --branch` and `rev-parse` output. No engine access.
2
3export type GitInfo = {
4  /** The branch, or the short commit while detached. */
5  branch: string
6  isDetached: boolean
7  /** Commits ahead of / behind the upstream; 0 without one. */
8  ahead: number
9  behind: number
10  hasUpstream: boolean
11  modified: number
12  added: number
13  deleted: number
14  untracked: number
15  conflicts: number
16  /** The name of the linked worktree this is, when it is one. */
17  worktree?: string
18}
19
20export const isDirty = (g: GitInfo): boolean => g.modified + g.added + g.deleted + g.untracked + g.conflicts > 0
21
22/**
23 * Reads `git status --porcelain=v2 --branch`. Each changed file counts once: deleted when either side is `D`, else added when
24 * the index added it (`A`), else modified (any other change, a rename or a type change included). `u` rows are conflicts
25 * (also counted as modified), `?` rows untracked; ignored rows (`!`) are left out. Undefined when it holds no branch header.
26 */
27export function parseStatusV2(text: string): GitInfo | undefined {
28  let head: string | undefined
29  let oid = ''
30  let ahead = 0
31  let behind = 0
32  let hasUpstream = false
33  const g = { modified: 0, added: 0, deleted: 0, untracked: 0, conflicts: 0 }
34  for (const line of text.split('\n')) {
35    if (line.startsWith('# branch.head ')) head = line.slice(14).trim()
36    else if (line.startsWith('# branch.oid ')) oid = line.slice(13).trim()
37    else if (line.startsWith('# branch.upstream ')) hasUpstream = true
38    else if (line.startsWith('# branch.ab ')) {
39      const m = /\+(\d+)\s+-(\d+)/.exec(line)
40      if (m) {
41        ahead = Number(m[1])
42        behind = Number(m[2])
43      }
44    } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
45      const xy = line.slice(2, 4)
46      const [x, y] = [xy[0] ?? '.', xy[1] ?? '.']
47      if (x === 'D' || y === 'D') g.deleted++
48      else if (x === 'A') g.added++
49      else if (x !== '.' || y !== '.') g.modified++
50    } else if (line.startsWith('u ')) {
51      g.conflicts++
52      g.modified++
53    } else if (line.startsWith('? ')) g.untracked++
54  }
55  if (head === undefined) return undefined
56  const isDetached = head === '(detached)'
57  const branch = isDetached ? (oid && oid !== '(initial)' ? oid.slice(0, 7) : 'detached') : head
58  return { branch, isDetached, ahead, behind, hasUpstream, ...g }
59}
60
61/**
62 * The linked worktree's name from `git rev-parse --git-dir` and `--git-common-dir` (two lines, in that order): the two differ
63 * in a linked worktree, whose git dir is `<common>/worktrees/<name>`. Undefined in the main checkout or when unreadable.
64 */
65export function linkedWorktree(revParse: string): string | undefined {
66  const [gitDir, commonDir] = revParse.split('\n').map(s => s.trim().replace(/\/+$/, ''))
67  if (!gitDir || !commonDir || gitDir === commonDir) return undefined
68  const m = /\/worktrees\/([^/]+)$/.exec(gitDir)
69  return m ? m[1] : undefined
70}
71
hooks/sessionrows.ts 379 lines
1// The Session box: pure builders from plain data to rows of coloured parts, and the two layouts. No engine access, so every
2// figure the engine does not give is simply a missing field here and its row, or part, is not drawn (never a zero).
3import { PACE_MIN_PERCENT, WINDOW_MS, fmtDuration, fmtTokens, paceOf, parseTime } from './pace'
4import type { Pace } from './pace'
5import { isDirty } from './gitinfo'
6import type { GitInfo } from './gitinfo'
7
8export type RowId = 'model' | 'mode' | 'context' | 'usage' | 'cost' | 'cache' | 'git' | 'backup' | 'tokens' | 'activity' | 'environment'
9export const ROW_IDS: readonly RowId[] = ['model', 'mode', 'context', 'usage', 'cost', 'cache', 'git', 'backup', 'tokens', 'activity', 'environment']
10export const DEFAULT_ROWS = ROW_IDS.join(',')
11export type Layout = 'expanded' | 'compact'
12
13/** A coloured piece of a line; colours are theme keys (`success`, `warning`, `error`, `inactive`). */
14export type Part = { text: string; color?: string; dim?: boolean; bold?: boolean }
15export type Row = { id: RowId; parts: Part[]; /** Own lines under the row, in both layouts. */ extra?: Part[][] }
16
17export type ToolRun = { id: string; name: string; target?: string; state: 'running' | 'done' | 'error' }
18export type Activity = { runs: ToolRun[]; skills: string[]; mcp: Record<string, number>; compactions: number }
19export const EMPTY_ACTIVITY: Activity = { runs: [], skills: [], mcp: {}, compactions: 0 }
20export type TokenTotals = { input: number; output: number; cacheRead: number; cacheWrite: number; tokensPerSecond?: number }
21export type EnvCounts = { claudeMd?: number; rules?: number; mcp?: number; hooks?: number; plugins?: number }
22
23export type Snapshot = {
24  now: number
25  model?: string
26  provider?: string
27  effort?: string | number
28  /** model-picker's advice when the effort level does not fit the last task (`medium would do`), else absent. */
29  effortAdvice?: string
30  /** The model model-picker routed this conversation to (`Haiku`), when it differs from the session's; else absent. */
31  routedTo?: string
32  /** The configured permission mode (`default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions`). */
33  permissionMode?: string
34  /** When the newest commit or checkpoint was made (ms); null = none; absent = not read. */
35  backupAt?: number | null
36  startedAt?: number
37  context?: { tokens?: number; window?: number; percent?: number }
38  /** The biggest categories of the window, only held while it is nearly full. */
39  breakdown?: { name: string; tokens: number }[]
40  rateLimits?: { kind: string; percentUsed: number; resetsAt?: string }[]
41  cost?: number
42  ledger?: { today?: number; week?: number | null }
43  cache?: { expiresAt?: number | null; hitRate?: number | null }
44  git?: GitInfo | null
45  tokens?: TokenTotals | null
46  activity?: Activity | null
47  env?: EnvCounts | null
48}
49
50export type BoxSettings = { sevenDayAt: number; toolsMaxVisible: number; pushWarn: number; envThreshold: number; width: number }
51export const DEFAULT_BOX: BoxSettings = { sevenDayAt: 80, toolsMaxVisible: 4, pushWarn: 5, envThreshold: 0, width: 34 }
52
53const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
54const sep = (text = ' · '): Part => ({ text, dim: true })
55
56/** Context and usage colours: green below 70%, yellow from 70%, red from 85%. */
57export const levelColor = (percent: number): string => (percent >= 85 ? 'error' : percent >= 70 ? 'warning' : 'success')
58
59export function bar(percent: number, width: number): string {
60  const w = Math.max(1, Math.floor(width))
61  const p = Math.max(0, Math.min(100, percent))
62  let filled = Math.round((p / 100) * w)
63  if (p > 0 && filled === 0) filled = 1
64  return '▰'.repeat(filled) + '▱'.repeat(w - filled)
65}
66
67const dollars = (usd: number): string => `$${usd.toFixed(2)}`
68const clock = (ms: number): string => {
69  const d = new Date(ms)
70  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
71}
72
73/** The cloud or gateway the model is reached through, from the documented environment: undefined for the default API. */
74export function providerOf(i: { bedrock?: string; vertex?: string; foundry?: string; baseUrl?: string }): string | undefined {
75  const on = (v: string | undefined) => v !== undefined && v.trim() !== '' && !/^(0|false|no|off)$/i.test(v.trim())
76  if (on(i.bedrock)) return 'Bedrock'
77  if (on(i.vertex)) return 'Vertex'
78  if (on(i.foundry)) return 'Foundry'
79  const url = i.baseUrl?.trim()
80  if (!url) return undefined
81  let host = ''
82  try {
83    host = new URL(url).host
84  } catch {
85    host = url.replace(/^[a-z]+:\/\//i, '').split('/')[0] ?? ''
86  }
87  return host === '' || host === 'api.anthropic.com' ? undefined : host
88}
89
90/** The last few tool calls as groups: finished calls of one tool merge (`Read ×3`), a running call keeps its own group and target. */
91export function groupRuns(runs: readonly ToolRun[], max: number): { state: ToolRun['state']; name: string; count: number; target?: string }[] {
92  const groups: { state: ToolRun['state']; name: string; count: number; target?: string }[] = []
93  for (const r of runs) {
94    if (r.state !== 'running') {
95      const at = groups.findIndex(g => g.state === r.state && g.name === r.name)
96      if (at !== -1) {
97        const [g] = groups.splice(at, 1)
98        groups.push({ ...g!, count: g!.count + 1 })
99        continue
100      }
101    }
102    groups.push({ state: r.state, name: r.name, count: 1, target: r.target })
103  }
104  return groups.slice(-Math.max(1, max))
105}
106
107const RUN_MARK: Record<ToolRun['state'], { icon: string; color: string }> = {
108  done: { icon: '✓', color: 'success' },
109  error: { icon: '✗', color: 'error' },
110  running: { icon: '◐', color: 'warning' },
111}
112
113function modelRow(s: Snapshot): Row | undefined {
114  const parts: Part[] = []
115  if (s.model) parts.push({ text: s.model, bold: s.routedTo === undefined, dim: s.routedTo !== undefined })
116  if (s.routedTo) parts.push({ text: ` → ${s.routedTo}`, bold: true }, { text: ' (routed)', dim: true })
117  if (s.provider) parts.push(sep(), { text: s.provider, dim: true })
118  if (s.effort !== undefined && s.effort !== '') parts.push(sep(), { text: String(s.effort), dim: s.effortAdvice === undefined, color: s.effortAdvice === undefined ? undefined : 'warning' })
119  if (s.effortAdvice) parts.push({ text: ` ⚠ ${s.effortAdvice}`, color: 'warning' })
120  if (isNum(s.startedAt) && s.now >= s.startedAt) parts.push(...(parts.length > 0 ? [sep()] : []), { text: fmtDuration(s.now - s.startedAt), dim: true })
121  return parts.length > 0 ? { id: 'model', parts } : undefined
122}
123
124const MODE_LABEL: Record<string, { text: string; color?: string; dim?: boolean }> = {
125  default: { text: 'asks before edits', dim: true },
126  acceptEdits: { text: 'accepts edits', color: 'warning' },
127  plan: { text: 'plan mode', color: 'success' },
128  auto: { text: 'auto mode', color: 'success' },
129  dontAsk: { text: 'denies unless allowed', color: 'warning' },
130  bypassPermissions: { text: 'permissions bypassed !', color: 'error' },
131}
132
133/** The permission mode in plain words; red for bypass, since nothing then asks before a command runs. */
134function modeRow(s: Snapshot): Row | undefined {
135  if (!s.permissionMode) return undefined
136  const m = MODE_LABEL[s.permissionMode] ?? { text: s.permissionMode, dim: true }
137  return { id: 'mode', parts: [{ text: 'Mode: ', dim: true }, { text: m.text, color: m.color, dim: m.dim }] }
138}
139
140export const BACKUP_WARN_MS = 2 * 60 * 60_000
141export const BACKUP_ERROR_MS = 8 * 60 * 60_000
142
143/** Time since the newest commit or checkpoint; amber once files are unsaved for 2 hours, red from 8 (rewind covers only Claude's own edits). */
144function backupRow(s: Snapshot): Row | undefined {
145  const g = s.git
146  if (!g || s.backupAt === undefined) return undefined
147  const unsaved = g.modified + g.added + g.deleted + g.untracked
148  if (s.backupAt === null) return unsaved > 0 ? { id: 'backup', parts: [{ text: 'Backup ', dim: true }, { text: `none · ${unsaved} unsaved`, color: 'warning' }] } : undefined
149  const age = Math.max(0, s.now - s.backupAt)
150  const color = unsaved === 0 ? 'success' : age >= BACKUP_ERROR_MS ? 'error' : age >= BACKUP_WARN_MS ? 'warning' : 'success'
151  const parts: Part[] = [{ text: 'Backup ', dim: true }, { text: age < 60_000 ? 'just now' : `${fmtDuration(age)} ago`, color }]
152  if (unsaved > 0) parts.push({ text: ` · ${unsaved} unsaved`, color: color === 'success' ? undefined : color, dim: color === 'success' })
153  return { id: 'backup', parts }
154}
155
156function contextRow(s: Snapshot, o: BoxSettings): Row | undefined {
157  const c = s.context
158  if (!c) return undefined
159  const percent = isNum(c.percent) ? c.percent : isNum(c.tokens) && isNum(c.window) && c.window > 0 ? Math.round((c.tokens / c.window) * 100) : undefined
160  if (percent === undefined) return undefined
161  const color = levelColor(percent)
162  const amount = isNum(c.tokens) && isNum(c.window) ? ` ${fmtTokens(c.tokens)}/${fmtTokens(c.window)}` : ''
163  const label = 'Context '
164  const tail = ` ${Math.round(percent)}%${amount}`
165  const parts: Part[] = [{ text: label, dim: true }, { text: bar(percent, Math.max(4, Math.min(10, o.width - label.length - tail.length))), color }, { text: tail, color }]
166  const row: Row = { id: 'context', parts }
167  if (percent >= 85 && s.breakdown && s.breakdown.length > 0) {
168    row.extra = [[{ text: s.breakdown.map(b => `${b.name} ${fmtTokens(b.tokens)}`).join(' · '), dim: true }]]
169  }
170  return row
171}
172
173const WINDOW_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
174const worse = (a: string, b: string): string => (a === 'error' || b === 'error' ? 'error' : a === 'warning' || b === 'warning' ? 'warning' : a)
175
176function usageRow(s: Snapshot, o: BoxSettings): Row | undefined {
177  const lines: Part[][] = []
178  for (const w of s.rateLimits ?? []) {
179    if (!isNum(w.percentUsed)) continue
180    if (w.kind === 'seven_day' && w.percentUsed < o.sevenDayAt) continue
181    const label = `${WINDOW_LABEL[w.kind] ?? w.kind} `
182    const resetsAt = parseTime(w.resetsAt)
183    const pace: Pace = paceOf(w.percentUsed, resetsAt, s.now, WINDOW_MS[w.kind])
184    const base = w.percentUsed < PACE_MIN_PERCENT ? 'success' : levelColor(w.percentUsed)
185    const color = pace === 'red' ? worse(base, 'error') : pace === 'amber' ? worse(base, 'warning') : base
186    const marker = pace === 'neutral' ? '' : ' ▲'
187    const reset = resetsAt !== undefined && resetsAt > s.now ? ` · resets in ${fmtDuration(Math.ceil((resetsAt - s.now) / 60_000) * 60_000)}` : ''
188    const tail = ` ${Math.round(w.percentUsed)}%${marker}`
189    const width = Math.max(4, Math.min(10, o.width - label.length - tail.length - reset.length))
190    lines.push([{ text: label, dim: true }, { text: bar(w.percentUsed, width), color }, { text: tail, color }, ...(reset ? [{ text: reset, dim: true }] : [])])
191  }
192  if (lines.length === 0) return undefined
193  return { id: 'usage', parts: lines[0]!, extra: lines.slice(1) }
194}
195
196function costRow(s: Snapshot): Row | undefined {
197  const parts: Part[] = []
198  if (isNum(s.cost)) parts.push({ text: dollars(s.cost), bold: true })
199  if (isNum(s.ledger?.today)) parts.push(...(parts.length > 0 ? [sep()] : []), { text: `Today ${dollars(s.ledger.today)}`, dim: true })
200  if (isNum(s.ledger?.week)) parts.push(...(parts.length > 0 ? [sep()] : []), { text: `Week ${dollars(s.ledger.week)}`, dim: true })
201  return parts.length > 0 ? { id: 'cost', parts } : undefined
202}
203
204function cacheRow(s: Snapshot): Row | undefined {
205  const c = s.cache
206  if (!c) return undefined
207  const parts: Part[] = []
208  if (isNum(c.expiresAt)) parts.push(c.expiresAt <= s.now ? { text: 'Cache expired', color: 'warning' } : { text: `Cache until ${clock(c.expiresAt)}`, color: 'success' })
209  if (isNum(c.hitRate)) {
210    const pct = Math.round(c.hitRate <= 1 ? c.hitRate * 100 : c.hitRate)
211    parts.push(...(parts.length > 0 ? [sep()] : []), { text: `hit ${pct}%`, color: pct >= 70 ? 'success' : pct >= 40 ? 'warning' : 'error' })
212  }
213  return parts.length > 0 ? { id: 'cache', parts } : undefined
214}
215
216function gitRow(s: Snapshot, o: BoxSettings): Row | undefined {
217  const g = s.git
218  if (!g) return undefined
219  const dirty = isDirty(g)
220  const parts: Part[] = [{ text: `${g.branch}${dirty ? '*' : ''}`, bold: true, color: dirty ? 'warning' : 'success' }]
221  if (g.ahead > 0) parts.push({ text: ` ↑${g.ahead}`, color: g.ahead >= o.pushWarn ? 'warning' : undefined })
222  if (g.behind > 0) parts.push({ text: ` ↓${g.behind}` })
223  if (dirty) parts.push({ text: ` !${g.modified} +${g.added} ✘${g.deleted} ?${g.untracked}`, dim: true })
224  if (g.worktree) parts.push({ text: ` ⎇ ${g.worktree}`, dim: true })
225  return { id: 'git', parts }
226}
227
228function tokensRow(s: Snapshot): Row | undefined {
229  const t = s.tokens
230  if (!t) return undefined
231  const fields = [`in ${fmtTokens(t.input)}`, `out ${fmtTokens(t.output)}`, `cache ${fmtTokens(t.cacheRead)}`]
232  const parts: Part[] = [{ text: fields.join(' · '), dim: true }]
233  if (isNum(t.tokensPerSecond) && t.tokensPerSecond > 0) parts.push(sep(), { text: `${Math.round(t.tokensPerSecond)} tok/s`, dim: true })
234  return { id: 'tokens', parts }
235}
236
237function activityRow(s: Snapshot, o: BoxSettings): Row | undefined {
238  const a = s.activity
239  if (!a) return undefined
240  const groups = groupRuns(a.runs, o.toolsMaxVisible)
241  const parts: Part[] = []
242  groups.forEach((g, i) => {
243    const m = RUN_MARK[g.state]
244    if (i > 0) parts.push(sep())
245    const detail = g.state === 'running' ? (g.target ? ` ${g.target}` : '') : g.count > 1 ? ` ×${g.count}` : ''
246    parts.push({ text: `${m.icon} ${g.name}${detail}`, color: m.color })
247  })
248  const extra: Part[][] = []
249  if (a.skills.length > 0) extra.push([{ text: `Skills: ${a.skills.join(', ')}`, dim: true }])
250  const servers = Object.entries(a.mcp)
251  if (servers.length > 0) extra.push([{ text: `MCP: ${servers.map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(', ')}`, dim: true }])
252  if (a.compactions > 0) extra.push([{ text: `Compactions: ${a.compactions}`, dim: true }])
253  if (parts.length === 0 && extra.length === 0) return undefined
254  if (parts.length === 0) return { id: 'activity', parts: extra[0]!, extra: extra.slice(1) }
255  return { id: 'activity', parts, extra }
256}
257
258function environmentRow(s: Snapshot, o: BoxSettings): Row | undefined {
259  const e = s.env
260  if (!e) return undefined
261  const items: [number | undefined, string][] = [[e.claudeMd, 'CLAUDE.md'], [e.rules, 'rules'], [e.mcp, 'MCP'], [e.hooks, 'hooks'], [e.plugins, 'plugins']]
262  const known = items.filter((i): i is [number, string] => isNum(i[0]) && i[0] > 0)
263  const total = known.reduce((n, [c]) => n + c, 0)
264  if (known.length === 0 || total < o.envThreshold) return undefined
265  return { id: 'environment', parts: [{ text: known.map(([c, name]) => `${c} ${name}`).join(' · '), dim: true }] }
266}
267
268/** Every row that has something to draw, keyed by id. */
269export function buildRows(s: Snapshot, o: BoxSettings = DEFAULT_BOX): Record<RowId, Row | undefined> {
270  return {
271    model: modelRow(s),
272    mode: modeRow(s),
273    context: contextRow(s, o),
274    usage: usageRow(s, o),
275    cost: costRow(s),
276    cache: cacheRow(s),
277    git: gitRow(s, o),
278    backup: backupRow(s),
279    tokens: tokensRow(s),
280    activity: activityRow(s, o),
281    environment: environmentRow(s, o),
282  }
283}
284
285/** `a, b, x` -> the known ids in that order, once each. */
286export function parseRows(list: string | undefined | null): RowId[] {
287  const out: RowId[] = []
288  for (const w of (list ?? '').split(',')) {
289    const id = w.trim().toLowerCase() as RowId
290    if ((ROW_IDS as readonly string[]).includes(id) && !out.includes(id)) out.push(id)
291  }
292  return out
293}
294
295/** Putting a row back keeps the canonical order among the rows shown. */
296export function showRow(current: readonly RowId[], id: RowId): RowId[] {
297  if (current.includes(id)) return [...current]
298  const rank = ROW_IDS.indexOf(id)
299  const at = current.findIndex(c => ROW_IDS.indexOf(c) > rank)
300  const next = [...current]
301  next.splice(at === -1 ? next.length : at, 0, id)
302  return next
303}
304
305export const hideRow = (current: readonly RowId[], id: RowId): RowId[] => current.filter(c => c !== id)
306
307const plainLength = (parts: readonly Part[]): number => parts.reduce((n, p) => n + p.text.length, 0)
308
309/** The lines to draw, in order: one per row (expanded), or rows joined with ` │ ` on as few lines as fit (compact). */
310export function layoutLines(rows: readonly Row[], layout: Layout, width: number): { key: string; parts: Part[] }[] {
311  const lines: { key: string; parts: Part[] }[] = []
312  if (layout === 'compact') {
313    let line: Part[] = []
314    let ids: string[] = []
315    const flush = () => {
316      if (line.length > 0) lines.push({ key: ids.join('+'), parts: line })
317      line = []
318      ids = []
319    }
320    for (const r of rows) {
321      if (line.length > 0 && plainLength(line) + 3 + plainLength(r.parts) > width) flush()
322      if (line.length > 0) line.push({ text: ' │ ', dim: true })
323      line.push(...r.parts)
324      ids.push(r.id)
325      if (r.extra && r.extra.length > 0) {
326        flush()
327        r.extra.forEach((x, i) => lines.push({ key: `${r.id}:extra:${i}`, parts: x }))
328      }
329    }
330    flush()
331    return lines
332  }
333  for (const r of rows) {
334    lines.push({ key: r.id, parts: r.parts })
335    r.extra?.forEach((x, i) => lines.push({ key: `${r.id}:extra:${i}`, parts: x }))
336  }
337  return lines
338}
339
340/** The file a tool call works on, or the short thing it looks for, for the running mark: `Edit auth.ts`. */
341export function targetOf(e: Record<string, unknown>): string | undefined {
342  for (const k of ['file_path', 'notebook_path', 'path']) {
343    const v = e[k]
344    if (typeof v === 'string' && v !== '') return v.split('/').filter(Boolean).pop()
345  }
346  for (const k of ['pattern', 'query', 'url']) {
347    const v = e[k]
348    if (typeof v === 'string' && v !== '') return v.length > 24 ? `${v.slice(0, 23)}…` : v
349  }
350  return undefined
351}
352
353/** The MCP server of a tool named `mcp__<server>__<tool>`, else undefined. */
354export function mcpServerOf(tool: string): string | undefined {
355  if (!tool.startsWith('mcp__')) return undefined
356  const server = tool.slice(5).split('__')[0]
357  return server ? server : undefined
358}
359
360export const RUN_CAP = 24
361export const SKILL_CAP = 8
362export const MCP_CAP = 8
363
364/** A tool call starts: bounded, newest last. */
365export function startRun(a: Activity, run: ToolRun): Activity {
366  const skills = run.name === 'Skill' && run.target ? [...a.skills.filter(s => s !== run.target), run.target].slice(-SKILL_CAP) : a.skills
367  const server = mcpServerOf(run.name)
368  let mcp = a.mcp
369  if (server) {
370    mcp = { ...a.mcp, [server]: (a.mcp[server] ?? 0) + 1 }
371    const names = Object.keys(mcp)
372    if (names.length > MCP_CAP) delete mcp[names.find(n => n !== server)!]
373  }
374  return { ...a, runs: [...a.runs, run].slice(-RUN_CAP), skills, mcp }
375}
376
377/** The call ends: its run turns done (or error). */
378export const finishRun = (a: Activity, id: string, state: 'done' | 'error'): Activity => ({ ...a, runs: a.runs.map(r => (r.id === id ? { ...r, state } : r)) })
379
hooks/update.ts 60 lines
1// Update check: pure helpers. The look-up itself is `git ls-remote --tags` run by register.tsx; nothing here touches the engine.
2
3/** The project release this copy of moddeck shipped with (a test and scripts/check-all.sh keep it equal to pyproject.toml). */
4export const RELEASE = '0.5.0'
5export const REPO = 'samuelbfernandes/moddeck'
6export const REMOTE = `https://github.com/${REPO}.git`
7/** The tags are looked up at most this often; a re-open inside the window reuses the last answer. */
8export const CHECK_EVERY_MS = 6 * 60 * 60 * 1000
9
10export type Version = readonly [number, number, number]
11
12/** `v0.4.2` or `0.4.2`; a pre-release (`0.5.0-rc1`) or anything else is not a version we offer. */
13export function parseVersion(text: string): Version | undefined {
14  const m = /^v?(\d{1,4})\.(\d{1,4})\.(\d{1,4})$/.exec(text.trim())
15  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined
16}
17
18export function isNewer(candidate: string, current: string): boolean {
19  const a = parseVersion(candidate)
20  const b = parseVersion(current)
21  if (a === undefined || b === undefined) return false
22  for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! > b[i]!
23  return false
24}
25
26/** The highest release tag in `git ls-remote --tags --refs` output ("<sha>\trefs/tags/v0.4.1" per line), without the `v`. */
27export function latestRelease(lsRemote: string): string | undefined {
28  let best: string | undefined
29  for (const line of lsRemote.split('\n')) {
30    const tag = /refs\/tags\/(\S+)\s*$/.exec(line)?.[1]
31    const v = tag === undefined ? undefined : parseVersion(tag)
32    if (v === undefined) continue
33    const text = v.join('.')
34    if (best === undefined || isNewer(text, best)) best = text
35  }
36  return best
37}
38
39export type Runner = 'pipx' | 'uvx'
40
41/** The installer's own documented one-liner (docs/INSTALL.md) for a given release, with `update --scope user -y` so the recorded choices are kept and nothing asks a question (there is no terminal to answer in). */
42export function installArgv(runner: Runner, version: string): string[] {
43  const from = `git+https://github.com/${REPO}@v${version}`
44  return runner === 'pipx' ? ['pipx', 'run', '--spec', from, 'moddeck', 'update', '--scope', 'user', '-y'] : ['uvx', '--from', from, 'moddeck', 'update', '--scope', 'user', '-y']
45}
46
47export const manualCommand = (version: string): string => installArgv('pipx', version).join(' ')
48
49export const ASK_HEADER = 'New version' // the chip is 12 characters at most
50export const UPDATE_NOW = 'Update now'
51export const LATER = 'Later'
52export const SKIP = 'Skip this version'
53
54export const askText = (latest: string, current: string): string =>
55  [
56    `Moddeck ${latest} is available (you have ${current}).`,
57    `Update now runs: ${manualCommand(latest)}`,
58    'It reinstalls with your recorded choices; restart Claude Code afterwards to use it.',
59  ].join('\n')
60
hooks/pace.ts 65 lines
1// Usage pace and time formatting: pure helpers, no engine access.
2//
3// A rate-limit window resets at a known time. At the rate it has been used so far (percent used over the fraction of the
4// window that has passed), it either reaches the limit before the reset or it does not; the side bar marks it with a ▲
5// (amber at >= 90% projected, red at >= 100%). A window under 10% used is never marked: too little to tell a trend from noise.
6
7export type Pace = 'neutral' | 'amber' | 'red'
8
9const HOUR = 3_600_000
10/** Window lengths the engine documents by kind; a kind not listed has no known length, so it gets no pace. */
11export const WINDOW_MS: Readonly<Record<string, number>> = { five_hour: 5 * HOUR, seven_day: 7 * 24 * HOUR }
12
13/** Below this share used a window stays neutral. */
14export const PACE_MIN_PERCENT = 10
15export const PACE_AMBER_AT = 90
16export const PACE_RED_AT = 100
17
18/** Where the current burn rate ends up at the reset, in percent of the limit; undefined when it cannot be told. */
19export function projectedPercent(percentUsed: number, resetsAtMs: number | undefined, nowMs: number, windowMs: number | undefined): number | undefined {
20  if (windowMs === undefined || windowMs <= 0 || resetsAtMs === undefined || !Number.isFinite(resetsAtMs) || !Number.isFinite(nowMs) || !Number.isFinite(percentUsed)) return undefined
21  const remaining = resetsAtMs - nowMs
22  if (remaining <= 0) return undefined // already past its reset: nothing to project
23  const elapsed = Math.min(windowMs, windowMs - remaining)
24  if (elapsed <= 0) return undefined // the window has not started by this clock
25  return percentUsed / (elapsed / windowMs)
26}
27
28export function paceOf(percentUsed: number, resetsAtMs: number | undefined, nowMs: number, windowMs: number | undefined): Pace {
29  if (!Number.isFinite(percentUsed) || percentUsed < PACE_MIN_PERCENT) return 'neutral'
30  if (percentUsed >= PACE_RED_AT) return 'red'
31  const projected = projectedPercent(percentUsed, resetsAtMs, nowMs, windowMs)
32  if (projected === undefined) return 'neutral'
33  if (projected >= PACE_RED_AT) return 'red'
34  if (projected >= PACE_AMBER_AT) return 'amber'
35  return 'neutral'
36}
37
38/** An ISO 8601 timestamp as milliseconds, or undefined when absent or unreadable. */
39export function parseTime(iso: string | undefined): number | undefined {
40  if (typeof iso !== 'string') return undefined
41  const ms = Date.parse(iso)
42  return Number.isFinite(ms) ? ms : undefined
43}
44
45/** `1h 12m`, `45m`, `2d 4h`, `30s`: the two largest units, rounded down; a negative length reads as 0. */
46export function fmtDuration(ms: number): string {
47  const total = Math.max(0, Math.floor(ms / 1000))
48  const d = Math.floor(total / 86400)
49  const h = Math.floor((total % 86400) / 3600)
50  const m = Math.floor((total % 3600) / 60)
51  if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`
52  if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`
53  if (m > 0) return `${m}m`
54  return `${total}s`
55}
56
57/** `12k`, `1.4M`, `850`: a token count in as few characters as read well. */
58export function fmtTokens(n: number): string {
59  if (!Number.isFinite(n)) return ''
60  const abs = Math.abs(n)
61  if (abs < 1000) return String(Math.round(n))
62  if (abs < 1_000_000) return `${(n / 1000).toFixed(abs < 10_000 ? 1 : 0).replace(/\.0$/, '')}k`
63  return `${(n / 1_000_000).toFixed(abs < 10_000_000 ? 1 : 0).replace(/\.0$/, '')}M`
64}
65
types/index.d.ts 97 lines
1/** One saved prompt, as prompt-library keeps it. */
2export type SavedPrompt = { name: string; text: string }
3/** One side question in the Side Question panel. */
4export type BtwEntry = { id: number; at: number; question: string; status: 'thinking' | 'done' | 'failed'; answer: string; isCut: boolean; tokens: number | null; note: string }
5/** The automatic open of the side bar, once per session. */
6export type AutoOpen = 'new' | 'checking' | 'done'
7/** How the Session box lays its rows out. */
8export type DeckLayout = 'expanded' | 'compact'
9/** The latest `session.measure` figures (or `session.usage()` at start); a figure the engine did not give is absent. */
10export type DeckMeasure = {
11  context: { tokens?: number; window?: number; percent?: number }
12  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
13  cost?: number
14}
15/** What the Session box's model row shows, plus the window's biggest categories while it is nearly full. */
16export type DeckMeta = { model?: string; effort?: string | number; provider?: string; startedAt?: number; breakdown?: { name: string; tokens: number }[] }
17/** The working directory's git state (porcelain v2): counts are files, each counted once. */
18export type DeckGit = {
19  branch: string
20  isDetached: boolean
21  ahead: number
22  behind: number
23  hasUpstream: boolean
24  modified: number
25  added: number
26  deleted: number
27  untracked: number
28  conflicts: number
29  worktree?: string
30}
31/** Session token totals (main loop) and the last turn's output speed. */
32export type DeckTokens = { input: number; output: number; cacheRead: number; cacheWrite: number; tokensPerSecond?: number }
33/** The last tool calls (bounded), the skills and MCP servers used, and the compactions seen. */
34export type DeckActivity = {
35  runs: { id: string; name: string; target?: string; state: 'running' | 'done' | 'error' }[]
36  skills: string[]
37  mcp: Record<string, number>
38  compactions: number
39}
40/** What the environment row counts; a field the engine did not give is absent. */
41export type DeckEnv = { claudeMd?: number; rules?: number; mcp?: number; hooks?: number; plugins?: number }
42
43declare module 'claude-code' {
44  interface PluginState {
45    moddeck: {
46      /** The one automatic open: 'new' until tried, 'checking' until the first drawing says whether it docked, then 'done'. */
47      autoOpen: AutoOpen
48      /** Index of the first row shown in each scrolling list (`skills`, `prompts`): moved by the scroll bar and the wheel. */
49      tops: Record<string, number>
50      /** What the person last picked in the dropdown (`command-guard`), and the level its switch goes back to (`command-guard:last`). */
51      picks: Record<string, string>
52      /** The Session box's latest figures; null before any. */
53      measure: DeckMeasure | null
54      /** Model, effort, provider, start time and (nearly full) breakdown for the model and context rows. */
55      meta: DeckMeta
56      /** Git state, refreshed at start and after each turn; null = not a repository. */
57      git: DeckGit | null
58      /** Session token totals; null until a turn reported usage. */
59      tokens: DeckTokens | null
60      /** Recent tool calls, skills, MCP servers and compactions (in memory only). */
61      activity: DeckActivity
62      /** Counts for the environment row; null until read. */
63      env: DeckEnv | null
64      /** Rows shown, a comma list in display order, as `/moddeck rows` set it (also kept in the store as `rows`); null = the settings apply. */
65      rows: string | null
66      /** The last few side questions and answers of the Side Question panel, newest first (in memory only). */
67      btw: BtwEntry[]
68      /** A newer release than the installed one, not yet updated to; null = none known. */
69      updateAvail: string | null
70      /** The newest commit or checkpoint (ms); null = none; undefined = not read. */
71      backupAt: number | null | undefined
72      /** The permission mode from the settings. */
73      permMode: string | undefined
74      /** The Side Question panel is expanded. */
75      btwOpen: boolean
76      /** Cards folded down to their title (`group:<name>`); also kept in the store. null = not loaded. */
77      folded: string[] | null
78      /** What each list's search field holds (`skills`, `prompts`). */
79      listQuery: Record<string, string>
80      /** The text in the Side Question's Ask here field, emptied after asking and by ✕ close. */
81      btwDraft: string
82      /** Lines each list shows (`skills`, `prompts`), as its more/fewer buttons set it (also kept in the store); null = not loaded. */
83      listRows: Record<string, number> | null
84      /** Starred skill ids, drawn at the top of the Skills box (also kept in the store); null = not loaded. */
85      starred: string[] | null
86      /** Every skill the session has (name, and the matching slash command's line), read once from the engine; null = not read yet. */
87      installedSkills: { name: string; desc?: string }[] | null
88      /** The pane is shown wide (the expand button); false = its usual width. */
89      wide: boolean
90      /** The Session box layout as `/moddeck rows compact|expanded` set it (also kept in the store as `layout`); null = the settings apply. */
91      layout: DeckLayout | null
92      /** The desktop app's side bar colours as `/moddeck theme light|dark` set them (also kept in the store as `theme`); null = the `appTheme` setting. */
93      theme: 'light' | 'dark' | null
94    }
95  }
96}
97