/ask: side questions you can list, reopen, continue, and send to the main conversation

Side questions that don't vanish. Like /btw, but every question becomes a thread you can list, reopen, continue, and hand to the main conversation.
/ask why did we pick SQLite over Postgres here?
The answer opens in a pane. It is asked over the main conversation's context (its prompt cache, no tools) and stays out of the main transcript, so the main task is undisturbed. /ask runs right away, even while Claude is mid-turn.
| Command | Does |
|---|---|
/ask <question> | Ask a side question; starts thread #N |
/ask or /ask list | List this session's threads |
/ask N | Reopen thread #N |
/ask N <follow-up> | Continue thread #N (or type in the pane's field) |
/ask pull N | Send thread #N to Claude with your next message |
/ask drop N | Delete thread #N |
In a thread, Send to main chat does the same as /ask pull N: the whole thread goes to Claude as hidden context with your next message, once. The status line confirms it until you send.
/plugin install ask-threads --marketplace yyosifov/prompt-mods
/btw itself? A mod can't reliably take over a built-in command that runs mid-turn, so /ask is its own command and /btw is left as it is.hooks/register.tsx 190 lines1import type { EngineInterface, Register } from 'claude-code';
2import { atom, read, update } from 'claude-code';
3
4import type { Exchange } from '../types';
5import { parseArgs } from './args';
6import { forkPrompt, pulledContext } from './prompts';
7
8const PANE = 'ask-threads';
9const threads = atom({ plugin: 'ask-threads', key: 'threads' } as const, []);
10const openId = atom({ plugin: 'ask-threads', key: 'openId' } as const, null);
11const busy = atom({ plugin: 'ask-threads', key: 'busy' } as const, []);
12const pulled = atom({ plugin: 'ask-threads', key: 'pulled' } as const, []);
13
14const preview = (text: string, width: number): string => {
15 const line = text.replace(/\s+/g, ' ').trim();
16 return line.length > width ? `${line.slice(0, width - 1)}…` : line;
17};
18
19const show = async ($: EngineInterface, id: number | null): Promise<void> => {
20 await update($, openId, () => id);
21 await $.ui.open({ id: PANE, title: 'ask', focus: true });
22};
23
24// Adds the question to the thread and asks the fork, on a timer of its own so
25// the answer lands even after the dispatch that asked has ended.
26const ask = async ($: EngineInterface, id: number, question: string): Promise<void> => {
27 const earlier = (await read($, threads)).find((t) => t.id === id)?.exchanges ?? [];
28 await update($, threads, (all) => all.map((t) => (t.id === id ? { ...t, exchanges: [...t.exchanges, { question, answer: null }] } : t)));
29 await update($, busy, (ids) => [...ids, id]);
30 $.clock.after(0, () => void answer($, id, question, earlier));
31};
32
33const answer = async ($: EngineInterface, id: number, question: string, earlier: readonly Exchange[]): Promise<void> => {
34 const reply = await $.model.fork({ prompt: forkPrompt(earlier, question) });
35 const exchange: Exchange = reply.isAnswered ? { question, answer: reply.text } : { question, answer: null, error: reply.reason };
36 await update($, threads, (all) => all.map((t) => (t.id === id ? { ...t, exchanges: [...t.exchanges.slice(0, -1), exchange] } : t)));
37 await update($, busy, (ids) => ids.filter((one) => one !== id));
38};
39
40const pull = async ($: EngineInterface, id: number): Promise<void> => {
41 const ids = await update($, pulled, (all) => (all.includes(id) ? all : [...all, id]));
42 $.ui.status(`ask ${ids.map((one) => `#${one}`).join(' ')} attached to your next message`);
43};
44
45export const register: Register = (on) => {
46 on('session.start', async ($, e, next) => {
47 await $.command.register({
48 name: 'ask',
49 description: 'Side question that stays out of the main conversation; /ask list to reopen one',
50 argumentHint: '<question> | list | N <follow-up> | pull N | drop N',
51 immediate: true,
52 });
53
54 return next(e);
55 });
56
57 on('command.run', { command: 'ask' }, async ($, e) => {
58 const action = parseArgs(e.args);
59 const list = await read($, threads);
60
61 if (action.kind === 'list') {
62 await show($, null);
63 return {};
64 }
65
66 if (action.kind === 'ask') {
67 const id = list.reduce((max, t) => Math.max(max, t.id), 0) + 1;
68 const startedAt = await $.clock.now();
69 await update($, threads, (all) => [...all, { id, startedAt, exchanges: [] }]);
70 await show($, id);
71 await ask($, id, action.question);
72 return {};
73 }
74
75 const thread = list.find((t) => t.id === action.id);
76 if (thread === undefined) {
77 return { text: `No ask thread #${action.id}. /ask list shows them.` };
78 }
79
80 switch (action.kind) {
81 case 'open':
82 await show($, thread.id);
83 return {};
84 case 'follow':
85 await show($, thread.id);
86 await ask($, thread.id, action.question);
87 return {};
88 case 'pull':
89 await pull($, thread.id);
90 return { text: `ask #${thread.id} goes to Claude with your next message.` };
91 case 'drop':
92 await update($, threads, (all) => all.filter((t) => t.id !== thread.id));
93 await update($, pulled, (ids) => ids.filter((one) => one !== thread.id));
94 await update($, openId, (id) => (id === thread.id ? null : id));
95 return { text: `Dropped ask #${thread.id}.` };
96 }
97 });
98
99 // A pulled thread rides along with the next prompt, unseen in the transcript.
100 on('prompt.submit', async ($, e, next) => {
101 const ids = await read($, pulled);
102 if (ids.length === 0) {
103 return next(e);
104 }
105
106 const all = await read($, threads);
107 const context = all.filter((t) => ids.includes(t.id)).map(pulledContext);
108 await update($, pulled, () => []);
109 $.ui.status(undefined);
110 $.ui.log(`▸ ask ${ids.map((id) => `#${id}`).join(' ')} sent along with this message`);
111
112 return next({ ...e, context: [...(e.context ?? []), ...context] });
113 });
114
115 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
116 const elements = $.ui.resolve(e);
117 const { Box, Text, Button, Markdown } = elements;
118 // Mobile draws no field: the follow-up is typed as /ask N there.
119 const Input = 'Input' in elements ? elements.Input : null;
120 const all = await read($, threads);
121 const id = await read($, openId);
122 const waiting = await read($, busy);
123 const attached = await read($, pulled);
124 const thread = all.find((t) => t.id === id);
125 const width = Math.max(20, (e.viewport?.columns ?? 80) - 16);
126
127 if (thread === undefined) {
128 return (
129 <Box flexDirection="column">
130 {all.length === 0 && <Text dimColor>No side questions yet. /ask <question> asks one.</Text>}
131 {all.map((t) => {
132 const question = preview(t.exchanges[0]?.question ?? '', width);
133 const attachedMark = attached.includes(t.id) ? ' ✓ attached' : '';
134
135 return (
136 <Button
137 key={`t${t.id}`}
138 plain
139 label={`#${t.id} ${question} (${t.exchanges.length})${attachedMark}`}
140 onPress={() => update($, openId, () => t.id)}
141 />
142 );
143 })}
144 {all.length > 0 && <Text dimColor>/ask N <follow-up> · /ask pull N · /ask drop N</Text>}
145 </Box>
146 );
147 }
148
149 const isBusy = waiting.includes(thread.id);
150 const isAttached = attached.includes(thread.id);
151
152 return (
153 <Box flexDirection="column">
154 <Text dimColor>ask #{thread.id}</Text>
155 {thread.exchanges.map((x, i) => (
156 <Box key={`x${i}`} flexDirection="column" marginBottom={1}>
157 <Text bold>› {x.question}</Text>
158 {x.answer !== null && <Markdown text={x.answer} />}
159 {x.answer === null && x.error !== undefined && <Text color="red">No answer: {x.error}</Text>}
160 {x.answer === null && x.error === undefined && <Text dimColor>thinking…</Text>}
161 </Box>
162 ))}
163 {!isBusy && Input === null && <Text dimColor>/ask {thread.id} <follow-up> to continue</Text>}
164 {!isBusy && Input !== null && (
165 <Input
166 key="follow"
167 placeholder={`Follow up on #${thread.id}…`}
168 autoFocus
169 onSubmit={(value: string) => {
170 const question = value.trim();
171 if (question !== '') {
172 void ask($, thread.id, question);
173 }
174 }}
175 />
176 )}
177 <Box>
178 <Button key="back" label="← All threads" onPress={() => update($, openId, () => null)} />
179 <Text> </Text>
180 {isAttached ? (
181 <Text color="green">✓ attached to your next message</Text>
182 ) : (
183 <Button key="pull" label="Send to main chat" onPress={() => void pull($, thread.id)} />
184 )}
185 </Box>
186 </Box>
187 );
188 });
189};
190hooks/args.ts 38 lines1export type AskAction =
2 | { kind: 'list' }
3 | { kind: 'ask'; question: string }
4 | { kind: 'open'; id: number }
5 | { kind: 'follow'; id: number; question: string }
6 | { kind: 'pull'; id: number }
7 | { kind: 'drop'; id: number };
8
9export const parseArgs = (args: string): AskAction => {
10 const text = args.trim();
11 if (text === '' || text === 'list' || text === 'ls') {
12 return { kind: 'list' };
13 }
14
15 const verb = /^(pull|drop|rm|open)\s+#?(\d+)$/i.exec(text);
16 if (verb !== null) {
17 const id = Number(verb[2]);
18 const name = (verb[1] ?? '').toLowerCase();
19 if (name === 'pull') {
20 return { kind: 'pull', id };
21 }
22 if (name === 'open') {
23 return { kind: 'open', id };
24 }
25
26 return { kind: 'drop', id };
27 }
28
29 const numbered = /^#?(\d+)(?:\s+([\s\S]+))?$/.exec(text);
30 if (numbered !== null) {
31 const id = Number(numbered[1]);
32 const question = numbered[2]?.trim();
33 return question ? { kind: 'follow', id, question } : { kind: 'open', id };
34 }
35
36 return { kind: 'ask', question: text };
37};
38hooks/prompts.ts 22 lines1import type { Exchange, Thread } from '../types';
2
3const PREAMBLE =
4 'This is a side question (/ask) asked while the main task continues. ' +
5 'Answer it directly and briefly from what you already know of this conversation; ' +
6 'it does not change the main task.';
7
8const transcript = (exchanges: readonly Exchange[]): string =>
9 exchanges
10 .filter((x) => x.answer !== null)
11 .map((x) => `Q: ${x.question}\nA: ${x.answer}`)
12 .join('\n\n');
13
14export const forkPrompt = (earlier: readonly Exchange[], question: string): string => {
15 const before = transcript(earlier);
16
17 return before === '' ? `${PREAMBLE}\n\n${question}` : `${PREAMBLE}\n\nThe side conversation so far:\n\n${before}\n\nFollow-up: ${question}`;
18};
19
20export const pulledContext = (thread: Thread): string =>
21 `The user pulled in side thread #${thread.id} (a /ask conversation you had alongside this one):\n\n${transcript(thread.exchanges)}`;
22types/index.d.ts 18 lines1export type Exchange = { question: string; answer: string | null; error?: string };
2
3export type Thread = { id: number; startedAt: number; exchanges: Exchange[] };
4
5declare module 'claude-code' {
6 interface PluginState {
7 'ask-threads': {
8 threads: Thread[];
9 // The thread the pane shows; null shows the list.
10 openId: number | null;
11 // Threads whose next answer is on its way.
12 busy: number[];
13 // Threads to hand the model with the next prompt.
14 pulled: number[];
15 };
16 }
17}
18