SLOPSHOPPER

cc-conversation-log-mod

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

newpanebandcommandtoast
v0.1.0Apache-2.0updated 2026-10-09kukaka/cc-mods/cc-conversation-log-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-conversation-log-mod
│ ┃ Conversation log ✕ › fix the failing auth test and add an audit log call │ ┃ 📜 Conversation log 1 user, 1 assistant, 1 │ ┃ ⏺ Read(src/auth.ts) │ ┃ Showing all 2 messages (newest at bottom). ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 👤 You — 1st ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ fix the failing auth test ⎿ 3 pass, 1 fail │ ┃ │ ┃ 🤖 Claude — 2nd ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ I updated src/auth.ts to reject expired ✻ Worked for 42s · done 4:20 PM │ ┃ claims and added an audit call. │ ┃ › /history │ ┃ ▶ 1 tool ⎿ cc-conversation-log-mod: history: opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Conversation log
📜 Conversation log 1 user, 1 assistant, 1 tool Refresh [ Showing all 2 messages (newest at bottom). 👤 You — 1st fix the failing auth test 🤖 Claude — 2nd I updated src/auth.ts to reject expired claims and added an audit call. ▶ 1 tool
README

cc-conversation-log-mod

A Claude Code mod that shows every user and assistant message in the current session as a read-only panel. Two surfaces:

  • AbovePrompt band — auto-shown once there is at least one message. One row: ▶ 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.
  • Pane (on demand, via the band's [ 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.

What you see

Band

▶ History: 5 user, 5 assistant, 14 tools                [ View ]
  • One row above the prompt. Magenta text + a [ View ] button (hotkey h).
  • Counts N user, N assistant, N tools; pluralises correctly.
  • Hidden when there are zero messages (visual noise) and while the Pane is open (its header already shows the same counts).
  • Plain 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.

Pane

┌─ 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 ...                                                  │
│   ...                                                                  │
│ ...                                                                    │
  • One group per message, ordered oldest-first at the top, newest at the bottom (the Pane's default scroll position lands the latest row near the visible area).
  • Each row's header carries an emoji (👤 / 🤖), 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.
  • User text is rendered as plain Text so what the user typed reads back verbatim.
  • Assistant text is rendered as Markdown for the same styling the engine uses in the transcript.
  • Tool calls are collapsed when an assistant message has any. The disclosure glyph (▶ / ▼) 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.
  • Each tool row's header shows the tool's glyph (📖 Read, 🖥 Bash, etc.), a one-line label summarising the input (Bash: ls -la, Read: cc-mods/README.md), and the result headline (✓ 12.3k chars · 320 lines / ✗ error / ⏳ in flight).
  • Expanded tool rows show the full 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).

How it works

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.

HookWhy
session.startReset 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.

Why these design choices

  • Pane, not just a band. A session can have hundreds of messages; cramming even fifty into a band would scroll past the prompt area. The Pane has its own scroll window (e.scroll.offset, e.scroll.bodyRows), so a long view stays navigable.
  • Read-only. The mod doesn't open a turn or call $.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).
  • 40 KB / row truncation, not unlimited Code. A Bash that produces 50 KB of ls -la should still let the rest of the Pane render. Truncation keeps the pane responsive.
  • Page = newest 50. This is enough for a glance at "what did we just do?" without losing the engine's small-message-efficiency on first render. The page is paginatable, not stream-paginated: there's no separate page-of-history state to track.
  • No $.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).

Configure

All knobs are module-local constants in hooks/format.ts / hooks/register.tsx:

ConstantDefaultPurpose
PAGE_SIZE50How many messages [ Load earlier ] adds per click.
DEFAULT_WINDOW50The newest messages shown on first open of the Pane.
MAX_TEXT_CHARS4_000Truncation cap per input / result / message-text block.
MAX_PREVIEW_CHARS200Truncation 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.

Limitations (v1)

  • No per-message timestamps. 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.
  • Cap at the engine's window. $.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.
  • No 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.
  • Assistant text re-parsed. 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.
  • Narrow terminals (< 110 cols) — opening the Pane returns 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.
  • No cross-session persistence by design — see Why these design choices.

Develop

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.
Source 3 files
hooks/register.tsx 671 lines
1// 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}
671
hooks/format.ts 298 lines
1// 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}
298
types/index.d.ts 17 lines
1// 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