PROBE (throwaway): chat-toc long-session read and render costs, ticket 12

English | 简体中文
A collection of Claude Code mods. Each mod is its own plugin under mods/<mod name>/: none depends on another or shares code or data, they can be enabled together, and each has its own install and usage notes.
| Mod | Version | What it does |
|---|---|---|
| chat-toc | 0.2.0 | Docks the current session's contents beside the conversation; a click jumps back to any turn |
For a new mod, add a row to the table above and a section at the same level as chat-toc's below.

In Claude Code's fullscreen terminal, chat-toc docks the current session's contents to the right of the transcript: one entry per turn of the conversation, and a click jumps back to that turn.
>= 2.1.287 (see claude --version)/tui fullscreen to switch/chat-toc from 110 columnsCLAUDE_CODE_ENABLE_FUNCTION_HOOKS is not neededWhile the contents are not on screen, a line above the prompt stands in for them and opens them (see Getting started).
Pick one of the two, never both:
Marketplace (recommended):
claude plugin marketplace add lkangd/cc-mods
claude plugin install chat-toc@cc-mods
After installing, claude plugin list shows chat-toc@cc-mods as enabled.
Local load (for development):
claude --plugin-dir /path/to/cc-mods/mods/chat-toc
ChatTOC ‹ › │ 12 user turns · 80 agent steps Open contents ❯ ✕. Where the line is too narrow for all of it, the counts shorten from the end, then the name goes; Claude Code Desktop draws no ‹ ›. Widen the terminal and they dock by themselves, and the line goes; or click Open contents ❯ (or run /chat-toc): Claude Code then docks them in a narrower terminal too, and below that places them above the prompt. Outside the fullscreen layout they open above the prompt by themselves; run /tui fullscreen to dock them on the right./chat-toc opens it again at any time, with the focus in it.| Control | What it does |
|---|---|
Click your input in an entry (from the ● line) | Jumps to that input, at the top of the screen, and selects the entry; it opens no preview. A slash command or ! command jumps to the row right after it and keeps the input row in sight |
Click the Agent's reply in an entry (from the ├╴ line) | Jumps to the turn's last text reply (without one, to the last thing the turn drew), and selects the entry |
| Click a block in the row of blocks under the filters | Jumps to that turn, as clicking its entry does, but selects nothing. One block per turn, as tall as the steps the Agent took; with more turns than the pane is wide, a block stands for several and jumps to the first of them that can be jumped to. The block of the turn you are on is the bright one. Hidden with fewer than two turns or in a very short pane |
| Wheel over the contents | Scrolls the contents by entries and pauses following |
Ctrl+X then Tab; or /chat-toc with an empty prompt | Moves the focus into the contents |
| Any click in the contents, with an empty prompt | Also moves the focus into the contents, so the keys below work at once. Not after r: Reuse (the focus goes to the prompt) or ‹ ›. With text in the prompt the focus stays there. A key that is none of the contents' keys goes back to the prompt and is typed there, but one that is (c, p, r, f, …) acts on the contents |
↑ ↓ (focus in the contents) | Moves the selection one at a time (your input and the Agent's reply count one each); the selected line is inverse. Moving it opens no preview and leaves an open one where it is. Entries that cannot be jumped to can be selected too |
Enter (focus in the contents) | Same as clicking the selected side: jumps and keeps the selection |
p, or click p: Preview on the row under the selected entry | Opens the preview on that turn. The row shows while the preview is closed: p: Preview · r: Reuse · c: Copy, each button only when it can act (as on the preview's lines, below); r reuses the turn's input, c copies the selected side (your input, or the Agent's last text reply). ↑ ↓ pass over the row |
1 2 3, or click 1: All 2: User 3: Agent at the top | Shows all / only your input / only the Agent's replies. A thick line in the highlight colour marks the chosen one. Remembered across sessions |
l, or click the "Layout: Card" button at the footer's right end | Cycles the layout Card → Compact → Timeline; the button names the one in use. Remembered across sessions |
p, or click p: ✕ at the right end of the preview's top line | Closes the preview, from the region or expanded; the list takes its rows back, and the selected entry's row of buttons comes back. Not remembered: the contents always open with the preview closed |
e, or click e: expand on the preview's last line | Expands the preview over the list and the row of blocks: the turn whole, scrolled with the wheel, PgUp PgDn or ↑ ↓. e again, or e: ← Back at its top, returns to the list with the preview below it; so does Esc. A preview opened expanded because the contents are too short closes on e: ← Back |
r, or click r: Reuse on the preview's User line | Puts the input the preview shows back into the prompt as a draft, as you typed it: into an empty prompt, or after a blank line below what you are typing, which is never lost. Never sent: edit it or press Enter yourself. Works while the Agent is working too (Enter then queues it, as usual). The focus moves to the prompt. A ! command has no r: Reuse (filled in, it would be sent to the model, not run) |
c, or click c: Copy on the preview's User line | Copies that input, whole, as you typed it (a ! command as !command); the button reads Copied for about 2 seconds. The focus stays in the contents |
a, or click a: Copy on the preview's Agent line | Copies the Agent's last text reply, whole, as Markdown; the button reads Copied for about 2 seconds. Not offered while the turn is running |
Click the search field under the filters, or f (f: Find) | Moves the focus into the field. Type words or part of a file path: the list narrows to the turns holding every word |
Enter in the search field | Jumps to the matching turn nearest the one you are viewing and ends the search (the list is whole again, the turn jumped to highlighted). With no match it only clears the field |
↓ or Tab from the search field | Moves the selection on to the matching entries; Enter on one jumps there, selects it and keeps the search |
Esc in the search field | Moves the focus back to the prompt and keeps the search |
Ctrl+U or Backspace in the search field, or click the ✕ at its right end | Clears the search |
Click ‹ or › at the search row's right end, or on the line above the prompt while the contents are not on screen; or a key you bind to them (see Keys for the previous and next turn) | Jumps the transcript to the turn before or after the one you are viewing (the highlighted one, not the ↑ ↓ selection), among the turns the list shows: the view choice applies, and while searching only the matches, without ending the search. It jumps to the side the view shows (your input under All and User, the reply under Agent) and the highlight stays on the turn jumped to, so each press goes one turn further. At either end nothing happens. Turns that cannot be jumped to are passed over; when no turn that way can be, the note "This entry is not in the conversation view now; cannot jump to it" shows under the list, or at the top right of the screen while the contents are not on screen |
Esc (focus in the contents) | Moves the focus back to the prompt; the contents stay open |
Ctrl+X then ← / → | Widens / narrows the contents (a Claude Code feature). A width you set wins |
| Click ✕ | Closes the contents; they do not open by themselves again this session. A new session (including after /clear or /resume) opens them again |
Click ChatTOC on the line above the prompt | Opens the contents, as Open contents ❯ does. Claude Code Desktop, and the terminal before Claude Code 2.1.303, draw it in the plain text colour |
Click ✕ at the right end of the line above the prompt (right after Open contents ❯ before Claude Code 2.1.295) | Hides that line, in this session and every later one, and says how to bring it back. /chat-toc still opens the contents |
/chat-toc-band | Shows the line above the prompt again |
/chat-toc | Opens the contents when closed, brings them to the front when open (also when another pane pushed them back); either way moves the focus into them (with an empty prompt). It never closes them |
| Claude Code Desktop: click an entry | Opens the preview right under that turn's entries, in a box with a rounded border; clicking another entry moves it there. Opening it does not scroll the contents: when it falls below the edge, scroll to it. The click also asks to jump, which the Claude app refuses without a word (see Known limits) |
| Claude Code Desktop: type in the search field under the view choices | Narrows the list as on the terminal, at each change; the row of bars draws the matching turns in the accent colour and the rest dim. There is no f: Find: click the field |
Claude Code Desktop: Enter in the search field, or its show button | Keeps the search and opens the preview on the newest match (the app cannot jump, nor say which turn you are viewing) |
Claude Code Desktop: click ✕ on the preview's top line, ↓ 12 more lines · expand on its last line, ← Back | As p, e and e: ← Back on the terminal. Desktop has no hotkeys, so none of the buttons names a key |
Claude Code Desktop: scroll the contents; click ↑ 12 earlier turns at the list's top, ↓ 12 later turns at its bottom, ↑ Back to top after the last entry | The app scrolls the whole contents, the view choices and the search field with them. A long conversation lists its newest turns (as many as fit in what the app takes in one drawing, about 45 in the Card layout); ↑ lists the turns before them and ↓ goes back toward the newest, and either closes an open preview. A search or a view choice narrows the list first. ↑ Back to top scrolls the contents back to the view choices |
Claude Code Desktop: click Copy on the preview's User or Agent line | Copies as c and a do; a copy the app cannot take says "Too long to copy here". There is no Reuse in Desktop |
The hotkeys 1 2 3 l p e f r c a work only with the focus in the contents, and not while it is in the search field, which takes every letter typed; p r c only while the selected entry's row or the open preview shows their button; e a only while the preview is open, e only while it is cut or expanded (a not while a short preview cuts the Agent line, until e). ‹ › have no hotkey, and ↑ ↓ pass over them.
chat-toc binds no key of its own. ‹ and › answer two Claude Code keybinding actions that are free in a normal session, so you can bind keys to them in ~/.claude/keybindings.json and step through the conversation from the prompt, while Claude works too:
{
"bindings": [
{
"context": "Global",
"bindings": {
"meta+k": "app:toggleDiffNoiseFilter",
"meta+j": "app:toggleDiffPreSession"
}
}
]
}
meta+k is the previous turn and meta+j the next. The action names do not match because a plugin cannot add an action: chat-toc borrows two that Claude Code names for its diff view and does not use in a normal session.meta+k, not a chord such as ctrl+x p, and not a bare key: when nothing answers a chord (the contents and the line above the prompt both away), its second key is typed into the prompt; a bare key never reaches the buttons.‹ › the contents draw, or, while the contents are not on screen (closed, waiting to dock, or a tab behind another pane), the ones on the line above the prompt. See Known limits for when nothing answers.! command are each an entry of their own; the same prompt submitted several times is several entries; a prompt queued while the Agent worked is an entry of its own too. Background task notifications and work started by /loop join the entry before them. Nothing from inside a subagent shows.●, the Agent's reply at a branch ├╴; the lines after them and the gaps between groups on the same day sit against the line │.… at the end when it does not fit. A slash command shows as /command args, a ! command as !command; code blocks, Markdown marks and paste markers are left out, and an input of only images shows "(image only)". When a line folds, commas and full stops never start a line, and English words and file paths up to 14 characters move down whole.N steps · reply…: the steps are how many tools the main Agent called this turn; parallel calls count one each, a subagent counts 1, thinking and text in between do not count. With 0 steps there is no count, only the reply.N steps · No text reply: the turn ended without a text reply.Interrupted · N steps: you interrupted the turn.Error · N steps · error message: the turn ended in an API error. A single failed tool call is not an error.Running · N steps: the Agent is still working on the last turn; an orange ◐ sits by the branch and the steps count up live.! command that set no Agent work going, has only your input. Under "Agent" those entries are hidden.HH:MM); Compact and Timeline write it in a column left of the line, on each turn's first row. Where the day changes (including at the top of the list) there is a line ─ Oct 9 ───; today reads Today · Oct 13, and another year's day carries the year. A turn whose time cannot be read gets no time and no day line; nothing is guessed.╌ Compacted 14:32 ╌╌╌ for /compact and ╌ Auto-compacted 14:32 ╌╌╌ for an automatic compaction, with the time it happened (no token figures). It never cuts a turn: after /compact it goes below the /compact entry; an automatic compaction goes before the turn it happened at the submit of, and after the turn it happened in the middle of (below a running turn, at the end of the list, until the next turn comes) or between two turns. It breaks the time rail as the day line does, in every layout, and shows under every view choice, between the turns shown; several between two turns shown fold into one line with a count and the last one's time, ╌ Compacted ×3 · 14:32 ╌. Where a day line meets it, the day line comes first. Entries above it are not dimmed.--fork-session, or a resumed file compacted before any turn of its own), a dim line at the top of the list says "Earlier turns are not in this session's file". The earlier turns are not read from anywhere else.◉, the branch ┣╸, the turn's line thickens to ┃, all in the highlight colour along with the time. The highlight and the dim text follow Claude Code's theme (/theme); the highlight is the colour of a selected item in Claude Code's menus, and the contents follow a theme change at once. After a click jump, the highlight stays on the turn jumped to until you scroll the transcript again. A ! command's turn (and, in a session opened with --resume, a local slash command's turn) has no row in the transcript to locate: when only such a row shows at the top of the screen, the highlight falls on the next turn. The highlight and the ↑ ↓ selection (inverse) are independent and can sit on different entries. With the pointer over an entry, that side's text turns the highlight colour and the line right under the pointer is inverse.f: Find and the field (words or a file path while empty). A search narrows the list to the turns that hold every word typed (words split on spaces), in any of: your input as you typed it (code blocks and pasted text included), the Agent's last text reply in full, and the full paths of the files the turn changed or read. Case does not matter, full-width and half-width letters match alike, and Chinese and other text match as plain substrings; no regular expressions, fuzzy matches or quoted phrases. It follows the view choice: All searches every field, User only your input, Agent only the reply and the paths. The row's right end says how many turns match of all (4 of 38) and holds ✕, then ‹ › (always there, see Controls); with no match the list says "No turns match". A new turn that matches joins the list, and a running turn is matched as it writes. While searching, the list keeps the match nearest the turn you are viewing in sight (the nearest at or before it, else the first after it), the highlight shows only when that turn itself matches, the compaction lines stay between the turns shown, and the row of blocks lights the blocks holding a match instead of the one you are on (all dim with no match). The search stays through a view or layout switch, new turns, another pane in front and closing and reopening the contents, until Claude Code quits; it is never stored.p, or p: Preview on the row under the selected entry, opens it on that turn, and it stays on it while you scroll the transcript, move the selection with ↑ ↓, switch the view or layout, new turns arrive or a search leaves that turn out of the list. Opening it from another entry moves it there. A click or Enter on an entry, Enter in the search field, a block in the row of blocks, ‹ › and the line above the prompt open nothing. It closes with p: ✕ at the right end of its top line, when its turn leaves the conversation (a rewind, /clear, another session), and whenever the contents open again; it is never stored. While searching, if the turn it shows matches, its line of figures ends with what matched, matched: User · Agent · files, and a matching file goes first on the files line (a matching file that was only read is named there). Its top line reads ─ Preview ─── p: ✕; under it:Oct 16 14:32 · 12 steps · 3m 20s · 3.5k out. The date always shows, today too; a state (Running, Interrupted, Error) only when the turn did not finish. The duration is how long the model worked (45s, 3m 20s, 1h 5m, <1s), summed over the turn's model turns, and shows only when the transcript records it; the tokens are what the main Agent wrote (out), each response counted once, and show only when the transcript records them. Nothing is estimated: a figure the transcript does not state is left out. When the line is too wide for the contents, its last figures are left out whole;User with r: Reuse · c: Copy at the right, then your input as you typed it: code blocks and pasted text kept, a slash command as /command args, a ! command as !command. An input of only images has neither button; an input with text and images fills or copies its text only, and a note says "Images not included";Agent with a: Copy at the right, then the Agent's last text reply drawn as Markdown; a running turn shows the latest text written so far, without a: Copy;changed register.tsx, pane.tsx +1 · read 5. Changed are files edited or written by the Agent and files a shell command changed as Claude Code recorded it; read are files the Agent read; a file both read and changed counts as changed. Paths are relative to the session's folder when inside it, else full with ~ for your home folder. The preview's height depends on the contents' height, not on the turn (about 40% of the rows below the top, 4 to 14 rows), so the list does not jump as you press another entry. What does not fit is cut, the files line first, then the Agent's reply, then your input; the last line then reads ↓ 12 more lines · e: expand. Chooks/register.tsx 696 lines1/* PROBE — throwaway. chat-toc wayfinder ticket 12: what does a 20 MB transcript
2 cost to read, group, redraw and track? Built from the ticket-05 prototype with
3 offset-based chunked reads and timers. Modes come from env:
4 TP_PANE naive|cached, TP_CP naive|fast, TP_TRIG events|poll, TP_POLL ms, TP_TAG. */
5import { atom, read, update } from 'claude-code'
6import type { Register } from 'claude-code'
7
8type $T = any
9const PANE = 'toc-perf'
10const cpAtom = atom({ plugin: 'toc-perf', key: 'cp' } as const, -1)
11const verAtom = atom({ plugin: 'toc-perf', key: 'ver' } as const, 0)
12const pathAtom = atom({ plugin: 'toc-perf', key: 'path' } as const, '')
13const FS_MAX = 4_194_304
14const CHUNK = 4_000_000
15
16const T0 = Date.now()
17const perf: any = (globalThis as any).performance
18const now = (): number => (perf?.now ? perf.now() : Date.now())
19const NOW0 = now()
20const rnd = (x: number) => Math.round(x * 100) / 100
21
22let PANE_MODE = 'cached'
23let CP_MODE = 'fast'
24let TRIG = 'events'
25let POLL = 300
26let TAG = 'x'
27let READ = 'dd'
28let REB = 'extend'
29
30/* ---------- log ---------- */
31const ev: any[] = []
32const series: Record<string, number[]> = {}
33const cnt: Record<string, number> = {}
34const bump = (k: string, n = 1) => { cnt[k] = (cnt[k] ?? 0) + n }
35const put = (k: string, v: number) => { (series[k] ??= []).push(rnd(v)) }
36const log = (o: Record<string, unknown>) => { ev.push({ t: Date.now() - T0, ...o }) }
37function pct(xs: number[], p: number) { if (!xs.length) return null; const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(s.length * p))] }
38const summary = (xs: number[] = []) => ({ n: xs.length, p50: pct(xs, 0.5), p95: pct(xs, 0.95), max: xs.length ? Math.max(...xs) : null })
39
40/* ---------- transcript model ---------- */
41type Group = {
42 idx: number; kind: 'prompt' | 'cmd' | 'bang'; userUuid: string; userText: string; time: string
43 uuids: string[]; steps: number; lastText: string; lastTextUuid: string | null; status: 'done' | 'interrupted' | 'error'
44}
45const byId = new Map<string, any>()
46let leaf: string | null = null
47let unknown = 0
48let groups: Group[] = []
49let chain: string[] = []
50const order = new Map<string, number>()
51const groupOfOrder: number[] = []
52const toolIdToUuid = new Map<string, string>()
53const siteRows = new Set<string>()
54let dataVer = 0
55let running = false
56let offset = 0
57let lastSize = -1
58let loadError = ''
59
60const textOf = (c: any): string => typeof c === 'string' ? c : Array.isArray(c) ? c.filter((b: any) => b?.type === 'text').map((b: any) => b.text).join('\n') : ''
61
62function opener(r: any): Group['kind'] | null {
63 if (r.type === 'system' && r.subtype === 'local_command' && String(r.content ?? '').includes('<command-name>')) return 'cmd'
64 if (r.type !== 'user' || r.isMeta || r.isCompactSummary || r.isSidechain) return null
65 const c = r.message?.content
66 if (Array.isArray(c) && c.some((b: any) => b?.type === 'tool_result')) return null
67 const t = textOf(c).trim()
68 if (t.includes('<command-name>')) return 'cmd'
69 if (t.startsWith('<bash-input>')) return 'bang'
70 if (/^<(local-command-stdout|local-command-caveat|bash-stdout|bash-stderr|task-notification)/.test(t)) return null
71 if (t.startsWith('[Request interrupted by user')) return null
72 if (r.origin && r.origin.kind !== 'human') return null
73 if (!t && !(Array.isArray(c) && c.some((b: any) => b?.type === 'image'))) return null
74 return 'prompt'
75}
76
77function userDisplay(kind: Group['kind'], r: any): string {
78 const c = r.message?.content
79 const t = r.type === 'system' ? String(r.content ?? '') : textOf(c)
80 if (kind === 'cmd') {
81 const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(t)?.[1]?.trim() ?? ''
82 const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(t)?.[1]?.trim() ?? ''
83 return `${name.startsWith('/') ? name : '/' + name}${args ? ' ' + args : ''}`
84 }
85 if (kind === 'bang') return '!' + (/<bash-input>([\s\S]*?)<\/bash-input>/.exec(t)?.[1]?.trim() ?? '')
86 return t.replace(/<\/?pasted_content[^>]*>/g, ' ').replace(/```[\s\S]*?```/g, ' ').replace(/!\[[^\]]*\]\([^)]*\)/g, ' ').replace(/[#*_`>]+/g, '').replace(/\s+/g, ' ').trim()
87}
88
89function fmtTime(ts: string | undefined): string {
90 if (!ts) return ''
91 const d = new Date(ts)
92 if (isNaN(d.getTime())) return ''
93 return `${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
94}
95
96function utf8Len(s: string): number {
97 let n = 0
98 for (let i = 0; i < s.length; i++) {
99 const c = s.charCodeAt(i)
100 if (c < 0x80) n += 1
101 else if (c < 0x800) n += 2
102 else if (c >= 0xd800 && c <= 0xdbff) { n += 4; i++ }
103 else n += 3
104 }
105 return n
106}
107
108function ingest(text: string) {
109 let a = 0
110 while (a < text.length) {
111 let b = text.indexOf('\n', a)
112 if (b < 0) b = text.length
113 if (b > a) {
114 let r: any
115 try { r = JSON.parse(text.slice(a, b)) } catch { unknown++; r = null }
116 if (r && typeof r.uuid === 'string' && !r.isSidechain) {
117 byId.set(r.uuid, r)
118 if (r.type === 'user' || r.type === 'assistant' || r.type === 'system') leaf = r.uuid
119 }
120 }
121 a = b + 1
122 }
123}
124
125let lastRebuild = ''
126/* Walk back from the leaf. When the walk meets the old chain's last row the
127 file only grew on the current branch: keep the chain, regroup from the last
128 group's opener. Anything else (a rewind's new branch, a /clear) rebuilds. */
129function rebuild() {
130 const back: string[] = []
131 const guard = new Set<string>()
132 const oldLeaf = chain.length ? chain[chain.length - 1]! : null
133 let cur = leaf
134 let joined = false
135 while (cur && byId.has(cur) && !guard.has(cur)) {
136 if (REB === 'extend' && cur === oldLeaf) { joined = true; break }
137 guard.add(cur); back.push(cur)
138 const r = byId.get(cur)
139 cur = r.parentUuid ?? (r.subtype === 'compact_boundary' ? r.logicalParentUuid : null)
140 }
141 back.reverse()
142 let s = 0
143 let out: Group[] = []
144 if (joined) {
145 lastRebuild = 'extend'
146 const last = groups[groups.length - 1]
147 s = last ? order.get(last.userUuid)! : chain.length
148 out = last ? groups.slice(0, -1) : groups.slice()
149 for (const id of back) chain.push(id)
150 } else {
151 lastRebuild = 'full'
152 chain = back
153 order.clear(); groupOfOrder.length = 0; toolIdToUuid.clear(); siteRows.clear()
154 }
155 if (!joined && REB === 'extend' && oldLeaf === null && back.length === 0) lastRebuild = 'none'
156 let g: Group | null = null
157 let tail: { uuid: string; mid: string; text: string }[] = []
158 const close = () => {
159 if (!g) return
160 if (tail.length) {
161 const mid = tail[tail.length - 1]!.mid
162 const parts = tail.filter(x => x.mid === mid)
163 g.lastText = parts.map(x => x.text).join(' ').replace(/[#*_`>]+/g, '').replace(/\s+/g, ' ').trim()
164 g.lastTextUuid = parts[0]!.uuid
165 }
166 tail = []
167 }
168 for (let i = s; i < chain.length; i++) {
169 const id = chain[i]!
170 order.set(id, i)
171 const r = byId.get(id)
172 const k = opener(r)
173 if ((r.type === 'user' && k === 'prompt') || (r.type === 'assistant' && Array.isArray(r.message?.content) && r.message.content.some((b: any) => b?.type === 'text' && String(b.text).trim()))
174 || (r.type === 'system' && r.subtype === 'local_command' && /<local-command-stdout>[\s\S]*\S[\s\S]*<\/local-command-stdout>/.test(String(r.content ?? '')))) siteRows.add(id)
175 if (k) {
176 close()
177 g = { idx: out.length, kind: k, userUuid: id, userText: userDisplay(k, r), time: fmtTime(r.timestamp), uuids: [], steps: 0, lastText: '', lastTextUuid: null, status: 'done' }
178 out.push(g)
179 }
180 groupOfOrder[i] = g ? g.idx : -1
181 if (!g) continue
182 g.uuids.push(id)
183 if (r.type === 'user' && textOf(r.message?.content).startsWith('[Request interrupted by user')) g.status = 'interrupted'
184 if (r.type === 'assistant') {
185 if (r.isApiErrorMessage) g.status = 'error'
186 for (const b of Array.isArray(r.message?.content) ? r.message.content : []) {
187 if (b?.type === 'tool_use') { g.steps++; tail = []; if (b.id) toolIdToUuid.set(b.id, id) }
188 else if (b?.type === 'text' && String(b.text).trim()) tail.push({ uuid: id, mid: r.message?.id ?? id, text: b.text })
189 }
190 }
191 }
192 close()
193 groups = out
194 dataVer++
195}
196
197async function findPath($: $T): Promise<string> {
198 const saved = await read($, pathAtom)
199 if (saved) return saved
200 const id = await $.session.id()
201 const r = await $.process.run(['sh', '-c', `ls "$HOME"/.claude/projects/*/${id}.jsonl 2>/dev/null | head -1`])
202 return r.stdout.trim()
203}
204
205/* Read from `offset` to `size`: whole-file `fs.read` while the file fits its
206 4 MiB cap and nothing is read yet, else `tail | head` chunks cut back to the
207 last newline, advancing `offset` by the bytes of the lines kept. */
208async function readNew($: $T, path: string, size: number) {
209 const m: any = { from: offset, chunks: 0, procMs: [] as number[], ingestMs: 0, bytes: 0, via: '' }
210 if (offset === 0 && size <= FS_MAX) {
211 const a = now(); const text = await $.fs.read(path); m.procMs.push(rnd(now() - a)); m.via = 'fs'
212 const cut = text.lastIndexOf('\n'); const part = text.slice(0, cut + 1)
213 const b = now(); ingest(part); m.ingestMs += now() - b
214 const n = utf8Len(part); offset += n; m.bytes += n; m.chunks = 1
215 return m
216 }
217 m.via = 'proc'
218 while (offset < size) {
219 const a = now()
220 const r = READ === 'tail'
221 ? await $.process.run(['sh', '-c', 'tail -c +"$1" "$2" | head -c "$3"', 'sh', String(offset + 1), path, String(CHUNK)])
222 : await $.process.run(['sh', '-c', '{ dd bs=1 skip="$1" count=0 2>/dev/null; head -c "$3"; } < "$2"', 'sh', String(offset), path, String(CHUNK)])
223 m.procMs.push(rnd(now() - a)); m.chunks++
224 const cut = r.stdout.lastIndexOf('\n')
225 if (cut < 0) { m.stuck = true; break }
226 const part = r.stdout.slice(0, cut + 1)
227 const b = now(); ingest(part); m.ingestMs += now() - b
228 const n = utf8Len(part); offset += n; m.bytes += n
229 if (r.stdout.length < 100) break
230 }
231 return m
232}
233
234let loading = false
235let again = false
236let readyLogged = false
237async function load($: $T, why: string) {
238 if (loading) { again = true; return }
239 loading = true
240 try {
241 do {
242 again = false
243 const t0 = now()
244 const path = await findPath($)
245 if (!path) { loadError = 'no transcript yet'; break }
246 const s0 = now()
247 let st
248 try { st = await $.fs.stat(path) } catch { loadError = 'no transcript yet'; break }
249 const statMs = now() - s0
250 put('statMs', statMs)
251 if (st.size === lastSize) { bump('load-same'); break }
252 if (st.size < lastSize) { byId.clear(); leaf = null; offset = 0; unknown = 0 }
253 lastSize = st.size
254 const m = await readNew($, path, st.size)
255 const r0 = now(); rebuild(); const rebuildMs = now() - r0
256 put(lastRebuild === 'extend' ? 'extendMs' : 'fullRebuildMs', rebuildMs)
257 const v0 = now(); resolveAll(); const resolveMs = now() - v0
258 const total = now() - t0
259 loadError = ''
260 const first = m.from === 0
261 put(first ? 'firstLoadMs' : 'incLoadMs', total)
262 if (!first) { put('incRebuildMs', rebuildMs); put('incReadMs', m.procMs.reduce((x: number, y: number) => x + y, 0)) }
263 log({ ev: 'load', why, size: st.size, ...m, ingestMs: rnd(m.ingestMs), statMs: rnd(statMs), rebuildMs: rnd(rebuildMs), how: lastRebuild, resolveMs: rnd(resolveMs), totalMs: rnd(total), rows: byId.size, chain: chain.length, groups: groups.length, unknown })
264 recompute($)
265 poke($)
266 } while (again)
267 } catch (err: any) {
268 loadError = String(err?.message ?? err).slice(0, 120)
269 log({ ev: 'load-error', why, err: loadError })
270 } finally { loading = false }
271}
272let loadTimer = false
273function loadSoon($: $T, why: string, ms = 300) {
274 bump('trig:' + why)
275 if (loadTimer) return
276 loadTimer = true
277 $.clock.after(ms, () => { loadTimer = false; void load($, why) })
278}
279
280/* ---------- what the transcript drew ---------- */
281type OS = { first: number; last: number; of: number } | null | undefined
282const onScreen = new Map<string, { rid: string; c: string; os: OS; at: number }>()
283const counts = (c: string) => c !== 'ToolUse'
284const drawnEver = new Set<string>()
285const refused = new Set<string>()
286let cp = -1
287let pokeQueued = false
288function poke($: $T) {
289 if (pokeQueued) return
290 pokeQueued = true
291 $.clock.after(0, () => { pokeQueued = false; update($, verAtom, v => v + 1).catch(() => bump('poke-denied')) })
292}
293
294const drawnRids = new Map<string, string>()
295const ridCache = new Map<string, string | null>()
296const uuidToRid = new Map<string, string>()
297const prefix24 = new Map<string, string | null>()
298function resolve(c: string, rid: string): string | null {
299 if (rid === 'placeholder') return null
300 const hit = ridCache.get(rid)
301 if (hit !== undefined) return hit
302 let u: string | null = null
303 const body = rid.replace(/^collapsed-/, '')
304 if (c === 'ToolUse' && toolIdToUuid.has(rid)) u = toolIdToUuid.get(rid)!
305 else if (order.has(body)) u = body
306 else { const q = prefix24.get(body.slice(0, 24)); if (q) u = q }
307 if (chain.length) ridCache.set(rid, u)
308 if (u && (!uuidToRid.has(u) || c !== 'ToolGroup')) uuidToRid.set(u, rid)
309 return u
310}
311let drawable: number[] = [] /* sorted orders of rows that could be on screen (fast mode) */
312let drawableDirty = true
313let prefixUpTo = 0
314function resolveAll() {
315 if (lastRebuild === 'extend') {
316 for (let i = prefixUpTo; i < chain.length; i++) { const u = chain[i]!; const k = u.slice(0, 24); prefix24.set(k, prefix24.has(k) ? null : u) }
317 prefixUpTo = chain.length
318 for (const [rid, u] of ridCache) if (u === null) ridCache.delete(rid)
319 for (const [rid, c] of drawnRids) { if (ridCache.has(rid)) continue; const u = resolve(c, rid); if (u) drawnEver.add(u) }
320 drawableDirty = true
321 return
322 }
323 ridCache.clear(); uuidToRid.clear(); drawnEver.clear(); prefix24.clear()
324 for (const u of chain) { const k = u.slice(0, 24); prefix24.set(k, prefix24.has(k) ? null : u) }
325 prefixUpTo = chain.length
326 for (const [rid, c] of drawnRids) { const u = resolve(c, rid); if (u) drawnEver.add(u) }
327 drawableDirty = true
328}
329
330let lock: { g: number; last: number } | null = null
331type L = { o: number; os: NonNullable<OS>; at: number }
332
333/* The ticket-05 algorithm: `off` is every drawable row not live, searched linearly per live row. */
334function cpNaive(): { best: L[]; lastDrawn: number } {
335 const live: L[] = []
336 const off: number[] = []
337 for (const { rid, c, os, at } of onScreen.values()) {
338 if (!counts(c)) continue
339 const u = resolve(c, rid); const o = u ? order.get(u) : undefined
340 if (o === undefined) continue
341 if (os) live.push({ o, os, at }); else if (os === null) off.push(o)
342 }
343 live.sort((x, y) => x.o - y.o)
344 const liveO = new Set(live.map(x => x.o))
345 for (const u of drawnEver) { const o = order.get(u); if (o !== undefined && !liveO.has(o)) off.push(o) }
346 for (const u of siteRows) { const o = order.get(u); if (o !== undefined && !liveO.has(o)) off.push(o) }
347 off.sort((x, y) => x - y)
348 const runs: L[][] = []
349 for (const x of live) {
350 const run = runs[runs.length - 1]
351 const prev = run?.[run.length - 1]
352 const k = prev ? off.findIndex(o => o > prev.o) : -1
353 if (prev && (k < 0 || off[k]! >= x.o)) run!.push(x); else runs.push([x])
354 }
355 let best: L[] = []
356 let latest = -1
357 for (const run of runs) for (const x of run) if (x.at > latest) { latest = x.at; best = run }
358 let lastDrawn = -1
359 for (const u of drawnEver) { const o = order.get(u); if (o !== undefined && o > lastDrawn) lastDrawn = o }
360 return { best, lastDrawn }
361}
362
363/* Same answer: rows reported off screen are dropped from the map, and two
364 neighbouring live rows share a run when no drawable row lies strictly
365 between them (binary search in a sorted array kept per load/draw). */
366let lastDrawnO = -1
367function cpFast(): { best: L[]; lastDrawn: number } {
368 if (drawableDirty) {
369 const s = new Set<number>()
370 for (const u of siteRows) { const o = order.get(u); if (o !== undefined) s.add(o) }
371 lastDrawnO = -1
372 for (const u of drawnEver) { const o = order.get(u); if (o !== undefined) { s.add(o); if (o > lastDrawnO) lastDrawnO = o } }
373 drawable = [...s].sort((a, b) => a - b)
374 drawableDirty = false
375 }
376 const live: L[] = []
377 for (const { rid, c, os, at } of onScreen.values()) {
378 if (!os || !counts(c)) continue
379 const u = resolve(c, rid); const o = u ? order.get(u) : undefined
380 if (o !== undefined) live.push({ o, os, at })
381 }
382 live.sort((x, y) => x.o - y.o)
383 const above = (v: number) => { let lo = 0, hi = drawable.length; while (lo < hi) { const m = (lo + hi) >> 1; if (drawable[m]! <= v) lo = m + 1; else hi = m } return lo }
384 let best: L[] = []
385 let cur: L[] = []
386 let latest = -1
387 let curLatest = -1
388 for (let i = 0; i < live.length; i++) {
389 const x = live[i]!
390 if (i > 0) {
391 const k = above(live[i - 1]!.o)
392 if (!(k >= drawable.length || drawable[k]! >= x.o)) {
393 if (curLatest > latest) { latest = curLatest; best = cur }
394 cur = []; curLatest = -1
395 }
396 }
397 cur.push(x); if (x.at > curLatest) curLatest = x.at
398 }
399 if (curLatest > latest) best = cur
400 return { best, lastDrawn: lastDrawnO }
401}
402
403function recompute($: $T) {
404 const a = now()
405 const { best, lastDrawn } = CP_MODE === 'naive' ? cpNaive() : cpFast()
406 put('cpMs', now() - a)
407 let n = -1
408 const lastRow = best.find(x => x.o === lastDrawn)
409 if (lock) n = lock.g
410 else if (lastRow && lastRow.os.last === lastRow.os.of - 1) n = groups.length - 1
411 else if (best.length) n = groupOfOrder[best[0]!.o] ?? -1
412 if (n !== cp) {
413 cp = n
414 $.clock.after(0, () => { update($, cpAtom, () => n).catch(() => bump('cp-denied')) })
415 }
416}
417
418/* ---------- pane ---------- */
419let top = 0
420let paused = false
421let followedCp = -2
422let lastVisible: string[] = []
423
424type Entry = { key: string; g: number; side: 'u' | 'a'; lines: { pre: string; text: string; dim?: boolean }[] }
425
426const wide = (c: number) => c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1faff) || (c >= 0x20000 && c <= 0x3fffd))
427const cells = (s: string) => { let n = 0; for (const ch of s) n += wide(ch.codePointAt(0)!) ? 2 : 1; return n }
428function wrapN(s: string, w: number, max: number): string[] {
429 w = Math.max(4, w)
430 const out: string[] = []
431 let cur = ''
432 let cw = 0
433 for (const ch of s) {
434 const c = wide(ch.codePointAt(0)!) ? 2 : 1
435 if (cw + c > w) {
436 out.push(cur); cur = ''; cw = 0
437 if (out.length === max) {
438 let l = out[max - 1]!
439 while (cells(l) + 1 > w) l = [...l].slice(0, -1).join('')
440 out[max - 1] = l + '…'
441 return out
442 }
443 }
444 cur += ch; cw += c
445 }
446 if (cur || !out.length) out.push(cur)
447 return out
448}
449
450function agentHead(g: Group, inProgress: boolean): string {
451 const st = g.steps ? `${g.steps} 步` : ''
452 if (inProgress) return `进行中${st ? ' · ' + st : ''}`
453 if (g.status === 'interrupted') return `已中断${st ? ' · ' + st : ''}`
454 if (g.status === 'error') return `出错${st ? ' · ' + st : ''}`
455 if (!g.lastText) return `${st || '0 步'} · 无文字回复`
456 return st
457}
458const hasAgent = (g: Group, i: number) => g.steps > 0 || !!g.lastText || (running && i === groups.length - 1 && g.kind !== 'bang')
459
460/* Layout A (cards) without the highlight: that is drawn per frame. */
461const memo = new WeakMap<Group, { key: string; u: Entry; a: Entry }>()
462function buildEntries(filter: string, w: number): Entry[] {
463 const out: Entry[] = []
464 groups.forEach((g, i) => {
465 const inProg = running && i === groups.length - 1
466 const showU = filter !== 'agent'
467 const showA = filter !== 'user' && hasAgent(g, i)
468 const key = `${w}|${inProg}|${g.steps}|${g.status}|${g.lastText.length}`
469 let m = PANE_MODE === 'cached' ? memo.get(g) : undefined
470 if (!m || m.key !== key) {
471 m = { key,
472 u: { key: `u${i}`, g: i, side: 'u', lines: [
473 { pre: '', text: `你${g.time ? ' · ' + g.time : ''}`, dim: true },
474 ...wrapN(g.userText || '(空)', w - 2, 2).map(t => ({ pre: '', text: t })),
475 ] },
476 a: { key: `a${i}`, g: i, side: 'a', lines: [
477 { pre: '', text: '↳ ' + agentHead(g, inProg), dim: true },
478 ...(g.lastText ? wrapN(g.lastText, w - 4, 2).map(t => ({ pre: ' ', text: t })) : []),
479 ] } }
480 if (PANE_MODE === 'cached') memo.set(g, m); else bump('memo-off')
481 }
482 if (showU) out.push(m.u)
483 if (showA) out.push(m.a)
484 })
485 return out
486}
487let cache: { key: string; entries: Entry[]; firstOfGroup: Map<number, number> } | null = null
488function entriesFor(filter: string, w: number) {
489 const key = `${dataVer}|${filter}|${w}|${running}`
490 if (PANE_MODE === 'cached' && cache && cache.key === key) { bump('cache-hit'); return cache }
491 const entries = buildEntries(filter, w)
492 const firstOfGroup = new Map<number, number>()
493 entries.forEach((e, k) => { if (!firstOfGroup.has(e.g)) firstOfGroup.set(e.g, k) })
494 cache = { key, entries, firstOfGroup }
495 return cache
496}
497const sepAfter = (e: Entry, next: Entry | undefined) => next !== undefined && next.g !== e.g
498
499async function jumpTo($: $T, gi: number, side: 'u' | 'a') {
500 const g = groups[gi]
501 if (!g) return 'no group'
502 const t = side === 'u' ? g.userUuid : g.lastTextUuid
503 if (!t) return 'no target'
504 const a = now()
505 const res = await $.ui.scroll({ to: { requestId: uuidToRid.get(t) ?? t }, block: 'start' })
506 put('jumpMs', now() - a)
507 if (!res.deny) lock = { g: gi, last: Date.now() }
508 return JSON.stringify(res)
509}
510
511/* cpstress: K stale "on screen" reports spread over the whole chain, as if a
512 long scroll left residues behind, plus a live run of 20 rows at the end. */
513function cpStress(k: number) {
514 const saved = new Map(onScreen)
515 const savedDrawn = new Set(drawnEver)
516 const sites = [...siteRows]
517 const step = Math.max(1, Math.floor(sites.length / k))
518 let at = 1
519 for (let i = 0; i < sites.length && onScreen.size < saved.size + k; i += step) {
520 onScreen.set(`S|${sites[i]}`, { rid: sites[i]!, c: 'AssistantMessage', os: { first: 0, last: 3, of: 4 }, at: at++ })
521 drawnEver.add(sites[i]!)
522 }
523 for (const u of sites.slice(-20)) onScreen.set(`E|${u}`, { rid: u, c: 'UserMessage', os: { first: 0, last: 1, of: 2 }, at: at++ })
524 drawableDirty = true
525 const res: Record<string, unknown> = { k, onScreen: onScreen.size, drawable: siteRows.size + drawnEver.size }
526 for (const mode of ['naive', 'fast']) {
527 const xs: number[] = []
528 let pick = -1
529 for (let r = 0; r < 30; r++) {
530 if (mode === 'fast') drawableDirty = r === 0
531 const a = now(); const o = mode === 'naive' ? cpNaive() : cpFast(); xs.push(rnd(now() - a))
532 pick = o.best.length ? (groupOfOrder[o.best[0]!.o] ?? -1) : -1
533 }
534 res[mode] = { ...summary(xs), firstWithBuild: xs[0], group: pick }
535 }
536 onScreen.clear(); for (const [k2, v] of saved) onScreen.set(k2, v)
537 drawnEver.clear(); for (const u of savedDrawn) drawnEver.add(u)
538 drawableDirty = true
539 return res
540}
541
542function paneStress(w: number) {
543 const res: Record<string, unknown> = {}
544 for (const f of ['all', 'user', 'agent']) {
545 const xs: number[] = []
546 for (let r = 0; r < 10; r++) { const a = now(); buildEntries(f, w); xs.push(rnd(now() - a)) }
547 res[f] = summary(xs)
548 }
549 return res
550}
551
552export const register: Register = on => {
553 on('classic.SessionStart', async ($, e, next) => {
554 await update($, pathAtom, () => e.transcript_path ?? '')
555 log({ ev: 'SessionStart', src: (e as any).source })
556 return next(e)
557 })
558
559 on('session.start', async ($, e, next) => {
560 PANE_MODE = (await $.env.get('TP_PANE')) || PANE_MODE
561 CP_MODE = (await $.env.get('TP_CP')) || CP_MODE
562 TRIG = (await $.env.get('TP_TRIG')) || TRIG
563 POLL = Number((await $.env.get('TP_POLL')) || POLL)
564 TAG = (await $.env.get('TP_TAG')) || TAG
565 READ = (await $.env.get('TP_READ')) || READ
566 REB = (await $.env.get('TP_REB')) || REB
567 log({ ev: 'session.start', sinceModule: Date.now() - T0, perfNow: !!perf?.now, PANE_MODE, CP_MODE, TRIG, POLL, TAG, READ, REB })
568 await $.command.register({ name: 'tperf', description: 'toc-perf probe', immediate: true } as any)
569 void (async () => {
570 await load($, 'start')
571 const r = await $.ui.open({ id: PANE, title: '目录', columns: 44 })
572 log({ ev: 'open', r })
573 })()
574 if (TRIG === 'poll') $.clock.every(POLL, () => { void load($, 'poll') })
575 return next(e)
576 })
577
578 on('turn.start', async ($, e, next) => { running = true; poke($); return next(e) })
579 on('turn.complete', async ($, e, next) => { running = false; loadSoon($, 'turn'); return next(e) })
580 on('session.append', async ($, e: any, next) => {
581 const r = await next(e)
582 if (!e.agentId) { log({ ev: 'append', uuid: e.uuid, door: e.door }); loadSoon($, 'append') }
583 return r
584 })
585
586 for (const c of ['UserMessage', 'AssistantMessage', 'CommandOutput', 'ToolGroup', 'ToolUse'] as const) {
587 (on as any)('ui.render', { component: c }, ($: $T, e: any, next: any) => {
588 const a = now()
589 bump('hook:' + c)
590 const rid = e.requestId as string
591 if (rid !== 'placeholder') {
592 const os = e.props.onScreen as OS
593 if (!drawnRids.has(rid)) {
594 drawnRids.set(rid, c)
595 const u = resolve(c, rid)
596 if (u && !drawnEver.has(u)) { drawnEver.add(u); refused.delete(u); drawableDirty = true }
597 }
598 if (os !== undefined && lock) { const t = Date.now(); if (t - lock.last < 300) lock.last = t; else lock = null }
599 if (os !== undefined) {
600 const key = `${c}|${rid}`
601 if (os === null && CP_MODE === 'fast') onScreen.delete(key)
602 else onScreen.set(key, { rid, c, os, at: Date.now() })
603 recompute($)
604 }
605 }
606 put('hookMs', now() - a)
607 return next(e)
608 })
609 }
610
611 on('command.run', { command: 'tperf' }, async ($, e) => {
612 const a = e.args.trim().split(/\s+/)
613 if (a[0] === 'dump') {
614 const sig = () => groups.map(g => `${g.userUuid}:${g.steps}:${g.status}:${g.lastTextUuid}:${g.userText}`).join('|')
615 const before = sig(); const nBefore = groups.length; const saveReb = REB; REB = 'full'; rebuild(); resolveAll(); REB = saveReb
616 log({ ev: 'verify', sameAsFull: before === sig(), groups: nBefore })
617 const out = { T0, tag: TAG, modes: { PANE_MODE, CP_MODE, TRIG, POLL, READ, REB }, cnt, summary: Object.fromEntries(Object.entries(series).map(([k, v]) => [k, summary(v)])), series, ev,
618 state: { rows: byId.size, chain: chain.length, groups: groups.length, offset, lastSize, unknown, onScreen: onScreen.size, drawnEver: drawnEver.size, siteRows: siteRows.size, cp } }
619 const name = `${$.plugin.root}/../logs/${TAG}-${Date.now()}.json`
620 await $.fs.write(name, JSON.stringify(out))
621 return { text: `TPDUMP ${name}` }
622 }
623 if (a[0] === 'jump') return { text: 'TPJUMP ' + await jumpTo($, Number(a[1] ?? 0), (a[2] as any) ?? 'u') }
624 if (a[0] === 'cpstress') { const r = cpStress(Number(a[1] ?? 3000)); log({ ev: 'cpstress', ...r }); return { text: 'TPCP ' + JSON.stringify(r) } }
625 if (a[0] === 'panestress') { const r = paneStress(Number(a[1] ?? 42)); log({ ev: 'panestress', ...r }); return { text: 'TPPANE ' + JSON.stringify(r) } }
626 if (a[0] === 'reload') { const t = now(); byId.clear(); leaf = null; offset = 0; lastSize = -1; chain = []; groups = []; order.clear(); groupOfOrder.length = 0; await load($, 'force'); return { text: `TPRELOAD ${rnd(now() - t)}` } }
627 if (a[0] === 'open') return { text: JSON.stringify(await $.ui.open({ id: PANE, title: '目录', columns: 44 })) }
628 return { text: 'tperf dump|jump G [u|a]|cpstress K|panestress W|reload|open' }
629 })
630
631 on('ui.scroll', { requestId: PANE }, async ($: $T, e: any) => {
632 if (e.origin.kind !== 'person') return {}
633 top = Math.max(0, top + e.by); paused = true; bump('wheel'); poke($)
634 return {}
635 })
636
637 on('ui.render', { component: 'Pane', requestId: PANE }, async ($: $T, e: any) => {
638 const t0 = now()
639 bump('pane')
640 const { Box, Text, Button } = $.ui.resolve(e)
641 await read($, verAtom)
642 const cpv = await read($, cpAtom)
643 const filter = ((await $.store.get('filter')) as string) ?? 'all'
644 const t1 = now()
645 const pr = e.props
646 const W = pr.bodyColumns
647 const rows = pr.scroll.bodyRows
648 const { entries, firstOfGroup } = entriesFor(filter, W)
649 const t2 = now()
650 if (cpv !== followedCp) { followedCp = cpv; paused = false }
651 const room = rows - 3
652 let maxTop = entries.length
653 { let acc = 0; for (let k = entries.length - 1; k >= 0; k--) { acc += entries[k]!.lines.length + (sepAfter(entries[k]!, entries[k + 1]) ? 1 : 0); if (acc > room) break; maxTop = k } }
654 if (!paused && cpv >= 0) {
655 const first = PANE_MODE === 'cached' ? (firstOfGroup.get(cpv) ?? -1) : entries.findIndex(x => x.g === cpv)
656 if (first >= 0) {
657 if (cpv === groups.length - 1) top = maxTop
658 else if (first < top || !lastVisible.includes(entries[first]!.key) || first >= top + Math.max(1, lastVisible.length) - 1) top = Math.max(0, first - 1)
659 }
660 }
661 top = Math.max(0, Math.min(top, maxTop))
662 const body: any[] = []
663 const vis: string[] = []
664 let used = 0
665 for (let k = top; k < entries.length; k++) {
666 const en = entries[k]!
667 if (used + en.lines.length > room) break
668 vis.push(en.key)
669 const hl = en.g === cpv
670 const bar = hl ? '▌ ' : ' '
671 /* 2.1.287's Button takes a label only: the highlight bar sits beside it */
672 body.push(<Box key={`row:${en.key}`} flexDirection="row">
673 <Text color={hl ? 'cyan' : undefined} dimColor={!hl}>{en.lines.map(() => bar).join('\n')}</Text>
674 <Button key={en.key} plain hover={{ inverse: true }} label={en.lines.map(l => l.pre + l.text).join('\n')} onPress={() => { void jumpTo($, en.g, en.side) }} />
675 </Box>)
676 used += en.lines.length
677 if (sepAfter(en, entries[k + 1]) && used < room) { body.push(<Text> </Text>); used++ }
678 }
679 lastVisible = vis
680 while (used < room) { body.push(<Text> </Text>); used++ }
681 const tree = (
682 <Box flexDirection="column">
683 <Text>{`${filter === 'all' ? '●' : '○'}全部 ○用户 ○Agent`}</Text>
684 {loadError ? <Text dimColor>{loadError}</Text> : null}
685 {body}
686 <Text dimColor wrap="truncate-end">{`TP g${groups.length} cp${cpv}${paused ? ' 暂停' : ''} ${PANE_MODE}/${CP_MODE}`}</Text>
687 <Text> </Text>
688 </Box>
689 )
690 const t3 = now()
691 put('paneAwaitMs', t1 - t0); put('paneBuildMs', t2 - t1); put('paneDrawMs', t3 - t2); put('paneMs', t3 - t0)
692 if (!readyLogged && groups.length) { readyLogged = true; log({ ev: 'ready', sinceModuleMs: Date.now() - T0, sincePerf0: rnd(now() - NOW0) }) }
693 return tree
694 })
695}
696types/index.d.ts 8 lines1export type Probe = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'toc-perf': { cp: number; ver: number; path: string }
6 }
7}
8