Throwaway probe: whether a Button action presses it from the prompt

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.ts 117 lines1import type { Register, RenderElement } from 'claude-code'
2
3// Throwaway probe for chat-toc 0.2 ticket 03: whether a Button's `action`
4// presses it from the prompt, and when. Every press and render count goes to
5// an in-memory log, written whole to PROBE_LOG after each entry.
6//
7// Panes: `action-probe` (the main one), `action-probe-2` (a second pane, to
8// make the first a back tab and to clash on one action), `action-probe-bad`
9// and `action-probe-reserved` (refused trees). The band above the prompt
10// carries its own action Buttons and a digit hotkey.
11
12const log: string[] = []
13let logPath: string | undefined
14const started = Date.now()
15const renders: Record<string, number> = {}
16let working = false
17
18function stamp(): string {
19 return `${((Date.now() - started) / 1000).toFixed(1)}s`
20}
21
22async function note($: any, line: string) {
23 log.push(`${stamp()} ${line}`)
24 logPath ??= (await $.env.get('PROBE_LOG')) ?? `${$.plugin.root}/../action-probe.log`
25 await $.fs.write(logPath, log.join('\n') + '\n').catch(() => undefined)
26}
27
28// Each main-pane Button names an action; the world's keybindings.json binds it.
29const PANE_ACTIONS: [key: string, action: string][] = [
30 ['noise', 'app:toggleDiffNoiseFilter'], // ctrl+x p (Global), and the band's too
31 ['presession', 'app:toggleDiffPreSession'], // meta+j (Global, a modified key)
32 ['repl', 'app:toggleReplTab'], // ctrl+x g (Global)
33 ['panenext', 'pane:next'], // ctrl+x . (Global; the engine's own when two panes are open)
34 ['redraw', 'app:redraw'], // ctrl+x r (Global; the engine handles it)
35 ['todos', 'app:toggleTodos'], // ctrl+t by default (Global; the engine handles it)
36 ['footer', 'footer:dismiss'], // ctrl+x d (bound in Global, a Footer action)
37 ['diffback', 'diff:back'], // ctrl+x q (bound in Global, a DiffDialog action)
38 ['prevfield', 'confirm:previousField'], // f6 (Global, a bare key)
39]
40
41export const register: Register = (on) => {
42 on('session.start', async ($, e, next) => {
43 await $.command.register({ name: 'actionprobe', description: 'action-probe: open | open2 | close | close2 | bad | reserved | focus | log', immediate: true })
44 await note($, `session.start version=${await $.session.version()}`)
45 return next(e)
46 })
47
48 on('turn.start', async ($, e, next) => { working = true; await note($, 'turn.start'); return next(e) })
49 on('turn.complete', async ($, e, next) => { working = false; await note($, 'turn.complete'); return next(e) })
50
51 // The press as the chain carries it: does anything say where it came from?
52 on('ui.press', async ($, e, next) => {
53 if (e.plugin === 'action-probe') {
54 await note($, `ui.press hook: ${JSON.stringify(e)} keys=${Object.keys(e).join(',')}`)
55 }
56 return next(e)
57 })
58
59 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
60 renders.band = (renders.band ?? 0) + 1
61 if ((await $.env.get('PROBE_BAND')) === '0') return next(e)
62 const { Box, Text, Button } = $.ui.resolve(e)
63 const press = (key: string) => (p: any) => note($, `PRESS ${key} in band (surface=${p.surface}, working=${working}, renders=${JSON.stringify(renders)})`)
64 return h(Box, { flexDirection: 'row', gap: 2 },
65 h(Text, { dimColor: true }, 'band:'),
66 h(Button, { key: 'band-noise', action: 'app:toggleDiffNoiseFilter', plain: true, onPress: press('band-noise') }, 'band noise'),
67 h(Button, { key: 'band-debug', action: 'permission:toggleDebug', plain: true, onPress: press('band-debug') }, 'band debug'),
68 h(Button, { key: 'band-seven', hotkey: '7', plain: true, onPress: press('band-seven') }, 'band seven')) as RenderElement
69 })
70
71 on('ui.render', { component: 'Pane', requestId: 'action-probe' }, async ($, e, next) => {
72 renders.pane = (renders.pane ?? 0) + 1
73 const { Box, Text, Button } = $.ui.resolve(e)
74 const press = (key: string) => (p: any) => note($, `PRESS ${key} in pane (surface=${p.surface}, working=${working}, renders=${JSON.stringify(renders)})`)
75 return h(Box, { flexDirection: 'column' },
76 h(Text, { bold: true }, `action-probe on ${e.surface}`),
77 ...PANE_ACTIONS.map(([key, action]) =>
78 h(Button, { key, action, plain: true, onPress: press(key) }, `${key} ${action}`)),
79 h(Button, { key: 'pane-seven', hotkey: '8', plain: true, onPress: press('pane-eight') }, 'pane eight'),
80 h(Text, { dimColor: true }, '--- log ---'),
81 ...log.slice(-5).map((line, i) => h(Text, { key: `log${i}`, wrap: 'truncate-end' }, line))) as RenderElement
82 })
83
84 on('ui.render', { component: 'Pane', requestId: 'action-probe-2' }, async ($, e, next) => {
85 renders.pane2 = (renders.pane2 ?? 0) + 1
86 const { Box, Text, Button } = $.ui.resolve(e)
87 const press = (key: string) => (p: any) => note($, `PRESS ${key} in pane2 (surface=${p.surface}, working=${working}, renders=${JSON.stringify(renders)})`)
88 return h(Box, { flexDirection: 'column' },
89 h(Text, { bold: true }, 'action-probe-2'),
90 h(Button, { key: 'noise2', action: 'app:toggleDiffNoiseFilter', plain: true, onPress: press('noise2') }, 'noise2 app:toggleDiffNoiseFilter')) as RenderElement
91 })
92
93 on('ui.render', { component: 'Pane', requestId: 'action-probe-bad' }, async ($, e, next) => {
94 const { Box, Button } = $.ui.resolve(e)
95 return h(Box, {}, h(Button, { key: 'bad', action: 'chattoc:previousTurn', onPress: () => undefined }, 'bad')) as RenderElement
96 })
97
98 on('ui.render', { component: 'Pane', requestId: 'action-probe-reserved' }, async ($, e, next) => {
99 const { Box, Button } = $.ui.resolve(e)
100 return h(Box, {}, h(Button, { key: 'reserved', action: 'app:interrupt', onPress: () => undefined }, 'reserved')) as RenderElement
101 })
102
103 on('command.run', { command: 'actionprobe' }, async ($, e) => {
104 const verb = e.args.trim()
105 if (verb === 'log') return { text: `${log.join('\n') || '(empty)'}\nrenders=${JSON.stringify(renders)}` }
106 if (verb === 'close') { await $.ui.close({ id: 'action-probe' }); await note($, 'closed'); return {} }
107 if (verb === 'close2') { await $.ui.close({ id: 'action-probe-2' }); await note($, 'closed2'); return {} }
108 if (verb === 'focus') { await note($, `focus ${JSON.stringify(await $.ui.focus({ requestId: 'action-probe', key: 'noise' }))}`); return {} }
109 const panes: Record<string, string> = { '': 'action-probe', open: 'action-probe', open2: 'action-probe-2', bad: 'action-probe-bad', reserved: 'action-probe-reserved' }
110 const id = panes[verb]
111 if (!id) return { text: `unknown: ${verb}` }
112 const r = await $.ui.open({ id, title: id, columns: 52 })
113 await note($, `open ${id} ${JSON.stringify(r)}`)
114 return {}
115 })
116}
117