SLOPSHOPPER

conclaude

A side pane of every prompt you sent this session, the TL;DR of each answer, and a jump back to it

newpanecommandtoastpromptmodel
v0.1.1MITupdated 2026-10-08walangstudio/conclaude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · conclaude
│ ┃ conClaude ✕ › fix the failing auth test and add an audit log call │ ┃ conClaude 1 prompt · 1 pending │ ┃ f: Find v: Saved x: Clear q: Close ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────── ⏺ Update(src/auth.ts) │ ┃ 1 fix the failing auth test and add an audit ⎿ Added 2 lines, removed 1 line │ ┃ ↳ queued · 08:53 ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /conClaude │ ⎿ conclaude: conClaude opened. Press a prompt, or press f to find │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · conClaude
conClaude 1 prompt · 1 pending f: Find v: Saved x: Clear q: Close ──────────────────────────────────────────────────────── 1 fix the failing auth test and add an audit log call ↳ queued · 08:53
README

conClaude

tests version

conClaude is a list of every prompt you sent Claude, with a one-line TL;DR of each answer.

It's for people who type the next prompt before Claude finished the last one. Three prompts later you've lost track of what you asked and what came back. conClaude sits in a pane next to your session and keeps that list for you. Press a prompt and the transcript scrolls back to it.

It's a mod: a plugin built on Claude Code's function hooks.

conClaude        13 prompts · 2 pending
Find  Saved  Clear  Close
────────────────────────────────────────
10 why is CI red
   ↳ Two flaky tests failed; retry passed
     after pinning Node. · 13:41 · 2m 5s
11 rename Foo to Bar across the repo
   ↳ …summarizing
12 add retries to the uploader
   ↳ …working
13 bump deps
   ↳ queued

Requirements

  • Claude Code 2.1.292 or later. Older builds are untested.
  • Mods turned on. They're still early access. Add this to the env block of ~/.claude/settings.json:
  "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"

Install

/plugin marketplace add walangstudio/marketplace
/plugin install conclaude@walangstudio

Restart Claude Code, then /conClaude.

Working from a checkout instead: claude --plugin-dir path/to/conclaude, or put the folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.

Commands

CommandDoes
/conClaudeOpen the pane, or close it if it's open
/conClaude 12Scroll the transcript to prompt 12

It runs at once, even while Claude is mid-reply.

In fullscreen the pane docks beside the transcript from 110 columns. Narrower than that, it sits above the prompt.

The pane

Each prompt gets a number, and the newest is at the bottom. Under it is the TL;DR of Claude's answer, or where the answer is at:

  • queued: Claude hasn't started on it.
  • …working: Claude is answering.
  • …summarizing: the answer is done and the TL;DR is being written.
  • (interrupted) in yellow, or an API error in red.

The dim tail on each TL;DR, · 14:02 · 1m 4s, is when you sent the prompt and how long you waited for the answer. The wait includes time spent in the queue. Prompts from an earlier day show the date too.

Press a prompt to jump to it, or Tab to it and press Enter.

Keys

With the pane focused. Esc hands the keyboard back to the prompt.

KeyDoes
fFind a prompt by its text, its number (#12) or the time you sent it (14: or 9:4). Enter jumps when there's one match
vSwitch between this session and saved sessions
xClear this session's list (asks first)
qClose the pane

In a saved session:

KeyDoes
bBack to the list
cCopy its claude --resume command
dDelete it (asks first)

Saved sessions

Every session saves itself to ~/.conclaude/sessions/<sessionId>.json, one file per session. That folder is outside anything Claude Code cleans up. Resume a session with claude --resume and its list comes back, TL;DRs included.

Press v to browse older sessions by date, prompt count and folder. A session keeps its last 200 prompts.

Slash commands aren't listed, and neither are prompts sent through claude -p.

Cost

The TL;DRs come from Claude Haiku at low effort, through your normal Claude Code login. That's one short call per answer. When a call fails, the TL;DR is the start of the answer instead.

conClaude writes its own TL;DRs. It doesn't need anything in your CLAUDE.md.

Known issues

  • Claude Desktop: pressing a prompt doesn't scroll the transcript. The desktop app doesn't let plugins scroll its transcript, so the jump only works in the terminal. The list, TL;DRs, find and saved sessions work in both.

Develop

claude plugin validate .
claude plugin test .

For type-checking, load the mod once (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS) so Claude Code writes .claude-plugin/types/, then run npx -y -p typescript@5 tsc -p ..

Everything lives in hooks/register.tsx. Its pure helpers are exported, and tests/conclaude.test.tsx covers them along with the hooks.

License

MIT, © walangstudio.

Source 2 files
hooks/register.tsx 670 lines
1import { atom, read, update } from 'claude-code'
2import type { CommandRunInput, EngineInterface, Register } from 'claude-code'
3
4import type { Asked, PaneStatus, PaneView, SavedMeta, SavedSession } from '../types'
5
6const PANE = 'conclaude'
7const TITLE = 'conClaude'
8const COMMAND = 'conClaude'
9const KEEP = 200
10const SUMMARIZING = '…summarizing'
11const MAX_TEXT = 2000
12const PENDING = new Set([undefined, SUMMARIZING])
13const DELETED = '{"deleted":true}'
14// A restored row's turn is gone; this id matches no new turn, so stampTurn and fillTldr leave the row alone.
15const RESTORED = 'restored'
16const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
17
18const asked = atom({ plugin: 'conclaude', key: 'asked' } as const, [])
19const turn = atom({ plugin: 'conclaude', key: 'turn' } as const, null, { shape: 'v2' })
20const jobs = atom({ plugin: 'conclaude', key: 'jobs' } as const, [])
21const view = atom({ plugin: 'conclaude', key: 'view' } as const, { mode: 'current' })
22const saved = atom({ plugin: 'conclaude', key: 'saved' } as const, [])
23const status = atom({ plugin: 'conclaude', key: 'status' } as const, null)
24const unsaved = atom({ plugin: 'conclaude', key: 'unsaved' } as const, [])
25const session = atom({ plugin: 'conclaude', key: 'session' } as const, null)
26const dirty = atom({ plugin: 'conclaude', key: 'isDirty' } as const, false)
27const locked = atom({ plugin: 'conclaude', key: 'locked' } as const, null)
28
29export const oneLine = (text: string, width: number) => {
30  const flat = text.replace(/\s+/g, ' ').trim()
31  return flat.length <= width ? flat : `${flat.slice(0, Math.max(1, width - 1))}…`
32}
33
34// A queued prompt's row can carry framing around the typed text.
35export const isRowOf = (rowText: string, prompt: string) => {
36  const head = prompt.trim().slice(0, 200)
37  return head.length > 0 && rowText.includes(head)
38}
39
40// Exact match first; else the longest prompt the row contains, so "yes" never takes "yes, and also ..."'s row.
41const bestMatch = (list: Asked[], text: string, isOpen: (one: Asked) => boolean) => {
42  const open = list.map((one, at) => ({ one, at })).filter(({ one }) => isOpen(one) && isRowOf(text, one.text))
43  const exact = open.find(({ one }) => one.text.trim() === text.trim())
44  return (exact ?? open.sort((a, b) => b.one.text.length - a.one.text.length)[0])?.at ?? -1
45}
46
47export const attachRow = (list: Asked[], rowText: string, uuid: string, running: string | null) => {
48  const at = bestMatch(list, rowText, one => !one.uuid && one.turnId !== RESTORED)
49  if (at < 0) return list
50  const copy = [...list]
51  copy[at] = { ...copy[at]!, uuid, turnId: copy[at]!.turnId ?? running ?? undefined }
52  return copy
53}
54
55// The turn belongs to the idle prompt that started it, plus queued prompts whose rows landed while idle.
56export const stampTurn = (list: Asked[], turnId: string, text: string) => {
57  const idle = (one: Asked) => one.isIdle && !one.turnId
58  const named = bestMatch(list, text, idle)
59  const starter = named >= 0 ? named : list.findLastIndex(idle)
60  return list.map((one, at) => (at === starter || (!one.turnId && !one.isIdle && one.uuid) ? { ...one, turnId } : one))
61}
62
63export const fillTldr = (list: Asked[], turnId: string, tldr: string) =>
64  list.map(one => (one.turnId === turnId && PENDING.has(one.tldr) ? { ...one, tldr } : one))
65
66export const endNote = (reason: string, answer: string) =>
67  reason === 'aborted' ? '(interrupted)'
68  : reason !== 'answer' ? `(${reason})`
69  : !answer.trim() ? '(no text reply)'
70  : undefined
71
72// "#12" is a number only; bare "12" is row 12 first, then rows whose text or TL;DR holds it.
73export const filterAsked = (list: Asked[], query: string) => {
74  const q = query.trim()
75  const n = parseNum(q)
76  const byId = n === undefined ? [] : list.filter(one => one.id === n)
77  if (q.startsWith('#') || !q) return q ? byId : list
78  const needle = q.toLowerCase()
79  const isTime = /^\d{1,2}:\d{0,2}$/.test(q)
80  const byText = list.filter(one => !byId.includes(one) && (isTime
81    ? one.at !== undefined && clock(one.at).startsWith(q.padStart(q.indexOf(':') === 1 ? q.length + 1 : q.length, '0'))
82    : `${one.text}\n${one.tldr ?? ''}`.toLowerCase().includes(needle)))
83  return [...byId, ...byText]
84}
85
86const isAsked = (one: unknown): one is Asked => {
87  const a = one as Asked
88  return typeof a?.id === 'number' && typeof a.text === 'string' && typeof a.isIdle === 'boolean'
89    && (a.uuid === undefined || typeof a.uuid === 'string') && (a.tldr === undefined || typeof a.tldr === 'string')
90    && (a.at === undefined || typeof a.at === 'number') && (a.tookMs === undefined || typeof a.tookMs === 'number')
91}
92
93// Files are the user's own, but hand-edited or half-written ones must not break the pane.
94// Rows whose summary is still queued in this process keep their turn, so it can land.
95export const parseSaved = (text: string, pending: ReadonlySet<string> = new Set()): SavedSession | undefined => {
96  try {
97    const raw = JSON.parse(text) as SavedSession
98    const isValid = raw?.version === 1 && typeof raw.sessionId === 'string' && typeof raw.cwd === 'string'
99      && typeof raw.savedAt === 'number' && Array.isArray(raw.asked) && raw.asked.every(isAsked)
100    if (!isValid) return undefined
101    const asked = raw.asked.slice(-KEEP).map(one => (one.turnId && pending.has(one.turnId) ? one
102      : { ...one, turnId: RESTORED, tldr: PENDING.has(one.tldr) ? '(no summary saved)' : one.tldr }))
103    return { ...raw, asked }
104  } catch {
105    return undefined
106  }
107}
108
109const clock = (ms: number) => {
110  const t = new Date(ms)
111  return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`
112}
113
114export const dayLabel = (at: number, now: number) => {
115  const yesterday = new Date(now)
116  yesterday.setDate(yesterday.getDate() - 1)
117  const day = new Date(at).toDateString()
118  if (day === new Date(now).toDateString()) return `today ${clock(at)}`
119  if (day === yesterday.toDateString()) return 'yesterday'
120  return `${MONTHS[new Date(at).getMonth()]} ${new Date(at).getDate()}`
121}
122
123export const parseNum = (query: string) => {
124  const num = /^#?(\d+)$/.exec(query.trim())
125  return num ? Number(num[1]) : undefined
126}
127
128export const noPrompt = (n: number, list: Asked[]) =>
129  list.length ? `no prompt ${n} (${list[0]!.id}-${list.at(-1)!.id})` : 'no prompts yet'
130
131// Every read and write of the saved files runs one at a time, so Clear, a session switch and the tick never interleave.
132let chain: Promise<unknown> = Promise.resolve()
133function serial<T>(work: () => Promise<T>) {
134  const run = chain.then(work)
135  chain = run.catch(() => undefined)
136  return run
137}
138
139export const took = (ms: number) => {
140  const sec = Math.round(ms / 1000)
141  if (sec < 60) return `${sec}s`
142  if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`
143  return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
144}
145
146export const stamp = (one: Asked, now: number) => {
147  const sent = one.at === undefined ? ''
148    : new Date(one.at).toDateString() === new Date(now).toDateString() ? clock(one.at)
149    : `${dayLabel(one.at, now)} ${clock(one.at)}`
150  return [sent, one.tookMs === undefined ? '' : took(one.tookMs)].filter(Boolean).join(' · ')
151}
152
153const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
154
155async function sessionsDir($: EngineInterface) {
156  const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
157  return home ? `${home.replace(/\\/g, '/')}/.conclaude/sessions` : undefined
158}
159
160async function edit($: EngineInterface, fn: (list: Asked[]) => Asked[]) {
161  await update($, asked, fn)
162  await update($, dirty, () => true)
163}
164
165// Runs inside serial(). Dirty clears before the list is read, so an edit during the write, or a failed write, marks it again.
166// Resolves to the file it failed to write, else undefined.
167async function flush($: EngineInterface): Promise<SavedSession | undefined> {
168  if (!(await read($, dirty))) return
169  const dir = await sessionsDir($)
170  const id = await read($, session)
171  if (!dir || !id || (await read($, locked)) === id) return
172  await update($, dirty, () => false)
173  const list = await read($, asked)
174  const path = `${dir}/${id}.json`
175  const file: SavedSession = { version: 1, sessionId: id, cwd: '', savedAt: await $.clock.now(), asked: list }
176  try {
177    file.cwd = await $.session.cwd()
178    const isNothingToSave = list.length === 0 && (!(await $.fs.exists(path)) || (await $.fs.read(path)) === DELETED)
179    if (!isNothingToSave) await $.fs.write(path, list.length ? JSON.stringify(file) : DELETED)
180    if (await read($, status)) await update($, status, () => null)
181  } catch (err) {
182    await update($, dirty, () => true)
183    await update($, status, () => ({ text: `autosave failed: ${(err as Error).message}` }))
184    return file
185  }
186  return undefined
187}
188
189// /clear and an in-app /resume change the session without a session.start. Runs inside serial().
190async function syncSession($: EngineInterface) {
191  const id = await $.session.id()
192  const was = await read($, session)
193  if (id === was) return
194  // A failed save must not hold the switch (new prompts would land in the old session) or drop the old rows: they wait in `unsaved`.
195  const failed = was === null ? undefined : await flush($)
196  if (failed) await update($, unsaved, list => [...list.filter(one => one.sessionId !== failed.sessionId), failed])
197  const dir = await sessionsDir($)
198  const file = `${dir}/${id}.json`
199  // Rows parked by a failed save are newer than the file on disk.
200  const parked = (await read($, unsaved)).find(one => one.sessionId === id)
201  let found: SavedSession | undefined = parked
202  let failure: string | undefined
203  try {
204    if (!parked && dir && (await $.fs.exists(file))) {
205      const text = await $.fs.read(file)
206      const running = await read($, turn)
207      const pending = new Set((await read($, jobs)).filter(job => job.sessionId === id).map(job => job.turnId))
208      if (running && running.sessionId === id) pending.add(running.turnId)
209      found = parseSaved(text, pending)
210      if (!found && text !== DELETED) failure = 'unreadable'
211    }
212  } catch (err) {
213    failure = (err as Error).message
214  }
215  await update($, session, () => id)
216  await update($, dirty, () => parked !== undefined)
217  if (parked) await update($, unsaved, list => list.filter(one => one.sessionId !== id))
218  await update($, locked, () => (failure ? id : null))
219  await update($, asked, () => found?.asked ?? [])
220  await update($, status, () => (failure ? { text: `couldn't read the saved session (${failure}); autosave is off for it` } : null))
221  if (found?.asked.length) void $.ui.toast(`restored ${plural(found.asked.length, 'prompt')}${parked ? ' (not saved to disk yet)' : ' from the saved session'}`)
222}
223
224// A turn or summary that lands after its session was switched away patches that session's file instead.
225async function patchSaved($: EngineInterface, sessionId: string, fn: (list: Asked[]) => Asked[]) {
226  const dir = await sessionsDir($)
227  const file = `${dir}/${sessionId}.json`
228  if (!dir || !(await $.fs.exists(file))) return
229  const text = await $.fs.read(file)
230  if (!parseSaved(text)) return
231  const raw = JSON.parse(text) as SavedSession
232  await $.fs.write(file, JSON.stringify({ ...raw, asked: fn(raw.asked) }))
233}
234
235async function isPaneShown($: EngineInterface) {
236  return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
237}
238
239// The pane follows its end only until something moves it, so each new prompt asks again.
240async function followEnd($: EngineInterface) {
241  const v = await read($, view)
242  if (v.mode !== 'current' || v.query !== undefined || v.confirm || !(await isPaneShown($))) return
243  await $.ui.scroll({ in: PANE, to: 'end' })
244}
245
246async function retryUnsaved($: EngineInterface) {
247  const files = await read($, unsaved)
248  const dir = files.length ? await sessionsDir($) : undefined
249  if (!dir) return
250  for (const file of files) {
251    try {
252      await $.fs.write(`${dir}/${file.sessionId}.json`, file.asked.length ? JSON.stringify(file) : DELETED)
253      await update($, unsaved, list => list.filter(one => one.sessionId !== file.sessionId))
254    } catch {
255      // Still failing; the next tick tries again.
256    }
257  }
258}
259
260// Every row write goes through here, one at a time. /clear and an in-app /resume give no event, so it syncs the session
261// first, then edits the live list, or the rows of a session no longer shown (waiting in `unsaved`, else its file).
262async function write($: EngineInterface, fn: (list: Asked[], running: string | null) => Asked[], sessionId?: string | null) {
263  return serial(async () => {
264    await syncSession($)
265    const current = await read($, session)
266    const target = sessionId ?? current
267    const now = await read($, turn)
268    const running = now && !now.isFinished ? now.turnId : null
269    const apply = (list: Asked[]) => fn(list, running)
270    if (target === current) await edit($, apply)
271    else if (target && (await read($, unsaved)).some(one => one.sessionId === target)) {
272      await update($, unsaved, list => list.map(one => (one.sessionId === target ? { ...one, asked: apply(one.asked) } : one)))
273    } else if (target) await patchSaved($, target, apply).catch(() => undefined)
274    return target
275  })
276}
277
278async function jumpTo($: EngineInterface, one: Asked) {
279  if (!one.uuid) return 'that prompt is not in the transcript yet'
280  const moved = await $.ui.scroll({ to: { requestId: one.uuid }, block: 'start' }).catch((err: Error) => ({ deny: err.message }))
281  if (!moved.deny) return undefined
282  // The desktop app keeps its transcript to itself; only the terminal lets a plugin scroll it.
283  return /not scrollable/.test(moved.deny) ? "this app doesn't let plugins scroll the transcript; jumping works in the terminal" : `can't jump there (${moved.deny})`
284}
285
286// The ↳ glyph carries the state's color; the words still say it, so color is never the only cue.
287export const tldrColor = (tldr: string | undefined) =>
288  tldr === undefined || tldr === SUMMARIZING || tldr === '(no text reply)' || tldr === '(no summary saved)' ? { glyph: 'inactive', text: { color: 'inactive', italic: true } }
289  : tldr === '(interrupted)' ? { glyph: 'warning', text: { color: 'warning' } }
290  : /^\(\w+\)$/.test(tldr) ? { glyph: 'error', text: { color: 'error' } }
291  : { glyph: 'success', text: {} }
292
293async function runCommand($: EngineInterface, e: CommandRunInput) {
294  const arg = e.args.trim()
295  if (!arg) {
296    if (await isPaneShown($)) {
297      await $.ui.close({ id: PANE })
298      return {}
299    }
300    await update($, view, () => ({ mode: 'current' }))
301    await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
302    void $.ui.scroll({ in: PANE, to: 'end' }).catch(() => undefined)
303    return { text: 'conClaude opened. Press a prompt, or press f to find one.' }
304  }
305  const n = parseNum(arg)
306  if (n === undefined) return { text: 'usage: /conClaude [n]' }
307  const list = await read($, asked)
308  const one = list.find(row => row.id === n)
309  if (!one) return { text: noPrompt(n, list) }
310  return { text: (await jumpTo($, one)) ?? `jumped to prompt ${one.id}` }
311}
312
313export const register: Register = on => {
314  on('session.start', async ($, e, next) => {
315    await $.command.register({
316      name: COMMAND,
317      immediate: true,
318      description: 'Toggle the pane of prompts you sent, with the TL;DR of each answer; /conClaude <n> jumps to prompt n',
319      argumentHint: '[n]',
320    }).catch(() => undefined)
321
322    // Summaries run off the turn's path; a reload picks up what was left.
323    let isBusy = false
324    const drain = async () => {
325      if (isBusy) return
326      isBusy = true
327      try {
328        for (let job = (await read($, jobs))[0]; job; job = (await read($, jobs))[0]) {
329          const { turnId, answer, sessionId } = job
330          let summary = oneLine(answer, 120)
331          try {
332            const reply = await $.model.complete({
333              model: 'haiku',
334              effort: 'low',
335              maxTokens: 120,
336              timeoutMs: 20_000,
337              system: 'Summarize the reply in one plain sentence of at most 20 words. Output only the sentence.',
338              prompt: answer.slice(0, 20_000),
339            })
340            if (reply.isAnswered && reply.text.trim()) summary = reply.text.trim()
341          } catch {
342            // A refused model (blocked by policy) keeps the trimmed reply as the TL;DR.
343          }
344          const done = summary
345          await write($, list => fillTldr(list, turnId, done), sessionId)
346          await update($, jobs, list => list.filter(one => one.turnId !== turnId))
347        }
348      } finally {
349        isBusy = false
350      }
351    }
352
353    const tick = () => serial(async () => {
354      await syncSession($)
355      await retryUnsaved($)
356      await flush($)
357    }).catch(() => undefined)
358    $.clock.every(1000, () => {
359      void tick()
360      void drain()
361    })
362    await tick()
363
364    return next(e)
365  })
366
367  // The autosave tick is a second away; a quit right after a prompt must not lose it.
368  on('session.end', async ($, e, next) => {
369    await serial(async () => {
370      await retryUnsaved($)
371      await flush($)
372    }).catch(() => undefined)
373
374    return next(e)
375  }).catch(($, e, next) => next(e))
376
377  on('command.run', { command: COMMAND }, runCommand)
378
379  on('prompt.submit', async ($, e, next) => {
380    const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
381    if (!isPerson || !e.text.trim()) return next(e)
382    let id = 0
383    const at = await $.clock.now()
384    const sessionId = await write($, list => {
385      id = (list.at(-1)?.id ?? 0) + 1
386      return [...list, { id, text: e.text.slice(0, MAX_TEXT), isIdle: e.turnId === undefined, at }].slice(-KEEP)
387    })
388    void followEnd($).catch(() => undefined)
389    const entered = await next(e)
390    if (entered.drop !== undefined) await write($, list => list.filter(one => one.id !== id), sessionId)
391
392    return entered
393  }).catch(($, e, next) => next(e))
394
395  // Rows a prompt can land in: typed at idle, folded into a running turn, or a queued command.
396  for (const door of ['prompt', 'delivery', 'attachment'] as const) {
397    on('session.append', { door }, async ($, e, next) => {
398      const stored = await next(e)
399      const isPromptRow = e.message.type === 'user' || (e.message.type === 'attachment' && e.message.name === 'queued_command')
400      if (e.agentId || !isPromptRow || !stored.uuid) return stored
401      const rowText = e.message.content.map(block => (block.type === 'text' ? block.text : '')).join('\n')
402      await write($, (list, running) => attachRow(list, rowText, stored.uuid, running))
403
404      return stored
405    }).catch(($, e, next) => next(e))
406  }
407
408  on('turn.start', async ($, e, next) => {
409    // Marked running first, so a queued row that lands meanwhile joins this turn; the write then names its real session.
410    const was = await read($, session)
411    await update($, turn, () => ({ turnId: e.turnId, sessionId: was }))
412    const sessionId = await write($, list => stampTurn(list, e.turnId, e.text))
413    await update($, turn, now => (now?.turnId === e.turnId ? { ...now, sessionId } : now))
414
415    return next(e)
416  })
417
418  on('turn.complete', async ($, e, next) => {
419    if (e.agentId) return next(e)
420    const running = await read($, turn)
421    await update($, turn, now => (now?.turnId === e.turnId ? { ...now, isFinished: true } : now))
422    const sessionId = running?.turnId === e.turnId ? running.sessionId : null
423    const note = endNote(e.reason, e.answer)
424    const endedAt = await $.clock.now()
425    // The job is queued before the rows are written, so a session switch in between still sees the turn as pending.
426    if (note === undefined) {
427      const owner = sessionId ?? (await read($, session))
428      if (owner) await update($, jobs, list => [...list, { turnId: e.turnId, answer: e.answer, sessionId: owner }])
429    }
430    await write($, list => fillTldr(list, e.turnId, note ?? SUMMARIZING)
431      .map(one => (one.turnId === e.turnId && one.tookMs === undefined && one.at !== undefined ? { ...one, tookMs: endedAt - one.at } : one)), sessionId)
432    await update($, turn, now => (now?.turnId === e.turnId ? null : now))
433
434    return next(e)
435  })
436
437  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
438    const els = $.ui.resolve(e)
439    const { Box, Text, Button } = els
440    const Input = 'Input' in els ? els.Input : undefined
441    const width = Math.max(1, e.props.bodyColumns)
442    const gap = e.props.placement === 'dock' ? 1 : 0
443    const rule = <Text color="subtle">{'─'.repeat(width)}</Text>
444    const toast = (text: string) => void $.ui.toast(text)
445    const setView = (next: PaneView) => update($, view, () => next)
446    const v = await read($, view)
447    const list = await read($, asked)
448    const now = await $.clock.now()
449    const sessionId = await read($, session)
450    const dir = await sessionsDir($)
451
452    const header = (title: string, counter: string, pending = 0) => (
453      <Box flexDirection="row" justifyContent="space-between">
454        <Text bold color="claude">{title}</Text>
455        <Text color="inactive" wrap="truncate-end">
456          {counter}
457          {pending > 0 && <Text color="suggestion" bold>{` · ${pending} pending`}</Text>}
458        </Text>
459      </Box>
460    )
461
462    const rows = (shown: Asked[], isButton: boolean, focusLast: boolean) => {
463      const numW = String(Math.max(0, ...shown.map(one => one.id))).length
464      const w = Math.max(4, width - numW - 1)
465      return shown.map((one, at) => {
466        const label = oneLine(one.text, w)
467        const tldr = one.tldr ?? (one.turnId ? '…working' : 'queued')
468        const meta = stamp(one, now)
469        const tone = tldrColor(one.tldr)
470        return (
471          <Box key={`row-${one.id}`} flexDirection="column" marginBottom={gap}>
472            <Box flexDirection="row">
473              <Text color="suggestion">{`${String(one.id).padStart(numW)} `}</Text>
474              {isButton
475                ? <Button key={`jump-${one.id}`} plain label={label} hover={{ underline: true }} autoFocus={focusLast && at === shown.length - 1 ? true : undefined} onPress={() => jump(one)} />
476                : <Text>{label}</Text>}
477            </Box>
478            <Text wrap="wrap">
479              {' '.repeat(numW + 1)}
480              <Text color={tone.glyph}>↳ </Text>
481              <Text {...tone.text}>{oneLine(tldr, Math.max(Math.min(8, w), 2 * (w - 2) - 1 - (meta ? meta.length + 3 : 0)))}</Text>
482              {meta && <Text color="inactive">{` · ${meta}`}</Text>}
483            </Text>
484          </Box>
485        )
486      })
487    }
488
489    const jump = async (one: Asked) => {
490      const problem = await jumpTo($, one)
491      if (problem) toast(problem)
492    }
493
494    const loadSaved = async () => {
495      if (!dir || !(await $.fs.exists(dir))) return []
496      const metas: SavedMeta[] = []
497      for (const entry of await $.fs.list(dir)) {
498        if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
499        const one = parseSaved(await $.fs.read(`${dir}/${entry.name}`).catch(() => DELETED))
500        if (!one || one.sessionId === sessionId || one.asked.length === 0) continue
501        metas.push({ sessionId: one.sessionId, cwd: one.cwd, savedAt: one.savedAt, count: one.asked.length, title: one.asked[0]!.text })
502      }
503      return metas.sort((a, b) => b.savedAt - a.savedAt)
504    }
505
506    const confirmBlock = (question: string, detail: string[], yes: string, onYes: () => Promise<unknown>) => (
507      <Box flexDirection="column">
508        <Text bold wrap="wrap">{question}</Text>
509        {detail.map((line, at) => <Text key={`detail-${at}`} color="inactive" wrap="wrap">{line}</Text>)}
510        <Text> </Text>
511        <Box flexDirection="row" gap={2}>
512          <Button key="yes" hotkey="y" label={yes} variant="primary" onPress={() => void onYes()} />
513          <Button key="no" hotkey="n" label="Keep" autoFocus onPress={() => void setView({ ...v, confirm: undefined })} />
514        </Box>
515        {rule}
516      </Box>
517    )
518
519    const close = <Button key="close" hotkey="q" plain label="Close" onPress={() => void $.ui.close({ id: PANE })} />
520
521    if (v.mode === 'current' && v.confirm === 'clear') {
522      const isLocked = (await read($, locked)) === sessionId
523      return confirmBlock(`Clear all ${plural(list.length, 'prompt')} from this list?`, [isLocked ? "The saved copy couldn't be read, so it stays as it is." : 'The saved copy is deleted too.'], 'Clear', async () => {
524        const count = list.length
525        const isCleared = await serial(async () => {
526          const id = await read($, session)
527          if (id !== sessionId) return false
528          await update($, jobs, list => list.filter(job => job.sessionId !== id))
529          await edit($, () => [])
530          await flush($)
531          return true
532        })
533        await setView({ mode: 'current' })
534        toast(isCleared ? `cleared ${plural(count, 'prompt')}` : 'the session changed; nothing was cleared')
535      })
536    }
537
538    if (v.mode === 'detail' && v.detail && v.confirm === 'delete') {
539      const d = v.detail
540      return confirmBlock('Delete this saved session?', [`"${oneLine(d.asked[0]?.text ?? '', width - 2)}"`, `${plural(d.asked.length, 'prompt')} · ${dayLabel(d.savedAt, now)}. This cannot be undone.`], 'Delete', async () => {
541        try {
542          await serial(async () => {
543            await $.fs.write(`${dir}/${d.sessionId}.json`, DELETED)
544            await update($, unsaved, list => list.filter(one => one.sessionId !== d.sessionId))
545          })
546          const metas = await loadSaved()
547          await update($, saved, () => metas)
548          await setView({ mode: 'saved' })
549          toast('deleted saved session')
550        } catch (err) {
551          toast(`couldn't delete: ${(err as Error).message}`)
552        }
553      })
554    }
555
556    if (v.mode === 'detail' && v.detail) {
557      const d = v.detail
558      const command = `claude --resume ${d.sessionId}`
559      return (
560        <Box flexDirection="column">
561          <Text bold color="claude" wrap="truncate-end">{`‹ Saved · ${oneLine(d.asked[0]?.text ?? '', width)}`}</Text>
562          <Box flexDirection="row" gap={2}>
563            <Button key="back" hotkey="b" plain label="Back" autoFocus onPress={() => void setView({ mode: 'saved' })} />
564            <Button key="copy" hotkey="c" plain label="Copy" onPress={async press => {
565              const copied = await $.ui.copy({ text: command, surface: press.surface })
566              toast(copied.isCopied ? `copied: ${oneLine(command, 30)}` : "couldn't copy - select the command in the pane")
567            }} />
568            <Button key="delete" hotkey="d" plain label="Delete" onPress={() => {
569              void setView({ ...v, confirm: 'delete' })
570              void $.ui.scroll({ in: PANE, to: 'start' }).catch(() => undefined)
571            }} />
572            {close}
573          </Box>
574          {rule}
575          {rows(d.asked, false, false)}
576          {rule}
577          <Text color="suggestion" wrap="wrap">{command}</Text>
578          <Text color="inactive" wrap="wrap">{`Read-only · ${dayLabel(d.savedAt, now)} · ${plural(d.asked.length, 'prompt')}`}</Text>
579        </Box>
580      )
581    }
582
583    if (v.mode === 'saved') {
584      const metas = await read($, saved)
585      const open = async (meta: SavedMeta) => {
586        const one = parseSaved(await $.fs.read(`${dir}/${meta.sessionId}.json`).catch(err => `${(err as Error).message}`))
587        if (!one) return toast("couldn't read that saved session (unreadable or deleted)")
588        await setView({ mode: 'detail', detail: one })
589      }
590      return (
591        <Box flexDirection="column">
592          {header(TITLE, `Saved · ${plural(metas.length, 'session')}`)}
593          <Box flexDirection="row" gap={2}>
594            <Button key="view" hotkey="v" plain label="Current" onPress={() => void setView({ mode: 'current' })} />
595            {close}
596          </Box>
597          {rule}
598          {metas.length === 0 && <Text color="inactive">No saved sessions yet.</Text>}
599          {metas.length === 0 && <Text color="inactive">Sessions autosave as you work.</Text>}
600          {metas.map((meta, at) => (
601            <Box key={`saved-${meta.sessionId}`} flexDirection="column" marginBottom={gap}>
602              <Button key={`open-${meta.sessionId}`} plain label={oneLine(meta.title, width)} autoFocus={at === 0 ? true : undefined} onPress={() => void open(meta)} />
603              <Text color="inactive" wrap="truncate-start">
604                {'  '}
605                <Text color="suggestion">{dayLabel(meta.savedAt, now)}</Text>
606                {` · ${plural(meta.count, 'prompt')} · ${meta.cwd}`}
607              </Text>
608            </Box>
609          ))}
610          {rule}
611          <Text color="inactive">Enter opens a session, read-only.</Text>
612        </Box>
613      )
614    }
615
616    const isFinding = v.query !== undefined
617    const shown = isFinding ? filterAsked(list, v.query!) : list
618    const pending = list.filter(one => PENDING.has(one.tldr)).length
619    const counter = isFinding && v.query!.trim()
620      ? `${shown.length} of ${list.length} match "${oneLine(v.query!, 12)}"`
621      : plural(list.length, 'prompt')
622    const waiting = (await read($, unsaved)).length
623    const st: PaneStatus | null = (await read($, status)) ?? (waiting ? { text: `${plural(waiting, 'earlier session')} not saved yet; retrying` } : null)
624
625    const submit = (query: string) => {
626      const found = filterAsked(list, query)
627      const n = parseNum(query)
628      const exact = found.find(one => one.id === n)
629      if (exact) return void jump(exact)
630      if (n !== undefined && query.trim().startsWith('#')) return toast(noPrompt(n, list))
631      if (found.length === 1) return void jump(found[0]!)
632      toast(found.length === 0 ? `no prompt matches "${oneLine(query, 20)}"` : `${found.length} matches - Tab to pick one`)
633    }
634
635    return (
636      <Box flexDirection="column">
637        {header(TITLE, counter, isFinding ? 0 : pending)}
638        <Box flexDirection="row" gap={2}>
639          {list.length > 0 && Input && <Button key="find" hotkey="f" plain label="Find" onPress={() => void setView({ mode: 'current', query: isFinding ? undefined : '' })} />}
640          <Button key="view" hotkey="v" plain label="Saved" onPress={async () => {
641            try {
642              const metas = await loadSaved()
643              await update($, saved, () => metas)
644              await setView({ mode: 'saved' })
645            } catch (err) {
646              toast(`couldn't list saved sessions: ${(err as Error).message}`)
647            }
648          }} />
649          {list.length > 0 && <Button key="clear" hotkey="x" plain label="Clear" onPress={() => {
650            void setView({ mode: 'current', confirm: 'clear' })
651            void $.ui.scroll({ in: PANE, to: 'start' }).catch(() => undefined)
652          }} />}
653          {close}
654        </Box>
655        {isFinding && Input && (
656          <Input key="find-input" label="Find" placeholder="text or #n" submitLabel="Jump" value={v.query} autoFocus
657            onInput={(value: string) => void setView({ mode: 'current', query: value })} onSubmit={submit} />
658        )}
659        {rule}
660        {list.length === 0 && <Text color="inactive">No prompts yet.</Text>}
661        {list.length === 0 && <Text color="inactive" wrap="wrap">Everything you send shows up here, with a one-line TL;DR of each answer.</Text>}
662        {list.length > 0 && shown.length === 0 && <Text color="inactive" wrap="wrap">{`No prompt matches "${oneLine(v.query ?? '', 20)}".`}</Text>}
663        {list.length > 0 && shown.length === 0 && <Text color="inactive" wrap="wrap">Try a word from the prompt or its summary, or #12 for a number.</Text>}
664        {rows(shown, true, !isFinding)}
665        {(st || !dir) && <Box flexDirection="column">{rule}<Text color="error" wrap="wrap">{`✗ ${st?.text ?? 'autosave off: no home folder'}`}</Text></Box>}
666      </Box>
667    )
668  })
669}
670
types/index.d.ts 43 lines
1export type Asked = {
2  id: number
3  text: string
4  uuid?: string
5  turnId?: string
6  isIdle: boolean
7  tldr?: string
8  at?: number
9  tookMs?: number
10}
11
12export type SummaryJob = { turnId: string; answer: string; sessionId: string }
13
14export type SavedSession = { version: 1; sessionId: string; cwd: string; savedAt: number; asked: Asked[] }
15
16export type SavedMeta = { sessionId: string; cwd: string; savedAt: number; count: number; title: string }
17
18export type PaneView = {
19  mode: 'current' | 'saved' | 'detail'
20  query?: string
21  confirm?: 'clear' | 'delete'
22  detail?: SavedSession
23}
24
25export type PaneStatus = { text: string }
26
27declare module 'claude-code' {
28  interface PluginState {
29    conclaude: {
30      asked: Asked[]
31      turn: Shaped<{ turnId: string; sessionId: string | null; isFinished?: boolean } | null>
32      jobs: SummaryJob[]
33      view: PaneView
34      saved: SavedMeta[]
35      status: PaneStatus | null
36      unsaved: SavedSession[]
37      session: string | null
38      isDirty: boolean
39      locked: string | null
40    }
41  }
42}
43