SLOPSHOPPER

aside

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

newpanecommandmodel
v0.3.0MITupdated 2026-10-08zrdqns/claude-code-aside
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aside
│ ┃ Aside ✕ › fix the failing auth test and add an audit log call │ ┃ Ask aside… ⏎ ↵ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /aside │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Aside
Ask aside… ⏎ ↵
README

aside

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.

How it works

  • /aside <question> opens the pane and asks. The pane has its own field to keep asking.
  • Each answer comes from a fork of the conversation as its last turn left it: same model and same system prompt, served from the prompt cache and with no access to tools.
  • Before the first turn ends there is nothing to fork yet. The mod then answers "live" with a light model over the conversation's text, or waits in a queue for the turn to end, depending on the liveFallback option.
  • Under each answer, a line with what it cost: time, tokens served from cache, uncached tokens and output tokens.
  • /aside clear, or the Clear button, wipes the pane's history.

Answers come in the language of the question, brief and in prose.

Options

OptionDefaultWhat it does
liveFallbacktrueAnswer "live" before the first turn ends. When off, the question waits in a queue.
liveModelhaikuModel for the "live" answers. The fork always uses the session's model.
maxHistory8How many earlier exchanges of the pane ride with each new question (1 to 50).

Installation

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

Development

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/.

License

MIT

Source 2 files
hooks/register.tsx 339 lines
1import { 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}
339
types/index.d.ts 32 lines
1/** 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