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

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
env block of ~/.claude/settings.json: "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
/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.
| Command | Does |
|---|---|
/conClaude | Open the pane, or close it if it's open |
/conClaude 12 | Scroll 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.
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.
With the pane focused. Esc hands the keyboard back to the prompt.
| Key | Does |
|---|---|
f | Find 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 |
v | Switch between this session and saved sessions |
x | Clear this session's list (asks first) |
q | Close the pane |
In a saved session:
| Key | Does |
|---|---|
b | Back to the list |
c | Copy its claude --resume command |
d | Delete it (asks first) |
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.
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.
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.
MIT, © walangstudio.
hooks/register.tsx 670 lines1import { 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}
670types/index.d.ts 43 lines1export 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