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

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 story behind it: The Warning My Agent Gave That I Never Saw.
/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.
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.
questions tool. You do nothing.ctrl+x tab focuses the box, with the first unanswered question already open.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:
| Event | What it does |
|---|---|
session.start | Registers the questions tool: ask adds questions, answered closes them by id. The tool description tells Claude when to call it. |
tool.call | Handles the tool: keeps the open questions in plugin state (atom), hands out ids in one update so parallel calls never share one. |
prompt.submit | Adds 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>.
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.
Issues and PRs welcome. Run claude plugin validate --strict . and claude plugin test . before sending one.
MIT
hooks/open-questions.tsx 250 lines1// 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}
250types/index.d.ts 14 lines1export 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