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…

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.


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.
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.
| row | what it shows | where it reads |
|---|---|---|
model | model 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 |
mode | the 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() |
backup | time 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 saved | read-only git log -1, git for-each-ref refs/moddeck/checkpoints, at start and after each turn |
context | bar, percent, tokens/window; green, yellow from 70%, red from 85%; from 85% also the three biggest categories | session.measure / $.session.usage(); the breakdown is breakdown: 'summary' (local estimate, never 'full') |
usage | one 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 pace | a ▲ (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 neutral | hooks/pace.ts |
cost | session $x.xx, plus Today $x and Week $x | usage().cost; atom usage-meter.ledger { today, week } (nothing drawn when absent) |
cache | Cache 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) |
git | branch 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 pushWarn | read-only git status --porcelain=v2 --branch and rev-parse, at session start and after each turn; not a repository: no row |
tokens | session in / out / cache totals and the last turn's output speed | turn.complete usage and durationMs (speed is output over the whole turn's time, so tool time makes it read low) |
activity | the 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 |
environment | counts 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.
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.
/<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.[status], [explain]), $ reflect, $ tldr with its off|highlight|summary dropdown (summary is the default), and concise.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.[recipe], [status] while on).[run], [status] while on), long-run-ping./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.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).$ marks what spends model tokens (reflect, the Side Question, /keepwarm)./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.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.
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.
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.try/catch), and each failing source drops only its row.cost and cache rows need usage-meter and cache-guard to publish their atoms; without them those parts are not drawn.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.
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.
hooks/register.tsx 1403 lines1// 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 lines1// 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}
84hooks/catalog.ts 324 lines1// 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 }
324hooks/layout.ts 61 lines1// 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)} `
61hooks/gitinfo.ts 71 lines1// 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}
71hooks/sessionrows.ts 379 lines1// 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)) })
379hooks/update.ts 60 lines1// 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')
60hooks/pace.ts 65 lines1// 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}
65types/index.d.ts 97 lines1/** 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