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

<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.
<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">
You're reading a long reply and one phrase doesn't click. Asking in the main chat costs more than it looks:
| Without side-ask | With side-ask | |
|---|---|---|
| Your tangent | Piles into the context, forever | Answered in a pane, never added |
| The thread | Derails; Claude may act on your aside | The main task stays on track |
| Cost | Every later turn carries it | One forked call that reuses the cache |
| Your place | The chat jumps to the bottom | The chat stays where you were reading |
; your question, or a one-key preset like ;w.Ask again without a new selection to follow up on the same excerpt. Select something else to start a new thread in the pane.
| | 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.
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>
$.model.fork, which reuses the conversation's cache. They're never written to the transcript.;x doesn't have this problem.;, or a prompt from a scheduled task or another session, goes to Claude as usual.CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Separate multiple folders with :..claude-plugin/types/ (gitignored), and tsc works after that.claude plugin test side-ask.scripts/make-gif.sh <recording.mov> "<start-end,…>" turns a screen recording into docs/demo.gif (usage in the script header).hooks/register.tsx 419 lines1import { 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};
419types/index.d.ts 16 lines1export 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