A side pane for questions about the session, answered from a fork of the transcript and kept out of it

My personal dotfiles managed with YADM.
Tested on Ubuntu 25.10 and Ubuntu 25.04.
yadm clone <your-repo-url>
yadm config local.class work # or personal
yadm config yadm.alt-copy true
yadm alt
yadm bootstrap
SUPER + Return - TerminalSUPER + T - Terminal with tmuxSUPER + Space/D - Application launcherhooks/register.tsx 74 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AsideEntry } from '../types'
5import { ASIDE_PROMPT, describeFailure } from '../core/prompt'
6
7const PANE = 'aside'
8const KEPT_ENTRIES = 20
9const entries = atom({ plugin: 'aside', key: 'entries' } as const, [])
10
11
12export const register: Register = on => {
13 on('session.start', async ($, e, next) => {
14 await $.command.register({
15 name: 'aside',
16 description: 'Ask a side question about this session without adding it to the conversation',
17 argumentHint: '[question]',
18 immediate: true,
19 })
20
21 return next(e)
22 })
23
24 on('command.run', { command: 'aside' }, async ($, e) => {
25 await $.ui.open({ id: PANE, title: 'Aside', focus: true, closeOnEscape: true })
26 if (e.args.trim() !== '') void askAside($, e.args.trim())
27
28 return { text: 'Aside opened.' }
29 })
30
31 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
32 const { Box, Markdown, Text } = $.ui.resolve(e)
33 const Input = e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input
34 const list = await read($, entries)
35 const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 30) - 4) / 6))
36
37 return (
38 <Box flexDirection="column">
39 {list.length === 0 && <Text dimColor>Ask about the session. The main conversation does not see it.</Text>}
40 {list.slice(-room).map(entry => (
41 <Box key={`entry-${entry.id}`} flexDirection="column" marginBottom={1}>
42 <Text bold>{'> '}{entry.question}</Text>
43 {entry.answer === null ? (
44 <Text dimColor>Thinking...</Text>
45 ) : entry.isError ? (
46 <Text color="red">{entry.answer}</Text>
47 ) : (
48 <Markdown key={`answer-${entry.id}`} text={entry.answer} />
49 )}
50 </Box>
51 ))}
52 {Input !== undefined && (
53 <Input key="question" placeholder="Ask about the session" submitLabel="ask" autoFocus onSubmit={(value: string) => void askAside($, value)} />
54 )}
55 </Box>
56 )
57 })
58}
59
60async function askAside($: EngineInterface, question: string): Promise<void> {
61 const text = question.trim()
62 if (text === '') return
63
64 const id = Math.max(0, ...(await read($, entries)).map(entry => entry.id)) + 1
65 await update($, entries, list => [...list, { id, question: text, answer: null, isError: false }].slice(-KEPT_ENTRIES))
66
67 const forked = await $.model.fork({ prompt: `${ASIDE_PROMPT}\n${text}` })
68 const answered: Pick<AsideEntry, 'answer' | 'isError'> = forked.isAnswered
69 ? { answer: forked.text, isError: false }
70 : { answer: describeFailure(forked.reason), isError: true }
71
72 await update($, entries, list => list.map(entry => (entry.id === id ? { ...entry, ...answered } : entry)))
73}
74core/prompt.ts 14 lines1export const ASIDE_PROMPT = [
2 'The user asks a side question about this session. The answer is shown in a side pane and does not enter the main conversation.',
3 'Answer from what the conversation already holds. Be short and direct. Do not plan new work or claim that you will do anything.',
4 '',
5 'Question:',
6].join('\n')
7
8export function describeFailure(reason: string): string {
9 if (reason === 'nothing-to-fork') return 'The session has no reply yet, so there is nothing to ask about.'
10 if (reason === 'aborted') return 'The question was stopped before it was answered.'
11
12 return `The question failed: ${reason}.`
13}
14types/index.d.ts 8 lines1export type AsideEntry = { id: number; question: string; answer: string | null; isError: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'aside': { entries: AsideEntry[] }
6 }
7}
8