SLOPSHOPPER

open-questions

A box above the Claude Code prompt with the questions Claude asked that you have not answered yet

bandguardprompttool
v1.0.0MITupdated 2026-10-17jaklimoff/claude-open-questions
A shopper browsing a rack in a slop shop
README

open-questions

A Claude Code plugin that keeps the questions Claude asked you in a box above the prompt, until you answer them.

Claude often asks something in the middle of a long reply, then keeps writing. The question scrolls away under the text that follows, and you answer only what you saw last. With this plugin, Claude files each question it asks in a box above the prompt. It stays there until you answer it, or until it no longer applies.

The box above the prompt: Claude asked three questions, two get answers, one send, and the third stays open

The story behind it: The Warning My Agent Gave That I Never Saw.

Install

/plugin marketplace add jaklimoff/claude-open-questions
/plugin install open-questions@open-questions

Or in one step from the shell (v2.1.292+): claude plugin install open-questions --marketplace jaklimoff/claude-open-questions.

Needs Claude Code v2.1.293 or later (mods shipped in v2.1.287; isDeferred needs v2.1.293). Tested with v2.1.302. Mods can change between releases, so if the box stops drawing after an update, check the mods changelog.

Tested in the terminal. Mods don't draw the band above the prompt in the VS Code extension, or anything in claude -p. Questions live in session state, so /clear, /resume and /branch start an empty list.

Filing a question is up to the model, which follows the tool's description. In a quick test, Opus 5.5 filed its questions and Sonnet 5.5 wrote them as plain text instead, so check with the model you use.

The box uses Nerd Font glyphs. Without a Nerd Font in your terminal, the icons show as empty squares.

What it can touch

A mod is code that runs inside Claude Code with your permissions, so read it before you install it. This one uses only $.state (the list, in memory for the session), $.tool.register (the questions tool), $.prompt.submit (sending your drafted answers) and $.ui.resolve / $.ui.focus (drawing the box). No files, no processes, no network, no model calls. claude plugin validate . prints the same list for any mod.

Use

  • Claude files each question it asks with the questions tool. You do nothing.
  • Press the pencil next to a question, type the answer, press Enter. The answer is kept as a draft and the next question opens. An empty Enter skips it.
  • send sends all drafts in one message. Any prompt you type also carries the drafts, after your text.
  • ctrl+x tab focuses the box, with the first unanswered question already open.
  • The cross (on hover) drops a question without an answer. The chevron folds the box to one line.
  • Each prompt reminds Claude of the open questions, so it closes the ones your message answered or made moot.

How it works

The whole plugin is one file, hooks/open-questions.tsx, about 250 lines. It is a Claude Code mod: a plugin whose hooks/hooks.json lists a module that runs inside Claude Code (mods docs). It hooks four events:

EventWhat it does
session.startRegisters the questions tool: ask adds questions, answered closes them by id. The tool description tells Claude when to call it.
tool.callHandles the tool: keeps the open questions in plugin state (atom), hands out ids in one update so parallel calls never share one.
prompt.submitAdds your drafted answers to the message, and the list of open questions to the context Claude sees.
ui.render (AbovePrompt)Draws the box with Box, Text, Button and Input.

The tests in tests/open-questions.test.ts show each behaviour. Run them with:

claude plugin test .

For development, start Claude Code with claude --plugin-dir <path to this folder>.

Build your own

This plugin is small on purpose. Fork it, keep the four hooks, and change what they track: TODOs Claude promised, files it said it would come back to, warnings it gave. Claude Code can write most of it for you. The work is in the tuning afterwards, one edge case at a time.

Contributing

Issues and PRs welcome. Run claude plugin validate --strict . and claude plugin test . before sending one.

License

MIT

Source 2 files
hooks/open-questions.tsx 250 lines
1// Open questions: the questions Claude asked that are not answered yet, in a box above the prompt.
2import { atom, read, update } from 'claude-code'
3import type { Register } from 'claude-code'
4
5import type { Question } from '../types'
6
7const open = atom({ plugin: 'open-questions', key: 'questions' } as const, [] as Question[])
8const nextId = atom({ plugin: 'open-questions', key: 'questionsNextId' } as const, 1)
9const isFolded = atom({ plugin: 'open-questions', key: 'questionsFolded' } as const, false)
10const answering = atom({ plugin: 'open-questions', key: 'questionsAnswering' } as const, null as number | null)
11const drafts = atom({ plugin: 'open-questions', key: 'questionsDrafts' } as const, {} as Record<string, string>)
12
13const TOOL = 'mcp__open-questions__questions'
14
15// Nerd Font glyphs: without a Nerd Font in the terminal they show as empty squares.
16const ICON = {
17  unfolded: '\uf078',
18  folded: '\uf054',
19  question: '\uf059',
20  done: '\uf00c',
21  answer: '\uf040',
22  edit: '\uf044',
23  close: '\uf00d',
24  send: '\uf1d8',
25  keyboard: '\uf11c',
26  branch: '╰─',
27}
28
29type Args = { ask?: string[]; answered?: number[] }
30
31/** The drafts of the questions still in `list`. */
32const keep = (d: Record<string, string>, list: Question[]) =>
33  Object.fromEntries(Object.entries(d).filter(([id]) => list.some(q => String(q.id) === id)))
34
35/** One message with every drafted answer. */
36export const answersMessage = (list: Question[], d: Record<string, string>): string => {
37  const lines = list.filter(q => d[q.id] !== undefined).map(q => `#${q.id} ("${q.text}"): ${d[q.id]}`)
38  return lines.length === 1 ? `Answer to your question ${lines[0]}` : `Answers to your questions:\n${lines.join('\n')}`
39}
40
41export const register: Register = on => {
42  on('session.start', async ($, e, next) => {
43    await $.tool.register({
44      name: 'questions',
45      isDeferred: false,
46      description:
47        'Track the questions you ask the user. Each time your reply asks the user a question that needs an answer, ' +
48        'call this tool with `ask` (one short line for each question). When the user answers a question, or it no ' +
49        'longer applies, call it with `answered` (the ids). Do not track rhetorical questions or offers. ' +
50        'Returns the open questions.',
51      inputSchema: {
52        type: 'object',
53        properties: {
54          ask: { type: 'array', items: { type: 'string' } },
55          answered: { type: 'array', items: { type: 'number' } },
56        },
57      },
58    })
59
60    return next(e)
61  })
62
63  // No .catch on the gating hooks: if one throws, Claude Code skips it and the call or prompt goes through unchanged,
64  // which is the right failure for a tracker.
65  on('tool.call', { tool: TOOL }, async ($, e) => {
66    const { ask = [], answered = [] } = e as unknown as Args
67    // Reserve the ids in one update, so parallel calls never hand out the same id.
68    const end = await update($, nextId, n => n + ask.length)
69    const added = ask.map((text, i) => ({ id: end - ask.length + i, text }))
70    const list = await update($, open, qs => [
71      ...qs.filter(q => !answered.includes(q.id)),
72      ...added,
73    ])
74    const d = await update($, drafts, x => keep(x, list))
75    // The first question without a draft is open, so ctrl+x tab lands in its field.
76    await update($, answering, cur =>
77      list.some(q => q.id === cur) ? cur : (list.find(q => d[q.id] === undefined)?.id ?? null),
78    )
79    const lines = list.map(q => `#${q.id} ${q.text}`).join('\n')
80
81    return { result: lines === '' ? 'No open questions.' : `Open questions:\n${lines}` }
82  })
83
84  // Each prompt carries the drafted answers and the open questions, so Claude drops the ones it answers or makes moot.
85  on('prompt.submit', async ($, e, next) => {
86    const all = await read($, open)
87    const d = await read($, drafts)
88    const ids = all.filter(q => d[q.id] !== undefined).map(q => q.id)
89    let text = e.text
90    if (ids.length > 0) {
91      text = [e.text.trim(), answersMessage(all, d)].filter(t => t !== '').join('\n\n')
92      await update($, drafts, () => ({}))
93    }
94    const list = await update($, open, qs => qs.filter(q => !ids.includes(q.id)))
95    if (ids.length > 0) {
96      await update($, answering, () => list[0]?.id ?? null)
97    }
98    if (list.length === 0) {
99      return next({ ...e, text })
100    }
101    const reminder =
102      `Open questions you asked:\n${list.map(q => `#${q.id} ${q.text}`).join('\n')}\n` +
103      `If this message answers any of them or makes them irrelevant, call ${TOOL} with \`answered\`.`
104
105    return next({ ...e, text, context: [...(e.context ?? []), reminder] })
106  })
107
108  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
109    const list = await read($, open)
110    if (list.length === 0 || e.props.hasSurvey) {
111      return next(e)
112    }
113
114    const folded = await read($, isFolded)
115    const current = await read($, answering)
116    const d = await read($, drafts)
117    const drafted = list.filter(q => d[q.id] !== undefined).length
118    const els = $.ui.resolve(e)
119    const { Box, Button, Text } = els
120    const Input = 'Input' in els ? els.Input : undefined
121    // A rounded pill: `color` as the fill, the text dark on it. `bold` is deprecated since v2.1.299 in favour of
122    // `weight: 'semibold'`; it still works and keeps the floor at v2.1.293.
123    const pill = (color: string, label: string | number) => (
124      <Text>
125        <Text color={color}>{'\ue0b6'}</Text>
126        <Text color="inverseText" backgroundColor={color} bold>
127          {label}
128        </Text>
129        <Text color={color}>{'\ue0b4'}</Text>
130      </Text>
131    )
132    const band = e.requestId
133    const toggle = () => update($, isFolded, f => !f)
134    const close = async (id: number) => {
135      const left = await update($, open, qs => qs.filter(x => x.id !== id))
136      await update($, drafts, x => keep(x, left))
137    }
138    const startAnswer = (q: Question) => update($, answering, () => q.id)
139    // Enter keeps the draft and opens the next question without one.
140    const save = async (q: Question, value: string) => {
141      const all = await update($, drafts, x => {
142        const { [q.id]: _, ...rest } = x
143        return value.trim() === '' ? rest : { ...rest, [q.id]: value.trim() }
144      })
145      const after = list.slice(list.indexOf(q) + 1).find(x => all[x.id] === undefined)
146      await update($, answering, () => after?.id ?? null)
147      void $.ui.focus({ requestId: band, key: after === undefined ? 'send' : `in${after.id}` }).catch(() => undefined)
148    }
149    const sendAll = async () => {
150      const all = await read($, drafts)
151      const message = answersMessage(list, all)
152      const ids = list.filter(q => all[q.id] !== undefined).map(q => q.id)
153      if (ids.length === 0) {
154        return
155      }
156      await update($, answering, () => null)
157      const left = await update($, open, qs => qs.filter(x => !ids.includes(x.id)))
158      await update($, drafts, x => keep(x, left))
159      await $.prompt.submit({ text: message, asUser: true })
160    }
161
162    const done = drafted === list.length
163    const accent = done ? 'success' : 'warning'
164
165    return (
166      <Box flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1}>
167        <Box>
168          <Button key="fold" label={folded ? ICON.folded : ICON.unfolded} plain dimColor onPress={toggle} />
169          <Text> </Text>
170          {pill(accent, `${done ? ICON.done : ICON.question} ${list.length} open`)}
171          <Text> </Text>
172          {list.length <= 8 ? (
173            <Text>
174              <Text color="success">{'●'.repeat(drafted)}</Text>
175              <Text dimColor>{'○'.repeat(list.length - drafted)}</Text>
176            </Text>
177          ) : (
178            <Text dimColor>
179              {drafted}/{list.length}
180            </Text>
181          )}
182          {folded && (
183            <Box flexShrink={1}>
184              <Text dimColor wrap="truncate-end">
185                {'  '}
186                {list[0]?.text}
187                {list.length > 1 ? ' …' : ''}
188              </Text>
189            </Box>
190          )}
191          <Box flexGrow={1} justifyContent="flex-end">
192            {drafted > 0 ? (
193              <Button key="send" variant="primary" onPress={sendAll}>
194                {ICON.send} send {drafted}
195              </Button>
196            ) : (
197              !folded && <Text dimColor>{ICON.keyboard} ctrl+x tab</Text>
198            )}
199          </Box>
200        </Box>
201        {!folded &&
202          list.map(q => {
203            const draft = d[q.id]
204            const isOpen = current === q.id && Input !== undefined
205            return (
206              <Box key={`q${q.id}`} flexDirection="column" marginTop={1}>
207                <Box>
208                  {pill(draft === undefined ? (isOpen ? 'warning' : 'inactive') : 'success', draft === undefined ? q.id : ICON.done)}
209                  <Text> </Text>
210                  <Box flexGrow={1} flexShrink={1}>
211                    <Text bold={isOpen} dimColor={draft !== undefined && !isOpen}>
212                      {q.text}
213                    </Text>
214                  </Box>
215                  <Text> </Text>
216                  <Button key={`a${q.id}`} label={draft === undefined ? ICON.answer : ICON.edit} plain dimColor onPress={() => startAnswer(q)} />
217                  {/* The cross shows only under the pointer, so the rows stay calm. */}
218                  <Box display="none" hover={{ display: 'flex' }}>
219                    <Text>  </Text>
220                    <Button key={`x${q.id}`} label={ICON.close} plain dimColor onPress={() => close(q.id)} />
221                  </Box>
222                </Box>
223                {isOpen ? (
224                  <Box paddingLeft={1}>
225                    <Input
226                      key={`in${q.id}`}
227                      label={`${ICON.branch} `}
228                      value={draft ?? ''}
229                      placeholder="your answer · Enter keeps it and moves on · empty skips"
230                      submitLabel="keep"
231                      autoFocus
232                      onSubmit={(value: string) => save(q, value)}
233                    />
234                  </Box>
235                ) : (
236                  draft !== undefined && (
237                    <Box paddingLeft={1}>
238                      <Text dimColor>{ICON.branch} </Text>
239                      <Text color="success">{draft}</Text>
240                    </Box>
241                  )
242                )}
243              </Box>
244            )
245          })}
246      </Box>
247    )
248  })
249}
250
types/index.d.ts 14 lines
1export type Question = { id: number; text: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'open-questions': {
6      questions: Question[]
7      questionsNextId: number
8      questionsFolded: boolean
9      questionsAnswering: number | null
10      questionsDrafts: Record<string, string>
11    }
12  }
13}
14