A side panel for questions about the current session: it reads the conversation from cache, looks things up read-only, recaps, says what the main thread is…

Two Claude Code mods.
In a terminal:
claude plugin marketplace add gabedeluna/claude-mods
claude plugin install ledger@claude-mods
claude plugin install sidechat@claude-mods
Then restart Claude Code.
A running record of the session, kept by Claude as you work: topics, the decisions you made (quoted in your own words), open questions with a recommended answer and buttons to answer them, and to-dos for you and for Claude, ticked off as they get done. /ledger opens the panel.
A side panel for questions about the session that never reach the main conversation. /sidechat <question> opens it and asks; recap, now and more (explains the text you last copied) are buttons at the top. Answers can look up files and code, follow-up buttons go deeper, Draw it turns an answer into a diagram, and Send to main writes a note into your prompt. Click the box at the bottom to type; Enter sends, Shift+Enter adds a line.
On Windows Terminal, Shift+Enter needs a key setting: add an action that sends \u001b\r on shift+enter in the terminal's settings.json.
hooks/register.tsx 593 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SideChat, SideEntry, SideKind, SideRunning } from '../types'
5import {
6 FOLLOW_UPS,
7 KIND_LABEL,
8 MAX_ROUNDS,
9 buildPrompt,
10 capText,
11 drawPrompt,
12 drawingPage,
13 elapsed,
14 isSecretPath,
15 lookupLabel,
16 moreQuestion,
17 nowQuestion,
18 parseDrawing,
19 parseLookups,
20 parseProposal,
21 boardOutcome,
22 recapQuestion,
23 sendToMainPrompt,
24 SPIN_MS,
25 busyLabel,
26 spinFrame,
27 sliceLines,
28 summarizeCall,
29} from './logic'
30import type { Lookup } from './logic'
31
32type Env = EngineInterface
33
34const PANE = 'sidechat'
35const ACC = '#e0905f'
36const AMB = '#e8c06a'
37const BLUE = '#79b8ff'
38const GREY = '#8b929c'
39const FAINT = '#5f6672'
40
41const chatA = atom({ plugin: 'sidechat', key: 'chat' } as const, { entries: [], busy: '' } as SideChat)
42const runningA = atom({ plugin: 'sidechat', key: 'running' } as const, null as SideRunning | null)
43const tickA = atom({ plugin: 'sidechat', key: 'tick' } as const, 0)
44// Whether the message box was last clicked or typed in, so it shows a blinking cursor.
45const boxA = atom({ plugin: 'sidechat', key: 'box' } as const, false)
46const blinkA = atom({ plugin: 'sidechat', key: 'blink' } as const, true)
47
48// Selects or clears the box; the cursor blinks on a timer only while it is selected.
49async function setBox($: Env, on: boolean) {
50 if (on && !blinker) blinker = $.clock.every(BLINK_MS, () => void update($, blinkA, (b) => !b))
51 if (!on && blinker) {
52 blinker.cancel()
53 blinker = undefined
54 }
55 await update($, blinkA, () => true)
56 await update($, boxA, () => on)
57}
58
59let sessionId = ''
60let root = ''
61let boardScript = ''
62let asked = 0
63const calls = new Map<string, SideRunning>()
64let ticker: { cancel: () => void } | undefined
65let spinner: { cancel: () => void } | undefined
66let blinker: { cancel: () => void } | undefined
67const BLINK_MS = 530
68let busySince = 0
69
70const newId = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
71
72async function save($: Env) {
73 if (!sessionId) return
74 const chat = await read($, chatA)
75 await $.store.set(`chat:${sessionId}`, { entries: chat.entries.slice(-30), busy: '' })
76 const ids = ((await $.store.get('sessions')) as string[] | undefined) ?? []
77 const next = [...ids.filter((i) => i !== sessionId), sessionId]
78 for (const old of next.slice(0, Math.max(0, next.length - 30))) await $.store.delete(`chat:${old}`)
79 await $.store.set('sessions', next.slice(-30))
80}
81
82async function setEntry($: Env, id: string, patch: Partial<SideEntry>) {
83 await update($, chatA, (c) => ({ ...c, entries: c.entries.map((x) => (x.id === id ? { ...x, ...patch } : x)) }))
84}
85
86// While busy, a timer moves the star; the start time stays put while the busy text changes.
87async function setBusy($: Env, busy: string) {
88 if (busy && !spinner) {
89 busySince = await $.clock.now()
90 spinner = $.clock.every(SPIN_MS, () => void update($, tickA, (t) => t + 1))
91 }
92 if (!busy && spinner) {
93 spinner.cancel()
94 spinner = undefined
95 }
96 await update($, chatA, (c) => ({ ...c, busy }))
97}
98
99async function findBoardScript($: Env): Promise<string> {
100 let dir = root
101 for (let i = 0; i < 5; i += 1) {
102 const p = `${dir}/../Telify-board/docs/board/board.mjs`
103 if (await $.fs.exists(p)) return p
104 dir = `${dir}/..`
105 }
106 return ''
107}
108
109// Found once, when first needed, so a reload or a fresh panel still reaches the board.
110async function ensureBoard($: Env): Promise<string> {
111 if (!root) root = (await $.session.root()).replace(/\\/g, '/')
112 if (!boardScript) boardScript = await findBoardScript($)
113 return boardScript
114}
115
116const abs = (p: string) => (/^([a-zA-Z]:)?[\\/]/.test(p) ? p : `${root}/${p.replace(/^\.\//, '')}`)
117
118// Read-only lookups the side chat asks for.
119async function runLookup($: Env, l: Lookup): Promise<string> {
120 try {
121 if (l.kind === 'read') {
122 if (isSecretPath(l.path)) return 'Not read: secret files stay out of the side chat.'
123 return capText(sliceLines((await $.fs.read(abs(l.path))) as string, l.from, l.to))
124 }
125 if (l.kind === 'list') {
126 const entries = await $.fs.list(abs(l.path))
127 return entries.slice(0, 200).map((x) => `${x.name}${x.kind === 'dir' ? '/' : ''}`).join('\n') || '(empty)'
128 }
129 if (l.kind === 'search') {
130 const argv = ['git', 'grep', '-n', '-I', '-F', '--max-count=5', '-e', l.text, '--', ...(l.path ? [l.path] : [])]
131 const r = await $.process.run(argv, { cwd: root, timeoutMs: 20000 })
132 const lines = r.stdout.split(/\r?\n/).filter(Boolean).filter((x) => !isSecretPath(x.split(':')[0] ?? ''))
133 return lines.length ? capText(lines.slice(0, 60).join('\n') + (lines.length > 60 ? `\n[${lines.length - 60} more matches]` : '')) : 'No matches.'
134 }
135 if (l.kind === 'board') {
136 if (!(await ensureBoard($))) return 'No board found beside this project.'
137 const op = /^[a-z0-9]+(-[a-z0-9]+)+$/.test(l.query) ? { op: 'show', id: l.query } : { op: 'find', text: l.query, limit: 12 }
138 const r = await $.process.run(['node', boardScript, '-'], { stdin: JSON.stringify({ ops: [op] }), timeoutMs: 30000 })
139 return capText((r.stdout || r.stderr).trim())
140 }
141 const r = await $.tool.call({ tool: 'mcp__ledger__ledger', ops: [] } as never)
142 const text = (r as { result?: unknown; deny?: string }).result ?? (r as { deny?: string }).deny
143 return capText(typeof text === 'string' ? text : JSON.stringify(text ?? 'The ledger is not available.'))
144 } catch (err) {
145 return `Could not ${lookupLabel(l)}: ${err instanceof Error ? err.message : String(err)}`
146 }
147}
148
149const failText = (reason: string) =>
150 reason === 'nothing-to-fork'
151 ? 'Nothing to ask about yet: the main conversation has no reply so far.'
152 : reason === 'aborted'
153 ? 'Stopped before it answered.'
154 : `No answer (${reason}). Try again.`
155
156// One side question: ask, run any lookups it requests, ask again, up to MAX_ROUNDS.
157async function ask($: Env, kind: SideKind, title: string, question: string) {
158 const chat = await read($, chatA)
159 if (chat.busy) {
160 await $.ui.toast('The side chat is still answering the last question.')
161 return
162 }
163 const history = chat.entries
164 const id = newId()
165 asked += 1
166 await update($, chatA, (c) => ({ ...c, entries: [...c.entries, { id, kind, title, answer: '', reads: [] }] }))
167 await setBusy($, 'thinking…')
168 await toEnd($)
169 const looked: { line: string; result: string }[] = []
170 const reads: string[] = []
171 try {
172 for (let round = 0; round <= MAX_ROUNDS; round += 1) {
173 const prompt = buildPrompt({ question, history, running: await read($, runningA), now: await $.clock.now(), looked, final: round === MAX_ROUNDS })
174 const r = await $.model.fork({ prompt })
175 if (!r.isAnswered) {
176 await setEntry($, id, { answer: failText(r.reason), failed: true, reads })
177 return
178 }
179 const req = round < MAX_ROUNDS ? parseLookups(r.text) : null
180 if (!req) {
181 const { answer, proposal, error } = parseProposal(r.text.trim())
182 await setEntry($, id, {
183 answer: error ? `${answer}
184
185(It tried to propose a board change, but ${error}. Ask again.)` : answer,
186 reads,
187 ...(proposal ? { proposal: { ...proposal, status: 'pending' as const } } : {}),
188 })
189 await toEnd($)
190 return
191 }
192 for (const [n, l] of req.lookups.entries()) {
193 const label = lookupLabel(l)
194 await setBusy($, `${label}…`)
195 looked.push({ line: req.lines[n] ?? label, result: await runLookup($, l) })
196 reads.push(label)
197 await setEntry($, id, { reads: [...reads] })
198 }
199 await setBusy($, 'thinking…')
200 }
201 } catch (err) {
202 await setEntry($, id, { answer: `No answer: ${err instanceof Error ? err.message : String(err)}`, failed: true, reads })
203 } finally {
204 await setBusy($, '')
205 await save($)
206 }
207}
208
209// Keeps the newest line of the conversation in view.
210async function toEnd($: Env) {
211 await $.ui.scroll({ in: PANE, to: 'end' }).catch(() => undefined)
212}
213
214const later = ($: Env, fn: () => Promise<void>) => {
215 $.clock.after(0, () => void fn())
216}
217
218async function recap($: Env) {
219 await ask($, 'recap', 'Recap of the session', recapQuestion)
220}
221
222async function whatNow($: Env) {
223 await ask($, 'now', 'What the main thread is doing', nowQuestion)
224}
225
226// The clipboard's text, through whichever reader this machine has: Windows, Mac, then Linux (X11, Wayland).
227const CLIPBOARD_READERS = [
228 ['powershell', '-NoProfile', '-Command', 'Get-Clipboard -Raw'],
229 ['pbpaste'],
230 ['xclip', '-o', '-selection', 'clipboard'],
231 ['wl-paste', '--no-newline'],
232]
233
234async function copied($: Env): Promise<string> {
235 const windows = (await $.env.get('OS').catch(() => undefined)) === 'Windows_NT'
236 for (const argv of windows ? CLIPBOARD_READERS.slice(0, 1) : CLIPBOARD_READERS.slice(1)) {
237 const r = await $.process.run(argv, { timeoutMs: 10000 }).catch(() => undefined)
238 if (r && r.exitCode === 0 && r.stdout.trim()) return r.stdout.trim()
239 }
240 return ''
241}
242
243async function more($: Env) {
244 const passage = await copied($)
245 if (!passage) {
246 await $.ui.toast('Copy some text from the conversation first, then press more.')
247 return
248 }
249 const clipped = passage.length > 160 ? `${passage.slice(0, 157)}…` : passage
250 await ask($, 'more', `"${clipped}"`, moreQuestion(passage.slice(0, 4000)))
251}
252
253async function followUp($: Env, key: string) {
254 const chat = await read($, chatA)
255 const last = [...chat.entries].reverse().find((x) => !x.failed && x.answer)
256 if (!last) return
257 if (key === 'draw') return draw($, last.id)
258 const f = FOLLOW_UPS.find((x) => x.key === key)
259 if (!f) return
260 await ask($, 'followup', f.says, `${f.ask} (This follows up your last side answer, to: ${last.title})`)
261}
262
263const urlIn = (r: unknown) => /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9-]+/.exec(JSON.stringify(r))?.[0]
264
265async function draw($: Env, entryId: string) {
266 const chat = await read($, chatA)
267 const entry = chat.entries.find((x) => x.id === entryId)
268 if (!entry || chat.busy) return
269 await setBusy($, 'drawing…')
270 try {
271 const r = await $.model.fork({ prompt: drawPrompt(entry) })
272 const drawing = r.isAnswered ? parseDrawing(r.text) : null
273 if (!drawing) {
274 await $.ui.toast('The side chat could not draw that one. Try again, or ask it differently.')
275 return
276 }
277 await setEntry($, entryId, { drawing })
278 const dir = ((await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? root).replace(/\\/g, '/')
279 const file = `${dir}/sidechat-${entryId}.html`
280 await $.fs.write(file, drawingPage(drawing.title, drawing.svg))
281 await setBusy($, 'publishing the drawing…')
282 const published = await $.tool.call({
283 tool: 'Artifact',
284 file_path: file,
285 icon: 'diagram',
286 description: `Side chat drawing: ${drawing.title}`,
287 consent: 'The user pressed "Draw it" in the side chat panel to publish this diagram as a private artifact.',
288 } as never)
289 const url = urlIn(published)
290 if (url) await setEntry($, entryId, { drawing: { ...drawing, url } })
291 else await $.ui.toast('Drawn, but publishing it as an artifact did not go through.')
292 await toEnd($)
293 } finally {
294 await setBusy($, '')
295 await save($)
296 }
297}
298
299// Writes a proposed board change only when the person presses Approve, through the board script and its checks.
300async function approveBoard($: Env, entryId: string) {
301 const chat = await read($, chatA)
302 const entry = chat.entries.find((x) => x.id === entryId)
303 const p = entry?.proposal
304 if (!p || p.status !== 'pending' || chat.busy) return
305 if (!(await ensureBoard($))) {
306 await setEntry($, entryId, { proposal: { ...p, status: 'failed', result: 'No board found beside this project.' } })
307 return
308 }
309 await setBusy($, 'changing the board…')
310 try {
311 const r = await $.process.run(['node', boardScript, '-'], { stdin: JSON.stringify({ ops: p.ops }), timeoutMs: 60000 })
312 const result = (r.stdout || r.stderr).trim()
313 await setEntry($, entryId, { proposal: { ...p, status: r.exitCode === 0 ? 'applied' : 'failed', result } })
314 } finally {
315 await setBusy($, '')
316 await save($)
317 await toEnd($)
318 }
319}
320
321async function discardBoard($: Env, entryId: string) {
322 const entry = (await read($, chatA)).entries.find((x) => x.id === entryId)
323 if (entry?.proposal?.status !== 'pending') return
324 await setEntry($, entryId, { proposal: { ...entry.proposal, status: 'discarded' } })
325 await save($)
326}
327
328async function openArtifact($: Env, url: string) {
329 await $.tool.call({ tool: 'Artifact', action: 'open', url, consent: 'The user pressed "Open artifact" in the side chat panel.' } as never)
330}
331
332// Puts text in the prompt box, after anything already typed there.
333async function fillPrompt($: Env, text: string) {
334 const box = await $.prompt.read()
335 const draft = box.text.trimEnd()
336 await $.prompt.fill({ text: draft ? `${draft}\n${text}` : text })
337}
338
339async function sendToMain($: Env) {
340 const chat = await read($, chatA)
341 if (!chat.entries.some((x) => !x.failed && x.answer) || chat.busy) return
342 await setBusy($, 'writing a note for the main thread…')
343 try {
344 const r = await $.model.fork({ prompt: sendToMainPrompt(chat.entries) })
345 if (r.isAnswered && r.text.trim()) {
346 await fillPrompt($, r.text.trim())
347 await $.ui.toast('A note from the side chat is in your prompt. Edit it, then send.')
348 } else await $.ui.toast('Could not write the note. Try again.')
349 } finally {
350 await setBusy($, '')
351 }
352}
353
354async function newChat($: Env) {
355 await update($, chatA, () => ({ entries: [], busy: '' }))
356 await save($)
357}
358
359async function openPane($: Env) {
360 await $.ui.open({ id: PANE, title: 'Side chat', focus: true, columns: 66 })
361 // Typing goes straight into the side chat's box where the surface allows it.
362 await $.ui.focus({ requestId: PANE, key: 'composer' }).catch(() => undefined)
363}
364
365// The newest of the main thread's tool calls still running.
366async function showRunning($: Env) {
367 const latest = [...calls.values()].sort((a, b) => b.startedAt - a.startedAt)[0] ?? null
368 await update($, runningA, () => latest)
369 if (latest && !ticker) ticker = $.clock.every(1000, () => void update($, tickA, (t) => t + 1))
370 if (!latest && ticker) {
371 ticker.cancel()
372 ticker = undefined
373 }
374}
375
376export const register: Register = (on) => {
377 // The terminal box posts a message when Enter sends it.
378 on('ui.message', async ($, e, next) => {
379 if (e.requestId !== PANE || e.element !== 'composer') return next(e)
380 const data = e.data as { submit?: unknown; focus?: unknown } | null
381 if (data?.focus) await setBox($, true)
382 const text = data?.submit
383 if (typeof text === 'string' && text.trim()) later($, () => ask($, 'ask', text.trim(), text.trim()))
384 return {}
385 })
386
387 // Tab onto a button moves the selection off the box.
388 on('ui.focus', async ($, e, next) => {
389 if (e.requestId === PANE && e.element && e.element !== 'composer') await setBox($, false)
390 return next(e)
391 })
392
393 // Typing in the main prompt means Esc handed the keys back there.
394 on('prompt.edit', async ($, e, next) => {
395 if (await read($, boxA)) await setBox($, false)
396 return next(e)
397 })
398
399 on('ui.close', async ($, e, next) => {
400 if (e.id === PANE) await setBox($, false)
401 return next(e)
402 })
403
404 on('session.start', async ($, e, next) => {
405 sessionId = await $.session.id()
406 root = (await $.session.root()).replace(/\\/g, '/')
407 boardScript = await findBoardScript($)
408 const held = await read($, chatA)
409 if (!held.entries.length) {
410 const stored = (await $.store.get(`chat:${sessionId}`)) as SideChat | undefined
411 if (stored?.entries?.length) await update($, chatA, () => ({ entries: stored.entries, busy: '' }))
412 } else if (held.busy) await setBusy($, '')
413 await $.command.register({ name: 'sidechat', description: 'Ask about this session in a side panel (recap · now · more · a question)', argumentHint: '[question|recap|now|more]' })
414 return next(e)
415 })
416
417 on('command.run', { command: 'sidechat' }, async ($, e) => {
418 const arg = e.args.trim()
419 await openPane($)
420 if (arg === 'recap') later($, () => recap($))
421 else if (arg === 'now') later($, () => whatNow($))
422 else if (arg === 'more') later($, () => more($))
423 else if (arg) later($, () => ask($, 'ask', arg, arg))
424 return {}
425 })
426
427 // What the main thread is running, for the Now strip and the side chat's own context.
428 on('tool.call', async ($, e, next) => {
429 const id = e.tool_use_id
430 if (e.agentId || !id || String(e.tool).startsWith('mcp__sidechat')) return next(e)
431 calls.set(id, { tool: String(e.tool), summary: summarizeCall(String(e.tool), e as unknown as Record<string, unknown>), startedAt: await $.clock.now() })
432 await showRunning($)
433 try {
434 return await next(e)
435 } finally {
436 calls.delete(id)
437 await showRunning($)
438 }
439 })
440
441 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
442 const chat = await read($, chatA)
443 const running = await read($, runningA)
444 await read($, tickA)
445 const boxHot = await read($, boxA)
446 // Selected from a click or key in it; the panel's own keyboard flag is off while the box has the keys.
447 const boxFocused = boxHot
448 const blink = await read($, blinkA)
449 const now = await $.clock.now()
450 const W = Math.max(30, e.props.bodyColumns - 2)
451 const desktop = e.surface === 'desktop'
452 const { Box, Text, Button, Input, Markdown, Svg, Client } = $.ui.resolve(e) as Record<string, any>
453 const entries = chat.entries
454 const latest = entries.at(-1)
455 const band = desktop ? '#e9e4d8' : '#2f3b52'
456 const settled = latest && !chat.busy && latest.answer && !latest.failed
457 const go = (fn: () => Promise<void>) => () => later($, fn)
458
459 const spinner = (
460 <Box flexDirection="row" marginTop={1} paddingLeft={2}>
461 <Text color={ACC}>{`${spinFrame(now - busySince)} `}</Text>
462 <Text color={ACC}>{busyLabel(chat.busy)}</Text>
463 <Text dimColor>{` (${elapsed(now - busySince)})`}</Text>
464 </Box>
465 )
466
467 // One turn: your message on a band, then what it looked up, then its reply after a bullet.
468 const turn = (x: SideEntry) => (
469 <Box key={`t-${x.id}`} flexDirection="column" marginTop={1}>
470 <Box flexDirection="row" backgroundColor={band} paddingX={1}>
471 <Text color={ACC} bold>{'› '}</Text>
472 <Box flexDirection="column" flexGrow={1}>
473 {x.kind === 'recap' || x.kind === 'now' || x.kind === 'more' ? <Text dimColor>{KIND_LABEL[x.kind]}</Text> : null}
474 <Text bold wrap="wrap">{x.title}</Text>
475 </Box>
476 </Box>
477 {x.reads.map((r, i) => (
478 <Text key={`rd-${x.id}-${i}`} color={GREY}>{` ◦ ${r}`}</Text>
479 ))}
480 {x.answer ? (
481 <Box flexDirection="row" marginTop={1}>
482 <Text color={x.failed ? AMB : GREY}>{'● '}</Text>
483 <Box flexDirection="column" flexGrow={1}>{x.failed ? <Text color={AMB}>{x.answer}</Text> : <Markdown text={x.answer} />}</Box>
484 </Box>
485 ) : null}
486 {x.proposal ? (
487 <Box flexDirection="column" marginTop={1} paddingLeft={2}>
488 {x.proposal.status === 'pending' ? (
489 [
490 <Text key="h" bold color={BLUE}>{'▤ Proposed board change'}</Text>,
491 ...x.proposal.lines.map((l, i) => (
492 <Text key={`pl-${x.id}-${i}`} wrap="wrap">{` · ${l}`}</Text>
493 )),
494 <Box key="b" flexDirection="row" gap={2} marginTop={1}>
495 <Button key={`approve-${x.id}`} variant="primary" label="Approve" onPress={go(() => approveBoard($, x.id))} />
496 <Button key={`discard-${x.id}`} label="Discard" onPress={go(() => discardBoard($, x.id))} />
497 </Box>,
498 ]
499 ) : (
500 // Once it is settled, a line or two: the full text is on the board.
501 boardOutcome(x.proposal).map((l, i) => (
502 <Text key={`po-${x.id}-${i}`} bold={i === 0} color={i === 0 ? (x.proposal!.status === 'failed' ? AMB : x.proposal!.status === 'discarded' ? GREY : BLUE) : GREY} wrap="wrap">{l}</Text>
503 ))
504 )}
505 </Box>
506 ) : null}
507 {x.drawing ? (
508 <Box flexDirection="column" marginTop={1} paddingLeft={2}>
509 {desktop && Svg ? <Svg source={x.drawing.svg} alt={x.drawing.title} width={W * 7} /> : null}
510 <Box flexDirection="row">
511 <Text color={BLUE}>{'▣ '}</Text>
512 <Text bold>{`Drawn: ${x.drawing.title} `}</Text>
513 {x.drawing.url ? <Button key={`open-${x.id}`} variant="primary" label="Open artifact" onPress={go(() => openArtifact($, x.drawing!.url!))} /> : <Text dimColor>not published</Text>}
514 </Box>
515 </Box>
516 ) : null}
517 </Box>
518 )
519
520 return (
521 <Box flexDirection="column" width={W}>
522 <Box flexDirection="row">
523 <Text color={ACC} bold>SIDECHAT</Text>
524 <Box flexGrow={1} />
525 <Button key="recap" plain label="recap" hotkey="r" onPress={go(() => recap($))} />
526 <Text color={FAINT}>{' · '}</Text>
527 <Button key="now" plain label="now" hotkey="n" onPress={go(() => whatNow($))} />
528 <Text color={FAINT}>{' · '}</Text>
529 <Button key="more" plain label="more" hotkey="m" onPress={go(() => more($))} />
530 </Box>
531 <Box flexDirection="row" marginTop={1}>
532 {running ? (
533 [
534 <Text key="g" color={AMB}>{'◐ '}</Text>,
535 <Text key="l" dimColor>{'Main is running: '}</Text>,
536 <Text key="s" wrap="truncate-end">{running.summary || running.tool}</Text>,
537 <Box key="f" flexGrow={1} />,
538 <Text key="t" dimColor>{` ${elapsed(now - running.startedAt)}`}</Text>,
539 ]
540 ) : (
541 <Text color={FAINT}>{'○ Main is not running a tool'}</Text>
542 )}
543 </Box>
544 <Box flexDirection="column">
545 {entries.length ? (
546 entries.map(turn)
547 ) : (
548 <Box marginTop={1}>
549 <Text dimColor wrap="wrap">{'Ask about anything in this session. Copy a passage and press more to have it explained. Nothing here reaches the main conversation unless you press Send to main.'}</Text>
550 </Box>
551 )}
552 {chat.busy ? spinner : null}
553 {settled ? (
554 <Box flexDirection="row" flexWrap="wrap" marginTop={1} paddingLeft={2}>
555 {FOLLOW_UPS.map((f, i) => (
556 <Box key={`fw-${f.key}`} flexDirection="row">
557 {i ? <Text color={FAINT}>{' · '}</Text> : null}
558 <Button key={`f-${f.key}`} plain label={f.label} onPress={go(() => followUp($, f.key))} />
559 </Box>
560 ))}
561 </Box>
562 ) : null}
563 </Box>
564 <Box flexDirection="column" marginTop={1}>
565 <Text color={FAINT}>{'─'.repeat(W)}</Text>
566 <Box flexDirection="row" gap={2}>
567 <Button key="send" variant="primary" label="Send to main" onPress={go(() => sendToMain($))} />
568 <Button key="new" label="New chat" onPress={go(() => newChat($))} />
569 </Box>
570 {/* The side chat's own box where the surface can draw one; the plain field elsewhere. */}
571 {Client ? (
572 <Box flexDirection="column" marginTop={1}>
573 <Client key="composer" module="./composer.tsx" props={{ placeholder: 'Ask about the main thread…', focused: boxFocused, blink }} />
574 <Text color={FAINT}>{boxFocused ? ' enter send · shift+enter new line · esc back to the main prompt' : ' click the box to type'}</Text>
575 </Box>
576 ) : (
577 <Input
578 key={`ask-${asked}`}
579 placeholder="Ask about the main thread…"
580 submitLabel="Ask"
581 autoFocus
582 onSubmit={(value: string) => {
583 const q = value.trim()
584 if (q) later($, () => ask($, 'ask', q, q))
585 }}
586 />
587 )}
588 </Box>
589 </Box>
590 )
591 })
592}
593hooks/logic.ts 279 lines1// The side chat's plain logic: what it tells the forked model, how it reads lookup requests, follow-ups.
2
3import type { SideEntry, SideRunning } from '../types'
4
5export type Lookup =
6 | { kind: 'read'; path: string; from?: number; to?: number }
7 | { kind: 'search'; text: string; path?: string }
8 | { kind: 'list'; path: string }
9 | { kind: 'board'; query: string }
10 | { kind: 'ledger' }
11
12export const MAX_ROUNDS = 3
13
14export const FOLLOW_UPS = [
15 { key: 'simpler', label: 'Simpler', says: 'Say it more simply', ask: 'Say that again more simply, in fewer words.' },
16 { key: 'example', label: 'Example', says: 'Give me an example', ask: 'Give one concrete example from this session that shows it.' },
17 { key: 'why', label: 'Why?', says: 'Why is it that way?', ask: 'Why is it this way? What problem does it solve, and what would go wrong otherwise?' },
18 { key: 'draw', label: 'Draw it', says: 'Draw it', ask: '' },
19 { key: 'technical', label: 'Technical', says: 'The technical version, please', ask: 'Now the technical version: name the files, functions and mechanisms involved.' },
20] as const
21
22export const elapsed = (ms: number) => {
23 const s = Math.max(0, Math.round(ms / 1000))
24 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
25}
26
27export const nowLine = (running: SideRunning | null, now: number) =>
28 running ? `The main thread is running ${running.tool}: ${running.summary} (for ${elapsed(now - running.startedAt)}).` : 'The main thread is not running a tool right now.'
29
30const HOW = [
31 'SIDE CHAT. This message is not from the main conversation and the main thread will never see this exchange.',
32 'The person is asking you ABOUT the conversation above, in a side panel. Do not continue the main task, do not act on anything above, and do not call tools: every tool is disabled here and a tool call fails.',
33 'Answer in plain language: everyday words, no file paths, function names or code unless the person asks for the technical version. Short: a few sentences, or a few bullets for a recap.',
34 'If you need to check something first, reply with ONLY lookup lines, one per line and nothing else:',
35 '@read <path> [first-last] a file, optionally a line range',
36 '@search <text> [in <path>] search the code',
37 '@list <folder> list a folder',
38 '@board <card id or words> look up the dev board',
39 '@ledger this session\'s ledger',
40 `The panel runs them and asks again with the results, ${MAX_ROUNDS} rounds at most. Paths are relative to the project root. Only look things up when the conversation does not already answer it.`,
41 'When the person asks you to change the dev board, look the card up with @board first, then answer in a sentence and end your reply with a proposal, nothing after it:',
42 '@board-edit',
43 '{"ops":[ ... ]}',
44 'Ops: move {id, status, log, plus trigger (backlog, ready) or nextAction (planning, building) or awaiting (review)} · log {id, text} · edit {id, set: {field: value}} · add {domain, id, type, title, note, status, its stage field, log} · link {id, kind, label, desc, url}.',
45 'Log text starts with a tag (Decided:, Direction:, Found:, Fixed:, Built:). A decision of the person\'s quotes their own words: Decided: (their name) ... ("their words"). Plain sentences, no em dashes. Nothing changes until the person presses Approve, and you only propose: never claim the board changed.',
46].join('\n')
47
48export const WRITE_OPS = new Set(['add', 'move', 'log', 'fix-log', 'edit', 'link', 'open-campaign', 'close-campaign'])
49
50export type Proposal = { ops: Record<string, unknown>[]; lines: string[] }
51
52// A reply that ends in a board-edit block: the answer above it, and the ops as plain lines to approve.
53export const parseProposal = (reply: string): { answer: string; proposal?: Proposal; error?: string } => {
54 const m = /^[ \t]*@board-edit[ \t]*$/m.exec(reply)
55 if (!m) return { answer: reply }
56 const answer = reply.slice(0, m.index).trim()
57 const raw = reply.slice(m.index + m[0].length).replace(/```(?:json)?/g, '').trim()
58 try {
59 const parsed = JSON.parse(raw) as unknown
60 const list = Array.isArray(parsed) ? parsed : (parsed as { ops?: unknown })?.ops
61 const ops = (Array.isArray(list) ? list : [list]).filter((o): o is Record<string, unknown> => !!o && typeof o === 'object')
62 const bad = ops.find((o) => !WRITE_OPS.has(String(o.op)))
63 if (!ops.length || bad) return { answer, error: bad ? `the side chat proposed "${String(bad.op)}", which is not a board change` : 'the proposal held no changes' }
64 return { answer, proposal: { ops, lines: ops.map(describeOp) } }
65 } catch {
66 return { answer, error: 'the proposal could not be read' }
67 }
68}
69
70const said = (v: unknown) => (typeof v === 'string' ? v : '')
71
72// One board change in plain words, as the Approve card lists it.
73export const describeOp = (o: Record<string, unknown>): string => {
74 const id = said(o.id)
75 switch (o.op) {
76 case 'move':
77 return `Move ${id} to ${said(o.status)}${o.log ? `, logging "${said(o.log)}"` : ''}`
78 case 'log':
79 return `Log on ${id}: "${said(o.text)}"`
80 case 'fix-log':
81 return `Rewrite the newest log entry on ${id}: "${said(o.text)}"`
82 case 'edit': {
83 const set = (o.set && typeof o.set === 'object' ? o.set : {}) as Record<string, unknown>
84 return `Change ${id}: ${Object.entries(set).map(([k, v]) => (v === null ? `clear ${k}` : `${k} → "${String(v)}"`)).join(', ') || 'nothing'}`
85 }
86 case 'add':
87 return `Add card ${id} to ${said(o.domain)} (${said(o.status) || 'backlog'}): "${said(o.title)}"`
88 case 'link':
89 return `Link "${said(o.label)}" on ${id}`
90 case 'open-campaign':
91 return `Open campaign ${id}: "${said(o.title)}"`
92 case 'close-campaign':
93 return `Close campaign ${id}`
94 default:
95 return `${String(o.op)} ${id}`
96 }
97}
98
99export type PromptInput = {
100 question: string
101 history: SideEntry[]
102 running: SideRunning | null
103 now: number
104 // Lookups already run for this question, with their results.
105 looked?: { line: string; result: string }[]
106 // The last round: answer with what you have.
107 final?: boolean
108}
109
110export const buildPrompt = ({ question, history, running, now, looked = [], final }: PromptInput) => {
111 const past = history.filter((h) => !h.failed).slice(-6)
112 return [
113 HOW,
114 '',
115 `Right now: ${nowLine(running, now)}`,
116 ...(past.length ? ['', 'Earlier in this side chat:', ...past.map((h) => `Q: ${h.title}\nA: ${h.answer}${h.proposal ? `\n(Board change you proposed: ${h.proposal.status})` : ''}`)] : []),
117 '',
118 `Question: ${question}`,
119 ...(looked.length ? ['', 'Lookups you asked for:', ...looked.map((l) => `${l.line}\n${l.result}`)] : []),
120 ...(final ? ['', 'No more lookups: answer now with what you have, and say what you could not check.'] : []),
121 ].join('\n')
122}
123
124const LOOKUP_LINE = /^@(read|search|list|board|ledger)\b\s*(.*)$/
125
126// A reply made only of lookup lines is a request; anything else is the answer.
127export const parseLookups = (reply: string): { lookups: Lookup[]; lines: string[] } | null => {
128 const lines = reply.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
129 if (!lines.length || !lines.every((l) => LOOKUP_LINE.test(l))) return null
130 const lookups: Lookup[] = []
131 for (const line of lines) {
132 const [, verb, rest = ''] = LOOKUP_LINE.exec(line)!
133 const arg = rest.trim()
134 if (verb === 'ledger') lookups.push({ kind: 'ledger' })
135 else if (verb === 'board' && arg) lookups.push({ kind: 'board', query: arg })
136 else if (verb === 'list') lookups.push({ kind: 'list', path: arg || '.' })
137 else if (verb === 'search' && arg) {
138 const m = /^(.*?)\s+in\s+(\S+)$/.exec(arg)
139 lookups.push(m ? { kind: 'search', text: unquote(m[1]!), path: m[2]! } : { kind: 'search', text: unquote(arg) })
140 } else if (verb === 'read' && arg) {
141 const m = /^(\S+)(?:\s+(\d+)\s*[-:]\s*(\d+))?$/.exec(arg)
142 if (m) lookups.push({ kind: 'read', path: m[1]!, ...(m[2] ? { from: Number(m[2]), to: Number(m[3]) } : {}) })
143 }
144 }
145 return lookups.length ? { lookups, lines } : null
146}
147
148const unquote = (s: string) => s.trim().replace(/^(["'`])(.*)\1$/, '$2')
149
150// What the panel shows for a lookup while and after it runs.
151export const lookupLabel = (l: Lookup) =>
152 l.kind === 'read' ? `read ${l.path}${l.from ? ` ${l.from}-${l.to}` : ''}`
153 : l.kind === 'search' ? `searched "${l.text}"${l.path ? ` in ${l.path}` : ''}`
154 : l.kind === 'list' ? `listed ${l.path}`
155 : l.kind === 'board' ? `looked up the board: ${l.query}`
156 : 'read the ledger'
157
158// Secrets stay out of the side chat.
159export const isSecretPath = (p: string) => /(^|[\\/])\.env(\.|$)|(^|[\\/])\.env$|secrets?[\\/]|\.pem$|\.key$/i.test(p)
160
161export const capText = (text: string, max = 12000) => (text.length > max ? `${text.slice(0, max)}\n[cut at ${max} characters]` : text)
162
163export const sliceLines = (text: string, from?: number, to?: number, max = 400) => {
164 const lines = text.split(/\r?\n/)
165 const a = Math.max(1, from ?? 1)
166 const b = Math.min(lines.length, to ?? a + max - 1, a + max - 1)
167 const body = lines.slice(a - 1, b).map((l, i) => `${a + i}: ${l}`).join('\n')
168 return b < lines.length && !to ? `${body}\n[${lines.length - b} more lines]` : body
169}
170
171export const recapQuestion = 'Recap this session so far for someone stepping back in: what was decided, what was built or changed, what is in progress right now, and what is still open or waiting on the person. Four to eight short bullets.'
172
173export const nowQuestion = 'What is the main thread doing right now, and why? Say what step of the overall work this is, what it is waiting on, and what comes next.'
174
175export const moreQuestion = (passage: string) => `Explain this passage from the conversation in more detail, so the person really understands it: «${passage.trim()}»`
176
177export const sendToMainPrompt = (history: SideEntry[]) =>
178 [
179 'SIDE CHAT. Not from the main conversation; do not call tools.',
180 'Below is a side chat the person had about this conversation. Write a short note, two to four sentences, that the person will put in their prompt to the main thread: what they learned or decided in the side chat that the main thread should know or act on. Write it as the person speaking to Claude, first person, plain words. Reply with the note only.',
181 '',
182 ...history.filter((h) => !h.failed).slice(-8).map((h) => `Q: ${h.title}\nA: ${h.answer}`),
183 ].join('\n')
184
185export const drawPrompt = (entry: SideEntry) =>
186 [
187 'SIDE CHAT. Not from the main conversation; do not call tools.',
188 'Draw the idea in this side answer as one simple diagram. Reply with exactly two parts and nothing else:',
189 'TITLE: a short title, at most six words',
190 'SVG: one self-contained <svg> element with a viewBox, at most 720 wide, using currentColor for lines and text, at most two fill colors, font-family inherit, text at least 13px, no scripts, no external images.',
191 '',
192 `Question: ${entry.title}`,
193 `Answer: ${entry.answer}`,
194 ].join('\n')
195
196export const parseDrawing = (reply: string): { title: string; svg: string } | null => {
197 const title = /TITLE:\s*(.+)/.exec(reply)?.[1]?.trim()
198 const svg = /<svg[\s\S]*?<\/svg>/i.exec(reply)?.[0]
199 if (!title || !svg) return null
200 if (/<script|on\w+\s*=|<foreignObject|href\s*=\s*["']?(?!#)/i.test(svg)) return null
201 return { title: title.replace(/^["']|["']$/g, ''), svg }
202}
203
204// A themed page for the artifact: the diagram on a plain ground, light and dark.
205export const drawingPage = (title: string, svg: string) => `<!doctype html>
206<html lang="en">
207<head>
208<meta charset="utf-8">
209<meta name="viewport" content="width=device-width, initial-scale=1">
210<title>${escapeHtml(title)}</title>
211<style>
212:root{--bg:#faf9f5;--ink:#1f1e1b;--muted:#6b6860}
213@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#16181d;--ink:#e6e8ec;--muted:#8b929c}}
214:root[data-theme="dark"]{--bg:#16181d;--ink:#e6e8ec;--muted:#8b929c}
215body{margin:0;background:var(--bg);color:var(--ink);font-family:system-ui,sans-serif}
216main{max-width:760px;margin:0 auto;padding:32px 16px}
217h1{font-size:20px;margin:0 0 20px}
218svg{width:100%;height:auto;color:var(--ink)}
219p{color:var(--muted);font-size:13px}
220</style>
221</head>
222<body>
223<main>
224<h1>${escapeHtml(title)}</h1>
225${svg}
226<p>Drawn by the side chat.</p>
227</main>
228</body>
229</html>
230`
231
232const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
233
234export const KIND_LABEL: Record<SideEntry['kind'], string> = {
235 ask: 'QUESTION',
236 recap: 'RECAP',
237 now: 'NOW',
238 more: 'MORE ON COPIED TEXT',
239 followup: 'FOLLOW-UP',
240}
241
242// One line saying what a main-thread tool call is about.
243export const summarizeCall = (tool: string, input: Record<string, unknown>) => {
244 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
245 const base = (p: string) => p.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? p
246 const text =
247 tool === 'Bash' || tool === 'PowerShell' ? s('description') || s('command')
248 : ['Read', 'Write', 'Edit', 'MultiEdit'].includes(tool) ? base(s('file_path'))
249 : tool === 'Grep' ? `"${s('pattern')}"`
250 : tool === 'Agent' || tool === 'Task' ? s('description')
251 : s('description') || s('command') || ''
252 return text.replace(/\s+/g, ' ').trim().slice(0, 80)
253}
254
255// Claude Code's spinner frames, with * for ✳, which Windows draws as a green emoji square.
256export const SPIN = ['·', '✢', '*', '✶', '✻', '✽', '✻', '✶', '*', '✢']
257export const SPIN_MS = 120
258export const spinFrame = (ms: number) => SPIN[Math.floor(Math.max(0, ms) / SPIN_MS) % SPIN.length]!
259export const busyLabel = (busy: string) => busy.charAt(0).toUpperCase() + busy.slice(1)
260
261// A finished board change in a line or two: what changed where, and only the notes that need a look.
262export const boardOutcome = (p: { ops: Record<string, unknown>[]; status: string; result?: string }): string[] => {
263 const cards = [...new Set(p.ops.map((o) => String(o.id ?? '')).filter(Boolean))]
264 const n = p.ops.length
265 const where = cards.length === 1 ? ` on ${cards[0]}` : cards.length ? ` across ${cards.length} cards` : ''
266 const result = p.result ?? ''
267 if (p.status === 'discarded') return [`▤ Board change discarded, nothing written`]
268 if (p.status === 'failed') {
269 const why = /REFUSED: (.+)/.exec(result)?.[1] ?? result.split('\n')[0] ?? ''
270 return [`▤ Board change refused, nothing written`, ...(why ? [` ${why.length > 140 ? `${why.slice(0, 137)}…` : why}`] : [])]
271 }
272 const notes: string[] = []
273 const campaigns = [...result.matchAll(/Campaign (\S+) is open\./g)].map((m) => m[1]!)
274 if (campaigns.length) notes.push(` campaign ${campaigns.join(', ')}: its summary may need an update`)
275 const warns = (result.match(/^WARN/gm) ?? []).length
276 if (warns) notes.push(` ${warns} new board warning${warns > 1 ? 's' : ''}`)
277 return [`▤ Board changed: ${n} change${n > 1 ? 's' : ''}${where}`, ...notes]
278}
279hooks/composer.tsx 83 lines1// The side chat's message box: it holds its own text and cursor, takes every key while it has the
2// focus, wraps as it draws, and posts the message to the hooks module on Enter. While it is selected
3// its border lights up and its cursor blinks on the panel's beat; a click or a key in it tells the
4// hooks it is selected.
5import type { ClientModule } from 'claude-code'
6
7import { EMPTY, applyKey, cursorAt, wrap } from './editor'
8import type { Ed } from './editor'
9
10type Props = { placeholder: string; focused: boolean; blink: boolean }
11// `solid` keeps the cursor on after a key until the next beat, as the main prompt's does.
12// `live` is read by the listeners, which outlive any one draw.
13type State = { ed: Ed; solid: boolean; beat: boolean; live: { focused: boolean } }
14
15const ACC = '#e0905f'
16const INK = '#16181d'
17const TEXT = '#d4d7dd'
18const LINE = '#3a3f4a'
19
20const Composer: ClientModule<Props, State> = (props, surface) => {
21 const { Box, Text } = surface.elements
22 if (surface.state === undefined) {
23 surface.setState({ ed: EMPTY, solid: false, beat: props.blink, live: { focused: props.focused } })
24 surface.onKey((k) => {
25 const s = surface.state
26 if (!s) return
27 const r = applyKey(s.ed, k, Math.max(10, surface.columns - 7))
28 if (r.submit) surface.post({ submit: r.submit })
29 else if (!s.live.focused) surface.post({ focus: true })
30 surface.setState({ ...s, ed: r.ed, solid: true })
31 })
32 surface.onPointer((p) => {
33 if (p.type === 'down' && !surface.state?.live.focused) surface.post({ focus: true })
34 })
35 }
36 const state = surface.state ?? { ed: EMPTY, solid: false, beat: props.blink, live: { focused: props.focused } }
37 state.live.focused = props.focused
38 // A new beat from the panel ends the solid stretch after typing.
39 if (state.beat !== props.blink) {
40 state.beat = props.blink
41 state.solid = false
42 }
43 const ed = state.ed
44 const showCursor = props.focused && (props.blink || state.solid)
45 // The box wraps the text itself; each drawn line is cut, never wrapped again by the terminal.
46 const width = Math.max(10, surface.columns - 7)
47 const lines = wrap(ed.text, width)
48 const at = cursorAt(lines, ed.cursor)
49 const cell = (ch: string, key: string) =>
50 showCursor ? <Text key={key} backgroundColor={TEXT} color={INK}>{ch || ' '}</Text> : <Text key={key} color={TEXT}>{ch || ' '}</Text>
51
52 return (
53 <Box flexDirection="column" borderStyle="round" borderColor={props.focused ? ACC : LINE} paddingX={1}>
54 {ed.text ? (
55 lines.map((l, row) => {
56 const s = ed.text.slice(l.start, l.end).replace(/\n/g, '')
57 return (
58 <Box key={`l${row}`} flexDirection="row">
59 <Text color={ACC}>{row === 0 ? '› ' : ' '}</Text>
60 {/* Siblings in the row itself: a fragment would draw as a column and drop the cursor a line. */}
61 {row === at.row
62 ? [
63 <Text key="b" color={TEXT} wrap="truncate">{s.slice(0, at.col)}</Text>,
64 cell(s.charAt(at.col), 'c'),
65 <Text key="a" color={TEXT} wrap="truncate">{s.slice(at.col + 1)}</Text>,
66 ]
67 : <Text color={TEXT} wrap="truncate">{s}</Text>}
68 </Box>
69 )
70 })
71 ) : (
72 <Box flexDirection="row">
73 <Text color={ACC}>{'› '}</Text>
74 {cell('', 'c')}
75 <Text dimColor>{props.focused ? props.placeholder : `${props.placeholder} (click to type)`}</Text>
76 </Box>
77 )}
78 </Box>
79 )
80}
81
82export default Composer
83hooks/editor.ts 135 lines1// The side chat's message box, as plain rules: the text, where the cursor is, what each key does,
2// and how the text wraps into the box's width. The box module draws what these return.
3
4export type Ed = { text: string; cursor: number }
5export type Key = { key: string; ctrl?: true; shift?: true; meta?: true }
6
7export const EMPTY: Ed = { text: '', cursor: 0 }
8
9const NAMED = new Set([
10 'up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete', 'pageup', 'pagedown',
11 'home', 'end', 'escape', 'insert', 'space',
12])
13const isNamed = (k: string) => NAMED.has(k) || /^f\d{1,2}$/.test(k)
14
15const clamp = (ed: Ed, cursor: number): Ed => ({ text: ed.text, cursor: Math.max(0, Math.min(ed.text.length, cursor)) })
16
17export const insert = (ed: Ed, s: string): Ed => {
18 const t = s.replace(/\r\n?/g, '\n').replace(/\t/g, ' ')
19 return { text: ed.text.slice(0, ed.cursor) + t + ed.text.slice(ed.cursor), cursor: ed.cursor + t.length }
20}
21
22const remove = (ed: Ed, from: number, to: number): Ed => {
23 const a = Math.max(0, Math.min(from, to))
24 const b = Math.min(ed.text.length, Math.max(from, to))
25 return { text: ed.text.slice(0, a) + ed.text.slice(b), cursor: a }
26}
27
28const wordStart = (text: string, i: number) => {
29 let j = i
30 while (j > 0 && /\s/.test(text[j - 1]!)) j -= 1
31 while (j > 0 && !/\s/.test(text[j - 1]!)) j -= 1
32 return j
33}
34
35const wordEnd = (text: string, i: number) => {
36 let j = i
37 while (j < text.length && /\s/.test(text[j]!)) j += 1
38 while (j < text.length && !/\s/.test(text[j]!)) j += 1
39 return j
40}
41
42export type Line = { start: number; end: number; last: boolean }
43
44// Wraps at spaces where it can, inside a word where it must; line breaks always start a new line.
45export const wrap = (text: string, width: number): Line[] => {
46 const w = Math.max(1, width)
47 const lines: Line[] = []
48 let ls = 0
49 for (;;) {
50 const nl = text.indexOf('\n', ls)
51 const le = nl === -1 ? text.length : nl
52 let pos = ls
53 while (le - pos > w) {
54 const space = text.lastIndexOf(' ', pos + w)
55 const brk = space >= pos && space < pos + w + 1 && space > pos ? space + 1 : pos + w
56 lines.push({ start: pos, end: brk, last: false })
57 pos = brk
58 }
59 lines.push({ start: pos, end: le, last: true })
60 if (nl === -1) break
61 ls = nl + 1
62 }
63 return lines
64}
65
66// Which wrapped line and column the cursor sits on.
67export const cursorAt = (lines: Line[], cursor: number) => {
68 for (let row = 0; row < lines.length; row += 1) {
69 const l = lines[row]!
70 if (cursor >= l.start && (cursor < l.end || (cursor === l.end && l.last))) return { row, col: cursor - l.start }
71 }
72 const l = lines.at(-1)!
73 return { row: lines.length - 1, col: l.end - l.start }
74}
75
76const vertical = (ed: Ed, width: number, dir: -1 | 1): Ed => {
77 const lines = wrap(ed.text, width)
78 const { row, col } = cursorAt(lines, ed.cursor)
79 const target = lines[row + dir]
80 if (!target) return clamp(ed, dir < 0 ? 0 : ed.text.length)
81 return clamp(ed, target.start + Math.min(col, target.end - target.start))
82}
83
84const lineEdge = (ed: Ed, width: number, edge: 'start' | 'end'): Ed => {
85 const lines = wrap(ed.text, width)
86 const l = lines[cursorAt(lines, ed.cursor).row]!
87 return clamp(ed, edge === 'start' ? l.start : l.end)
88}
89
90// One key: the box after it, and the text to send when it was Enter on a message.
91export const applyKey = (ed: Ed, k: Key, width: number): { ed: Ed; submit?: string } => {
92 const key = k.key
93 if (key === 'return' || key === 'enter') {
94 if (k.shift || k.meta) return { ed: insert(ed, '\n') }
95 // As in the main prompt: a backslash before Enter makes a line break.
96 if (ed.cursor > 0 && ed.text[ed.cursor - 1] === '\\') return { ed: insert(remove(ed, ed.cursor - 1, ed.cursor), '\n') }
97 return ed.text.trim() ? { ed: EMPTY, submit: ed.text.trim() } : { ed }
98 }
99 if (k.ctrl) {
100 if (key === 'a') return { ed: lineEdge(ed, width, 'start') }
101 if (key === 'e') return { ed: lineEdge(ed, width, 'end') }
102 if (key === 'u') return { ed: remove(ed, lineEdge(ed, width, 'start').cursor, ed.cursor) }
103 if (key === 'k') return { ed: remove(ed, ed.cursor, lineEdge(ed, width, 'end').cursor) }
104 if (key === 'w' || key === 'backspace') return { ed: remove(ed, wordStart(ed.text, ed.cursor), ed.cursor) }
105 if (key === 'left') return { ed: clamp(ed, wordStart(ed.text, ed.cursor)) }
106 if (key === 'right') return { ed: clamp(ed, wordEnd(ed.text, ed.cursor)) }
107 return { ed }
108 }
109 switch (key) {
110 case 'backspace':
111 return { ed: ed.cursor ? remove(ed, ed.cursor - 1, ed.cursor) : ed }
112 case 'delete':
113 return { ed: remove(ed, ed.cursor, ed.cursor + 1) }
114 case 'left':
115 return { ed: clamp(ed, ed.cursor - 1) }
116 case 'right':
117 return { ed: clamp(ed, ed.cursor + 1) }
118 case 'up':
119 return { ed: vertical(ed, width, -1) }
120 case 'down':
121 return { ed: vertical(ed, width, 1) }
122 case 'home':
123 return { ed: lineEdge(ed, width, 'start') }
124 case 'end':
125 return { ed: lineEdge(ed, width, 'end') }
126 case 'space':
127 return { ed: insert(ed, ' ') }
128 case 'tab':
129 return { ed: insert(ed, ' ') }
130 }
131 if (isNamed(key)) return { ed }
132 // One character typed, or a whole paste arriving as one key.
133 return { ed: insert(ed, key) }
134}
135types/index.d.ts 44 lines1export type SideKind = 'ask' | 'recap' | 'now' | 'more' | 'followup'
2
3export type SideDrawing = { title: string; svg: string; url?: string }
4
5export type SideEntry = {
6 id: string
7 kind: SideKind
8 // What was asked, as the panel shows it (the question, or the copied passage).
9 title: string
10 answer: string
11 reads: string[]
12 drawing?: SideDrawing
13 // A board change the side chat proposed: nothing is written until the person approves it.
14 proposal?: SideProposal
15 failed?: boolean
16}
17
18export type SideProposal = {
19 ops: Record<string, unknown>[]
20 lines: string[]
21 status: 'pending' | 'applied' | 'discarded' | 'failed'
22 result?: string
23}
24
25export type SideRunning = { tool: string; summary: string; startedAt: number }
26
27export type SideChat = {
28 entries: SideEntry[]
29 // What the panel is doing right now, shown while a reply is on its way; empty when idle.
30 busy: string
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 sidechat: {
36 chat: SideChat
37 running: SideRunning | null
38 tick: number
39 box: boolean
40 blink: boolean
41 }
42 }
43}
44