PROTOTYPE (throwaway): docked Chat TOC pane for ticket 05

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 669 lines1/* PROTOTYPE — throwaway. chat-toc wayfinder ticket 05: does the docked TOC pane
2 read and behave right? Three layout variants (A 卡片 / B 紧凑 / C 时间轴),
3 switch with the footer ‹ › buttons, hotkey v, or `/chat-toc a|b|c`.
4 `/chat-toc w 36` reopens at another width; `/chat-toc log` dumps observations.
5 No tests, no polish, transcript re-read whole on every change. */
6import { atom, read, update } from 'claude-code'
7import type { Register } from 'claude-code'
8
9type $T = any
10const P = 'chat-toc-proto'
11const PANE = 'chat-toc'
12const cpAtom = atom({ plugin: 'chat-toc-proto', key: 'cp' } as const, -1)
13const verAtom = atom({ plugin: 'chat-toc-proto', key: 'ver' } as const, 0)
14const closedAtom = atom({ plugin: 'chat-toc-proto', key: 'closed' } as const, false)
15const pathAtom = atom({ plugin: 'chat-toc-proto', key: 'path' } as const, '')
16const noteAtom = atom({ plugin: 'chat-toc-proto', key: 'note' } as const, '')
17const VARIANTS = ['A', 'B', 'C'] as const
18const VNAME: Record<string, string> = { A: '卡片', B: '紧凑', C: '时间轴' }
19const MAX = 4_000_000
20
21/* ---------- observation log ---------- */
22const STAMP = Date.now()
23const logLines: string[] = []
24let flushing = false
25let dirty = false
26const stats: Record<string, number> = {}
27const bump = (k: string, n = 1) => { stats[k] = (stats[k] ?? 0) + n }
28async function flush($: $T) {
29 dirty = true
30 if (flushing) return
31 flushing = true
32 while (dirty) {
33 dirty = false
34 try { await $.fs.write(`${$.plugin.root}/../chat-toc-proto-logs/${STAMP}.jsonl`, logLines.join('\n') + '\n') } catch { }
35 }
36 flushing = false
37}
38function log($: $T, o: Record<string, unknown>) { logLines.push(JSON.stringify({ t: Date.now(), ...o })); void flush($) }
39
40/* ---------- transcript model ---------- */
41type Group = {
42 idx: number
43 kind: 'prompt' | 'cmd' | 'bang'
44 userUuid: string
45 userText: string
46 time: string
47 uuids: string[]
48 toolUuids: string[]
49 steps: number
50 lastText: string
51 lastTextUuid: string | null
52 status: 'done' | 'interrupted' | 'error'
53}
54let groups: Group[] = []
55let chain: string[] = []
56const order = new Map<string, number>()
57const groupOfOrder: number[] = []
58const toolIdToUuid = new Map<string, string>()
59const siteRows = new Set<string>()
60let loadError = ''
61let truncated = false
62let running = false
63let lastSize = -1
64
65const 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') : ''
66
67function opener(r: any): Group['kind'] | null {
68 if (r.type === 'system' && r.subtype === 'local_command' && String(r.content ?? '').includes('<command-name>')) return 'cmd'
69 if (r.type !== 'user' || r.isMeta || r.isCompactSummary || r.isSidechain) return null
70 const c = r.message?.content
71 if (Array.isArray(c) && c.some((b: any) => b?.type === 'tool_result')) return null
72 const t = textOf(c).trim()
73 if (t.includes('<command-name>')) return 'cmd'
74 if (t.startsWith('<bash-input>')) return 'bang'
75 if (/^<(local-command-stdout|local-command-caveat|bash-stdout|bash-stderr|task-notification)/.test(t)) return null
76 if (t.startsWith('[Request interrupted by user')) return null
77 if (r.origin && r.origin.kind !== 'human') return null
78 if (!t && !(Array.isArray(c) && c.some((b: any) => b?.type === 'image'))) return null
79 return 'prompt'
80}
81
82function userDisplay(kind: Group['kind'], r: any): string {
83 const c = r.message?.content
84 const t = r.type === 'system' ? String(r.content ?? '') : textOf(c)
85 if (kind === 'cmd') {
86 const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(t)?.[1]?.trim() ?? ''
87 const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(t)?.[1]?.trim() ?? ''
88 return `${name.startsWith('/') ? name : '/' + name}${args ? ' ' + args : ''}`
89 }
90 if (kind === 'bang') return '!' + (/<bash-input>([\s\S]*?)<\/bash-input>/.exec(t)?.[1]?.trim() ?? '')
91 const s = t.replace(/<\/?pasted_content[^>]*>/g, ' ').replace(/```[\s\S]*?```/g, ' ').replace(/!\[[^\]]*\]\([^)]*\)/g, ' ').replace(/[#*_`>]+/g, '').replace(/\s+/g, ' ').trim()
92 if (s) return s
93 return Array.isArray(c) && c.some((b: any) => b?.type === 'image') ? '(只有图片)' : ''
94}
95
96function fmtTime(ts: string | undefined): string {
97 if (!ts) return ''
98 const d = new Date(ts)
99 if (isNaN(d.getTime())) return ''
100 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
101 const md = `${d.getMonth() + 1}月${d.getDate()}日`
102 return d.getFullYear() === new Date().getFullYear() ? `${md} ${hm}` : `${d.getFullYear()}年${md} ${hm}`
103}
104
105function parse(text: string) {
106 const byId = new Map<string, any>()
107 let leaf: string | null = null
108 for (const line of text.split('\n')) {
109 if (!line) continue
110 let r: any
111 try { r = JSON.parse(line) } catch { continue }
112 if (!r || typeof r.uuid !== 'string' || r.isSidechain) continue
113 byId.set(r.uuid, r)
114 if (r.type === 'user' || r.type === 'assistant' || r.type === 'system') leaf = r.uuid
115 }
116 const back: string[] = []
117 const guard = new Set<string>()
118 let cur = leaf
119 while (cur && byId.has(cur) && !guard.has(cur)) {
120 guard.add(cur)
121 back.push(cur)
122 const r = byId.get(cur)
123 cur = r.parentUuid ?? (r.subtype === 'compact_boundary' ? r.logicalParentUuid : null)
124 }
125 chain = back.reverse()
126 order.clear(); groupOfOrder.length = 0; toolIdToUuid.clear(); siteRows.clear()
127 const out: Group[] = []
128 let g: Group | null = null
129 let tail: { uuid: string; mid: string; text: string }[] = []
130 const close = () => {
131 if (!g) return
132 if (tail.length) {
133 const mid = tail[tail.length - 1]!.mid
134 const parts = tail.filter(x => x.mid === mid)
135 g.lastText = parts.map(x => x.text).join(' ').replace(/[#*_`>]+/g, '').replace(/\s+/g, ' ').trim()
136 g.lastTextUuid = parts[0]!.uuid
137 }
138 tail = []
139 }
140 chain.forEach((id, i) => {
141 order.set(id, i)
142 const r = byId.get(id)
143 if ((r.type === 'user' && opener(r) === 'prompt') || (r.type === 'assistant' && Array.isArray(r.message?.content) && r.message.content.some((b: any) => b?.type === 'text' && String(b.text).trim()))
144 || (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)
145 const k = opener(r)
146 if (k) {
147 close()
148 g = { idx: out.length, kind: k, userUuid: id, userText: userDisplay(k, r), time: fmtTime(r.timestamp), uuids: [], toolUuids: [], steps: 0, lastText: '', lastTextUuid: null, status: 'done' }
149 out.push(g)
150 }
151 groupOfOrder[i] = g ? g.idx : -1
152 if (!g) return
153 g.uuids.push(id)
154 if (r.type === 'user' && textOf(r.message?.content).startsWith('[Request interrupted by user')) g.status = 'interrupted'
155 if (r.type === 'assistant') {
156 if (r.isApiErrorMessage) g.status = 'error'
157 else if (g.status === 'error') g.status = 'done'
158 const blocks = Array.isArray(r.message?.content) ? r.message.content : []
159 for (const b of blocks) {
160 if (b?.type === 'tool_use') {
161 g.steps++
162 tail = []
163 if (!g.toolUuids.includes(id)) g.toolUuids.push(id)
164 if (b.id) toolIdToUuid.set(b.id, id)
165 } else if (b?.type === 'text' && String(b.text).trim()) {
166 tail.push({ uuid: id, mid: r.message?.id ?? id, text: b.text })
167 }
168 }
169 }
170 })
171 close()
172 groups = out
173}
174
175async function findPath($: $T): Promise<string> {
176 const saved = await read($, pathAtom)
177 if (saved) return saved
178 const id = await $.session.id()
179 const r = await $.process.run(['sh', '-c', `ls "$HOME"/.claude/projects/*/${id}.jsonl 2>/dev/null | head -1`])
180 return r.stdout.trim()
181}
182
183async function load($: $T, why: string) {
184 const t0 = Date.now()
185 try {
186 const path = await findPath($)
187 if (!path) { loadError = '还没有 transcript 文件:第一条消息发出后这里显示目录。'; groups = []; poke($); return }
188 let st
189 try { st = await $.fs.stat(path) } catch { loadError = '还没有 transcript 文件:第一条消息发出后这里显示目录。'; groups = []; poke($); return }
190 if (st.size === lastSize && why !== 'force') return
191 lastSize = st.size
192 let text: string
193 if (st.size <= MAX) { text = await $.fs.read(path); truncated = false } else {
194 const r = await $.process.run(['tail', '-c', String(MAX - 1), path])
195 text = r.stdout.slice(r.stdout.indexOf('\n') + 1)
196 truncated = true
197 }
198 loadError = ''
199 parse(text)
200 resolveAll()
201 log($, { ev: 'load', why, ms: Date.now() - t0, size: st.size, chain: chain.length, groups: groups.length })
202 } catch (err: any) {
203 loadError = `读不到 transcript:${String(err?.message ?? err).slice(0, 80)}`
204 log($, { ev: 'load-error', why, err: loadError })
205 }
206 recompute($)
207 poke($)
208}
209let loadTimer = false
210function loadSoon($: $T, why: string) {
211 if (loadTimer) return
212 loadTimer = true
213 $.clock.after(300, () => { loadTimer = false; void load($, why) })
214}
215
216/* ---------- what the transcript drew ---------- */
217type OS = { first: number; last: number; of: number } | null | undefined
218/* keyed by site instance (component|requestId): one row may be drawn by several sites */
219const onScreen = new Map<string, { rid: string; c: string; os: OS; at: number }>()
220let cpSrc: 'msg' | 'all' | 'noToolUse' = 'noToolUse'
221const counts = (c: string) => cpSrc === 'all' || (cpSrc === 'noToolUse' ? c !== 'ToolUse' : c === 'UserMessage' || c === 'AssistantMessage' || c === 'CommandOutput')
222const drawnEver = new Set<string>()
223const refused = new Set<string>()
224let cp = -1
225let pokeQueued = false
226function poke($: $T) {
227 if (pokeQueued) return
228 pokeQueued = true
229 $.clock.after(0, () => { pokeQueued = false; update($, verAtom, v => v + 1).catch(() => bump('poke-denied')) })
230}
231
232/* A drawn requestId resolves to a chain row lazily, after each load: the
233 transcript lags the screen, and a reload redraws before the first load.
234 Exact uuid first, then the host's derived ids (`collapsed-<uuid>`, or a uuid
235 whose last 12 hex digits are replaced) by their 24-char prefix. */
236const drawnRids = new Map<string, string>()
237const ridCache = new Map<string, string | null>()
238const uuidToRid = new Map<string, string>()
239const prefix24 = new Map<string, string | null>()
240const resolveStats: Record<string, number> = {}
241const ridSamples: string[] = []
242function resolve(c: string, rid: string): string | null {
243 if (rid === 'placeholder') return null
244 const hit = ridCache.get(rid)
245 if (hit !== undefined) return hit
246 let u: string | null = null
247 let how = 'none'
248 const body = rid.replace(/^collapsed-/, '')
249 if (c === 'ToolUse' && toolIdToUuid.has(rid)) { u = toolIdToUuid.get(rid)!; how = 'toolid' }
250 else if (order.has(body)) { u = body; how = 'exact' }
251 else { const q = prefix24.get(body.slice(0, 24)); if (q) { u = q; how = 'prefix24' } }
252 if (chain.length) {
253 ridCache.set(rid, u)
254 const k = `${c}:${how}`
255 resolveStats[k] = (resolveStats[k] ?? 0) + 1
256 if (how !== 'exact' && ridSamples.length < 12) ridSamples.push(`${c}:${how}:${rid.slice(0, 48)}`)
257 }
258 if (u && (!uuidToRid.has(u) || c !== 'ToolGroup')) uuidToRid.set(u, rid)
259 return u
260}
261function resolveAll() {
262 ridCache.clear(); uuidToRid.clear(); drawnEver.clear()
263 prefix24.clear()
264 for (const u of chain) { const k = u.slice(0, 24); prefix24.set(k, prefix24.has(k) ? null : u) }
265 for (const [rid, c] of drawnRids) { const u = resolve(c, rid); if (u) drawnEver.add(u) }
266}
267
268/* After a jump the highlight is the jumped-to group, not the view's top: a
269 fallback jump centers its row, so the top shows the groups above it. The
270 lock holds while the jump's own redraws keep coming (each within 300 ms of
271 the last) and lets go at the first view change after that quiet. */
272let lock: { g: number; last: number } | null = null
273let stale = 0
274let lockOn = true
275function recompute($: $T) {
276 /* The host draws only near the view and unmounts rows without a last
277 `null`, so stale "on screen" rows linger where the view used to be. Rows
278 truly on screen are contiguous in transcript order: split the live rows
279 wherever a drawable row that is not live lies between two of them (one
280 reported off screen, drawn and gone, or never drawn), and trust only the
281 run holding the latest report. */
282 type L = { o: number; os: NonNullable<OS>; at: number }
283 const live: L[] = []
284 const off: number[] = []
285 for (const { rid, c, os, at } of onScreen.values()) {
286 if (!counts(c)) continue
287 const u = resolve(c, rid); const o = u ? order.get(u) : undefined
288 if (o === undefined) continue
289 if (os) live.push({ o, os, at }); else if (os === null) off.push(o)
290 }
291 live.sort((x, y) => x.o - y.o)
292 const liveO = new Set(live.map(x => x.o))
293 for (const u of drawnEver) { const o = order.get(u); if (o !== undefined && !liveO.has(o)) off.push(o) }
294 for (const u of siteRows) { const o = order.get(u); if (o !== undefined && !liveO.has(o)) off.push(o) }
295 off.sort((x, y) => x - y)
296 const runs: L[][] = []
297 for (const x of live) {
298 const run = runs[runs.length - 1]
299 const prev = run?.[run.length - 1]
300 const k = prev ? off.findIndex(o => o > prev.o) : -1
301 if (prev && (k < 0 || off[k]! >= x.o)) run!.push(x); else runs.push([x])
302 }
303 let best: L[] = []
304 let latest = -1
305 for (const run of runs) for (const x of run) if (x.at > latest) { latest = x.at; best = run }
306 let lastDrawn = -1
307 for (const u of drawnEver) { const o = order.get(u); if (o !== undefined && o > lastDrawn) lastDrawn = o }
308 let n = -1
309 const shown = best
310 const lastRow = best.find(x => x.o === lastDrawn)
311 if (lock) n = lock.g
312 else if (lastRow && lastRow.os.last === lastRow.os.of - 1) n = groups.length - 1
313 else if (shown.length) n = groupOfOrder[shown[0]!.o] ?? -1
314 if (n !== cp) {
315 cp = n
316 $.clock.after(0, () => { update($, cpAtom, () => n).catch(() => bump('cp-denied')) })
317 }
318 stale = live.length - best.length
319}
320
321/* ---------- pane-side state ---------- */
322let top = 0
323let paused = false
324let hold = false /* test-only: goto keeps the window until a wheel or a jump */
325let followedCp = -2
326let ringKey: string | undefined
327let focused = false
328let lastEntries: Entry[] = []
329let lastVisible: string[] = []
330let bodyRowsSeen = 20
331let noteTimer = 0
332
333type Entry = { key: string; g: number; side: 'u' | 'a'; dim: boolean; lines: { pre: string; preColor?: string; text: string; dim?: boolean; bold?: boolean }[] }
334
335/* cell widths */
336const 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))
337const cells = (s: string) => { let n = 0; for (const ch of s) n += wide(ch.codePointAt(0)!) ? 2 : 1; return n }
338function wrapN(s: string, w: number, max: number): string[] {
339 w = Math.max(4, w)
340 const out: string[] = []
341 let cur = ''
342 let cw = 0
343 for (const ch of s) {
344 const c = wide(ch.codePointAt(0)!) ? 2 : 1
345 if (cw + c > w) {
346 out.push(cur); cur = ''; cw = 0
347 if (out.length === max) {
348 let l = out[max - 1]!
349 while (cells(l) + 1 > w) l = [...l].slice(0, -1).join('')
350 out[max - 1] = l + '…'
351 return out
352 }
353 }
354 cur += ch; cw += c
355 }
356 if (cur || !out.length) out.push(cur)
357 return out
358}
359const clip = (s: string, w: number) => wrapN(s, w, 1)[0]!
360
361function agentHead(g: Group, inProgress: boolean): string {
362 const st = g.steps ? `${g.steps} 步` : ''
363 if (inProgress) return `进行中${st ? ' · ' + st : ''}`
364 if (g.status === 'interrupted') return `已中断${st ? ' · ' + st : ''}`
365 if (g.status === 'error') return `出错${st ? ' · ' + st : ''}`
366 if (!g.lastText) return `${st || '0 步'} · 无文字回复`
367 return st
368}
369const hasAgent = (g: Group, i: number) => g.steps > 0 || !!g.lastText || (running && i === groups.length - 1 && g.kind !== 'bang')
370
371/* where a side jumps: [requestId, block] or null (dimmed) */
372function targetOf(g: Group, side: 'u' | 'a'): [string, 'start' | 'center'] | null {
373 const cand = (u: string) => !refused.has(u) && (drawnEver.has(u) || siteRows.has(u))
374 if (side === 'u') {
375 if (g.kind === 'prompt') return refused.has(g.userUuid) ? null : [g.userUuid, 'start']
376 const at = order.get(g.userUuid) ?? -1
377 const next = chain.slice(at + 1).find(cand)
378 if (next) return [next, 'center']
379 const prev = chain.slice(0, at).reverse().find(cand)
380 return prev ? [prev, 'center'] : null
381 }
382 if (g.lastTextUuid) return refused.has(g.lastTextUuid) ? null : [g.lastTextUuid, 'start']
383 const last = [...g.uuids].reverse().find(u => !refused.has(u) && drawnEver.has(u))
384 return last ? [last, 'start'] : null
385}
386
387function buildEntries(variant: string, filter: string, w: number): Entry[] {
388 const out: Entry[] = []
389 groups.forEach((g, i) => {
390 const hl = i === cp
391 const inProg = running && i === groups.length - 1
392 const showU = filter !== 'agent'
393 const showA = filter !== 'user' && hasAgent(g, i)
394 if (!showU && !showA) return
395 if (variant === 'A') {
396 const bar = hl ? '▌ ' : ' '
397 const pc = hl ? 'cyan' : undefined
398 if (showU) out.push({ key: `u${i}`, g: i, side: 'u', dim: !targetOf(g, 'u'), lines: [
399 { pre: bar, preColor: pc, text: `你${g.time ? ' · ' + g.time : ''}`, dim: true, bold: hl },
400 ...wrapN(g.userText || '(空)', w - 2, 2).map(t => ({ pre: bar, preColor: pc, text: t })),
401 ] })
402 if (showA) out.push({ key: `a${i}`, g: i, side: 'a', dim: !targetOf(g, 'a'), lines: [
403 { pre: bar, preColor: pc, text: '↳ ' + agentHead(g, inProg), dim: true, bold: hl },
404 ...(g.lastText ? wrapN(g.lastText, w - 4, 2).map(t => ({ pre: bar + ' ', preColor: pc, text: t })) : []),
405 ] })
406 } else if (variant === 'B') {
407 const mk = hl ? '● ' : ' '
408 const pc = hl ? 'cyan' : undefined
409 if (showU) {
410 const tm = g.time ? g.time.replace(/^.*日 /, '') : ''
411 const room = w - 2 - (tm ? cells(tm) + 1 : 0)
412 const body = clip('› ' + (g.userText || '(空)'), room)
413 out.push({ key: `u${i}`, g: i, side: 'u', dim: !targetOf(g, 'u'), lines: [{ pre: mk, preColor: pc, text: body + ' '.repeat(Math.max(0, room - cells(body))) + (tm ? ' ' + tm : ''), bold: hl }] })
414 }
415 if (showA) {
416 const head = agentHead(g, inProg)
417 out.push({ key: `a${i}`, g: i, side: 'a', dim: !targetOf(g, 'a'), lines: [{ pre: mk, preColor: pc, text: clip(` ⤷ ${head}${head && g.lastText ? ' · ' : ''}${g.lastText}`, w - 2), dim: true }] })
418 }
419 } else {
420 const tm = g.time ? g.time.replace(/^.*日 /, '') : ''
421 const gut = hl ? '┃ ' : '│ '
422 const pc = hl ? 'cyan' : 'gray'
423 if (showU) out.push({ key: `u${i}`, g: i, side: 'u', dim: !targetOf(g, 'u'), lines:
424 wrapN(g.userText || '(空)', w - 8, 2).map((t, k) => ({ pre: (k === 0 ? tm.padEnd(5) : ' ') + ' ' + gut, preColor: pc, text: t, bold: hl })) })
425 if (showA) {
426 const head = agentHead(g, inProg)
427 const body = wrapN(`↳ ${head}${head && g.lastText ? ' · ' : ''}${g.lastText}`, w - 8, 2)
428 out.push({ key: `a${i}`, g: i, side: 'a', dim: !targetOf(g, 'a'), lines: body.map(t => ({ pre: ' ' + gut, preColor: pc, text: t, dim: true })) })
429 }
430 }
431 })
432 return out
433}
434const sepAfter = (variant: string, e: Entry, next: Entry | undefined) => variant !== 'B' && next !== undefined && next.g !== e.g
435
436async function setNote($: $T, s: string) {
437 await update($, noteAtom, () => s)
438 const mine = ++noteTimer
439 if (s) $.clock.after(5000, () => { if (mine === noteTimer) void update($, noteAtom, () => '') })
440}
441
442async function jump($: $T, en: Entry, how: string) {
443 const g = groups[en.g]
444 if (!g) return
445 for (let tries = 0; tries < 3; tries++) {
446 const t = targetOf(g, en.side)
447 if (!t) {
448 log($, { ev: 'jump-dim', key: en.key, how, tries })
449 await setNote($, '这一条当前没有显示在对话里,无法跳转')
450 poke($)
451 return
452 }
453 const res = await $.ui.scroll({ to: { requestId: uuidToRid.get(t[0]) ?? t[0] }, block: t[1] })
454 log($, { ev: 'jump', key: en.key, how, rid: t[0], block: t[1], drawn: drawnEver.has(t[0]), fallback: t[0] !== (en.side === 'u' ? g.userUuid : g.lastTextUuid), res })
455 if (!res.deny) { if (lockOn) { lock = { g: en.g, last: Date.now() }; recompute($) } await setNote($, ''); return }
456 refused.add(t[0])
457 }
458 await setNote($, '这一条当前没有显示在对话里,无法跳转')
459 poke($)
460}
461
462/* step the selection ring one entry, scrolling our window to keep it shown */
463async function stepRing($: $T, by: number) {
464 const keys = lastEntries.map(e => e.key)
465 let i = ringKey ? keys.indexOf(ringKey) : -1
466 i = i < 0 ? Math.max(0, keys.indexOf(lastVisible[0] ?? '')) : Math.min(keys.length - 1, Math.max(0, i + by))
467 const k = keys[i]
468 if (!k) return
469 paused = true
470 if (!lastVisible.includes(k)) { top = by > 0 ? Math.max(0, i - Math.max(0, lastVisible.length - 2)) : i; poke($) }
471 void setNote($, '')
472 const r = await $.ui.focus({ requestId: PANE, key: k })
473 bump(r.deny ? 'focus-deny' : 'focus-ok')
474 if (r.deny) log($, { ev: 'focus-deny', k, r })
475}
476
477async function openPane($: $T, asked: boolean, columns?: number) {
478 const r = await $.ui.open({ id: PANE, title: '目录', columns: columns ?? 44 })
479 log($, { ev: 'open', asked, r })
480 if (!r.isPlaced) $.ui.status(`目录等待停靠:终端需全屏且 ≥144 列(${r.reason});放宽终端会自动出现,或输入 /chat-toc`)
481 else $.ui.status(undefined)
482 return r
483}
484
485export const register: Register = on => {
486 on('classic.SessionStart', async ($, e, next) => {
487 const src = (e as any).source
488 await update($, pathAtom, () => e.transcript_path ?? '')
489 log($, { ev: 'SessionStart', src, path: e.transcript_path })
490 if (src === 'clear' || src === 'resume') { lastSize = -1; drawnEver.clear(); drawnRids.clear(); onScreen.clear(); refused.clear(); top = 0; void load($, src) }
491 return next(e)
492 })
493
494 on('session.start', async ($, e, next) => {
495 await $.command.register({ name: 'chat-toc', description: '打开对话目录(原型)', immediate: true } as any)
496 log($, { ev: 'session.start' })
497 void (async () => {
498 await load($, 'start')
499 const closed = await read($, closedAtom)
500 const panes = await $.ui.panes()
501 log($, { ev: 'autoopen?', closed, panes })
502 if (!closed && !panes.some((p: any) => p.id === PANE)) await openPane($, false)
503 })()
504 return next(e)
505 })
506
507 on('turn.start', async ($, e, next) => { running = true; poke($); return next(e) })
508 on('turn.complete', async ($, e, next) => { running = false; loadSoon($, 'turn'); return next(e) })
509 on('session.append', async ($, e, next) => { loadSoon($, 'append'); return next(e) })
510
511 for (const c of ['UserMessage', 'AssistantMessage', 'CommandOutput', 'ToolGroup', 'ToolUse'] as const) {
512 (on as any)('ui.render', { component: c }, ($: $T, e: any, next: any) => {
513 bump('hook:' + c)
514 const rid = e.requestId as string
515 if (rid !== 'placeholder') {
516 const os = e.props.onScreen as OS
517 if (!drawnRids.has(rid)) {
518 drawnRids.set(rid, c)
519 const u = resolve(c, rid)
520 if (u && !drawnEver.has(u)) { drawnEver.add(u); refused.delete(u); poke($) }
521 }
522 if (os !== undefined && lock) { const now = Date.now(); if (now - lock.last < 300) lock.last = now; else { lock = null; log($, { ev: 'unlock' }) } }
523 if (os !== undefined) { onScreen.set(`${c}|${rid}`, { rid, c, os, at: Date.now() }); recompute($) }
524 }
525 return next(e)
526 })
527 }
528
529 on('ui.close', async ($, e, next) => {
530 const r = await next(e)
531 if (e.id === PANE && e.origin.kind === 'person') { await update($, closedAtom, () => true); log($, { ev: 'closed-by-person' }) }
532 if (e.id === PANE) $.ui.status(undefined)
533 return r
534 })
535
536 on('command.run', { command: 'chat-toc' }, async ($, e) => {
537 const a = e.args.trim().toLowerCase()
538 if (a === 'a' || a === 'b' || a === 'c') { await $.store.set('variant', a.toUpperCase()); poke($); return { text: `变体 ${a.toUpperCase()}(${VNAME[a.toUpperCase()]})` } }
539 const src = /^src\s+(msg|all|notooluse)$/.exec(a)
540 if (src) { cpSrc = src[1] === 'notooluse' ? 'noToolUse' : src[1] as any; recompute($); return { text: `Current position 只看:${cpSrc}` } }
541 const lk = /^lock\s+(on|off)$/.exec(a)
542 if (lk) { lockOn = lk[1] === 'on'; lock = null; recompute($); return { text: `跳转后锁定高亮:${lk[1]}` } }
543 const gt = /^goto\s+(\d+)$/.exec(a)
544 if (gt) { const i = lastEntries.findIndex(x => x.g === Number(gt[1])); if (i >= 0) { top = i; paused = true; hold = true; poke($) } return { text: `TOC 滚到第 ${gt[1]} 组(条目 ${i})` } }
545 if (a === 'log') {
546 const s = { stale, resolveStats, ridSamples, stats, chain: chain.length, groups: groups.length, drawnEver: drawnEver.size, drawnInChain: chain.filter(u => drawnEver.has(u)).length, cpSrc, live: [...onScreen.values()].filter(x => x.os).map(x => { const o = order.get(resolve(x.c, x.rid) ?? ''); return `${x.c}:${x.rid.slice(0, 13)}#${o}g${groupOfOrder[o ?? -1] ?? '?'}` + ` ${x.os!.first}-${x.os!.last}/${x.os!.of} ${Math.round((Date.now() - x.at) / 1000)}s` }), cp, top, paused, ringKey, truncated, loadError }
547 log($, { ev: 'dump', ...s })
548 return { text: JSON.stringify(s) }
549 }
550 const w = /^w\s*(\d+)$/.exec(a)
551 await update($, closedAtom, () => false)
552 if (w) { await $.ui.close({ id: PANE }).catch(() => undefined); const r = await openPane($, true, Number(w[1])); return { text: `目录宽 ${w[1]}:${JSON.stringify(r)}` } }
553 await load($, 'force')
554 const r = await openPane($, true)
555 return { text: r.isPlaced ? '目录已打开' : `目录未落座:${r.reason}` }
556 })
557
558 on('ui.focus', { requestId: PANE }, async ($: $T, e: any, next: any) => {
559 const r = await next(e)
560 if (!r.deny) { ringKey = e.element; if (e.origin.kind === 'person' && /^[ua]\d+$/.test(e.element ?? '')) paused = true }
561 return r
562 })
563
564 on('ui.scroll', { requestId: PANE }, async ($: $T, e: any, next: any) => {
565 if (e.origin.kind !== 'person') return next(e)
566 const arrow = e.pointer === undefined && Math.abs(e.by) === 1
567 if (arrow && focused) { await stepRing($, e.by); return {} }
568 hold = false
569 top = Math.max(0, top + e.by)
570 paused = true
571 bump('wheel')
572 poke($)
573 return {}
574 })
575
576 on('ui.render', { component: 'Pane', requestId: PANE }, async ($: $T, e: any) => {
577 const t0 = Date.now()
578 bump('pane')
579 const { Box, Text, Button } = $.ui.resolve(e)
580 await read($, verAtom)
581 const variant = ((await $.store.get('variant')) as string) ?? 'A'
582 const note = await read($, noteAtom)
583 const cpv = await read($, cpAtom)
584 const filter = ((await $.store.get('filter')) as string) ?? 'all'
585 const pr = e.props
586 focused = pr.isFocused
587 if (e.viewport?.isFullscreen === false || pr.placement === 'inline') {
588 $.clock.after(0, () => {
589 void $.ui.close({ id: PANE }).catch(() => undefined)
590 $.ui.status('目录只停靠在全屏布局的右侧;当前不是全屏(/tui fullscreen 切换后可用 /chat-toc 打开)')
591 })
592 return <Text dimColor>目录只在全屏布局停靠。</Text>
593 }
594 const W = pr.bodyColumns
595 const rows = pr.scroll.bodyRows
596 bodyRowsSeen = rows
597 const entries = buildEntries(variant, filter, W)
598 lastEntries = entries
599
600 /* follow Current position */
601 if (cpv !== followedCp) {
602 followedCp = cpv
603 paused = hold
604 }
605 const room = rows - 3
606 let maxTop = entries.length
607 { let acc = 0; for (let k = entries.length - 1; k >= 0; k--) { acc += entries[k]!.lines.length + (sepAfter(variant, entries[k]!, entries[k + 1]) ? 1 : 0); if (acc > room) break; maxTop = k } }
608 if (!paused && cpv >= 0) {
609 const first = entries.findIndex(x => x.g === cpv)
610 if (first >= 0) {
611 if (cpv === groups.length - 1) top = maxTop
612 else if (first < top || !lastVisible.includes(entries[first]!.key) || first >= top + Math.max(1, lastVisible.length) - 1) top = Math.max(0, first - 1)
613 }
614 }
615 top = Math.max(0, Math.min(top, maxTop))
616
617 const body: any[] = []
618 const vis: string[] = []
619 let used = 0
620 for (let k = top; k < entries.length; k++) {
621 const en = entries[k]!
622 const rowH = en.lines.length + (sepAfter(variant, en, entries[k + 1]) ? 1 : 0)
623 if (used + en.lines.length > room) break
624 vis.push(en.key)
625 const kids: any[] = []
626 en.lines.forEach((l, j) => {
627 kids.push(<Text color={l.preColor} dimColor={!l.preColor}>{l.pre}</Text>)
628 kids.push(<Text dimColor={l.dim || en.dim} bold={l.bold && !en.dim}>{l.text + (j < en.lines.length - 1 ? '\n' : '')}</Text>)
629 })
630 body.push(
631 <Box key={`row:${en.key}`}>
632 <Button key={en.key} plain dimColor={en.dim} hover={{ inverse: true }} onPress={() => { void jump($, en, 'press') }}>{kids}</Button>
633 </Box>,
634 )
635 used += en.lines.length
636 if (rowH > en.lines.length && used < room) { body.push(<Text> </Text>); used++ }
637 }
638 lastVisible = vis
639 while (used < room) { body.push(<Text> </Text>); used++ }
640
641 const fbtn = (k: string, label: string, hk: string) => (
642 <Button key={`f:${k}`} plain hotkey={hk} onPress={async () => { await $.store.set('filter', k); top = 0; paused = false; followedCp = -2; poke($) }}>{(filter === k ? '●' : '○') + label}</Button>
643 )
644 const vi = VARIANTS.indexOf(variant as any)
645 const cycle = async (d: number) => { await $.store.set('variant', VARIANTS[(vi + d + 3) % 3]!); poke($) }
646 const empty = loadError || (groups.length === 0 ? '还没有对话。' : entries.length === 0 ? (filter === 'agent' ? '还没有 Agent 回复。' : '没有可显示的条目。') : '')
647 const tree = (
648 <Box flexDirection="column">
649 <Box flexDirection="row">
650 {fbtn('all', '全部', '1')}<Text> </Text>{fbtn('user', '用户', '2')}<Text> </Text>{fbtn('agent', 'Agent', '3')}
651 </Box>
652 {empty ? <Text dimColor wrap="wrap">{empty}</Text> : null}
653 {body}
654 <Text dimColor wrap="truncate-end">{note || (truncated ? '只读了 transcript 最后 4 MB' : ' ')}</Text>
655 <Box flexDirection="row">
656 <Text color="magenta">原型 </Text>
657 <Button key="v:prev" plain onPress={() => cycle(-1)}>‹</Button>
658 <Text color="magenta">{` ${variant} ${VNAME[variant]} `}</Text>
659 <Button key="v:next" plain hotkey="v" onPress={() => cycle(1)}>›</Button>
660 <Text dimColor>{` cp${cpv}${paused ? ' 暂停' : ''}`}</Text>
661 </Box>
662 <Text> </Text>
663 </Box>
664 )
665 bump('pane-ms', Date.now() - t0)
666 return tree
667 })
668}
669types/index.d.ts 8 lines1export type Variant = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'chat-toc-proto': { cp: number; ver: number; closed: boolean; path: string; note: string }
6 }
7}
8