SLOPSHOPPER

sidechat

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…

newpaneguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-03gabedeluna/claude-mods/plugins/sidechat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sidechat
│ ┃ Side chat ✕ › fix the failing auth test and add an audit log call │ ┃ SIDECHATr: recap · n: now · m: more │ ┃ ⏺ Read(src/auth.ts) │ ┃ ○ Main is not running a tool ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Ask about anything in this session. Copy a p ⎿ Added 2 lines, removed 1 line │ ┃ press more to have it explained. Nothing her ⏺ Bash(bun test) │ ┃ the main conversation unless you press Send ⎿ 3 pass, 1 fail │ ┃ │ ┃ ──────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Send to main ] [ New chat ] │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ▣ client module ./composer.tsx │ ┃ click the box to type › /sidechat │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Side chat
SIDECHATr: recap · n: now · m: more ○ Main is not running a tool 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. ────────────────────────────────────────────────────── [ Send to main ] [ New chat ] ▣ client module ./composer.tsx click the box to type
README

claude-mods

Two Claude Code mods.

Install

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.

ledger

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.

sidechat

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.

Source 5 files
hooks/register.tsx 593 lines
1import { 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}
593
hooks/logic.ts 279 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
279
hooks/composer.tsx 83 lines
1// 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
83
hooks/editor.ts 135 lines
1// 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}
135
types/index.d.ts 44 lines
1export 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