/aside side chat for asking about the conversation without spending its context

A mod for Claude Code: a side chat for asking about the conversation without spending its context.
/aside why did it pick that library and not the other one?
The question and its answer stay in the Aside pane: the main thread never sees them, so they take up none of its context window and do not derail the task in progress.
/aside <question> opens the pane and asks. The pane has its own field to keep asking.liveFallback option./aside clear, or the Clear button, wipes the pane's history.Answers come in the language of the question, brief and in prose.
| Option | Default | What it does |
|---|---|---|
liveFallback | true | Answer "live" before the first turn ends. When off, the question waits in a queue. |
liveModel | haiku | Model for the "live" answers. The fork always uses the session's model. |
maxHistory | 8 | How many earlier exchanges of the pane ride with each new question (1 to 50). |
At the prompt of a terminal session:
/plugin install aside --marketplace zrdqns/claude-code-aside
Answer y to add the marketplace and choose the scope (the user scope loads it in every session, including the desktop app's).
To try it from a local copy, without installing it:
claude --plugin-dir ./claude-code-aside
claude plugin validate .
claude plugin test .
The module is in hooks/register.tsx, its state contract in types/index.d.ts and the tests in tests/.
hooks/register.tsx 339 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Exchange } from '../types'
5
6const PANE = 'aside'
7const TITLE = 'Aside'
8const FIELD = 'question'
9const CLEAR = 'clear'
10// Exchanges the pane keeps and draws; `maxHistory` says how many ride a prompt.
11const KEPT = 12
12// A Markdown element draws at most 10000 characters.
13const MAX_ANSWER = 9000
14// An earlier answer, as a later question's prompt carries it.
15const MAX_CARRIED = 1200
16// What a live answer is given of the transcript, and may write.
17const LIVE_CHARS = 60000
18const LIVE_TOKENS = 400
19// A hairline between exchanges. The desktop draws it as an image, outside
20// the theme: this grey reads on a dark and on a light pane alike.
21const RULE =
22 '<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="9" viewBox="0 0 100 9" preserveAspectRatio="none">' +
23 '<rect y="4" width="100" height="1" fill="#9c9a92" fill-opacity="0.3"/></svg>'
24
25const exchanges = atom({ plugin: 'aside', key: 'asked' } as const, [])
26
27const INTRO = [
28 'This is an aside question from the user about the conversation above.',
29 'It is not part of the task: do not continue it, do not propose edits or use tools.',
30 'Answer in the language of the question, briefly and in plain prose, only with what is already in the conversation;',
31 'use a list only if the question asks for an enumeration.',
32].join(' ')
33
34const LIVE_SYSTEM =
35 'You answer read-only aside questions about a Claude Code session from its ' +
36 'transcript. No tools; do not continue the task; answer in the language of the question, briefly and in plain prose.'
37
38const FAILURES: Record<string, string> = {
39 'api-error': 'The model could not answer (API error). Try again.',
40 'empty-reply': 'The model returned no text. Try again.',
41 aborted: 'The query was interrupted. Try again.',
42}
43
44type Settings = { liveFallback: boolean; liveModel: string; carried: number }
45
46type Outcome = Pick<
47 Exchange,
48 'answer' | 'failure' | 'kind' | 'isQueued' | 'cacheRead' | 'fresh' | 'output'
49>
50
51const PENDING: Outcome = {
52 answer: null,
53 failure: null,
54 kind: null,
55 isQueued: false,
56 cacheRead: 0,
57 fresh: 0,
58 output: 0,
59}
60
61const failed = (failure: string): Outcome => ({ ...PENDING, failure })
62
63const settingsOf = (options: PluginOptions): Settings => ({
64 liveFallback:
65 typeof options.liveFallback === 'boolean' ? options.liveFallback : true,
66 liveModel:
67 typeof options.liveModel === 'string' && options.liveModel !== ''
68 ? options.liveModel
69 : 'haiku',
70 carried:
71 typeof options.maxHistory === 'number' && options.maxHistory >= 1
72 ? Math.floor(options.maxHistory)
73 : 8,
74})
75
76const compact = (n: number) =>
77 n >= 1e6
78 ? `${(n / 1e6).toFixed(2)}M`
79 : n >= 1e3
80 ? `${(n / 1e3).toFixed(1)}k`
81 : `${n}`
82
83const seconds = (ms: number) => `${(ms / 1000).toFixed(1)} s`
84
85/** What an answer cost, in one dim line. */
86const footer = (one: Exchange) =>
87 [
88 ...(one.kind === 'live' ? ['live'] : []),
89 seconds(one.ms),
90 ...(one.cacheRead > 0 ? [`${compact(one.cacheRead)} cached`] : []),
91 // Paid in full: all of a live answer, or a fork whose cache had lapsed.
92 ...(one.kind === 'live' || one.fresh >= 1000
93 ? [`${compact(one.fresh)} uncached`]
94 : []),
95 `${compact(one.output)} output`,
96 ].join(' · ')
97
98/**
99 * The question as the model reads it. A fork takes one message, so the side
100 * chat's own history rides in it: the last `carried` answered exchanges.
101 */
102const promptFor = (earlier: Exchange[], question: string, carried: number) =>
103 [
104 INTRO,
105 ...earlier
106 .flatMap(one =>
107 one.answer === null
108 ? []
109 : [
110 `Earlier aside question: ${one.question}\nYour answer: ${one.answer.slice(0, MAX_CARRIED)}`,
111 ],
112 )
113 .slice(-carried),
114 `Aside question: ${question}`,
115 ].join('\n\n')
116
117/** Before any turn has ended: a plain completion over the transcript's text. */
118const answerLive = async (
119 $: EngineInterface,
120 prompt: string,
121 cfg: Settings,
122): Promise<Outcome> => {
123 const messages = await $.session.messages()
124 if (messages.length === 0) {
125 return failed('Nothing to ask about yet: the conversation is empty.')
126 }
127 const transcript = messages
128 .map(one => `${one.role === 'user' ? 'USER' : 'ASSISTANT'}: ${one.text}`)
129 .join('\n\n')
130 .slice(-LIVE_CHARS)
131 const reply = await $.model.complete({
132 model: cfg.liveModel,
133 maxTokens: LIVE_TOKENS,
134 system: LIVE_SYSTEM,
135 prompt: `Conversation so far:\n\n${transcript}\n\n${prompt}`,
136 })
137
138 return reply.isAnswered
139 ? {
140 ...PENDING,
141 answer: reply.text.slice(0, MAX_ANSWER),
142 kind: 'live',
143 cacheRead: reply.usage.cache_read_input_tokens,
144 fresh:
145 reply.usage.input_tokens + reply.usage.cache_creation_input_tokens,
146 output: reply.usage.output_tokens,
147 }
148 : failed(FAILURES[reply.reason] ?? 'There was no answer.')
149}
150
151/**
152 * One fork of the session's transcript as of its last completed turn: the
153 * main thread never sees the question, and the fork can call no tool.
154 */
155const answer = async (
156 $: EngineInterface,
157 prompt: string,
158 cfg: Settings,
159): Promise<Outcome> => {
160 const reply = await $.model.fork({ prompt })
161 if (reply.isAnswered) {
162 return {
163 ...PENDING,
164 answer: reply.text.slice(0, MAX_ANSWER),
165 kind: 'fork',
166 cacheRead: reply.usage.cache_read_input_tokens,
167 fresh: reply.usage.input_tokens + reply.usage.cache_creation_input_tokens,
168 output: reply.usage.output_tokens,
169 }
170 }
171 if (reply.reason !== 'nothing-to-fork') {
172 return failed(FAILURES[reply.reason] ?? 'There was no answer.')
173 }
174
175 return cfg.liveFallback
176 ? answerLive($, prompt, cfg)
177 : { ...PENDING, isQueued: true }
178}
179
180/** Answers the exchange `id` and writes down what came of it. */
181const settle = async ($: EngineInterface, id: number, cfg: Settings) => {
182 const [all, started] = await Promise.all([read($, exchanges), $.clock.now()])
183 const entry = all.find(one => one.id === id)
184 if (entry === undefined) return
185 const earlier = all.filter(one => one.id < id)
186 const outcome = await answer(
187 $,
188 promptFor(earlier, entry.question, cfg.carried),
189 cfg,
190 ).catch(() => failed('The question could not be sent.'))
191 const ms = (await $.clock.now()) - started
192 await update($, exchanges, list =>
193 list.map(one => (one.id === id ? { ...one, ...outcome, ms } : one)),
194 )
195}
196
197const ask = async ($: EngineInterface, text: string, cfg: Settings) => {
198 const question = text.trim()
199 if (question === '') return
200 const [now, earlier] = await Promise.all([$.clock.now(), read($, exchanges)])
201 // Two questions in one millisecond still get ids of their own.
202 const id = Math.max(now, (earlier.at(-1)?.id ?? 0) + 1)
203 await update($, exchanges, all =>
204 [...all, { ...PENDING, id, question, ms: 0 }].slice(-KEPT),
205 )
206 await settle($, id, cfg)
207}
208
209/** Once a turn has ended there is a transcript to fork for what was waiting. */
210const answerQueued = async ($: EngineInterface, cfg: Settings) => {
211 const waiting = (await read($, exchanges)).filter(one => one.isQueued)
212 for (const one of waiting) await settle($, one.id, cfg)
213}
214
215export const register: Register = (on, options) => {
216 const cfg = settingsOf(options)
217 // Work nobody waits on runs through the `$` of session.start, which
218 // outlives the dispatch that asked for it. Absent until then.
219 let background:
220 | { ask: (question: string) => void; answerQueued: () => void }
221 | undefined
222
223 on('session.start', async ($, e, next) => {
224 await $.command.register({
225 name: 'aside',
226 description:
227 'Aside question about the conversation, without spending its context; "clear" wipes the history',
228 })
229 background = {
230 ask: question => {
231 void ask($, question, cfg)
232 },
233 answerQueued: () => {
234 void answerQueued($, cfg)
235 },
236 }
237
238 return next(e)
239 })
240
241 // Answers with no text: a line here would enter the main conversation.
242 on('command.run', { command: 'aside' }, async ($, e) => {
243 const text = e.args.trim()
244 if (text.toLowerCase() === CLEAR) {
245 await update($, exchanges, () => [])
246
247 return {}
248 }
249 await $.ui.open({ id: PANE, title: TITLE, focus: true })
250 if (background === undefined) await ask($, text, cfg)
251 else background.ask(text)
252
253 return {}
254 })
255
256 // The field and the button are answered here, not in closures on the
257 // elements: a closure belongs to one drawing, and an Enter that lands while
258 // the pane redraws would find it gone.
259 on('ui.input', { plugin: 'aside' }, async ($, e, next) => {
260 if (e.kind !== 'submit' || !e.element.startsWith(FIELD)) return next(e)
261 if (background === undefined) await ask($, e.value, cfg)
262 else background.ask(e.value)
263
264 return { element: e.element, value: e.value }
265 })
266
267 on('ui.press', { plugin: 'aside' }, async ($, e, next) => {
268 if (e.element !== CLEAR) return next(e)
269 await update($, exchanges, () => [])
270
271 return { element: e.element }
272 })
273
274 on('turn.complete', async ($, e, next) => {
275 if (e.agentId === undefined) {
276 if (background === undefined) await answerQueued($, cfg)
277 else background.answerQueued()
278 }
279
280 return next(e)
281 })
282
283 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
284 if (e.surface === 'mobile') return next(e)
285 const { Box, Text, Markdown, Input, Button } = $.ui.resolve(e)
286 const list = [...(await read($, exchanges))].reverse()
287
288 const rule = () => {
289 if (e.surface === 'terminal') return null
290 const { Svg } = $.ui.resolve(e)
291
292 return <Svg source={RULE} alt="separator" height={9} />
293 }
294
295 return (
296 <Box flexDirection="column" gap={1}>
297 {/* A new key after each question draws the field empty again. */}
298 <Input
299 key={`${FIELD}-${list[0]?.id ?? 0}`}
300 placeholder="Ask aside…"
301 submitLabel="↵"
302 onSubmit={() => undefined}
303 />
304 {list.map((one, index) => (
305 <Box flexDirection="column" gap={1}>
306 {index > 0 && rule()}
307 <Box flexDirection="column">
308 <Text dimColor>{`› ${one.question}`}</Text>
309 {one.answer !== null && <Markdown text={one.answer} />}
310 {one.failure !== null && (
311 <Text color="warning">{one.failure}</Text>
312 )}
313 {one.answer === null && one.failure === null && (
314 <Text dimColor>
315 {one.isQueued
316 ? 'waiting for the turn to end…'
317 : 'thinking…'}
318 </Text>
319 )}
320 {one.answer !== null && <Text dimColor>{footer(one)}</Text>}
321 </Box>
322 </Box>
323 ))}
324 {list.length > 0 && (
325 <Box flexDirection="row" justifyContent="flex-end">
326 <Button
327 key={CLEAR}
328 label="Clear"
329 plain
330 dimColor
331 onPress={() => undefined}
332 />
333 </Box>
334 )}
335 </Box>
336 )
337 })
338}
339types/index.d.ts 32 lines1/** One question asked aside, and what came of it. */
2export type Exchange = {
3 /** When it was asked, in `$.clock.now()`'s milliseconds; its identity too. */
4 id: number
5 question: string
6 /** The answer's markdown; null until the model has answered. */
7 answer: string | null
8 /** Why there is no answer, in words for the person; null when there is one. */
9 failure: string | null
10 /**
11 * How it was answered: `fork`, over the session's own cached transcript;
12 * `live`, a plain completion over the transcript's text, before any turn
13 * has ended. Null until answered.
14 */
15 kind: 'fork' | 'live' | null
16 /** Waiting for the turn in flight to end, to be answered by a fork then. */
17 isQueued: boolean
18 /** How long the answer took. */
19 ms: number
20 /** Input tokens the prompt cache served. */
21 cacheRead: number
22 /** Input tokens paid in full: uncached, plus those the call cached. */
23 fresh: number
24 output: number
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 aside: { asked: Exchange[] }
30 }
31}
32