SLOPSHOPPER

ask-threads

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

newpanecommandstatuspromptmodel
★ 1v0.1.0MITupdated 2026-10-08yyosifov/prompt-mods/mods/ask-threads
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ask-threads
│ ┃ ask ✕ › fix the failing auth test and add an audit log call │ ┃ No side questions yet. /ask <question> asks │ ┃ one. ⏺ 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 │ │ › /ask │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ask
No side questions yet. /ask <question> asks one.
README

ask-threads

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.

Commands

CommandDoes
/ask <question>Ask a side question; starts thread #N
/ask or /ask listList this session's threads
/ask NReopen thread #N
/ask N <follow-up>Continue thread #N (or type in the pane's field)
/ask pull NSend thread #N to Claude with your next message
/ask drop NDelete 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.

Install

/plugin install ask-threads --marketplace yyosifov/prompt-mods

Notes

  • Threads last for the session.
  • The answer sees the main conversation as of its last model request; text Claude is streaming at that moment is not included yet.
  • Why not extend /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.
Source 4 files
hooks/register.tsx 190 lines
1import 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 &lt;question&gt; 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 &lt;follow-up&gt; · /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} &lt;follow-up&gt; 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};
190
hooks/args.ts 38 lines
1export 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};
38
hooks/prompts.ts 22 lines
1import 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)}`;
22
types/index.d.ts 18 lines
1export 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