SLOPSHOPPER

chat-toc-proto

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

newpanerowscommandstatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chat-toc-proto
│ ┃ 目录 ✕ › fix the failing auth test and add an audit log call │ ┃ 1: ●全部 2: ○用户 3: ○Agent │ ┃ 还没有 transcript ⏺ Read(src/auth.ts) │ ┃ 文件:第一条消息发出后这里显示目录。 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /chat-toc │ ┃ ⎿ chat-toc-proto: 目录已打开 │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 目录
1: ●全部 2: ○用户 3: ○Agent 还没有 transcript 文件:第一条消息发出后这里显示目录。 原型 ‹ A 卡片 v: › cp-1
README

English | 简体中文

cc-mods

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.

Mods

ModVersionWhat it does
chat-toc0.2.0Docks 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.


chat-toc

chat-toc in a fullscreen Claude Code terminal: clicking the minimap and an entry jumps the transcript back to that turn

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.

Requirements

  • Claude Code >= 2.1.287 (see claude --version)
  • An interactive terminal. In Claude Code Desktop (and VS Code and mobile) the contents show, but a click cannot jump: the Claude app does not let plugins scroll the conversation yet (see Known limits). JetBrains is not supported
  • The fullscreen layout: run /tui fullscreen to switch
  • A wide enough terminal: whether the pane can dock is Claude Code's call. Measured on 2.1.287 and 2.1.295: it docks by itself from 144 columns, and when opened with /chat-toc from 110 columns
  • CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is not needed
  • Any operating system, but tested on macOS only (see Known limits)

While the contents are not on screen, a line above the prompt stands in for them and opens them (see Getting started).

Install

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

Getting started

  1. Start Claude Code in the fullscreen layout. The contents dock on the right by themselves, titled "ChatTOC" (2.1.287 draws no title row).
  2. If the contents do not show, the terminal is narrower than Claude Code docks them by itself, and a line above the prompt stands in for them: 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.
  1. Click any entry to jump back to that turn in the conversation. After closing the pane with ✕, /chat-toc opens it again at any time, with the focus in it.

Controls

ControlWhat 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 filtersJumps 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 contentsScrolls the contents by entries and pauses following
Ctrl+X then Tab; or /chat-toc with an empty promptMoves the focus into the contents
Any click in the contents, with an empty promptAlso 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 entryOpens 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 topShows 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 endCycles 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 lineCloses 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 lineExpands 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 linePuts 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 lineCopies 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 lineCopies 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 fieldJumps 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 fieldMoves the selection on to the matching entries; Enter on one jumps there, selects it and keeps the search
Esc in the search fieldMoves the focus back to the prompt and keeps the search
Ctrl+U or Backspace in the search field, or click the ✕ at its right endClears 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 promptOpens 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-bandShows the line above the prompt again
/chat-tocOpens 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 entryOpens 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 choicesNarrows 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 buttonKeeps 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, ← BackAs 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 entryThe 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 lineCopies 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.

Keys for the previous and next turn

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.
  • On macOS the terminal must send Option as Meta (iTerm2: Profiles → Keys → Left Option key: Esc+; Terminal: Settings → Profiles → Keyboard → Use Option as Meta key).
  • Use a modified key such as 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 keys press the ‹ › 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.

What it shows

  • Entries: each entry is one turn of the conversation: one input of yours, plus what the Agent did from that input to your next one. In conversation order. A slash command and a ! 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.
  • Time rail: entries hang on a vertical line on the left. Your input starts at a node ●, the Agent's reply at a branch ├╴; the lines after them and the gaps between groups on the same day sit against the line │.
  • Your input: its first 2 lines, with … 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.
  • The Agent's reply: dim text, first the state and the steps, then the last text reply, at most 2 lines:
  • 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.
  • A local slash command, or a ! command that set no Agent work going, has only your input. Under "Agent" those entries are hidden.
  • Time and day: times come from the transcript, in this machine's time zone. The card layout writes the time at the right end of your input's first line (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.
  • Compactions: where the conversation was compacted, a dashed line crosses the list, ╌ 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.
  • Earlier turns elsewhere: when this session's file starts at a compaction whose earlier turns it does not hold (a session forked with --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.
  • Three layouts: Card (the default) draws up to 2 lines a side, the time at the right end; Compact draws one line a side, the time in the left column; Timeline draws up to 2 lines a side, the time in the left column. The layout changes only how entries are drawn, not which show. On a switch, the selected entry (or, with no selection, the turn being viewed) stays in view.
  • Highlight: marks the turn you are viewing in the transcript (the turn holding the top line of the screen; the last turn when the conversation's end shows whole): the node becomes ◉, 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.
  • Search: the row under the view choices holds 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.
  • Top and footer: the top holds the three view choices, a line under them marking the chosen one, the search row, then the row of blocks and the list. The footer sits at the bottom of the contents, under the preview when it is open: a thin line, under it on the left the number of turns ("12 turns", the same under every view choice) and on the right the layout button.
  • Context use: how full the context window was at the last response, as Claude Code reports it, follows the turn count in the footer: "38 turns · context 62% used"; when the contents are narrow it shortens to "38 turns · 62% used" and then leaves it out. The line above the prompt that stands in for contents not on screen adds "· context 62% used" after its counts. It is read when the session starts and after every response, always dim, never coloured, with nothing said near the limit (Claude Code warns before compacting by itself). Until the first response, and from a compaction until the next response, Claude Code has no figure, so none shows.
  • Preview: a region at the bottom, above the footer, shows one turn whole. It is closed until you ask for it: 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:
  • a dim line of what the transcript states about the turn: 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;
  • the files the turn touched: 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. C
Source 2 files
hooks/register.tsx 669 lines
1/* 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}
669
types/index.d.ts 8 lines
1export 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