Read-only view of every user and assistant message in the current session, with collapsible tool-call details and paged loading. Open with /history.

A Claude Code mod that shows every user and assistant message in the current session as a read-only panel. Two surfaces:
▶ History: 5 user, 5 assistant, 14 tools plus a [ View ] button (hotkey h). Click or press h to open the Pane. Hidden while the Pane is open so the title bar isn't duplicated.[ View ] or /history) — one row per message in the current session, newest at the bottom. Each assistant message with tool calls collapses them into [ ▶ 3 tools ]; click the disclosure to expand into a per-tool block with input and result. A [ Load 50 earlier ] button at the top grows the page window one step at a time, up to the whole transcript.The slash command is the secondary surface — the band is the entry point:
/history
/history toggles the Pane. Closing via the engine's [X] or Esc also dismisses it; the on('ui.close', ...) hook keeps our local flag in sync so the next /history always flips the right way. /clear / /resume / /fork resets the page window to its default (newest 50) and clears any expanded tool-call groups.
▶ History: 5 user, 5 assistant, 14 tools [ View ]
[ View ] button (hotkey h).N user, N assistant, N tools; pluralises correctly.Box({ flexDirection: 'row', gap: 2, paddingX: 1, children: [Text, Button] }) shape — no nested Boxes, no flexGrow: 1, no flexWrap. Same constraint cc-file-history-mod settled on.┌─ Conversation log ─────── 5 user, 5 assistant, 14 tools ─ [ Close ] ┐
│ [ Load 50 earlier · 87 more older ] │
│ Showing 50 of 137 messages (newest at bottom). │
│ │
│ 👤 You — 130th │
│ What's the difference between a class and a struct in C++? │
│ │
│ 🤖 Claude — 131st │
│ In C++ a `class` and a `struct` are essentially the same thing — │
│ the only difference is the default visibility ... │
│ [ ▼ 3 tools ] │
│ 📖 Read: cc-mods/README.md ✓ 12.3k chars · 320 lines │
│ input │
│ { "file_path": "cc-mods/README.md" } │
│ result │
│ # cc-mods ... │
│ ... │
│ ... │
👤 / 🤖), the role, and the message's ordinal position in the transcript (e.g. 131st). The transcript doesn't carry per-message timestamps as part of SessionMessage, so the ordinal is the most stable label we have.Text so what the user typed reads back verbatim.Markdown for the same styling the engine uses in the transcript.▶ / ▼) reflects whether all tools in the message are open — a partially-expanded group reads as collapsed (clicking ▼/▶ expands everything). The [ Show ] / [ Hide ] button per tool handles fine-grained toggling.Bash: ls -la, Read: cc-mods/README.md), and the result headline (✓ 12.3k chars · 320 lines / ✗ error / ⏳ in flight).input and result as Code { format: 'text' } blocks. Both truncate at 4000 chars with a …(+N chars) marker so a 50KB Bash result doesn't blow up the Pane.[ Load 50 earlier · N more older ] appears at the top while the page window is smaller than the transcript by a full page. Each click grows the window by 50; once fewer than 50 messages remain hidden, the label switches to [ Jump to oldest ] for one-tap viewing.[ Refresh ] clears the page window back to the newest 50 and collapses all expanded tool groups — useful after a long session that has accrued many expanded rows.[ Close ] closes the Pane (matches the engine's X / Esc, both of which also fire ui.close).All data is read from $.session.messages() on each Pane render — the engine gives us up to 4096 messages, opening on a user message. There is no module-local cache: the engine call is cheap, and adding a cache would mean guessing when it's stale. A Refresh button plus the natural rhythm of ui.render (which fires after every action) means the data is always current.
| Hook | Why | ||
|---|---|---|---|
session.start | Reset page window, register the /history slash command. | ||
| `classic.SessionStart { clear \ | resume \ | fork }` | Reset page window + expanded groups on /clear, /resume, /branch (no fresh session.start fires on these). |
command.run { command: 'history' } | Toggle the Pane. | ||
ui.render { component: 'AbovePrompt' } | Draw the band tree (▶ History: … + [ View ]). Yields next(e) when there are zero messages or the Pane is open. Composes with next(e) (typically cc-context-mod's band) via a column Box when both bands are present. | ||
ui.render { component: 'Pane', requestId: PANE_ID } | Render the message list with paging controls and collapsible tool groups. | ||
ui.close { id: PANE_ID } | Keep paneOpen in sync when the engine closes the Pane (Escape, X). |
paneOpen is the same shape cc-file-history-mod uses — best-effort mirrored from the engine so the next /history always goes the right way.
e.scroll.offset, e.scroll.bodyRows), so a long view stays navigable.$.prompt.submit. Drawing the transcript doesn't need to mutate it; the only mutations are the page window and the expanded set, both module-local.Markdown only for assistant text. User prompts are usually short and Text keeps < and > literal (which a Markdown render would escape and break how the user wrote it).Code. A Bash that produces 50 KB of ls -la should still let the rest of the Pane render. Truncation keeps the pane responsive.$.store. Conversation history lives in the engine's transcript; persisting a copy across sessions is wasteful (the transcript is the source of truth) and would create a divergence the user would notice if they ever fixed a corrupted file (--continue would replay from disk, not from our cache).All knobs are module-local constants in hooks/format.ts / hooks/register.tsx:
| Constant | Default | Purpose |
|---|---|---|
PAGE_SIZE | 50 | How many messages [ Load earlier ] adds per click. |
DEFAULT_WINDOW | 50 | The newest messages shown on first open of the Pane. |
MAX_TEXT_CHARS | 4_000 | Truncation cap per input / result / message-text block. |
MAX_PREVIEW_CHARS | 200 | Truncation cap for the inline tool label (Bash: ls -la …). |
PANE_ID | 'cc-conversation-log-mod-pane' | The Pane's id (one per id; reopening retitles). |
COMMAND_NAME | 'history' | The slash command's name. |
SessionMessage doesn't carry a wall clock; only the transcript file does, and we'd rather not read it. The message ordinal (131st) and the band counts (5 user, 5 assistant, 14 tools) are the navigational anchors we have.$.session.messages() returns up to 4096 messages, opening on a user one. Compaction summary messages earlier in the transcript don't appear in either form, by design. Paging doesn't get around this cap — once the engine has compacted old messages, they're gone.agentId support. Only the main conversation is shown. Subagents' transcripts are reachable via $.session.messages({ agentId }) and a future version could open a second Pane for them; not implemented in v1.Markdown re-renders what the engine already showed in the transcript. Identical themes today, but if the engine's Markdown dialect changes, the two renders could drift apart.paneOpen flag can lag (same caveat cc-file-history-mod has). X / Esc immediately fires on('ui.close', ...) so the next /history is in sync.isPlaced: false with reason: below 110 columns. The toast tells the user and the band stays visible. Widen the terminal and the next click / /history will place.This mod lives in a marketplace folder, so:
# From /Users/lixinghui/Documents/code/fe/testground/cc-mods
claude plugin validate ./cc-conversation-log-mod
claude plugin test ./cc-conversation-log-mod
End-to-end:
claude --plugin-dir ./cc-conversation-log-mod
# inside: send a few prompts, then type /history. Click [▼ N tools] on
# one of the assistant rows to expand a tool call; click [Load 50 earlier]
# to grow the page window if the session is long.hooks/register.tsx 671 lines1// cc-conversation-log-mod — read-only Pane of every user/assistant message
2// in the current session, with collapsible tool-call groups and paged
3// loading of older turns.
4//
5// Three surfaces:
6// - AbovePrompt band (auto-shown when there is anything to show):
7// `▶ History: 5 user / 5 assistant / 14 tools [ View (h) ]`
8// Hidden while the Pane is open (chrome already shows the title).
9// - Pane (on demand, via the band's button or /history):
10// One row per message in the current session, newest at the bottom.
11// Each assistant message with tool calls collapses them into
12// `[ ▶ 3 tools ]`; click to expand into a per-tool block with input +
13// result. A `[ Load 50 earlier ]` button at the top grows the window
14// until the whole transcript is in view.
15// - Slash command `/history`: toggles the Pane.
16//
17// All data comes from `$.session.messages()` — re-fetched on each Pane
18// render so a freshly-finished assistant turn is visible the moment the
19// user clicks View. No module-local cache (the engine call is cheap; a
20// cache adds complexity without benefit at this size).
21//
22// Module-local state (resets on hot reload):
23// paneOpen — mirror of the engine's own Pane placement. `ui.close`
24// keeps this honest when the user closes via X / Escape.
25// windowSize — number of newest messages to render; grows on each
26// `[ Load earlier ]` click up to the transcript length.
27// expandedTools — Set of "<msgIndex>:<tool_use_id>" we already opened.
28// Smaller than storing the whole Map<number, Set> — strings
29// compose naturally with React-style keys.
30//
31// Hard resets happen on session.start AND on classic.SessionStart
32// { clear|resume|fork } — same shape cc-file-history-mod uses, since
33// /clear / /resume / /branch replace the conversation without firing a
34// fresh session.start.
35
36import type { EngineInterface, Register } from 'claude-code'
37
38import {
39 ASSISTANT_NO_TEXT,
40 MAX_TEXT_CHARS,
41 PAGE_SIZE,
42 countMessages,
43 earlierCount,
44 ordinal,
45 pageLatest,
46 roleColor,
47 roleIcon,
48 toolIcon,
49 toolLabel,
50 toolResultHead,
51 toolResultSummary,
52 truncateText,
53} from './format'
54
55// ---------------------------------------------------------------------------
56// Constants & state
57// ---------------------------------------------------------------------------
58
59/** Engine Pane id; must be 1-64 chars of letters/digits/`_`/`-`. */
60const PANE_ID = 'cc-conversation-log-mod-pane'
61
62/** Slash command shown in the engine's command list. */
63const COMMAND_NAME = 'history'
64
65/** Default number of newest messages the Pane shows on first open.
66 * Grows on each `[ Load earlier ]` click in PAGE_SIZE steps. */
67const DEFAULT_WINDOW = 50
68
69let paneOpen = false
70let windowSize = DEFAULT_WINDOW
71const expandedTools = new Set<string>()
72
73function resetState() {
74 paneOpen = false
75 windowSize = DEFAULT_WINDOW
76 expandedTools.clear()
77}
78
79// ---------------------------------------------------------------------------
80// Element type — narrow alias to keep the JSX tree short and the type
81// surface easy to read.
82// ---------------------------------------------------------------------------
83
84type El = (props: Record<string, unknown> & { children?: unknown }) => unknown
85type Elements = {
86 Box: El
87 Text: El
88 Button: El
89 Code: El
90 Markdown: El
91}
92
93function resolve($: EngineInterface, e: { surface: string }): Elements {
94 return $.ui.resolve(e as never) as unknown as Elements
95}
96
97// ---------------------------------------------------------------------------
98// Page / window mutation helpers
99// ---------------------------------------------------------------------------
100
101// Grow the window by PAGE_SIZE — capped by the caller when the actual
102// transcript length is known. We don't take the total as a parameter
103// here because `loadEarlier` is called inside a Button `onPress` where
104// `messages.length` was captured at render time; we trust that the next
105// render will re-cap it via `pageLatest` (which slices to a fresh array).
106const loadEarlier = (): void => {
107 windowSize = windowSize + PAGE_SIZE
108}
109
110const jumpToOldest = (currentTotal: number): void => {
111 windowSize = currentTotal
112}
113
114const resetWindow = (): void => {
115 windowSize = DEFAULT_WINDOW
116 expandedTools.clear()
117}
118
119// ---------------------------------------------------------------------------
120// One message's row — used by the Pane render below.
121//
122// Shape of `message` is the engine's `SessionMessage` (see
123// `.claude-plugin/types/claude-code/index.d.ts:11067`): `{ role, text,
124// toolUses, toolResults? }`. `toolUses` is required (possibly empty) on
125// the engine type, but we declare it optional here so the Pane render can
126// pass through a `filter()` result without an explicit narrowing step
127// (the runtime check `toolUses?.length ?? 0` is what protects callers).
128// ---------------------------------------------------------------------------
129
130type Message = {
131 role: 'user' | 'assistant'
132 text: string
133 toolUses?: Array<{
134 tool_use_id: string
135 tool: string
136 input: Record<string, unknown>
137 text?: string
138 result?: unknown
139 isError?: true
140 }>
141}
142
143type ToolUse = NonNullable<Message['toolUses']>[number]
144
145function renderMessage(
146 $: EngineInterface,
147 $el: Elements,
148 message: Message,
149 globalIndex: number,
150): unknown {
151 const isUser = message.role === 'user'
152 const toolCount = message.toolUses?.length ?? 0
153
154 // Header row — role icon + ordinal position + turn count (e.g. "3rd / 5")
155 // is intentionally absent (we'd need to count user turns separately and
156 // the band already shows "5 user / 5 assistant"). The ordinal alone is
157 // monotonic and stable across renders.
158 const header = $el.Box({
159 flexDirection: 'row',
160 gap: 1,
161 children: [
162 $el.Text({ children: roleIcon(message.role) }),
163 $el.Text({
164 bold: true,
165 color: roleColor(message.role),
166 children: isUser ? `You — ${ordinal(globalIndex + 1)}` : `Claude — ${ordinal(globalIndex + 1)}`,
167 }),
168 ],
169 })
170
171 // Body — text first, then collapsible tool calls for assistants.
172 const text = message.text?.trim()
173 const hasText = !!text && text.length > 0
174 const isAssistantNoText = !isUser && !hasText && toolCount > 0
175 const body: unknown[] = []
176
177 if (hasText) {
178 const truncated = truncateText(message.text, MAX_TEXT_CHARS)
179 // Markdown for assistants — same engine styling as their in-transcript
180 // rows. Plain Text for users — they typed it verbatim, no need to
181 // re-parse markdown and risk rendering < > differently than the
182 // transcript did.
183 if (isUser) {
184 body.push($el.Text({ wrap: 'wrap', children: truncated }))
185 } else {
186 body.push(
187 $el.Markdown({
188 key: `msg-text-${globalIndex}`,
189 text: truncated,
190 }),
191 )
192 }
193 } else if (isAssistantNoText) {
194 body.push(
195 $el.Text({ dimColor: true, children: ASSISTANT_NO_TEXT }),
196 )
197 }
198
199 if (!isUser && toolCount > 0) {
200 body.push(renderToolGroup($, $el, message.toolUses!, globalIndex))
201 }
202
203 return $el.Box({
204 flexDirection: 'column',
205 paddingX: 1,
206 paddingY: 0,
207 gap: 1,
208 children: [header, ...body],
209 })
210}
211
212// ---------------------------------------------------------------------------
213// Tool group — collapsed "[ ▶ 3 tools ]" → expanded rows with input/result.
214// ---------------------------------------------------------------------------
215
216function renderToolGroup(
217 $: EngineInterface,
218 $el: Elements,
219 toolUses: ToolUse[],
220 globalIndex: number,
221): unknown {
222 // "expanded" reads as "every tool in the group is open". A partially-
223 // expanded group (some open, some closed) reads as collapsed for the
224 // disclosure icon — clicking it should expand-all, not collapse. The
225 // [ Show/Hide ] button per tool handles fine-grained toggling.
226 const expanded = toolUses.every((t) =>
227 expandedTools.has(`${globalIndex}:${t.tool_use_id}`),
228 )
229
230 const toggleGroup = (): void => {
231 if (expanded) {
232 // Collapse: clear every tool's slot.
233 for (const t of toolUses) {
234 expandedTools.delete(`${globalIndex}:${t.tool_use_id}`)
235 }
236 } else {
237 // Expand: register every tool's slot up front so the renderer
238 // recomputes and shows the rows immediately. Partial→full collapse
239 // is the more useful direction than leaving the partial state.
240 for (const t of toolUses) {
241 expandedTools.add(`${globalIndex}:${t.tool_use_id}`)
242 }
243 }
244 $.ui.invalidate('ui.render')
245 }
246
247 const toggleOne = (toolUseId: string): void => {
248 const key = `${globalIndex}:${toolUseId}`
249 if (expandedTools.has(key)) expandedTools.delete(key)
250 else expandedTools.add(key)
251 $.ui.invalidate('ui.render')
252 }
253
254 // Group toggle button. `plain: true` so the leading `▶`/`▼` reads as
255 // a disclosure glyph on a single line, not a labelled primary button.
256 const summary = $el.Button({
257 key: `toggle-tools-${globalIndex}`,
258 label: expanded ? `▼ ${toolUses.length} tool${toolUses.length === 1 ? '' : 's'}` : `▶ ${toolUses.length} tool${toolUses.length === 1 ? '' : 's'}`,
259 plain: true,
260 dimColor: true,
261 onPress: toggleGroup,
262 })
263
264 if (expanded) {
265 const rows = toolUses.map((t) => renderToolRow($, $el, t, globalIndex, toggleOne))
266 return $el.Box({
267 flexDirection: 'column',
268 paddingLeft: 2,
269 gap: 0,
270 children: [summary, ...rows],
271 })
272 }
273
274 return summary
275}
276
277// ---------------------------------------------------------------------------
278// One tool row — collapsed shows icon + label + result summary, expanded
279// adds the full input record and result text.
280// ---------------------------------------------------------------------------
281
282function renderToolRow(
283 $: EngineInterface,
284 $el: Elements,
285 toolUse: ToolUse,
286 globalIndex: number,
287 toggleOne: (toolUseId: string) => void,
288): unknown {
289 const key = `${globalIndex}:${toolUse.tool_use_id}`
290 const isOpen = expandedTools.has(key)
291 const summary = toolResultSummary(
292 toolUse.text,
293 toolUse.isError,
294 toolUse.text !== undefined || toolUse.isError === true,
295 )
296
297 const header = $el.Box({
298 flexDirection: 'row',
299 gap: 1,
300 children: [
301 $el.Text({ children: toolIcon(toolUse.tool) }),
302 $el.Text({ children: toolLabel(toolUse.tool, toolUse.input) }),
303 $el.Text({ dimColor: true, children: ' ' + summary }),
304 $el.Box({ flexGrow: 1 }),
305 $el.Button({
306 key: `toggle-tool-${toolUse.tool_use_id}`,
307 label: isOpen ? 'Hide' : 'Show',
308 plain: true,
309 dimColor: true,
310 onPress: () => toggleOne(toolUse.tool_use_id),
311 }),
312 ],
313 })
314
315 if (!isOpen) return header
316
317 // Expanded body — input first, then result. Both as `Code` (the default
318 // `format: 'source'` renders JSON / shell output cleanly). Truncated to
319 // MAX_TEXT_CHARS each so a 50KB Bash result doesn't blow up the pane.
320 const body: unknown[] = []
321 const inputText = safeStringify(toolUse.input)
322 if (inputText) {
323 body.push(
324 $el.Text({ dimColor: true, bold: true, children: 'input' }),
325 )
326 body.push(
327 $el.Code({
328 key: `input-${toolUse.tool_use_id}`,
329 source: truncateText(inputText, MAX_TEXT_CHARS),
330 }),
331 )
332 }
333 const resultText = toolUse.text ?? ''
334 if (resultText) {
335 body.push(
336 $el.Text({ dimColor: true, bold: true, children: 'result' }),
337 )
338 body.push(
339 $el.Code({
340 key: `result-${toolUse.tool_use_id}`,
341 source: truncateText(resultText, MAX_TEXT_CHARS),
342 }),
343 )
344 } else if (toolUse.isError === true) {
345 body.push(
346 $el.Text({ dimColor: true, children: '(no result text on error)' }),
347 )
348 } else {
349 body.push(
350 $el.Text({
351 dimColor: true,
352 children: toolResultHead(resultText, 0) || '(in flight)',
353 }),
354 )
355 }
356
357 return $el.Box({
358 flexDirection: 'column',
359 paddingLeft: 2,
360 gap: 0,
361 children: [header, ...body],
362 })
363}
364
365function safeStringify(value: unknown): string {
366 try {
367 return JSON.stringify(value, null, 2) ?? ''
368 } catch {
369 return String(value)
370 }
371}
372
373// ---------------------------------------------------------------------------
374// Pane render
375// ---------------------------------------------------------------------------
376
377async function renderPane($: EngineInterface, e: { surface: string }): Promise<unknown> {
378 const $el = resolve($, e)
379
380 const raw = await $.session.messages()
381 // Filter to user + assistant rows only — the engine can store other
382 // row kinds (system, attachment), and we don't want noise in the view.
383 const messages = (raw as Message[]).filter(
384 (m) => m && (m.role === 'user' || m.role === 'assistant'),
385 )
386
387 const counts = countMessages(messages)
388 const page = pageLatest(messages, windowSize)
389 const olderShown = Math.max(0, messages.length - windowSize)
390 // Cap how much "older" we can load — re-fetching the same transcript and
391 // getting a million rows is fine, but a single huge transcript cuts the
392 // engine's own read cost dramatically. PAGE_SIZE matches the load step.
393 const earlierAvailable = earlierCount(messages.length, windowSize)
394 const atOldest = olderShown === 0
395
396 // Header.
397 const closePane = async (): Promise<void> => {
398 paneOpen = false
399 await $.ui.close({ id: PANE_ID })
400 }
401 const refresh = (): void => {
402 resetWindow()
403 $.ui.invalidate('ui.render')
404 }
405
406 const header = $el.Box({
407 flexDirection: 'row',
408 gap: 1,
409 children: [
410 $el.Text({ children: '📜' }),
411 $el.Text({ bold: true, color: 'magenta', children: 'Conversation log' }),
412 $el.Text({
413 dimColor: true,
414 children: `${counts.user} user, ${counts.assistant} assistant, ${counts.tools} tool${counts.tools === 1 ? '' : 's'}`,
415 }),
416 $el.Box({ flexGrow: 1 }),
417 $el.Button({
418 key: 'refresh',
419 label: 'Refresh',
420 plain: true,
421 dimColor: true,
422 onPress: refresh,
423 }),
424 $el.Button({
425 key: 'close',
426 label: 'Close',
427 variant: 'primary',
428 onPress: closePane,
429 }),
430 ],
431 })
432
433 // Empty state.
434 if (messages.length === 0) {
435 return $el.Box({
436 flexDirection: 'column',
437 paddingX: 1,
438 gap: 1,
439 children: [
440 header,
441 $el.Text({ dimColor: true, children: 'no messages yet — send a prompt first.' }),
442 ],
443 })
444 }
445
446 // Paging controls (top of the list, before the messages themselves).
447 // The whole pane scrolls as one unit, so "earlier" lives ABOVE the
448 // current page; reading top-to-bottom is oldest-on-top.
449 const paging: unknown[] = []
450 if (!atOldest) {
451 const remaining = messages.length - windowSize
452 // When the remaining load is smaller than PAGE_SIZE, a single click
453 // would clear it — so just label the button "Jump to oldest" instead
454 // of pretending one more page is hidden. (Earlier `earlierAvailable`
455 // is exposed but only used for the cap on the onPress side; here we
456 // want the user-facing wording.)
457 const label =
458 remaining < PAGE_SIZE
459 ? 'Jump to oldest'
460 : `Load ${PAGE_SIZE} earlier · ${remaining} more older`
461 paging.push(
462 $el.Box({
463 flexDirection: 'row',
464 gap: 1,
465 children: [
466 $el.Button({
467 key: 'load-earlier',
468 label,
469 variant: 'primary',
470 onPress: () => {
471 // `earlierAvailable === 0` would be unreachable given the
472 // branch above — but keep the guard so a future change to
473 // PAGE_SIZE / windowed semantics still terminates cleanly.
474 if (earlierAvailable === 0) {
475 jumpToOldest(messages.length)
476 } else {
477 loadEarlier()
478 }
479 $.ui.invalidate('ui.render')
480 },
481 }),
482 ],
483 }),
484 )
485 paging.push(
486 $el.Text({
487 dimColor: true,
488 children: `Showing ${page.length} of ${messages.length} messages (newest at bottom).`,
489 }),
490 )
491 } else {
492 paging.push(
493 $el.Text({
494 dimColor: true,
495 children: `Showing all ${messages.length} messages (newest at bottom).`,
496 }),
497 )
498 }
499
500 // The message rows. Each row gets its global index so the
501 // expandedTools set stays stable across re-renders that change the page
502 // window (a tool expanded while showing page 1 should stay expanded
503 // when the user pages back later).
504 const rows = page.map((m, i) => {
505 const globalIndex = messages.length - page.length + i
506 return renderMessage($, $el, m, globalIndex)
507 })
508
509 return $el.Box({
510 flexDirection: 'column',
511 paddingX: 1,
512 gap: 1,
513 children: [header, ...paging, ...rows],
514 })
515}
516
517// ---------------------------------------------------------------------------
518// AbovePrompt band — a single row with counts + [ View ].
519//
520// Same coexistence pattern cc-file-history-mod uses: yield via next(e) when
521// there's nothing of our own to draw, otherwise compose our tree after
522// `next(e)` in a column so the engine picks both up.
523// ---------------------------------------------------------------------------
524
525type BandRenderEvent = {
526 surface: string
527 props?: { bodyColumns?: number }
528 hasSurvey?: boolean
529}
530
531async function renderBand($: EngineInterface, e: BandRenderEvent): Promise<unknown | null> {
532 const $el = resolve($, e)
533 const raw = await $.session.messages()
534 const messages = (raw as Message[]).filter(
535 (m) => m && (m.role === 'user' || m.role === 'assistant'),
536 )
537 // Hide the band entirely when there's nothing to show — the band
538 // existing with "0 / 0" would just be visual noise on a fresh session.
539 if (messages.length === 0) return null
540 // And hide while the Pane is open — the Pane's header already shows
541 // the counts and the user has explicitly chosen to look at the full
542 // view, so a duplicate row above the prompt reads as clutter.
543 if (paneOpen) return null
544
545 const c = countMessages(messages)
546
547 const openPane = async (): Promise<void> => {
548 try {
549 const r = await $.ui.open({
550 id: PANE_ID,
551 title: 'Conversation log',
552 focus: true,
553 closeOnEscape: true,
554 })
555 paneOpen = r.isPlaced === true
556 if (!r.isPlaced) {
557 const reason = 'reason' in r ? String(r.reason) : 'unknown'
558 const hint = reason.includes('below 110 columns') ? ' — type /history once to unlock' : ''
559 $.ui.toast(`history: pane open refused${hint}: ${reason}`, { timeoutMs: 6_000 })
560 }
561 } catch (err) {
562 const msg = String((err as { message?: unknown })?.message ?? err)
563 $.ui.toast(`history: pane open failed: ${msg}`, { timeoutMs: 4_000 })
564 }
565 $.ui.invalidate('ui.render')
566 }
567
568 return $el.Box({
569 flexDirection: 'row',
570 gap: 2,
571 paddingX: 1,
572 children: [
573 $el.Text({
574 color: 'magenta',
575 bold: true,
576 children: `▶ History: ${c.user} user, ${c.assistant} assistant, ${c.tools} tool${c.tools === 1 ? '' : 's'}`,
577 }),
578 $el.Button({
579 key: 'open-pane',
580 label: 'View',
581 hotkey: 'h',
582 variant: 'primary',
583 onPress: openPane,
584 }),
585 ],
586 })
587}
588
589// ---------------------------------------------------------------------------
590// Register
591// ---------------------------------------------------------------------------
592
593export const register: Register = (on) => {
594 on('session.start', async ($, e, next) => {
595 resetState()
596 await $.command.register({
597 name: COMMAND_NAME,
598 description: 'Open / close the conversation log pane',
599 })
600 return next(e)
601 })
602
603 on(
604 'classic.SessionStart',
605 { source: ['clear', 'resume', 'fork'] },
606 async ($, e, next) => {
607 resetState()
608 return next(e)
609 },
610 )
611
612 on('command.run', { command: COMMAND_NAME }, async ($) => {
613 if (paneOpen) {
614 try {
615 await $.ui.close({ id: PANE_ID })
616 paneOpen = false
617 return { text: 'history: closed.' }
618 } catch (err) {
619 const msg = String((err as { message?: unknown })?.message ?? err)
620 return { text: `history: close error: ${msg}` }
621 }
622 }
623 try {
624 const r = await $.ui.open({
625 id: PANE_ID,
626 title: 'Conversation log',
627 focus: true,
628 closeOnEscape: true,
629 })
630 paneOpen = r.isPlaced === true
631 return r.isPlaced ? { text: 'history: opened.' } : { text: 'history: open refused.' }
632 } catch (err) {
633 const msg = String((err as { message?: unknown })?.message ?? err)
634 return { text: `history: open error: ${msg}` }
635 }
636 })
637
638 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
639 return renderPane($, e as never) as never
640 })
641
642 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
643 const $el = resolve($, e as never)
644 const ourBand = await renderBand($, e as never)
645 if (ourBand === null) {
646 return (await next(e)) as never
647 }
648 let others: unknown = null
649 try {
650 others = await next(e)
651 } catch {
652 others = null
653 }
654 const looksLikeElement =
655 others !== null &&
656 others !== undefined &&
657 typeof others === 'object' &&
658 typeof (others as { type?: unknown }).type === 'string'
659 if (!looksLikeElement) return ourBand as never
660 return $el.Box({ flexDirection: 'column', children: [others, ourBand] }) as never
661 })
662
663 on('ui.close', ($, e, next) => {
664 if (e.id === PANE_ID) {
665 paneOpen = false
666 $.ui.invalidate('ui.render')
667 }
668 return next(e)
669 })
670}
671hooks/format.ts 298 lines1// Pure formatting helpers for cc-conversation-log-mod.
2//
3// Everything here is engine-independent: no `$.` imports, no DOM, no Node.
4// `hooks/register.tsx` calls into these from render; `tests/format.test.ts`
5// drives them directly.
6//
7// Inputs are plain SessionMessage shapes from `$.session.messages()`:
8// { role: 'user' | 'assistant', text: string, toolUses?, toolResults? }
9//
10// Output is plain strings / small shapes the renderer turns into Box / Text /
11// Code elements. Keeping the format layer engine-free is what lets the Pane
12// render with the right truncation without touching the JSX tree.
13
14// ---------------------------------------------------------------------------
15// Page-size constants — also surfaced via the plugin's main constants block.
16// ---------------------------------------------------------------------------
17
18/** Per-page window when paging older messages into view. */
19export const PAGE_SIZE = 50
20
21/** Hard cap on a single text field a row draws. Beyond this we truncate
22 * with a trailing marker so a 50kb tool result can't blow up the Pane. */
23export const MAX_TEXT_CHARS = 4_000
24
25/** Same, but for the inline summary we show on each tool row before
26 * expanding — too long defeats the "▶ N tools" glance. */
27export const MAX_PREVIEW_CHARS = 200
28
29/** When an Assistant message has zero text and only tool calls, we still
30 * want a non-empty body for the row; this placeholder prevents an
31 * awkward blank line. */
32export const ASSISTANT_NO_TEXT = '(no prose)'
33
34// ---------------------------------------------------------------------------
35// Text truncation
36// ---------------------------------------------------------------------------
37
38/**
39 * Slice `text` to at most `max` characters, cutting on a boundary that
40 * keeps the last full line if possible. Trailing marker shows the real
41 * length the user is missing. Empty / undefined input returns ''.
42 *
43 * Why we don't just `.slice(0, max)`: mid-line cuts land on a tool name
44 * and read as a fresh typo. Cutting on the last newline before `max` keeps
45 * the row readable. Long single-line strings still get cut mid-line —
46 * acceptable; the marker tells the user.
47 */
48export function truncateText(text: string | undefined, max: number): string {
49 if (!text) return ''
50 if (text.length <= max) return text
51 // Find the last newline at or before `max` that would not leave a useless
52 // tail of whitespace. Failing that, cut at `max` regardless.
53 const cut = findLastNewline(text, max)
54 const head = cut > 0 ? text.slice(0, cut) : text.slice(0, max)
55 const dropped = text.length - head.length
56 return `${head}\n…(+${dropped} chars)`
57}
58
59/** Last `\n` at index `<= max` and `> 0`. `-1` if none / cut at max. */
60function findLastNewline(text: string, max: number): number {
61 // `text.lastIndexOf('\n', max - 1)` would search BEFORE position max; we
62 // want AT OR BEFORE. The string API searches the position too, so pass
63 // `max` (last index is inclusive in lastIndexOf semantics).
64 for (let i = max; i > 0; i--) {
65 if (text.charCodeAt(i) === 10) return i
66 }
67 return -1
68}
69
70// ---------------------------------------------------------------------------
71// Tool-call summaries
72// ---------------------------------------------------------------------------
73
74/** One label per known built-in tool. MCP tools fall back to the tool's
75 * own name. Keep this map small — it's the per-row tagline, not a docs
76 * site. New entries go behind tests/format.test.ts. */
77const TOOL_ICON: Record<string, string> = {
78 Read: '📖',
79 Edit: '✏️',
80 Write: '📝',
81 Bash: '🖥 ',
82 Glob: '🔎',
83 Grep: '🔍',
84 WebFetch: '🌐',
85 WebSearch: '🔍',
86 Task: '🤖',
87 Agent: '🤖',
88 TodoWrite: '☑',
89 NotebookEdit: '📓',
90}
91
92const TOOL_GLYPH = (tool: string): string => TOOL_ICON[tool] ?? '⚙'
93
94/**
95 * "Bash: ls -la" or "Read: foo.txt" — one-liner describing what the model
96 * asked each tool to do. Truncated to `MAX_PREVIEW_CHARS` so a long Bash
97 * command doesn't dominate the row before the user expands it.
98 *
99 * The label extractor is best-effort: tools it doesn't recognise use
100 * `JSON.stringify(input)` as the user-informative fallback. Empty input →
101 * empty label (tool name alone reads cleaner than `: {}`).
102 */
103export function toolLabel(
104 tool: string,
105 input: Record<string, unknown>,
106): string {
107 const value = primaryArg(tool, input)
108 if (!value) return tool
109 const text = typeof value === 'string' ? value : JSON.stringify(value)
110 return `${tool}: ${truncateInline(text, MAX_PREVIEW_CHARS)}`
111}
112
113/**
114 * Just the icon — `'📖'`, `'🖥 '`, `'🤖'`, `'⚙'`. Used at the head of each
115 * collapsed tool row so the eye scans by colour.
116 */
117export function toolIcon(tool: string): string {
118 return TOOL_GLYPH(tool)
119}
120
121/** Best single argument for each known tool. Falls back to the first
122 * string-typed input field; then to `JSON.stringify(input)`; then to
123 * `undefined` when input is empty. */
124function primaryArg(
125 tool: string,
126 input: Record<string, unknown>,
127): unknown {
128 if (!input || typeof input !== 'object') return undefined
129 switch (tool) {
130 case 'Read':
131 case 'Write':
132 case 'Edit':
133 case 'NotebookEdit':
134 return pickString(input, 'file_path') ?? pickString(input, 'notebook_path')
135 case 'Bash':
136 return pickString(input, 'command') ?? pickString(input, 'description')
137 case 'Glob':
138 return pickString(input, 'pattern')
139 case 'Grep':
140 return pickString(input, 'pattern')
141 case 'WebFetch':
142 return pickString(input, 'url')
143 case 'WebSearch':
144 return pickString(input, 'query')
145 case 'Agent':
146 case 'Task':
147 return pickString(input, 'description') ?? pickString(input, 'prompt')
148 default:
149 // Generic MCP tool: pick the first string-typed arg we see.
150 for (const k of Object.keys(input)) {
151 const v = input[k]
152 if (typeof v === 'string' && v.length > 0) return v
153 }
154 return undefined
155 }
156}
157
158function pickString(o: Record<string, unknown>, key: string): string | undefined {
159 const v = o[key]
160 return typeof v === 'string' ? v : undefined
161}
162
163/** Inline truncation suitable for a label (no leading indent, no marker —
164 * the marker would push the row off-screen when chained). Plain ellipsis. */
165function truncateInline(text: string, max: number): string {
166 if (text.length <= max) return text
167 return text.slice(0, Math.max(1, max - 1)) + '…'
168}
169
170// ---------------------------------------------------------------------------
171// Tool-result summaries (collapsed view)
172// ---------------------------------------------------------------------------
173
174/**
175 * "→ ok · 412 chars" / "→ error · 12 lines" / "→ (running)" / "→ no result".
176 * Used to give the collapsed tool row an outcome colour without forcing the
177 * user to expand.
178 */
179export function toolResultSummary(
180 text: string | undefined,
181 isError: boolean | undefined,
182 hasResult: boolean,
183): string {
184 if (!hasResult) return '⏳ in flight'
185 if (isError) return '✗ error'
186 const len = text?.length ?? 0
187 if (len === 0) return '✓ empty'
188 if (len <= 60) return `✓ ${text.replace(/\s+/g, ' ').trim()}`
189 const lines = (text.match(/\n/g)?.length ?? -1) + 1
190 return `✓ ${len} chars · ${lines} line${lines === 1 ? '' : 's'}`
191}
192
193/** A compact representation of a tool result's first line / first 60 chars
194 * — shown when the user expands the tool row but we don't want to dump
195 * 50kb into a `Code { format: 'text' }` block. */
196export function toolResultHead(text: string | undefined, max = 200): string {
197 if (!text) return ''
198 const first = text.split('\n', 1)[0] ?? ''
199 return truncateInline(first, max)
200}
201
202// ---------------------------------------------------------------------------
203// Role / icon / colour helpers
204// ---------------------------------------------------------------------------
205
206/** '👤' for user, '🤖' for assistant — the row header glyph. */
207export function roleIcon(role: 'user' | 'assistant'): string {
208 return role === 'user' ? '👤' : '🤖'
209}
210
211/** 'cyan' for user (matches transcript default), 'magenta' for assistant. */
212export function roleColor(role: 'user' | 'assistant'): string {
213 return role === 'user' ? 'cyan' : 'magenta'
214}
215
216// ---------------------------------------------------------------------------
217// Counting — band + header summary
218// ---------------------------------------------------------------------------
219
220export type MessageCounts = {
221 user: number
222 assistant: number
223 tools: number
224 total: number
225}
226
227/** Sum what the band / header needs in one pass. */
228export function countMessages(
229 messages: ReadonlyArray<{ role: 'user' | 'assistant'; toolUses?: unknown[] }>,
230): MessageCounts {
231 let user = 0
232 let assistant = 0
233 let tools = 0
234 for (const m of messages) {
235 if (m.role === 'user') user++
236 else if (m.role === 'assistant') assistant++
237 if (Array.isArray(m.toolUses)) tools += m.toolUses.length
238 }
239 return { user, assistant, tools, total: messages.length }
240}
241
242/**
243 * Subset of `messages` to render — the NEWEST `window` entries. Older
244 * entries are dropped until the user clicks `[ Load earlier ]`.
245 *
246 * Callers pass `windowSize` (the current page width) and grow it on each
247 * press of the load-earlier button until it reaches `messages.length`.
248 * Returning a NEW array (slice) instead of an index range is friendlier
249 * to the renderer's `messages.map(...)` and avoids an off-by-one when
250 * `windowSize > messages.length`.
251 */
252export function pageLatest<T>(
253 messages: readonly T[],
254 windowSize: number,
255): T[] {
256 if (windowSize >= messages.length) return messages.slice()
257 const start = Math.max(0, messages.length - windowSize)
258 return messages.slice(start)
259}
260
261/**
262 * How many older messages a `[ Load earlier ]` button at the top of the
263 * pane would add right now. Used both for the button label
264 * ("Load 50 earlier · 187 more older") and to disable the button at the
265 * very top of the transcript.
266 */
267export function earlierCount(
268 totalMessages: number,
269 windowSize: number,
270): number {
271 if (windowSize >= totalMessages) return 0
272 return Math.min(PAGE_SIZE, totalMessages - windowSize)
273}
274
275// ---------------------------------------------------------------------------
276// Timestamp formatting — the transcript stores no per-message wall-clock,
277// so we approximate from the message index (the order the engine wrote
278// them). Use a relative index suffix as a stable label.
279//
280// The "HH:MM" wall clock used by cc-file-history-mod is fine there because
281// `tool.call` carries a wall clock; SessionMessage does not. Falling back
282// to a position label keeps something visible in the row header that's at
283// least monotonic.
284// ---------------------------------------------------------------------------
285
286/** "1st", "2nd", "3rd", "4th", ...; "11th"/"12th"/"13th" included. */
287export function ordinal(n: number): string {
288 const abs = Math.abs(Math.trunc(n))
289 const lastTwo = abs % 100
290 if (lastTwo >= 11 && lastTwo <= 13) return `${abs}th`
291 switch (abs % 10) {
292 case 1: return `${abs}st`
293 case 2: return `${abs}nd`
294 case 3: return `${abs}rd`
295 default: return `${abs}th`
296 }
297}
298types/index.d.ts 17 lines1// Plugin state contract for cc-conversation-log-mod.
2//
3// The engine uses this file to type every `$.state.get` / `$.state.set` call
4// in hooks/register.tsx (and to verify each value's key on plugin load via
5// `claude plugin validate`). This plugin declares no `$.state` values — all
6// state (paneOpen, windowSize, expandedTools) is module-local and resets on
7// hot reload and at session boundaries (`session.start`, the /clear / /resume
8// / /branch hooks).
9
10declare module 'claude-code' {
11 interface PluginState {
12 'cc-conversation-log-mod': {
13 // intentionally empty: no persisted state
14 }
15 }
16}
17