SLOPSHOPPER

prompt-toc

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…

panerowsguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-toc
│ ┃ prompt-toc ✕ › fix the failing auth test and add an audit log call │ ┃ prompt-toc is off (/prompt-toc-open). │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /prompt-toc │ ⎿ prompt-toc: prompt-toc pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · prompt-toc
prompt-toc is off (/prompt-toc-open).
README

prompt-toc

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.

Install

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.

Use

CommandDoes
/prompt-toc [keyword]Open the pane, optionally filtered to rows containing the keyword
/prompt-toc-open, /prompt-toc-closeTurn the mod on or off
/prompt-toc-export [path]Write the index as a Markdown file
/prompt-toc-updateUpdate 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 update toast: prompt-toc 0.8.1 is available: run /prompt-toc-update

prompt-toc demo: click a prompt in the side pane and the transcript scrolls to it

All and Prompts

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.

The All view: each prompt with a ↳ preview of its reply

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.

The Prompts view: one numbered, timed row per prompt

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:

  • Cost share: each prompt row ends with its share of the list's token cost, e.g. 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 (labelled 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.
  • ⚙ settings: Open pane on start (wide terminals only), Show time (the time of each prompt) and Show cost share (each prompt's share of the token cost), all on by default. All are also /config rows, synced both ways.
  • The row in view is underlined as you scroll (the topmost one; at the very end of the transcript, the last one), and a /compact divider can hide or show the rows above it.

Privacy

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.

License

MIT

Source 10 files
hooks/register.tsx 979 lines
1import { 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}
979
hooks/shared/fmt.ts 9 lines
1// 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}
9
hooks/settings.ts 24 lines
1import 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] })
24
hooks/filter.ts 20 lines
1import 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}
20
hooks/export.ts 24 lines
1import 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}
24
hooks/shared/rows.ts 85 lines
1// 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}
85
hooks/shared/update.ts 82 lines
1// 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}
82
hooks/window.ts 87 lines
1// 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
87
hooks/backdrop.ts 10 lines
1import 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
10
types/index.d.ts 22 lines
1// 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