SLOPSHOPPER

side-ask

Select text in a reply, type ; and a question, press shift+enter: the answer shows in a side pane and never enters the main context

newpanespinnercommandtoastprompt
v0.7.0MITupdated 2026-10-24Analyst-Harsh/claude-code-mods/side-ask
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · side-ask
│ ┃ Side question ✕ › fix the failing auth test and add an audit log call │ ┃ SIDE 0 asks · off the record │ ┃ ⏺ Read(src/auth.ts) │ ┃ ; ask ;e example ;w why ;p pitfalls ;v v ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ ✦ Select text in the chat, then type ; │ ⏺ Bash(bun test) │ ┃ │ and your question. │ ⎿ 3 pass, 1 fail │ ┃ │ ; why this? ;e example ;w why ;p │ │ ┃ │ pitfalls ;v vs │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ shift+enter asks and keeps your place · ;x │ ┃ closes · wheel scrolls › /side │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Side question
SIDE 0 asks · off the record ; ask ;e example ;w why ;p pitfalls ;v vs ;x close ╭──────────────────────────────────────────────────────────╮ │ ✦ Select text in the chat, then type ; and your │ │ question. │ │ ; why this? ;e example ;w why ;p pitfalls ;v │ │ vs │ ╰──────────────────────────────────────────────────────────╯ shift+enter asks and keeps your place · ;x closes · wheel scrolls
README

<img src="docs/banner-light.svg" alt="side-ask: ask on the side, keep your context clean" width="100%">

Ask about any part of Claude's reply. The answer lives in a side pane, never in your context.

release tests license Claude Code mod

<img src="docs/demo.gif" alt="Selecting a phrase in Claude's reply, typing ;w and pressing shift+enter: a side pane opens beside the chat and answers why, while the main conversation stays untouched." width="900">

Why

You're reading a long reply and one phrase doesn't click. Asking in the main chat costs more than it looks:

Without side-askWith side-ask
Your tangentPiles into the context, foreverAnswered in a pane, never added
The threadDerails; Claude may act on your asideThe main task stays on track
CostEvery later turn carries itOne forked call that reuses the cache
Your placeThe chat jumps to the bottomThe chat stays where you were reading

How it works

  1. Select any text in Claude's reply with the mouse.
  2. Type ; your question, or a one-key preset like ;w.
  3. Press shift+enter. The pane docks beside the chat and answers. Nothing is added to the conversation.

Ask again without a new selection to follow up on the same excerpt. Select something else to start a new thread in the pane.

Keys

| | Type | Asks | |:-:|---|---| | 🟢 | ; | Explain this simply | | 🔵 | ;e | Show a minimal code example | | 🟡 | ;w | Why is it done this way? | | 🔴 | ;p | Common pitfalls and gotchas | | 🟣 | ;v | Compare with the alternatives | | 💬 | ; text | Your own question | | ⚪ | ;x | Close the pane (clears its history) |

Typing ; shows these keys under the prompt. /side opens the pane on its own.

Install

Inside Claude Code:

/plugin marketplace add Analyst-Harsh/claude-code-mods
/plugin install side-ask@claude-code-mods

Or from your shell:

claude plugin install side-ask --marketplace Analyst-Harsh/claude-code-mods

[!NOTE] Requires Claude Code 2.1.310 or newer. Mods are an early-access surface and may change between releases.

<a id="notes--quirks"></a>

  • Answers come from $.model.fork, which reuses the conversation's cache. They're never written to the transcript.
  • Plain Enter works too, but submitting jumps the chat to the bottom. The mod then scrolls back to the message you selected (its top, if it's taller than the window).
  • Opening the pane narrows the chat, so the text rewraps and your line drifts a few rows. The engine can only scroll the chat to whole messages, never to a line.
  • Resize the pane by dragging its left edge. The engine caps it at 45% of the terminal and remembers your width.
  • After you close the pane with ×, shift+enter can't reopen it in a terminal under 144 columns: the engine counts shift+enter as unasked. A toast says so; press Enter once and shift+enter works again. ;x doesn't have this problem.
  • Scroll the pane with the mouse wheel, or press ctrl+x tab and then ↑↓ (Esc gives the keys back).
  • Only single-line drafts you type yourself trigger it. A multi-line draft starting with ;, or a prompt from a scheduled task or another session, goes to Claude as usual.
  1. Clone the repo and add this folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Separate multiple folders with :.
  2. Start a session once. Claude Code then writes the engine types to .claude-plugin/types/ (gitignored), and tsc works after that.
  3. Run the tests: claude plugin test side-ask.
  4. Re-record the demo: scripts/make-gif.sh <recording.mov> "<start-end,…>" turns a screen recording into docs/demo.gif (usage in the script header).
Source 2 files
hooks/register.tsx 419 lines
1import { atom, read, update } from "claude-code";
2import type { ElementConstructor, EngineInterface, ModelForkResult, Register, TextProps, ThemeKey } from "claude-code";
3
4import type { Group, Turn } from "../types";
5
6const PANE = "side-ask";
7// No closeOnEscape: the Esc that hands the keys back after scrolling would also close (and wipe) the pane.
8// `columns` is only a request: a width the person dragged the dock to wins, and the engine caps it at 45%.
9const OPEN = { id: PANE, title: "Side question", rows: 22, columns: 64 } as const;
10const MAX_TURNS = 60;
11const SPIN = ["✻", "✳", "✢", "·"];
12
13const groups = atom({ plugin: "side-ask", key: "groups" } as const, []);
14const busy = atom({ plugin: "side-ask", key: "busy" } as const, []); // the questions being asked, oldest first
15const tick = atom({ plugin: "side-ask", key: "tick" } as const, 0);
16const typingSide = atom({ plugin: "side-ask", key: "typingSide" } as const, false); // the draft is a side trigger so far
17
18// `;` alone, or `;e`/`;w`/`;p`/`;v`, asks a preset; `; text` asks the text; `;x` closes.
19// The second group stops a pasted line of code that merely starts with ";" from matching;
20// one line only, so a multi-line draft (asm, INI or Lisp comments) is never asked or wiped.
21const TRIGGER = /^[ \t]*;([ewpvx]?)(?:[ \t]+([^\n]*))?$/;
22const PRESETS: Record<string, { q: string; color: ThemeKey }> = {
23  "": { q: "Explain this simply, as to a junior developer.", color: "success" },
24  e: { q: "Show a minimal code example of this.", color: "suggestion" },
25  w: { q: "Why is it done this way? What problem does it solve?", color: "warning" },
26  p: { q: "What are the common pitfalls or gotchas here?", color: "error" },
27  v: { q: "Briefly compare this with the main alternatives.", color: "permission" },
28};
29// Shown after the engine's "Prompt dropped by a hook:" (a row when idle, a passing notice mid-turn); never sent to the model.
30const DROP = "asked on the side ↗";
31const CLOSED = "side pane closed";
32// The keys as the pane and the prompt hint show them: each in its preset's theme colour.
33const KEYS: [string, string, ThemeKey][] = [
34  [";", "ask", PRESETS[""]!.color],
35  [";e", "example", PRESETS.e!.color],
36  [";w", "why", PRESETS.w!.color],
37  [";p", "pitfalls", PRESETS.p!.color],
38  [";v", "vs", PRESETS.v!.color],
39  [";x", "close", "inactive"],
40];
41
42// The pane's body width as last drawn, so answers are written to fit it (the answer gets 4 columns less).
43let paneColumns = 44;
44
45// A drop leaves the typed text in the box; empty it once the drop has settled,
46// unless the person has already started a new draft.
47const clearPrompt = ($: EngineInterface, typed: string) =>
48  $.clock.after(50, () =>
49    void $.prompt
50      .read()
51      .then(({ text }) => (text.trim() === typed.trim() ? $.prompt.fill({ text: "" }) : undefined))
52      .catch(() => undefined),
53  );
54
55// A submit jumps the chat to its end: bring the selected message back once the dock has narrowed it.
56// ponytail: whole-message precision; a message taller than the window comes back at its top. The
57// engine scrolls the chat to messages only (a `{ key }` target must sit in the mod's own pane or band).
58async function keepPlace($: EngineInterface, requestId: string) {
59  const args = { to: { requestId }, block: "center" } as const;
60  const r = await $.ui.scroll(args).catch(() => ({ deny: "" }));
61  if (r.deny?.includes("moved meanwhile")) {
62    await $.ui.scroll(args).catch(() => undefined);
63  }
64}
65
66// `$.ui.open` resolves before the pane's first draw; the reflowed chat is ready once it draws.
67let drawn: (() => void) | undefined;
68const paneDrawn = ($: EngineInterface) =>
69  new Promise<void>((done) => {
70    drawn = done;
71    $.clock.after(200, done); // ponytail: guessed fallback; tune if the scroll lands before the reflow
72  });
73
74// Bumped when a close wipes the history, so an answer still on its way does not bring it back.
75let epoch = 0;
76
77const API_ERRORS: Partial<Record<string, string>> = {
78  rate_limit: "Rate limited. Try again in a moment.",
79  overloaded: "Claude is overloaded right now. Try again.",
80};
81const why = (r: Extract<ModelForkResult, { isAnswered: false }>) => {
82  switch (r.reason) {
83    case "nothing-to-fork":
84      return "No conversation to ask about yet.";
85    case "aborted":
86      return "Cancelled: Esc or a reload stopped this ask.";
87    case "empty-reply":
88      return "Got an empty answer. Try rephrasing.";
89    case "api-error":
90      return API_ERRORS[r.error] ?? `No answer: API error (${r.error}).`;
91    default: // a reason a newer engine adds
92      return `No answer (${(r as { reason: string }).reason}).`;
93  }
94};
95
96type Selection = Awaited<ReturnType<EngineInterface["ui"]["selection"]>>;
97
98// The pane is a cached drawing: a state write alone does not redraw it.
99const redraw = ($: EngineInterface) => $.ui.invalidate("ui.render");
100
101let count = 0;
102const newId = () => `${Date.now().toString(36)}${++count}`;
103const words = (s: string) => s.trim().split(/\s+/).filter(Boolean).length;
104const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
105const clip = (s: string, lines: number) => {
106  const all = s.trim().split("\n");
107  return all.length > lines ? `${all.slice(0, lines).join("\n")}…` : all.join("\n");
108};
109const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
110
111// The fork's prefix ends before the latest reply, so the whole message the excerpt
112// came from goes in the prompt, with this excerpt's last few Q&As for follow-ups.
113const FORK_PROMPT = (g: Group, q: string) => `This is a side question; it will not be added to our conversation.
114Answer ONLY the question at the end. Do not continue, redo or answer any earlier request, even one still unanswered.
115Answer briefly (a few sentences, a short list or a small code block), plain and easy to follow.
116The answer shows in a narrow side panel: every line must fit in ${paneColumns - 4} characters, or it wraps and breaks.
117Tables and small diagrams are welcome when they fit: a table of 2-3 short columns, a diagram or code block no wider than ${paneColumns - 4} characters. Wider than that, use a list instead.
118${g.source ? `The message of yours the user is reading:\n"""\n${g.source}\n"""\n` : ""}${g.excerpt ? `The part they selected:\n"""\n${g.excerpt}\n"""\n` : ""}${g.turns
119  .filter((t) => !t.isError)
120  .slice(-3)
121  .map((t) => `Earlier side question: ${t.q}\nYour answer: ${cut(t.a, 400)}\n`)
122  .join("")}Question: ${q}`;
123
124// The last assistant message that holds the selection, or the last one when none is given.
125async function findSource($: EngineInterface, excerpt: string) {
126  const all = await $.session.messages().catch(() => []);
127  const replies = all.filter((m) => m.role === "assistant" && m.text.trim());
128  const needle = norm(excerpt).slice(0, 60);
129  const hit = needle ? replies.findLast((m) => norm(m.text).includes(needle)) : replies.at(-1);
130  return cut(hit?.text ?? "", 6000);
131}
132
133// A new selection starts a new group; the same one (or none) keeps asking about the current.
134async function groupFor($: EngineInterface, got: Selection) {
135  const list = await read($, groups);
136  const current = list.at(-1);
137  const sel = got?.text.trim() ?? "";
138  // requestId is absent for a selection in our own pane: answers are not excerpts.
139  const isNew = sel.length >= 3 && sel !== current?.excerpt && !!got?.requestId;
140  if (current && !isNew) {
141    return current;
142  }
143  const group: Group = { id: newId(), excerpt: isNew ? sel : "", source: await findSource($, isNew ? sel : ""), turns: [] };
144  await update($, groups, (l) => [...l, group]);
145  redraw($);
146  return group;
147}
148
149async function ask($: EngineInterface, group: Group, q: string, color: ThemeKey) {
150  const asked = epoch;
151  await update($, busy, (l) => [...l, q]);
152  redraw($);
153  let turn: Turn;
154  try {
155    const r = await $.model.fork({ prompt: FORK_PROMPT(group, q) });
156    turn = r.isAnswered
157      ? {
158          id: newId(), q, color,
159          a: r.text.trim(),
160          meta: `${(r.usage.cache_read_input_tokens / 1000).toFixed(1)}k cached · ${r.usage.output_tokens} out`,
161        }
162      : {
163          id: newId(), q, color, meta: "", isError: true,
164          a: why(r),
165        };
166  } catch (err) {
167    turn = { id: newId(), q, color, meta: "", isError: true, a: `No answer: ${String(err)}` };
168  } finally {
169    // After a close, `busy` was wiped and may hold a newer ask of the same question.
170    if (asked === epoch) {
171      await update($, busy, (l) => l.filter((x, i) => i !== l.indexOf(q)));
172    }
173    redraw($);
174  }
175  // A close wiped the history while this was asking; the answer belongs to it.
176  if (asked !== epoch) {
177    return;
178  }
179  await update($, groups, (list) => {
180    const next = list.map((g) => (g.id === group.id ? { ...g, turns: [...g.turns, turn] } : g));
181    while (next.length > 1 && next.reduce((n, g) => n + g.turns.length, 0) > MAX_TURNS) {
182      next.shift();
183    }
184    // One excerpt asked about past the cap keeps only its latest turns.
185    const last = next.at(-1)!;
186    next[next.length - 1] = { ...last, turns: last.turns.slice(-MAX_TURNS) };
187    return next;
188  });
189  redraw($);
190  void $.ui.scroll({ in: PANE, to: "start" }).catch(() => undefined);
191}
192
193// `;x` closes; anything else opens the pane and asks. `jumped`: a submit moved the chat to its end.
194async function sideAsk($: EngineInterface, typed: string, jumped: boolean) {
195  const [, code = "", text = ""] = TRIGGER.exec(typed.trim()) ?? [];
196  await update($, typingSide, () => false);
197  if (code === "x") {
198    await $.ui.close({ id: PANE });
199    return;
200  }
201  const preset = PRESETS[code] ?? PRESETS[""]!;
202  // Read before the dock narrows the chat.
203  const sel = await $.ui.selection();
204  // Opened before the person's hook returns: from Enter that is asked and placed at any width.
205  const draw = paneDrawn($);
206  const opened = await $.ui.open(OPEN);
207  redraw($);
208  if (opened.isPlaced) {
209    // An edit leaves the chat where it was: the dock's reflow only drifts the selected line a few rows,
210    // where scrolling to a tall message would show its top. A submit's jump to the end is worse.
211    await draw;
212    if (jumped && sel?.requestId) {
213      await keepPlace($, sel.requestId);
214    }
215  } else {
216    // shift+enter is an edit, which the engine counts as unasked: after a close by hand (×) such an
217    // open waits for 144 columns. An Enter is asked and seats it (a refused × close is not honoured).
218    $.ui.toast(
219      opened.reason.includes("unasked")
220        ? "Side pane closed by hand: press Enter (not shift+enter) once to bring it back."
221        : `Side pane waiting: ${opened.reason}`,
222      { timeoutMs: 6000 },
223    );
224  }
225  const group = await groupFor($, sel);
226  void ask($, group, text.trim() || preset.q, text.trim() ? "claude" : preset.color).catch(() => undefined);
227}
228
229// `;e example  ;w why …`: the key bold in its colour, the label dim.
230const keyRow = (Text: ElementConstructor<TextProps>) =>
231  KEYS.map(([k, label, color]) => (
232    <Text key={k}>
233      <Text bold color={color}>
234        {k}
235      </Text>
236      <Text color="inactive"> {label}  </Text>
237    </Text>
238  ));
239
240export const register: Register = (on) => {
241  on("session.start", async ($, e, next) => {
242    await $.command.register({ name: "side", description: "Open the side-question pane (or type ; and a question)" });
243    // A reload may have cut a fork short.
244    await update($, busy, () => []);
245    redraw($);
246    // Spins the thinking glyph; idle ticks cost one state read.
247    $.clock.every(600, () =>
248      void read($, busy).then(async (q) => {
249        if (q.length) {
250          await update($, tick, (n) => n + 1);
251          redraw($);
252        }
253      }).catch(() => undefined), // a tick that outlives a reload has nothing to draw
254    );
255
256    return next(e);
257  });
258
259  // `; question` + Enter: dropped before it becomes a turn, answered in the pane.
260  // Submitting jumps the chat to its end; sideAsk brings the selected message back.
261  on("prompt.submit", { text: TRIGGER, origin: { kind: "composer" } }, async ($, e) => {
262    await sideAsk($, e.text, true);
263    clearPrompt($, e.text);
264
265    return { drop: TRIGGER.exec(e.text)?.[1] === "x" ? CLOSED : DROP };
266  }).catch(($, e, next) => (next.called ? next(e) : { drop: "side-ask failed" }));
267
268  // `; question` + a newline (shift+enter, option+enter, ctrl+j, \ Enter) is an edit, not a submit: the chat stays put.
269  on("prompt.edit", async ($, e, next) => {
270    const typed = e.text.replace(/\\\s*$/, ""); // `\ Enter` leaves its backslash in the draft
271    if (e.inputText === "\n" && TRIGGER.test(typed)) {
272      // Awaited so the toast and the scroll back happen inside the person's edit. A throw fails open: the draft stays.
273      await sideAsk($, typed, false);
274      return { text: "", cursor: 0 };
275    }
276    // `e.text` is the draft before this edit; the hint follows the draft after it.
277    const r = await next(e);
278    const typing = TRIGGER.test(r.text);
279    if (typing !== (await read($, typingSide))) {
280      await update($, typingSide, () => typing);
281      redraw($);
282    }
283    return r;
284  });
285
286  // Any submit empties the box without a `prompt.edit`.
287  on("prompt.submit", async ($, e, next) => {
288    if (await read($, typingSide)) {
289      await update($, typingSide, () => false);
290      redraw($);
291    }
292    return next(e);
293  });
294
295  on("command.run", { command: "side" }, async ($) => {
296    await $.ui.open(OPEN);
297    redraw($);
298
299    return {};
300  });
301
302  // Closing the pane ends its history; reopening starts fresh. A reload's close keeps it.
303  on("ui.close", { id: PANE }, async ($, e, next) => {
304    const r = await next(e);
305    if (e.origin.kind !== "unload") {
306      epoch++;
307      await update($, groups, () => []);
308      await update($, busy, () => []);
309      redraw($);
310    }
311
312    return r;
313  });
314
315  // While a draft starts with ";", the hint line under the prompt is the keys, whole (a tail is cut at the row's end).
316  on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
317    if (!(await read($, typingSide))) {
318      return next(e);
319    }
320    const { Text } = $.ui.resolve(e);
321    return (
322      <Text>
323        {"  "}
324        {keyRow(Text)}
325        <Text color="inactive">shift+enter keeps your place</Text>
326      </Text>
327    );
328  });
329
330  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
331    drawn?.();
332    drawn = undefined;
333    const { Box, Text, Markdown } = $.ui.resolve(e);
334    paneColumns = e.props.bodyColumns;
335    const width = Math.max(10, e.props.bodyColumns - 2);
336    const list = await read($, groups);
337    const pending = await read($, busy);
338    const spin = SPIN[(await read($, tick)) % SPIN.length];
339    const current = list.at(-1);
340    const total = list.reduce((n, g) => n + g.turns.length, 0);
341    const latest = current?.turns.at(-1)?.id;
342
343    const turnView = (t: Turn) =>
344      t.id === latest ? (
345        <Box key={t.id} flexDirection="column">
346          <Text color="suggestion">❯ {t.q}</Text>
347          {t.isError ? (
348            <Text color="error">⎿ {t.a}</Text>
349          ) : (
350            <Box flexDirection="row">
351              <Text color={t.color}>● </Text>
352              <Box width={width}>
353                <Markdown text={t.a} />
354              </Box>
355            </Box>
356          )}
357          {t.meta && <Text color="inactive">  ⎿ {t.meta}</Text>}
358        </Box>
359      ) : (
360        <Box key={t.id} flexDirection="column">
361          <Text color="suggestion">❯ {cut(t.q, width - 2)}</Text>
362          <Text color="inactive">  {cut(t.a.replace(/[*_`#>]/g, "").replace(/\s+/g, " "), width - 2)}</Text>
363        </Box>
364      );
365
366    return (
367      <Box flexDirection="column" gap={1}>
368        <Text>
369          <Text backgroundColor="claude" color="inverseText" bold>
370            {" SIDE "}
371          </Text>
372          <Text color="inactive">
373            {"  "}
374            {total} {total === 1 ? "ask" : "asks"} · off the record
375          </Text>
376        </Text>
377        <Box flexDirection="row" flexWrap="wrap">
378          {keyRow(Text)}
379        </Box>
380
381        {current ? (
382          <Box key="card" flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
383            <Text color="inactive">{current.excerpt ? `excerpt · ${words(current.excerpt)} words` : "about the last reply"}</Text>
384            <Text italic>{clip(current.excerpt || current.source || "(no reply yet)", 4)}</Text>
385          </Box>
386        ) : (
387          <Box key="card" flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
388            <Text color="claude">✦ Select text in the chat, then type ; and your question.</Text>
389            <Text color="inactive">  ; why this?   ;e example   ;w why   ;p pitfalls   ;v vs</Text>
390          </Box>
391        )}
392
393        {/* Asks on their way, newest first, drawn where their answers will land. */}
394        {[...pending].reverse().map((q, i) => (
395          <Box key={`pending-${i}`} flexDirection="column">
396            <Text color="suggestion">❯ {q}</Text>
397            <Text>
398              <Text color="claude">{spin}</Text>
399              <Text color="inactive"> thinking…</Text>
400            </Text>
401          </Box>
402        ))}
403
404        {/* An empty group other than the current one is an ask still on its way. */}
405        {[...list].filter((g) => g === current || g.turns.length).reverse().map((g) => (
406          <Box key={g.id} flexDirection="column" gap={1}>
407            {g !== current && g.turns.length > 0 && (
408              <Text color="subtle">── "{cut(g.excerpt || "last reply", Math.max(8, width - 22))}" ── {g.turns.length}</Text>
409            )}
410            {[...g.turns].reverse().map(turnView)}
411          </Box>
412        ))}
413
414        <Text color="inactive">shift+enter asks and keeps your place · ;x closes · wheel scrolls</Text>
415      </Box>
416    );
417  });
418};
419
types/index.d.ts 16 lines
1export type Turn = { id: string; q: string; a: string; meta: string; color: string; isError?: boolean }
2
3// One excerpt and the questions asked about it; `source` is the whole message it came from.
4export type Group = { id: string; excerpt: string; source: string; turns: Turn[] }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'side-ask': {
9      groups: Group[]
10      busy: string[]
11      tick: number
12      typingSide: boolean
13    }
14  }
15}
16