Markdown-style TOC pane of your prompts and reply headings; press one to scroll there. Prompts-only chip, time per prompt, follow-viewport underline, settings…

A Markdown-style table of contents of your session in a side pane: every prompt you typed, and the #–### headings of each reply under it. Press a row to scroll the transcript there.
claude plugin marketplace add weskao/weskao-agent-plugins
claude plugin install prompt-toc@weskao-agent-plugins
Needs Claude Code 2.1.287 or later (mods load by default) and python3 on PATH.
| Command | Does |
|---|---|
/prompt-toc [keyword] | Open the pane, optionally filtered to rows containing the keyword |
/prompt-toc-open, /prompt-toc-close | Turn the mod on or off |
/prompt-toc-export [path] | Write the index as a Markdown file |
/prompt-toc-update | Update to the latest version (then restart Claude Code) |
At each session start a toast says when a newer version is published (the version list is fetched at most every 10 minutes).


The chips at the top of the pane switch between two views of the same session.
All puts what came back under each prompt: the #–### headings of the reply, or, when the reply has none, a ↳ line with the start of the final answer. Each turn gets its own line, including turns started by a background agent reporting back, so a long /orch run lists every update and not only the last one. Use it to find the turn where something was said or decided, not only the question that led to it.

Prompts keeps only the lines you typed, one per prompt, numbered and timed. More of the session fits on screen, so it is the quick way to jump back to an earlier request.

In both views, rows above a /compact are dimmed under a ── compacted ── divider; press ▴ hide earlier to fold them away. The ▲ top · ▼ end buttons jump to either end of the list (they work for touch terminals that tap but send no wheel, such as an iPhone SSH app). They show in the docked and the inline pane alike.
Open a subagent's transcript (from the tasks list) and the pane lists that agent instead: its task (the Agent call's description) as the prompt, then the final reply of each of its turns. Switch back and the main conversation returns. /prompt-toc-export writes the main conversation only.
Also in the pane:
41.37% (up to two decimals, and the rows add up to exactly 100%): 50% or more is bold red, 25% or more bold yellow, under that the theme's subtle gray, distinct from the time's dim (the statusline's tiers). Tokens are weighted by price relative to uncached input (cache read 0.1, cache write 1.25 or 2 for a 1h cache, output 5), so a long turn that mostly re-reads the cache does not outrank a short one with big output; the footer adds the total, ≈ 1.3M tokens (cost-weighted). A turn a background agent's hand-back starts shows its share on its own answer, and a subagent's run counts toward the prompt that started it. A subagent's view shares out that agent's own total. /prompt-toc-export adds each prompt's in / cache read / cache write / out. Counted live as the turn runs; turn it off with Show cost share in ⚙ settings.Search on Windows and Linux, where the emoji may not render): unfolds a search field under the toolbar that filters the rows as you type, the same filter as /prompt-toc <keyword>. Enter (or 🔍 again) folds the field away and keeps the filter as a filter: … ✕ chip; ✕ clears it. Not shown on mobile, which has no text field./config rows, synced both ways./compact divider can hide or show the rows above it.The pane is built from the session transcript already on your disk (scripts/scan.py reads it). Nothing is sent anywhere. The only network request is the version check (at most every 10 minutes), a plain GET of the public marketplace.json on raw.githubusercontent.com.
MIT
hooks/register.tsx 979 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import type { EngineInterface } from 'claude-code'
4import type { TocEntry, Tokens } from '../types'
5import { fmt } from './shared/fmt'
6import { DEFAULTS, KEYS, LABELS, loadSettings, toggleSetting } from './settings'
7import type { Settings } from './settings'
8import { view } from './filter'
9import { short, toMarkdown } from './export'
10import { classify, commandEcho, headings, isEngineText, oneLine } from './shared/rows'
11import { notifyUpdate, registerUpdate, runUpdate } from './shared/update'
12import { cells, cellsOf, clamp, fit, isEdge, lastFirst, step, wrappedRows } from './window'
13
14const OFF = { plugin: 'prompt-toc', key: 'isOff' } as const
15
16async function isOff($: EngineInterface) {
17 return (await $.state.get(OFF)).value === true
18}
19// close the settings popover on a press on, or focus moving to, anything but the gear or one of its toggles (focus
20// first lands on the gear/toggle being clicked: closing then would let its press reopen it); a module function, since
21// a helper that takes $ must be declared at the top of the file
22async function dismiss($: EngineInterface, key?: string) {
23 try {
24 const inPopover = key === 'settings-toggle' || key?.startsWith('setting-') === true
25 if (!inPopover && (await read($, settingsOpen))) await update($, settingsOpen, () => false)
26 } catch {}
27}
28// moves the pane's focus ring onto one of its elements; a deny (the pane not holding the keys) leaves it where it was
29async function focusOn($: EngineInterface, key: string) {
30 try {
31 await $.ui.focus({ requestId: PANE, key })
32 } catch {}
33}
34const OFF_NOTE = 'prompt-toc is off (/prompt-toc-open to turn it on).'
35
36const PANE = 'prompt-toc'
37// the settings popover's click-catching layer (backdrop.ts)
38const BACKDROP = 'settings-backdrop'
39// the same layer spanning the open search row under the field: a click there types in the field, with no hover
40const SEARCH_ROW = 'search-row'
41const TITLE = 'Prompt TOC'
42const OLD_ROW = 'before /compact: not in the transcript anymore'
43const MAX_LABEL = 72
44// heading level → bullet and theme color (follows the person's theme)
45const LEVEL: Record<number, { glyph: string; color: string }> = {
46 1: { glyph: '◆', color: 'suggestion' },
47 2: { glyph: '▸', color: 'success' },
48 3: { glyph: '·', color: 'subtle' },
49}
50const entries = atom({ plugin: 'prompt-toc', key: 'entries' } as const, [])
51// keyOf() of the entry last pressed: its row is drawn highlighted
52const selected = atom({ plugin: 'prompt-toc', key: 'selected' } as const, '')
53// keyOf() of the entry of the topmost prompt/reply on screen: its row is drawn underlined (follow-viewport)
54const following = atom({ plugin: 'prompt-toc', key: 'following' } as const, '')
55// the prompt/reply rows the terminal reports on screen, by requestId: module memory, since a render hook may not write
56// $.state (drawing is pure); lost on a reload, refilled by the next report
57const shown = new Set<string>()
58// those of them whose last row is on screen too: the last entry's in it means the transcript is scrolled to its end
59const ended = new Set<string>()
60let flush: { cancel(): void } | undefined
61// the session id the index was last rebuilt from (module memory); undefined after a session end, so the next follow tick rebuilds
62let indexedFor: string | undefined
63// the context fill session.measure last reported; a fall by more than half reads as a compaction
64let lastFill: number | undefined
65// the pane's user settings (hydrated from $.store in session.start) and whether its section is unfolded
66const settings = atom({ plugin: 'prompt-toc', key: 'settings' } as const, DEFAULTS)
67const settingsOpen = atom({ plugin: 'prompt-toc', key: 'settingsOpen' } as const, false)
68// whether the pane held the keyboard at its last drawing (see the ui.render hook)
69let wasFocused = false
70// prompts-only: the pane draws each prompt without its reply headings (the collapsed outline)
71const promptsOnly = atom({ plugin: 'prompt-toc', key: 'promptsOnly' } as const, false)
72// the rows from before the last /compact are hidden behind its divider (pressed on the divider's fold button)
73const foldOld = atom({ plugin: 'prompt-toc', key: 'foldOld' } as const, false)
74// /prompt-toc <keyword>: the pane lists only matching rows (plus their prompts); '' = no filter
75const keyword = atom({ plugin: 'prompt-toc', key: 'keyword' } as const, '')
76// the 🔍 toolbar button unfolds a search field under the toolbar that edits the same keyword; folded, the filter stays
77const searchOpen = atom({ plugin: 'prompt-toc', key: 'searchOpen' } as const, false)
78// 🔍 is a color emoji: tofu on Windows conhost and on Linux without an emoji font, so it is drawn on macOS only
79// (the mod API names no OS; this file exists only there). Set in session.start, before the default open draws the pane
80let searchLabel = ' Search '
81// the pane's own window over its rows (window.ts): the index of the topmost item shown under the fixed toolbar
82const winTop = atom({ plugin: 'prompt-toc', key: 'winTop' } as const, 0)
83// a new view (prompts-only, a keyword, a cleared index) starts its window at the top
84const resetWindow = ($: EngineInterface) => update($, winTop, () => 0)
85// the item heights and row budget the pane last drew its window with: module memory (a render hook may not write
86// $.state), read by the ui.scroll hook, whose event carries no width to measure wrapped labels by; lost on a reload,
87// refilled by the next drawing
88let layout: { heights: number[]; budget: number } | undefined
89// the popover's items (settings.ts LABELS) are padded to one width, so each item's whole row (not just its text) hovers and presses
90const labelWidth = Math.max(...KEYS.map(k => LABELS[k].length))
91// both view buttons are padded to the longer label, so each one's press range is the same width
92const VIEW_WIDTH = 'Prompts'.length
93// the filter's ✕ chip lights as a pill on hover, in a colour of its own (not the toolbar's accent): the pointer's own
94// inversion of a dim plain Button barely shows. A scope, as the chip sits in no keyed Box
95const CLEAR_HOVER = { scope: 'filter-clear', color: 'inverseText', backgroundColor: 'suggestion', dimColor: false } as const
96// set once the default-open ran: a reload re-fires session.start, which must not reopen a pane the person has since closed
97const autoOpened = atom({ plugin: 'prompt-toc', key: 'autoOpened' } as const, false)
98// a prompt's time is drawn after its label as a space and HH:MM:SS
99const TIME_CELLS = 9
100// a row's share of the list's cost in hundredths of a percent, by largest remainder so the rows add up to exactly 100%
101const splits = (costs: Map<TocEntry, number>, total: number) => {
102 const raw = [...costs].map(([x, cost]) => [x, (cost / total) * 10000] as const)
103 const units = new Map(raw.map(([x, r]) => [x, Math.floor(r)]))
104 let left = 10000 - [...units.values()].reduce((a, b) => a + b, 0)
105 for (const [x] of [...raw].sort((a, b) => (b[1] % 1) - (a[1] % 1))) {
106 if (left-- <= 0) break
107 units.set(x, (units.get(x) ?? 0) + 1)
108 }
109 return units
110}
111// drawn after a row's label as `41.37%` (at most two decimals, none when whole), under 0.01% as `<0.01%`; the tiers are
112// the statusline's (statusline-command.sh tier_color): 50% or more red, 25% or more yellow, else the theme's subtle gray (not the time's dim)
113const share = (units: number) => ({ label: units === 0 ? '<0.01%' : `${+(units / 100).toFixed(2)}%`, color: units >= 5000 ? ('error' as const) : units >= 2500 ? ('warning' as const) : ('subtle' as const), isMinor: units < 2500 })
114// a live call's cost in uncached-input tokens (prices relative to it, the same for every model); the live usage has no
115// 5m / 1h split, so a cache write weighs as a 5m one until a rescan reads the split (scan.py)
116const costed = (u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number; output_tokens: number }): Tokens => ({
117 input: u.input_tokens,
118 cacheRead: u.cache_read_input_tokens,
119 cacheWrite: u.cache_creation_input_tokens,
120 output: u.output_tokens,
121 cost: u.input_tokens + 0.1 * u.cache_read_input_tokens + 1.25 * u.cache_creation_input_tokens + 5 * u.output_tokens,
122})
123const plus = (a: Tokens | undefined, b: Tokens): Tokens =>
124 a ? { input: a.input + b.input, cacheRead: a.cacheRead + b.cacheRead, cacheWrite: a.cacheWrite + b.cacheWrite, output: a.output + b.output, cost: a.cost + b.cost } : b
125// per loop ('' = the main conversation): its prompt's turn has answered, so a turn before the next prompt is a promptless
126// one (a background agent's hand-back) whose steps' tokens wait in `pending` until its answer. Module memory: a reload
127// loses it, and the rescan a reload runs reads the same from the transcript
128const answered = new Set<string>()
129const pending = new Map<string, { tokens: Tokens; since?: string }>()
130
131// a reply's lines in the pane stop at this many terminal cells (a CJK character takes two), the rest drawn as '...'
132const MAX_REPLY_CELLS = 60
133
134// a /compact boundary is a divider entry at this level: below 0 (a prompt) and 1-3 (headings), so no count, number or
135// `level === 0 / > 0` test ever mistakes it for either
136const DIVIDER_LEVEL = -1
137const isPromptEntry = (x: TocEntry) => !x.divider && x.level === 0
138
139// the cost of each row of one loop's list: its own turns' and, in the main list, each subagent's run on the main prompt
140// before its start (the agent's first entry, its task); a row with none is absent
141const costs = (all: readonly TocEntry[], list: readonly TocEntry[], isMain: boolean) => {
142 const out = new Map<TocEntry, number>()
143 const add = (x: TocEntry, n: number) => n > 0 && out.set(x, (out.get(x) ?? 0) + n)
144 for (const x of list) if (x.tokens) add(x, x.tokens.cost)
145 if (!isMain) return out
146 const runs = new Map<string, { ts?: number; cost: number }>()
147 for (const x of all) {
148 if (!x.agent) continue
149 const run = runs.get(x.agent) ?? { ts: x.ts, cost: 0 }
150 run.cost += x.tokens?.cost ?? 0
151 runs.set(x.agent, run)
152 }
153 const prompts = list.filter(x => isPromptEntry(x) && x.ts !== undefined)
154 for (const { ts, cost } of runs.values()) {
155 const owner = ts === undefined ? undefined : prompts.findLast(x => (x.ts ?? Infinity) <= ts)
156 if (owner) add(owner, cost)
157 }
158 return out
159}
160
161// a prompt row → one level-0 entry; a reply row → its headings, or one preview line where it has none (an answer row,
162// the reply that ended its turn, the same marked final); a compact row → one divider (rescan rows arrive as stored,
163// hence the same commandEcho / isEngineText the live classifier applies)
164const toEntries = (id: string, kind: 'prompt' | 'reply' | 'answer' | 'compact', text: string, ts?: number, agent?: string): TocEntry[] =>
165 toRows(id, kind, text, ts).map(x => (agent ? { ...x, agent } : x))
166
167const toRows = (id: string, kind: 'prompt' | 'reply' | 'answer' | 'compact', text: string, ts?: number): TocEntry[] => {
168 if (kind === 'compact') return [{ id, level: DIVIDER_LEVEL, text: 'compacted', divider: true }]
169 if (kind === 'reply' || kind === 'answer') {
170 const found: TocEntry[] = headings(text, Infinity).map((h, at) => ({ id, level: h.level, text: clip(h.text, MAX_REPLY_CELLS), at }))
171 const first = found.length ? '' : lead(text)
172 const out: TocEntry[] = first ? [{ id, level: 1, text: clip(first, MAX_REPLY_CELLS), preview: true }] : found
173 return kind === 'answer' ? out.map(x => ({ ...x, final: true })) : out
174 }
175 return text.trim() && !text.startsWith('[Request interrupted') && !isEngineText(text) ? [{ id, level: 0, text: oneLine(commandEcho(text) ?? text, MAX_LABEL), ...(ts !== undefined && { ts }) }] : []
176}
177
178const MAX_ENTRIES = 500
179// the newest MAX_ENTRIES entries; a divider the cut left leading has nothing above it to mark off
180const cap = (list: TocEntry[]) => {
181 const out = list.slice(-MAX_ENTRIES)
182 return out.length < list.length && out[0]?.divider ? out.slice(1) : out
183}
184
185// cut to `max` cells, '...' standing for the rest
186const clip = (text: string, max: number) => {
187 if (cells(text) <= max) return text
188 let out = ''
189 let width = 0
190 for (const ch of text) {
191 width += cellsOf(ch)
192 if (width > max - 3) break
193 out += ch
194 }
195 return `${out.trimEnd()}...`
196}
197
198// a reply's first prose line outside code fences, its markdown markup (quote, bullet, bold, code, link) stripped
199const lead = (text: string) => {
200 let isFenced = false
201 for (const raw of text.split('\n')) {
202 if (/^\s*(```|~~~)/.test(raw)) {
203 isFenced = !isFenced
204 continue
205 }
206 if (isFenced) continue
207 const line = raw
208 .replace(/^\s*(>\s*|[-*+]\s+|\d+[.)]\s+|\|\s*)+/, '')
209 .replace(/\*\*|__|`/g, '')
210 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
211 .replace(/\s+/g, ' ')
212 .trim()
213 if (line && !/^[-|:\s]+$/.test(line)) return line
214 }
215 return ''
216}
217
218// under each prompt, per turn: its replies' headings, or else only the last reply's preview (the turn's final answer,
219// not the narration between tool calls). Turns a background agent's hand-back starts add no prompt row, so a final
220// entry closes its turn as a prompt would: each turn's answer stays
221const tidy = (list: TocEntry[]) => {
222 const out: TocEntry[] = []
223 let start = 0
224 for (const x of list) {
225 if (x.divider || x.level === 0) {
226 // a divider closes the replies above it: later replies never merge into them
227 out.push(x)
228 start = out.length
229 continue
230 } else if (x.preview) {
231 if (out.slice(start).some(y => !y.preview)) continue
232 out.splice(start)
233 out.push(x)
234 } else {
235 out.splice(start, out.length - start, ...out.slice(start).filter(y => !y.preview), x)
236 }
237 if (x.final) start = out.length
238 }
239 return out
240}
241
242// each loop's entries settle on their own (its prompts, turns and cap), the main conversation's first: the pane draws one
243// loop at a time, so a subagent's rows never close or replace the main conversation's replies
244const settle = (list: TocEntry[]) => {
245 const byAgent = new Map<string, TocEntry[]>()
246 for (const x of list) {
247 const rows = byAgent.get(x.agent ?? '')
248 if (rows) rows.push(x)
249 else byAgent.set(x.agent ?? '', [x])
250 }
251 return [...byAgent.values()].flatMap(rows => cap(tidy(rows)))
252}
253
254const promptMark = (n: number) => `❯ ${n} `
255// a sub-item's text starts where its prompt's does, each heading level two cells further in
256const marker = (entry: TocEntry, n: number) => {
257 if (isPromptEntry(entry)) return promptMark(n)
258 const depth = entry.preview ? 0 : entry.level - 1
259 return `${' '.repeat(depth)}${entry.preview ? '↳' : LEVEL[entry.level]?.glyph}`.padEnd(promptMark(n).length + 2 * depth)
260}
261// `at` tells apart two headings of one reply with the same text
262const keyOf = (x: TocEntry) => (x.divider ? `${x.id}|divider` : `${x.id}|${x.level}|${x.at ?? ''}|${x.text}`)
263
264// the address of a reply's `at`-th heading section, drawn by the AssistantMessage hook below
265const sectionKey = (id: string, at: number) => `${PANE}:${id}:${at}`
266
267// a reply's markdown cut before each heading line outside fences: [text before the first heading, its sections...],
268// so section i + 1 starts at headings()[i]
269const sections = (text: string) => {
270 const out: string[][] = [[]]
271 let isFenced = false
272 for (const line of text.split('\n')) {
273 if (/^\s*(```|~~~)/.test(line)) isFenced = !isFenced
274 else if (!isFenced && headings(line).length) out.push([])
275 out[out.length - 1]?.push(line)
276 }
277 return out.map(x => x.join('\n'))
278}
279
280// rebuilds the index from the session's transcript file (resume, reload, a mod enabled mid-session),
281// keeping rows appended while the scan ran; only the scanner reads the file, so a big transcript stays out of here.
282// The scan replaces everything indexed before it, so an entry an older rule let in never outlives a reload; only a
283// prompt's live time survives it (the transcript's time is for rows stored before this process).
284async function rescan($: EngineInterface, transcript?: string) {
285 try {
286 const sid = await $.session.id()
287 // set before the scan, so the follow ticks during it, or a failed scan (no python3), never start another
288 indexedFor = sid
289 const before = new Set((await read($, entries)).map(keyOf))
290 // shortcut: needs python3 on PATH; rows appended live still index without it
291 const argv = ['python3', `${$.plugin.root}/scripts/scan.py`, sid, ...(transcript ? [transcript] : [])]
292 const ran = await $.process.run(argv, { timeoutMs: 20000 })
293 if (ran.exitCode !== 0) return
294 const rows = JSON.parse(ran.stdout) as [string, 'prompt' | 'reply' | 'answer' | 'compact' | 'tokens', string, string?, string?][]
295 // a tokens row follows a turn: 'INPUT CACHE_READ CACHE_WRITE OUTPUT COST' for the first entry of that uuid (a prompt,
296 // or a promptless turn's answer), summed where a uuid has two
297 const spent = new Map<string, Tokens>()
298 for (const [id, kind, text] of rows) {
299 const n = kind === 'tokens' && typeof text === 'string' ? text.split(' ').map(Number) : []
300 const [input = 0, cacheRead = 0, cacheWrite = 0, output = 0, cost = 0] = n
301 if (n.length === 5 && n.every(Number.isFinite)) spent.set(id, plus(spent.get(id), { input, cacheRead, cacheWrite, output, cost }))
302 }
303 const given = new Set<string>()
304 // the transcript's iso time as epoch ms, the unit $.clock.now() gives live rows; '' / unparsable = no time
305 // a kind this mod does not know is skipped, and so is a non-string text: one bad row must not throw away the whole rebuild
306 const scanned = rows.flatMap(([id, kind, text, iso, agent]) => {
307 const at = Date.parse(iso ?? '')
308 return typeof text === 'string' && (kind === 'prompt' || kind === 'reply' || kind === 'answer' || kind === 'compact') ? toEntries(id, kind, text, Number.isNaN(at) ? undefined : at, agent) : []
309 }).map(x => {
310 const tokens = spent.get(x.id)
311 return tokens && !given.has(x.id) && given.add(x.id) ? { ...x, tokens } : x
312 })
313 const seen = new Set(scanned.map(keyOf))
314 await update($, entries, list => {
315 // a prompt stamped live keeps that time, as message-clock keeps its own: one time per row in both mods
316 const live = new Map(list.flatMap(x => (x.ts === undefined ? [] : [[keyOf(x), x.ts] as const])))
317 const timed = scanned.map(x => (live.has(keyOf(x)) ? { ...x, ts: live.get(keyOf(x)) } : x))
318 return settle([...timed, ...list.filter(x => !seen.has(keyOf(x)) && !before.has(keyOf(x)))])
319 })
320 } catch {}
321}
322
323// a subagent's label in its own list: the Agent call's description, else its id
324async function describe($: EngineInterface, agentId: string) {
325 try {
326 return (await $.agent.list()).find(a => a.id === agentId)?.description || `agent ${agentId.slice(0, 7)}`
327 } catch {
328 return `agent ${agentId.slice(0, 7)}`
329 }
330}
331
332// the one open path: /prompt-toc, /prompt-toc-open, the pane tool and open-on-start (which never takes focus)
333async function openPane($: EngineInterface, focus = true) {
334 // focus is typed `true` only: left out, not false, when not wanted
335 await $.ui.open({ id: PANE, title: TITLE, ...(focus && { focus: true as const }) })
336 await rescan($)
337}
338
339// the timer's write: the first entry (list order = transcript order) whose row is on screen; recomputed from module
340// memory and written only on change, so a stale timer (one that outlives a reload) is harmless
341async function follow($: EngineInterface) {
342 flush = undefined
343 try {
344 // rows of another session on screen (a /resume, a fork, a /clear whose SessionStart never reached this mod):
345 // rebuild from the session now running, so the open pane needs no close and reopen
346 if ((await $.session.id().catch(() => indexedFor)) !== indexedFor) await rescan($)
347 const list = (await read($, entries)).filter(x => !x.divider)
348 const top = list.find(x => shown.has(x.id))
349 // scrolled to the end, a short last exchange leaves an earlier row at the top: mark the bottom-most entry instead,
350 // the last of its loop's list (a reply with headings: its last heading)
351 const bottom = list.findLast(x => shown.has(x.id))
352 const isEnd = bottom !== undefined && ended.has(bottom.id) && list.findLast(x => x.agent === bottom.agent) === bottom
353 const at = isEnd ? bottom : top
354 // nothing indexed on screen (narration between tool calls): keep the last position
355 if (at && keyOf(at) !== (await read($, following))) await update($, following, () => keyOf(at))
356 } catch {}
357}
358
359// the export's file, as a plain relative path under the session cwd: no absolute / drive / network / `~` spelling and no
360// `..` segment (either separator); undefined = refused. The folder's real path is checked separately (symlinks).
361const exportPath = (arg: string) => {
362 if (/^([\\/]|~|[A-Za-z]:)/.test(arg)) return undefined
363 const parts = arg.split(/[\\/]+/).filter(x => x && x !== '.')
364 return parts.length && !parts.includes('..') ? parts.join('/') : undefined
365}
366
367// /prompt-toc-export [path]: the whole index as Markdown into a new file under the cwd; the answer is shown to the person
368// shortcut: check-then-write races with another writer, and a folder not there yet is refused (stat needs it); upgrade if asked
369async function exportToc($: EngineInterface, arg: string) {
370 if (await isOff($)) return OFF_NOTE
371 const list = (await read($, entries)).filter(x => !x.agent)
372 if (!list.length) return 'prompt-toc: nothing indexed yet, nothing to export.'
373 const id = (await $.session.id().catch(() => '')).replace(/[^\w-]/g, '').slice(0, 8)
374 const path = exportPath(arg || `prompt-toc-${id || 'export'}.md`)
375 if (!path) return `prompt-toc: "${arg}" must be a plain relative path inside the working directory.`
376 try {
377 const cut = path.lastIndexOf('/')
378 const dir = cut < 0 ? '.' : path.slice(0, cut)
379 const real = async (p: string) => {
380 const st = await $.fs.stat(p, { resolve: true }).catch(() => undefined)
381 return st?.kind === 'dir' ? st.realPath?.replace(/[\\/]$/, '') : undefined
382 }
383 const [root, folder] = await Promise.all([real('.'), real(dir)])
384 if (!root || !folder || !(folder === root || folder.startsWith(root + (root.includes('\\') ? '\\' : '/')))) {
385 return `prompt-toc: ${path} is outside the working directory, or its folder is not there.`
386 }
387 // exists() follows links, so a dangling symlink reads as absent and a write would go through it: the folder listing shows it
388 // macOS volumes are case-insensitive: 'LINK.md' would write through an existing 'link.md', so names compare case- and NFC-folded
389 const fold = (n: string) => n.normalize('NFC').toLowerCase()
390 const taken = (await $.fs.exists(path)) || (await $.fs.list(dir).catch(() => [])).some(x => fold(x.name) === fold(path.slice(cut + 1)))
391 if (taken) return `prompt-toc: ${path} exists, not overwritten; pass another name.`
392 await $.fs.write(path, toMarkdown(list, { title: PANE }))
393 return `prompt-toc: exported ${list.length} entries to ${path} (under ${root}).`
394 } catch (err) {
395 return `prompt-toc: export failed: ${String(err)}`
396 }
397}
398
399export const register: Register = (on, options) => {
400 on('command.run', { command: 'prompt-toc-update' }, async $ => ({ text: await runUpdate($) }))
401
402 on('command.run', { command: ['prompt-toc-open', 'prompt-toc-close'] }, async ($, e) => {
403 const off = e.command === 'prompt-toc-close'
404 await $.state.set(OFF, off)
405 await $.store.set('isOff', off)
406 if (off) {
407 $.ui.status(undefined)
408 await $.ui.close({ id: PANE })
409 } else {
410 await openPane($)
411 }
412 return { text: off ? OFF_NOTE : 'prompt-toc is on, pane opened.' }
413 })
414
415 on('session.start', async ($, e, next) => {
416 await $.command.register({ name: 'prompt-toc', description: 'Open the prompt/heading index pane, optionally filtered', argumentHint: '[keyword]' })
417 // /prompt-toc-open and /prompt-toc-close; the choice is kept across sessions in $.store
418 try {
419 await $.command.register({ name: 'prompt-toc-open', description: 'Turn prompt-toc on' })
420 await $.command.register({ name: 'prompt-toc-close', description: 'Turn prompt-toc off' })
421 await $.command.register({ name: 'prompt-toc-export', description: 'Export the index as a Markdown file', argumentHint: '[path]' })
422 } catch {}
423 await registerUpdate($)
424 // its own try: a refused registration above must not leave an off mod looking on (default-open would open the pane)
425 try {
426 if ((await $.store.get('isOff')) === true) await $.state.set(OFF, true)
427 } catch {}
428 try {
429 await $.tool.register({
430 name: 'pane',
431 description: 'Open or close the prompt-toc index pane (prompt and heading outline) when the person asks to show or hide it.',
432 inputSchema: {
433 type: 'object',
434 properties: { action: { type: 'string', enum: ['open', 'close'] } },
435 required: ['action'],
436 },
437 isDeferred: false,
438 })
439 } catch {}
440 try {
441 if (await $.fs.exists('/System/Library/CoreServices/SystemVersion.plist')) searchLabel = ' 🔍 '
442 } catch {}
443 // the settings live in the plugin's userConfig (the /config rows); a change made there reloads this module with new options
444 try {
445 await update($, settings, () => loadSettings(k => options[k]))
446 } catch {}
447 const started = await next(e)
448 notifyUpdate($)
449 // open by default: once per process (a refused open is not retried), never when off, never with focus. This open is unasked, so the host places
450 // the pane only on a wide terminal (>= 144 columns, 110 once the person has opened it before) and otherwise
451 // holds it undrawn until the window widens; a refused open must not become an unhandled rejection
452 try {
453 if ((await read($, settings)).openByDefault && !(await isOff($)) && !(await read($, autoOpened))) {
454 await update($, autoOpened, () => true)
455 void openPane($, false).catch(() => rescan($))
456 return started
457 }
458 } catch {}
459 // a reload or a resumed launch fires session.start with the pane possibly already open: rebuild now,
460 // unawaited so a big transcript never holds the first prompt
461 void rescan($)
462 return started
463 })
464
465 // /prompt-toc <keyword> opens the pane filtered; no argument clears the filter
466 on('command.run', { command: 'prompt-toc' }, async ($, e) => {
467 const kw = e.args.trim()
468 await update($, keyword, () => kw)
469 await resetWindow($)
470 await openPane($)
471 return { text: kw ? `prompt-toc pane opened, filtered by "${kw}" (/prompt-toc clears it).` : 'prompt-toc pane opened.' }
472 })
473
474 on('command.run', { command: 'prompt-toc-export' }, async ($, e) => ({ text: await exportToc($, e.args.trim()) }))
475
476 // natural-language open/close: the model calls mcp__prompt-toc__pane (registered in session.start)
477 on('tool.call', { tool: 'mcp__prompt-toc__pane' }, async ($, e) => {
478 const isClose = (e as { action?: string }).action === 'close'
479 if (isClose) await $.ui.close({ id: PANE })
480 else await openPane($)
481 return { result: `prompt-toc pane ${isClose ? 'closed' : 'opened'}.` }
482 })
483
484 on('session.end', async ($, e, next) => {
485 flush?.cancel()
486 flush = undefined
487 shown.clear()
488 ended.clear()
489 answered.clear()
490 pending.clear()
491 indexedFor = undefined
492 // the pane stays open across session ends: /clear starts the index empty, /resume refills it (the first drawn row of the new session rescans)
493 if (e.reason === 'clear' || e.reason === 'resume') {
494 await update($, entries, () => [])
495 await update($, selected, () => '')
496 await update($, following, () => '')
497 await update($, keyword, () => '')
498 await resetWindow($)
499 }
500 return next(e)
501 }).catch(($, e, next) => next(e))
502
503 // an in-process /resume or fork raises no session.start, and classic.SessionStart never reaches a user-tier mod (the
504 // built-in cc-plugin-sec-default sends classic.* past the user tier): the first row of the new session drawn on screen
505 // rescans instead (the UserMessage render hook, by indexedFor).
506 // A live /compact appends no boundary row this mod sees (session.append's compaction rows, session.compact and
507 // classic.PostCompact are all held past the user tier), so the divider comes from a rescan of the transcript once the
508 // context fill reported by session.measure falls by more than half: a compaction's signature, after it is done.
509 // shortcut: a fill that halves without a compaction (a far larger window after a model switch) rescans once, harmless
510 on('session.measure', async ($, e, next) => {
511 const result = await next(e)
512 try {
513 const tokens = e.context.tokens
514 if (tokens !== undefined) {
515 if (lastFill !== undefined && tokens < lastFill / 2) {
516 // the rows before the compact left without an onScreen null: they must not pin the follow mark
517 shown.clear()
518 ended.clear()
519 void rescan($)
520 }
521 lastFill = tokens
522 }
523 } catch {}
524 return result
525 })
526
527 // index typed prompts (level 0) and reply headings (level 1-3) or previews, keyed by row uuid = render requestId
528 on('session.append', async ($, e, next) => {
529 const result = await next(e)
530 try {
531 const row = classify(e)
532 // a queued prompt is no row of the conversation yet: it is indexed once it is sent
533 const isPrompt = row?.kind !== 'reply'
534 // shortcut: the event carries no stored timestamp, so this and message-clock each read the clock after the append
535 // (a second or two after the transcript's time); the two reads can straddle a second, share one stamp if that shows
536 const ts = row && row.source !== 'queued' && isPrompt ? await $.clock.now().catch(() => undefined) : undefined
537 let found = row && row.source !== 'queued' ? toEntries(e.uuid, isPrompt ? 'prompt' : 'reply', row.text, ts, e.agentId) : []
538 // a subagent's first row opens its list: its task brief reads as the agent's description, not the brief's text
539 if (e.agentId && !(await read($, entries)).some(x => x.agent === e.agentId)) {
540 const task: TocEntry = { id: e.uuid, level: 0, text: oneLine(await describe($, e.agentId), MAX_LABEL), agent: e.agentId, ...(ts !== undefined && { ts }) }
541 found = [task, ...found.filter(x => x.level !== 0)]
542 }
543 if (found.length) {
544 await update($, entries, list => {
545 const seen = new Set(list.map(keyOf))
546 return settle([...list, ...found.filter(x => !seen.has(keyOf(x)))])
547 })
548 }
549 // a new prompt: the loop's next steps are its turn's
550 if (found.some(isPromptEntry)) answered.delete(e.agentId ?? '')
551 } catch {}
552 return result
553 })
554
555 // each API call of a turn (main, or a subagent's) adds its usage to the loop's latest prompt, or after that prompt's
556 // turn answered, to the promptless turn's held tokens (see `answered`); a rescan reads the same from the transcript
557 // and replaces it
558 // turn.step streams: yield* forwards the chunks untouched and evaluates to the result
559 on('turn.step', async function* ($, e, next) {
560 const result = yield* next(e)
561 try {
562 const loop = e.agentId ?? ''
563 if (result.usage && answered.has(loop)) {
564 const held = pending.get(loop)
565 const last = held ? undefined : (await read($, entries)).findLast(x => x.agent === e.agentId)
566 pending.set(loop, { tokens: plus(held?.tokens, costed(result.usage)), since: held ? held.since : last && keyOf(last) })
567 } else if (result.usage) {
568 const add = costed(result.usage)
569 await update($, entries, list => {
570 const last = list.findLast(x => x.agent === e.agentId && isPromptEntry(x))
571 return last ? list.map(x => (x === last ? { ...x, tokens: plus(x.tokens, add) } : x)) : list
572 })
573 }
574 } catch {}
575 return result
576 })
577
578 // a loop's turn ended (main, or a subagent's): its last reply is the turn's answer, marked final so a later turn's replies (one a
579 // background agent's hand-back started, with no prompt row) never replace it; a rescan reads the same from stop_reason
580 on('turn.complete', async ($, e, next) => {
581 const result = await next(e)
582 try {
583 await update($, entries, list => {
584 const last = list.findLast(x => x.agent === e.agentId)
585 return last && !last.divider && last.level > 0 && !last.final ? list.map(x => (x.id === last.id ? { ...x, final: true as const } : x)) : list
586 })
587 // a promptless turn's held tokens go on its answer (the reply's first entry), or with no new reply on the prompt
588 const loop = e.agentId ?? ''
589 const held = pending.get(loop)
590 pending.delete(loop)
591 answered.add(loop)
592 if (held) {
593 await update($, entries, list => {
594 const mine = list.filter(x => x.agent === e.agentId)
595 const last = mine.at(-1)
596 const owner = last && !last.divider && last.level > 0 && keyOf(last) !== held.since ? mine.find(x => x.id === last.id) : mine.findLast(isPromptEntry)
597 return owner ? list.map(x => (x === owner ? { ...x, tokens: plus(x.tokens, held.tokens) } : x)) : list
598 })
599 }
600 } catch {}
601 return result
602 })
603
604 // follow-viewport: note which prompt/reply rows the terminal shows (onScreen: a row range, null = off screen, absent =
605 // not reported); per row, not per heading section. Observes only: whatever next draws goes back untouched, onScreen
606 // included (a rewrite that drops it is skipped). It never reads `following`, or the write would redraw it in a loop.
607 // perf: a scroll tick re-reports the edge rows; only a row entering or leaving the screen schedules the one coalesced
608 // timer, and the timer writes only when the topmost entry changed
609 // shortcut: a refused clock.after never runs follow(), so `flush` stays set and the mark stops until session.end;
610 // age it out if a host refuses timers
611 // shortcut: a far jump that does not null the rows it left keeps them in `shown` (unverified on 2.1.300); prune by
612 // range if the mark sticks after a pane jump
613 on('ui.render', { component: ['UserMessage', 'AssistantMessage'] }, ($, e, next) => {
614 const at = e.props.onScreen
615 // its last row in view: `last` is inclusive, `of` the whole extent
616 const isEnded = !!at && at.last >= at.of - 1
617 if (at !== undefined && e.surface === 'terminal' && (!!at !== shown.has(e.requestId) || isEnded !== ended.has(e.requestId))) {
618 if (at) shown.add(e.requestId)
619 else shown.delete(e.requestId)
620 if (isEnded) ended.add(e.requestId)
621 else ended.delete(e.requestId)
622 // a timer refused by the host must never fail the message's drawing
623 try {
624 flush ??= $.clock.after(50, () => void follow($))
625 } catch {}
626 }
627 return next(e)
628 })
629
630 // a reply with headings is drawn one engine block per heading section, each in a keyed Box, so a heading's entry
631 // scrolls to the heading itself, not to the reply's top
632 // shortcut: a plugin beneath that decorates each AssistantMessage drawing decorates each section; draw it whole if that shows
633 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
634 const parts = sections(e.props.text)
635 if (parts.length < 2 || (await isOff($))) return next(e)
636 const { Box } = $.ui.resolve(e)
637 const drawn = []
638 for (const [i, text] of parts.entries()) {
639 if (i === 0 && !text.trim()) continue
640 const block = await next({ ...e, props: { ...e.props, text, isFirstOfReply: e.props.isFirstOfReply && drawn.length === 0 } })
641 drawn.push(
642 <Box key={i > 0 ? sectionKey(e.requestId, i - 1) : undefined} flexDirection="column">
643 {block}
644 </Box>,
645 )
646 }
647 return <Box flexDirection="column">{drawn}</Box>
648 })
649
650 // the pane draws its own window under a fixed toolbar: the person's wheel and scroll keys (and any $.ui.scroll on the
651 // pane) move that window here, and with no `next` the engine's never moves, so the toolbar never scrolls away (a bar
652 // pinned to `scroll.offset` lags a frame instead: the engine moves first, the hook is asked after, and rows flash
653 // through). Steps over the rows last drawn; none drawn yet: no move
654 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
655 try {
656 if (layout && e.by) {
657 const { heights, budget } = layout
658 const toEdge = isEdge(e.by, e.bodyRows, e.contentRows)
659 await update($, winTop, cur => (toEdge ? clamp(e.by > 0 ? Infinity : 0, heights, budget) : step(heights, cur, e.by, budget)))
660 }
661 } catch {}
662 return {}
663 })
664
665 // the settings popover closes when the person goes elsewhere (see dismiss)
666 on('ui.press', { plugin: 'prompt-toc', component: 'Pane', requestId: PANE }, async ($, e, next) => {
667 await dismiss($, e.element)
668 return next(e)
669 })
670 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
671 await dismiss($, e.element)
672 return next(e)
673 })
674 on('ui.message', { component: 'Pane', requestId: PANE, element: BACKDROP }, async ($, e, next) => {
675 await dismiss($)
676 return next(e)
677 })
678 on('ui.message', { component: 'Pane', requestId: PANE, element: SEARCH_ROW }, async ($, e, next) => {
679 await focusOn($, 'search')
680 return next(e)
681 })
682
683 // the pane is this mod's own, so it draws without next
684 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
685 const elements = $.ui.resolve(e)
686 const { Box, Text, Button } = elements
687 const Client = 'Client' in elements ? elements.Client : undefined
688 // mobile has no Input: no search button there, /prompt-toc <keyword> still filters
689 const Input = 'Input' in elements ? elements.Input : undefined
690 // the toolbar is a solid accent bar (the colour of a prompt's ❯, set apart from the grey selected-row band) in theme keys,
691 // so it follows light and dark: an inactive label is the bar's inverse text, the active one an inverted pill
692 const chrome = (active: boolean, text: string) => (
693 <Text color={active ? 'claude' : 'inverseText'} backgroundColor={active ? 'inverseText' : 'claude'} weight={active ? 'bold' : 'regular'}>
694 {text}
695 </Text>
696 )
697 // the view switch's two sides: the active one is bold (and its ◉), never a pill. The pointer inverts the hovered
698 // button's colours, and an inactive side inverted IS the pill (accent text on the inverse), so a pill for the active
699 // side made a hovered inactive side look just like it: both seemed to change, though only one was under the pointer
700 const side = (active: boolean, text: string) => (
701 <Text color="inverseText" backgroundColor="claude" weight={active ? 'bold' : 'regular'}>
702 {text}
703 </Text>
704 )
705 if (await isOff($)) return <Text dimColor>prompt-toc is off (/prompt-toc-open).</Text>
706 // one loop at a time: the agent whose transcript is in view, else the main conversation
707 const all = await read($, entries)
708 const list = all.filter(x => x.agent === e.props.view?.agentId)
709 const current = await read($, selected)
710 const followed = await read($, following)
711 const prefs = await read($, settings)
712 // a click outside the pane (transcript, prompt) raises no ui.press or ui.focus here: the keyboard leaving is
713 // the one signal, drawn as isFocused turning false, and it dismisses as a press elsewhere does. Deferred, as
714 // a state write is refused while this hook draws; the redraw it causes sees no edge, so it writes once
715 if (wasFocused && !e.props.isFocused) $.clock.after(0, () => void dismiss($))
716 wasFocused = e.props.isFocused
717 const isSettingsOpen = await read($, settingsOpen)
718 const isPromptsOnly = await read($, promptsOnly)
719 const kw = await read($, keyword)
720 const isSearchOpen = Input !== undefined && (await read($, searchOpen))
721 const isFolded = await read($, foldOld)
722 const top = await read($, winTop)
723
724 // a row id scrolls the transcript to that row, or with `at` to that heading's section
725 const jump = async (to: string, pressed?: string, at?: number) => {
726 try {
727 const target = at === undefined ? { requestId: to } : { key: sectionKey(to, at) }
728 // scroll before any other await: a transcript row moves only while the call still answers the press
729 const scrolled = $.ui.scroll({ to: target, block: 'start' })
730 if (pressed !== undefined) await update($, selected, () => pressed)
731 let { deny } = await scrolled
732 // a section not drawn as keyed (the reply's drawn text differs from the stored one): its reply's top
733 if (deny && at !== undefined) ({ deny } = await $.ui.scroll({ to: { requestId: to }, block: 'start' }))
734 if (deny) $.ui.toast(`prompt-toc: ${deny}`)
735 } catch (err) {
736 $.ui.toast(`prompt-toc: ${String(err)}`)
737 }
738 }
739
740 // a new keyword starts the window at the top, as /prompt-toc <keyword> does
741 const filterBy = (text: string) => void Promise.all([update($, keyword, () => text), resetWindow($)])
742 // folding hands the ring to 🔍 first: left on the field as it goes, the engine re-lands it on the row taking the
743 // field's place in the focus order (the first item), drawn inverted as if selected
744 const foldSearch = async () => {
745 await focusOn($, 'search-toggle')
746 await update($, searchOpen, () => false)
747 }
748 // unfolding puts the keys in the field
749 const toggleSearch = async () => {
750 try {
751 if (await read($, searchOpen)) return await foldSearch()
752 await update($, searchOpen, () => true)
753 await focusOn($, 'search')
754 } catch {}
755 }
756
757 // flip one setting and mirror it to its /config row (userConfig field), so the menu shows the same value
758 const flip = async (key: keyof Settings) => {
759 try {
760 const next = toggleSetting(await read($, settings), key)
761 await update($, settings, () => next)
762 const { deny } = await $.config.set({ key: `prompt-toc.${key}`, value: next[key] })
763 if (deny) $.ui.toast(`prompt-toc: ${deny}`)
764 } catch (err) {
765 $.ui.toast(`prompt-toc: ${String(err)}`)
766 }
767 }
768
769 // THE one derived list: everything below that draws rows or measures their height reads `visible`, never `list`
770 // (the divider and the keyword filter feed the same view())
771 const shownRows = view(list, { promptsOnly: isPromptsOnly, keyword: kw })
772 // folded: nothing above the last divider is drawn, so the old rows no longer take window rows
773 const cut = shownRows.findLastIndex(x => x.divider)
774 const above = shownRows.slice(0, Math.max(cut, 0)).filter(x => !x.divider).length
775 const visible = isFolded && above > 0 ? shownRows.slice(cut) : shownRows
776 // the followed entry's row, or where the view hides it (prompts-only) the nearest drawn row above it
777 const drawnKeys = new Set(visible.map(keyOf))
778 const followRow = followed ? list.slice(0, list.findIndex(x => keyOf(x) === followed) + 1).findLast(x => drawnKeys.has(keyOf(x))) : undefined
779 const followKey = followRow && keyOf(followRow)
780
781 // rendered-outline look: prompts numbered, headings as indented bullets by level
782 // footer counts come from the full `list`, so prompts-only keeps meaningful totals and says what it hides
783 // prompt numbers come from the full `list` too: a keyword filter drops prompts, the rest keep their numbers
784 // (a heading takes its prompt's number: its marker pads to that prompt's width)
785 const numbers = new Map<TocEntry, number>()
786 let count = 0
787 for (const x of list) numbers.set(x, isPromptEntry(x) ? ++count : count)
788 const isNoMatch = list.length > 0 && !visible.some(x => !x.divider)
789 const prompts = list.filter(isPromptEntry).length
790 const titled = list.filter(x => x.level > 0 && !x.preview).length
791 // each row's share of the list's cost-weighted tokens (Show cost share)
792 const costOf = costs(all, list, e.props.view?.agentId === undefined)
793 const total = [...costOf.values()].reduce((a, b) => a + b, 0)
794 const units = total > 0 ? splits(costOf, total) : new Map<TocEntry, number>()
795 const shareOf = (x: TocEntry) => {
796 const u = prefs.showTokens ? units.get(x) : undefined
797 return u === undefined ? undefined : share(u)
798 }
799 // rows above the last divider are from before a /compact: still listed, but no longer in the transcript to scroll to
800 const lastDivider = visible.findLastIndex(x => x.divider)
801
802 // the rows each drawn item takes: a wrapped label and a gap above each later prompt, a divider one, then the
803 // footer and its gap (the settings popover is absolute: it adds no rows)
804 const heights = visible.map((entry, i) => {
805 if (entry.divider) return 1
806 const cost = shareOf(entry)
807 const width = Math.max(1, e.props.bodyColumns - cells(marker(entry, numbers.get(entry) ?? 0)) - (prefs.showTime && entry.ts !== undefined ? TIME_CELLS : 0) - (cost ? 1 + cells(cost.label) : 0))
808 return wrappedRows(entry.text, width) + (isPromptEntry(entry) && i > 0 ? 1 : 0)
809 })
810 if (list.length > 0) heights.push(2)
811 // in a body the engine windows (`scroll`), the tree is the window: the fixed rows (toolbar, filter, the empty /
812 // no-match line), then the items that fit, a row short of the body for the jump chips; the engine's window never
813 // moves (ui.scroll above). Inline (no `scroll`) the frame fits the tree: every item
814 const scroll = e.props.scroll
815 const fixedRows = 1 + (isSearchOpen || kw ? 1 : 0) + (list.length === 0 || isNoMatch ? 1 : 0)
816 const budget = scroll ? scroll.bodyRows - 1 - fixedRows : Infinity
817 const first = scroll ? clamp(top, heights, budget) : 0
818 const end = scroll ? fit(heights, first, budget) : heights.length
819 if (scroll) layout = { heights, budget }
820 // a jump shows while its end of the list is out of the window
821 const toTop = first > 0
822 const toEnd = end < heights.length
823 // while the rows overflow, the tree spans the whole body: an inline pane's frame fits its tree, so a shorter one cut
824 // off the chip band's row (absolute at bodyRows - 1) and resized the frame on every scroll
825 const isPaged = scroll !== undefined && (toTop || toEnd)
826 return (
827 <Box flexDirection="column" minHeight={isPaged ? scroll.bodyRows : undefined}>
828 {/* the toolbar is the first row, in flow, above the pane's own window of rows: it never scrolls */}
829 <Box flexDirection="row" backgroundColor="claude">
830 {/* the segmented control shows the CURRENT view, not the action; each side sets it explicitly. Always drawn, so the header never changes shape */}
831 <Button key="view-all" plain onPress={() => void Promise.all([update($, promptsOnly, () => false), resetWindow($)])}>
832 {side(!isPromptsOnly, ` ${isPromptsOnly ? '○' : '◉'} ${'All'.padEnd(VIEW_WIDTH)} `)}
833 </Button>
834 <Button key="view-prompts" plain onPress={() => void Promise.all([update($, promptsOnly, () => true), resetWindow($)])}>
835 {side(isPromptsOnly, ` ${isPromptsOnly ? '◉' : '○'} ${'Prompts'.padEnd(VIEW_WIDTH)} `)}
836 </Button>
837 <Box flexGrow={1} />
838 {Input && (
839 <Button key="search-toggle" plain onPress={() => void toggleSearch()}>
840 {chrome(isSearchOpen, searchLabel)}
841 </Button>
842 )}
843 <Button key="settings-toggle" plain onPress={() => void update($, settingsOpen, open => !open)}>
844 {chrome(isSettingsOpen, ' ⚙ Settings ')}
845 </Button>
846 </Box>
847 {isSearchOpen && Input ? (
848 // one row, like the folded chip below: Enter keeps the filter and folds the field back into that chip
849 <Box flexDirection="row">
850 {/* the Input takes a click only on its own cells: a pointer layer spans the whole row under it (painted first)
851 and focuses it. Not a blank Button (the pointer inverts it); sized in cells like the settings backdrop, as a
852 Client sized by flexGrow alone drew zero cells wide and caught nothing */}
853 {Client && (
854 <Box position="absolute" top={0} left={0}>
855 <Client key={SEARCH_ROW} module="./backdrop.ts" width={e.props.bodyColumns} height={1} />
856 </Box>
857 )}
858 <Box flexShrink={1}>
859 <Input key="search" placeholder="filter rows" value={kw} submitLabel="done" onInput={filterBy} onSubmit={() => void foldSearch()} />
860 </Box>
861 <Box flexGrow={1} />
862 {kw && (
863 <Button key="filter-clear" plain dimColor hover={CLEAR_HOVER} onPress={() => filterBy('')}>
864 {' ✕ '}
865 </Button>
866 )}
867 </Box>
868 ) : (
869 // only the ✕ presses and lights: the keyword beside it is a plain label
870 kw && (
871 <Box flexDirection="row">
872 <Text dimColor>{` filter: ${kw} `}</Text>
873 <Button key="filter-clear" plain dimColor hover={CLEAR_HOVER} onPress={() => filterBy('')}>
874 {' ✕ '}
875 </Button>
876 </Box>
877 )
878 )}
879 {list.length === 0 && <Text dimColor>No prompts yet. Send one and it appears here.</Text>}
880 {isNoMatch && <Text dimColor>no matches</Text>}
881 {visible.slice(first, end).map((entry, at) => {
882 const i = first + at
883 if (entry.divider) {
884 return (
885 <Box key={`row-${i}`} flexDirection="row">
886 <Text dimColor>── compacted ──</Text>
887 {i === lastDivider && above > 0 && (
888 // its own button: the divider's text stays one plain Text
889 <Button key="fold-old" plain dimColor onPress={() => void Promise.all([update($, foldOld, f => !f), resetWindow($)])}>
890 {isFolded ? ` ▴ show ${above} earlier ` : ' ▴ hide earlier '}
891 </Button>
892 )}
893 </Box>
894 )
895 }
896 const isP = isPromptEntry(entry)
897 const n = numbers.get(entry) ?? 0
898 const isOld = i < lastDivider
899 const bg = keyOf(entry) === current ? 'userMessageBackground' : undefined
900 // the follow mark is subtler than a press's band, and a pressed row shows only the band
901 const isFollowed = !bg && keyOf(entry) === followKey
902 const cost = shareOf(entry)
903 return (
904 // marker and label side by side: a Button holds only text, so a wrapped label hangs under its own first column
905 <Box key={`row-${i}`} flexDirection="row" marginTop={isP && i > 0 ? 1 : 0} backgroundColor={bg}>
906 <Box flexShrink={0}>
907 {isP ? (
908 <Text color="claude" weight="bold" dimColor={isOld} backgroundColor={bg}>{marker(entry, n)}</Text>
909 ) : (
910 <Text color={entry.preview ? 'subtle' : LEVEL[entry.level]?.color} dimColor={isOld} backgroundColor={bg}>{marker(entry, n)}</Text>
911 )}
912 </Box>
913 <Box flexGrow={1} flexShrink={1}>
914 <Button key={`toc-${i}`} plain onPress={() => (isOld ? $.ui.toast(OLD_ROW) : void jump(entry.id, keyOf(entry), entry.at))}>
915 <Text weight={isP || (entry.level === 1 && !entry.preview) ? 'bold' : 'regular'} dimColor={entry.level === 3 || entry.preview || isOld} underline={isFollowed} backgroundColor={bg}>
916 {entry.text}
917 </Text>
918 </Button>
919 </Box>
920 {cost && (
921 <Box flexShrink={0} marginLeft={1}>
922 <Text color={cost.color} weight={cost.isMinor ? 'regular' : 'bold'} dimColor={isOld} backgroundColor={bg}>
923 {cost.label}
924 </Text>
925 </Box>
926 )}
927 {prefs.showTime && entry.ts !== undefined && (
928 <Box flexShrink={0} marginLeft={1}>
929 <Text dimColor backgroundColor={bg}>{fmt(entry.ts)}</Text>
930 </Box>
931 )}
932 </Box>
933 )
934 })}
935 {list.length > 0 && end === heights.length && (
936 <Box marginTop={1}>
937 <Text dimColor>{`${prompts} prompts · ${titled} headings${isPromptsOnly && titled > 0 ? ' hidden' : ''}${prefs.showTokens && total > 0 ? ` · ≈ ${short(Math.round(total))} tokens (cost-weighted)` : ''}`}</Text>
938 </Box>
939 )}
940 {isSettingsOpen && Client && (
941 // a blank cell (a gap between prompts, the toolbar's empty middle) holds no Button, so a click there raises no
942 // press: this layer under the popover catches it (backdrop.ts) and dismisses. Surfaces with no Client skip it.
943 // Sized in cells, not %: the tree is only as tall as its rows, and a docked body runs on below the footer
944 <Box position="absolute" top={0} left={0}>
945 <Client key={BACKDROP} module="./backdrop.ts" width={e.props.bodyColumns} height={scroll ? scroll.bodyRows : fixedRows + heights.reduce((a, b) => a + b, 0)} />
946 </Box>
947 )}
948 {isSettingsOpen && (
949 // a popover under the gear: absolute at the toolbar's row below, so unfolding it never reflows the list
950 <Box position="absolute" top={1} right={0} flexDirection="column" backgroundColor="userMessageBackground">
951 {KEYS.map(key => (
952 <Button key={`setting-${key}`} plain onPress={() => void flip(key)}>
953 {' '}{prefs[key] ? <Text color="claude">[x]</Text> : '[ ]'}{` ${LABELS[key].padEnd(labelWidth)} `}
954 </Button>
955 ))}
956 </Box>
957 )}
958 {isPaged && (
959 // a dim chip on a subtle band at the window's bottom right: it covers only its own cells, brightens on hover;
960 // the padding sits inside each label so the whole band, not just the glyphs, presses and lights
961 <Box position="absolute" top={scroll.bodyRows - 1} right={0} flexDirection="row" backgroundColor="userMessageBackground">
962 {toTop && (
963 <Button key="jump-top" plain dimColor onPress={() => void update($, winTop, () => 0)}>
964 {' ▲ top '}
965 </Button>
966 )}
967 {toTop && toEnd && <Text dimColor>·</Text>}
968 {toEnd && (
969 <Button key="jump-end" plain dimColor onPress={() => void update($, winTop, () => lastFirst(heights, budget))}>
970 {' ▼ end '}
971 </Button>
972 )}
973 </Box>
974 )}
975 </Box>
976 )
977 })
978}
979hooks/shared/fmt.ts 9 lines1// generated from mods/_shared/fmt.ts by mods/_shared/sync.py: edit the source there, then run it
2const pad = (n: number) => String(n).padStart(2, '0')
3
4// ms since epoch as local-time HH:MM:SS
5export const fmt = (ms: number) => {
6 const d = new Date(ms)
7 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
8}
9hooks/settings.ts 24 lines1import type { Settings } from '../types'
2export type { Settings }
3
4// the settings popover's items, top to bottom. Each key is also its userConfig field (the /config row) and every one
5// starts on; a Record, so a setting added to the Settings type without an item here fails to compile
6export const LABELS: Record<keyof Settings, string> = {
7 openByDefault: 'Open pane on start',
8 showTime: 'Show time',
9 showTokens: 'Show cost share',
10}
11export const KEYS = Object.keys(LABELS) as (keyof Settings)[]
12
13const fill = (value: (k: keyof Settings) => boolean) => Object.fromEntries(KEYS.map(k => [k, value(k)])) as Settings
14
15export const DEFAULTS: Settings = fill(() => true)
16
17export const loadSettings = (get: (key: string) => unknown): Settings =>
18 fill(k => {
19 const v = get(k)
20 return typeof v === 'boolean' ? v : DEFAULTS[k]
21 })
22
23export const toggleSetting = (s: Settings, key: keyof Settings): Settings => ({ ...s, [key]: !s[key] })
24hooks/filter.ts 20 lines1import type { TocEntry } from '../types'
2
3// The one derived visible list: keyword (case-insensitive) keeps each match plus its parent prompt (the nearest
4// level-0 row before it, within the same /compact span); promptsOnly then drops every reply heading, so a heading
5// match shows as its parent prompt alone. A divider always survives.
6export const view = (list: readonly TocEntry[], opts: { promptsOnly?: boolean; keyword?: string }) => {
7 const kw = (opts.keyword ?? '').trim().toLowerCase()
8 const hit = (e: TocEntry) => !kw || e.text.toLowerCase().includes(kw)
9 const keep = new Set<TocEntry>()
10 let parent: TocEntry | undefined
11 for (const e of list) {
12 if (e.divider) parent = undefined
13 else if (e.level === 0) parent = e
14 if (e.divider || !hit(e)) continue
15 keep.add(e)
16 if (parent) keep.add(parent)
17 }
18 return list.filter(e => e.divider || (keep.has(e) && !(opts.promptsOnly && e.level > 0)))
19}
20hooks/export.ts 24 lines1import type { TocEntry, Tokens } from '../types'
2import { fmt } from './shared/fmt'
3
4// 1234 → 1.2k, 12345 → 12k, millions as M
5export const short = (n: number) => (n < 1000 ? String(n) : n < 1e6 ? `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k` : `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0)}M`)
6const breakdown = (t: Tokens) => ` \`in ${short(t.input)} · cache read ${short(t.cacheRead)} · cache write ${short(t.cacheWrite)} · out ${short(t.output)}\``
7
8// The TOC as a Markdown outline: a prompt is a numbered item, a heading a bullet nested under its prompt (indent = the prompt marker's width + 2 per extra level), a
9// /compact divider a rule. Prompt numbers run on across a divider.
10export const toMarkdown = (entries: readonly TocEntry[], opts: { title?: string } = {}) => {
11 const out: string[] = opts.title ? [`# ${opts.title}`, ''] : []
12 let n = 0
13 for (const e of entries) {
14 if (e.divider) {
15 out.push('', '---', '')
16 continue
17 }
18 const time = (e.ts === undefined ? '' : ` \`${fmt(e.ts)}\``) + (e.tokens ? breakdown(e.tokens) : '')
19 const text = e.text.replace(/\s*\n\s*/g, ' ')
20 out.push(e.level === 0 ? `${++n}. ${text}${time}` : `${' '.repeat(String(n).length + 2 * e.level)}- ${text}${time}`)
21 }
22 return out.join('\n') + '\n'
23}
24hooks/shared/rows.ts 85 lines1// generated from mods/_shared/rows.ts by mods/_shared/sync.py: edit the source there, then run it
2// Which transcript rows are the person's input or a reply, and how each reads: one rule per way a row arrives.
3// Pure (no `$`), so any mod can classify a `session.append` row and agree with the others on it.
4
5// the parts of `session.append`'s input the rules read
6export type AppendedRow = {
7 door: string
8 origin: { kind: string }
9 message: { type: string; name?: string; isMeta?: true; content: readonly { type: string; text?: string }[] }
10}
11
12// prompt: typed and sent · command: a typed slash command (`/commit`, `/compact`) · local-command: a plugin or
13// local command, echoed as a system row · mid-turn: sent while a turn ran · queued: waiting in the queue (no row
14// of the conversation) · reply: an assistant text row
15export type RowSource = 'prompt' | 'command' | 'local-command' | 'mid-turn' | 'queued' | 'reply'
16
17// text: an input as the transcript shows it (`/name args` for a command), or a reply's whole text
18export type Row = { kind: 'input' | 'reply'; source: RowSource; text: string }
19
20type Rule = { source: RowSource; read: (row: AppendedRow, text: string) => string | undefined }
21
22const textOf = (row: AppendedRow) => row.message.content.flatMap(b => (b.type === 'text' && b.text ? [b.text] : [])).join('\n')
23
24const isPersonUser = (row: AppendedRow) => row.message.type === 'user' && !row.message.isMeta
25
26// `/name args` from a slash command's stored form (`<command-name>` / `<command-args>` tags), `! cmd` from a
27// shell escape's (`<bash-input>`)
28export const commandEcho = (text: string) => {
29 const bash = /^\s*<bash-input>([\s\S]*?)<\/bash-input>/.exec(text)?.[1]?.trim()
30 if (bash) return `! ${bash}`
31 const name = /<command-name>(.*?)<\/command-name>/.exec(text)?.[1]
32 if (!name) return undefined
33 const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text)?.[1]?.trim()
34 return args ? `${name} ${args}` : name
35}
36
37// text the engine wrote in the person's place (`<local-command-stdout>`, `<bash-stdout>`, `<task-notification>`, ...):
38// it opens with a kebab-case tag, so one rule keeps out tags not seen yet. A command echo is typed input.
39// shortcut: a prompt that itself opens with a custom-element tag (`<my-card>`) is dropped too; check the tag name if that shows
40const ENGINE_TAG = /^\s*<[a-z]+(?:-[a-z]+)+>/
41export const isEngineText = (text: string) => ENGINE_TAG.test(text) && commandEcho(text) === undefined
42
43// shortcut: a mid-turn message is only readable from the engine's reminder wording; update if that wording changes
44const MID_TURN = /while you were working:\n([\s\S]*?)\n\nThis is how/
45
46// first match wins
47const RULES: readonly Rule[] = [
48 // a command's echo by its stored form, whatever door the engine stores it by (`/clear` and `/model` use others)
49 { source: 'command', read: (row, text) => (isPersonUser(row) && (row.door === 'command' || commandEcho(text) !== undefined) ? (commandEcho(text) ?? text) : undefined) },
50 { source: 'prompt', read: (row, text) => (isPersonUser(row) && (row.door === 'prompt' || (row.door === 'delivery' && row.origin.kind === 'composer')) ? text : undefined) },
51 { source: 'queued', read: (row, text) => (isPersonUser(row) && row.door === 'queue' ? (commandEcho(text) ?? text) : undefined) },
52 { source: 'local-command', read: (row, text) => (row.message.type === 'system' && row.message.name === 'local_command' ? commandEcho(text) : undefined) },
53 { source: 'mid-turn', read: (row, text) => (row.message.name === 'queued_command' ? MID_TURN.exec(text)?.[1] : undefined) },
54 { source: 'reply', read: (row, text) => (row.door === 'response' ? text : undefined) },
55]
56
57export const classify = (row: AppendedRow): Row | undefined => {
58 const text = textOf(row)
59 for (const rule of RULES) {
60 const read = rule.read(row, text)
61 if (read === undefined) continue
62 if (!read.trim() || read.startsWith('[Request interrupted') || (rule.source !== 'reply' && isEngineText(read))) return undefined
63 return { kind: rule.source === 'reply' ? 'reply' : 'input', source: rule.source, text: read }
64 }
65 return undefined
66}
67
68// the first line, cut to `max` characters; a pasted block's `<pasted_content id>` tags are not text
69export const oneLine = (text: string, max: number) => {
70 const line = (text.replace(/<\/?pasted_content[^>]*>/g, '').trim().split('\n')[0] ?? '').trim()
71 return line.length > max ? `${line.slice(0, max - 1)}…` : line
72}
73
74// markdown # / ## / ### lines outside ``` fences
75export const headings = (text: string, max = 72) => {
76 const found: { level: number; text: string }[] = []
77 let isFenced = false
78 for (const line of text.split('\n')) {
79 if (/^\s*(```|~~~)/.test(line)) isFenced = !isFenced
80 const m = !isFenced && /^(#{1,3})\s+(.+?)\s*#*\s*$/.exec(line)
81 if (m) found.push({ level: (m[1] ?? '').length, text: oneLine(m[2] ?? '', max) })
82 }
83 return found
84}
85hooks/shared/update.ts 82 lines1// generated from mods/_shared/update.ts by mods/_shared/sync.py: edit the source there, then run it
2import type { EngineInterface } from 'claude-code'
3
4// the public marketplace the mods are published to (skills/marketplace-publish). A copy loaded from anywhere else
5// (the local dev marketplace, --plugin-dir) has no upstream to compare with: it is never checked or updated
6export const MARKETPLACE = 'weskao-agent-plugins'
7const CATALOG = `https://raw.githubusercontent.com/weskao/${MARKETPLACE}/main/.claude-plugin/marketplace.json`
8// update-kit's default (ai-accounts); a raw.githubusercontent.com GET has no API rate limit and is CDN-cached ~5 min
9const TTL = 10 * 60 * 1000
10// { at, latest }: the last fetch's time and result, reused until TTL so every session start inside it still toasts
11const CHECK = 'updateCheck'
12
13// >0 when a is newer; dotted numbers only, a pre-release suffix (`-beta`) is ignored
14export const cmpVersion = (a: string, b: string): number => {
15 const pa = a.replace(/-.*/, '').split('.').map(Number)
16 const pb = b.replace(/-.*/, '').split('.').map(Number)
17 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
18 const d = (pa[i] || 0) - (pb[i] || 0)
19 if (d) return d
20 }
21 return 0
22}
23
24// an installed plugin lives under plugins/cache/<marketplace>/<name>/<version>
25export const isPublished = (root: string) => root.includes(`/${MARKETPLACE}/`)
26
27// the newer upstream version, at most one fetch per TTL; undefined when current, unpublished, offline or unparsable
28export async function checkUpdate($: EngineInterface, now = Date.now(), root = $.plugin.root): Promise<string | undefined> {
29 if (!isPublished(root)) return
30 try {
31 const local = JSON.parse((await $.fs.read(`${root}/.claude-plugin/plugin.json`)) as string).version as string
32 const last = (await $.store.get(CHECK)) as { at?: number; latest?: string } | undefined
33 let latest = last?.latest
34 // a clock that moved backwards refetches instead of trusting the entry
35 if (typeof last?.at !== 'number' || now - last.at < 0 || now - last.at >= TTL) {
36 // stamped before the fetch: a failing network is not retried on every launch
37 await $.store.set(CHECK, { at: now })
38 const res = await $.http.fetch(CATALOG)
39 if (!res.ok) return
40 const plugins = JSON.parse(res.text).plugins as { name: string; version?: string }[]
41 latest = plugins.find(p => p.name === $.plugin.name)?.version
42 await $.store.set(CHECK, { at: now, latest })
43 }
44 return latest && local && cmpVersion(latest, local) > 0 ? latest : undefined
45 } catch {
46 return
47 }
48}
49
50// /<name>-update: refresh the marketplace, then update this plugin; the new code loads after a restart
51export async function runUpdate($: EngineInterface, root = $.plugin.root): Promise<string> {
52 const id = `${$.plugin.name}@${MARKETPLACE}`
53 if (!isPublished(root)) return `${$.plugin.name} was not installed from ${MARKETPLACE}, so it has nothing to update from. To switch: claude plugin install ${id}`
54 const out: string[] = []
55 for (const argv of [['claude', 'plugin', 'marketplace', 'update', MARKETPLACE], ['claude', 'plugin', 'update', id]]) {
56 try {
57 const r = await $.process.run(argv, { timeoutMs: 120_000 })
58 if (r.exitCode !== 0) return `\`${argv.join(' ')}\` failed:\n${(r.stderr || r.stdout).trim()}`
59 out.push(r.stdout.trim())
60 } catch (err) {
61 return `\`${argv.join(' ')}\` could not run (${String(err)}). Run it in a terminal instead.`
62 }
63 }
64 await $.store.delete(CHECK)
65 return `${out.filter(Boolean).join('\n')}\nRestart Claude Code to load the new ${$.plugin.name}.`
66}
67
68// a module may hook session.start once, so each mod calls these from its own hook and answers
69// `command.run` for `<name>-update` with runUpdate
70export async function registerUpdate($: EngineInterface) {
71 try {
72 await $.command.register({ name: `${$.plugin.name}-update`, description: `Update ${$.plugin.name} to the latest version` })
73 } catch {}
74}
75
76// call unawaited after next(e): the network never holds the first prompt
77export function notifyUpdate($: EngineInterface) {
78 void checkUpdate($)
79 .then(v => v && $.ui.toast(`${$.plugin.name} ${v} is available: run /${$.plugin.name}-update`))
80 .catch(() => {})
81}
82hooks/window.ts 87 lines1// The pane draws its own window over its rows, so the toolbar above them never moves: `heights` are the rows each item
2// takes (the entries, then the footer), `first` the index of the topmost item shown, `budget` the rows under the
3// fixed header. Whole items only.
4// shortcut: the window moves in whole entries, so a wheel tick over a long wrapped label skips all its rows; scroll by
5// row inside an entry if that reads badly
6
7// terminal cells a label takes: East Asian wide characters count two
8// shortcut: emoji and combining marks count one, so a wrap estimate can run a row short; use a real width table if that shows
9export const cellsOf = (ch: string) => (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch) ? 2 : 1)
10export const cells = (text: string) => [...text].reduce((w, ch) => w + cellsOf(ch), 0)
11
12// rows a label takes wrapped at `width` cells, as the terminal's word wrap lays it: a word that does not fit the row
13// starts the next one, a word wider than a row breaks by character, a wide character never straddles a row end. Errs
14// a row high, never low: a row too few would clip the window's last entry out of reach
15export const wrappedRows = (text: string, width: number) => {
16 let rows = 1
17 let used = 0
18 for (const word of text.split(' ')) {
19 const w = cells(word)
20 const gap = used > 0 ? 1 : 0
21 if (used + gap + w <= width) {
22 used += gap + w
23 continue
24 }
25 if (used > 0) rows++
26 used = 0
27 if (w <= width) {
28 used = w
29 continue
30 }
31 for (const ch of word) {
32 if (used + cellsOf(ch) > width) {
33 rows++
34 used = 0
35 }
36 used += cellsOf(ch)
37 }
38 }
39 return rows
40}
41
42// one past the last item shown from `first`: as many as fit the budget, at least one
43export const fit = (heights: readonly number[], first: number, budget: number) => {
44 let end = first
45 let used = 0
46 while (end < heights.length && (end === first || used + (heights[end] ?? 0) <= budget)) used += heights[end++] ?? 0
47 return end
48}
49
50// the first item of the window that ends on the last item: scrolling stops there, never past the end into blank rows
51export const lastFirst = (heights: readonly number[], budget: number) => {
52 let first = heights.length
53 let used = 0
54 while (first > 0 && (first === heights.length || used + (heights[first - 1] ?? 0) <= budget)) used += heights[--first] ?? 0
55 return first
56}
57
58export const clamp = (first: number, heights: readonly number[], budget: number) => Math.max(0, Math.min(first, lastFirst(heights, budget)))
59
60// the window moved `by` rows (signed, negative toward the top): by the whole items that fit in |by| rows, at least
61// one, and at most a window's worth, so a page key shows the next page with nothing skipped
62export const step = (heights: readonly number[], first: number, by: number, budget: number) => {
63 let at = clamp(first, heights, budget)
64 if (!by) return at
65 const target = Math.min(Math.abs(by), budget)
66 let covered = 0
67 let moved = 0
68 if (by > 0) {
69 while (at < heights.length && (moved === 0 || covered + (heights[at] ?? 0) <= target)) {
70 covered += heights[at++] ?? 0
71 moved++
72 }
73 } else {
74 while (at > 0 && (moved === 0 || covered + (heights[at - 1] ?? 0) <= target)) {
75 covered += heights[--at] ?? 0
76 moved++
77 }
78 }
79 return clamp(at, heights, budget)
80}
81
82// shortcut: a crowded wheel sum that equals contentRows reads as Home / End, and a tree that fills the body makes
83// End page instead; tell them apart by the key if the engine ever says which
84// Home / End: the engine asks for the tree's whole height (`contentRows`); a page key asks for `bodyRows`, which the
85// pane's own tree (kept a row short of the window) never reaches, so the two tell apart
86export const isEdge = (by: number, bodyRows: number, contentRows: number) => by !== 0 && Math.abs(by) === contentRows && contentRows !== bodyRows
87hooks/backdrop.ts 10 lines1import type { ClientModule } from 'claude-code'
2
3// a click-catching layer (the settings popover's backdrop, the open search row's blank rest): draws nothing, and
4// reports a click on a cell no Button holds
5const Backdrop: ClientModule = (_props, surface) => {
6 surface.onPointer(e => e.type === 'down' && surface.post('down'))
7 return surface.elements.Box({})
8}
9export default Backdrop
10types/index.d.ts 22 lines1// level 0 = a typed prompt, 1-3 = a reply heading (# .. ###); preview = a reply's first line where it has no
2// headings (level 1); id = the row's uuid; at = a heading's index among its reply's headings; ts = the row's
3// transcript time (epoch ms); divider = a /compact boundary marker, not a prompt or reply; final = from the reply
4// that ended its turn (later turns' replies never replace it); agent = the subagent whose transcript holds the row
5// (absent = the main conversation), listed only while that agent is in view; tokens = a turn's API usage, on its prompt or,
6// for a turn no prompt started (a background agent's hand-back), on its answer
7export type TocEntry = { id: string; level: number; text: string; preview?: true; at?: number; ts?: number; divider?: true; final?: true; agent?: string; tokens?: Tokens }
8// input = uncached input; cost = the cost-weighted sum, in uncached-input tokens
9export type Tokens = { input: number; cacheRead: number; cacheWrite: number; output: number; cost: number }
10
11// The pane's user settings: each is a userConfig field of the plugin (a /config row), absent = its default.
12// Defined here, not imported: the types contract must be self-contained (`claude plugin validate`).
13export type Settings = { showTime: boolean; showTokens: boolean; openByDefault: boolean }
14// inside the block below a bare `Settings` is claude-code's own (an untyped record): name ours by another name there
15export type TocSettings = Settings
16
17declare module 'claude-code' {
18 interface PluginState {
19 'prompt-toc': { isOff: boolean; entries: TocEntry[]; selected: string; following: string; settings: TocSettings; settingsOpen: boolean; promptsOnly: boolean; keyword: string; searchOpen: boolean; winTop: number; autoOpened: boolean; foldOld: boolean }
20 }
21}
22