See what Claude assumed and what you forgot to ask. A side pane that lists Claude's unstated assumptions and the scenarios you might have missed, updated after…

See what Claude assumed and what you forgot to ask, right next to your conversation.

When you ask Claude Code to build something, it quietly fills in the blanks. It decides how people sign in, which database to use and what happens when something fails. Usually you only find out later.
Blind spots adds a side pane that lists those blanks after every reply.
Press any item and it goes to Claude as your next message. Correcting an assumption or digging into a scenario takes one key.
Add the marketplace and install the plugin, then start a new session.
claude plugin marketplace add aadityasp/blind-spots
claude plugin install blind-spots@blind-spots
You can also clone it straight into your skills folder.
git clone https://github.com/aadityasp/blind-spots ~/.claude/skills/blind-spots
The pane opens by itself at the start of every session. To try it for a single session without installing it, point Claude Code at the folder instead.
claude --plugin-dir /path/to/blind-spots
Built and tested on Claude Code 2.1.303. It needs a version that supports mods.
| Key | What it does |
|---|---|
1 to 6 | Ask Claude about that scenario |
| Click or Tab and Enter on an assumption | Confirm or correct it |
r | Refresh now |
a | Turn auto refresh on or off |
/blind-spots | Reopen the pane and refresh it |
Hotkeys work while the pane has focus. Click it or press ctrl+x then tab to focus it. Items you've already asked about turn dim and get a ✓.
The pane docks beside the conversation in fullscreen mode and sits above the prompt otherwise. Claude Code only opens a pane by itself on a terminal at least 144 columns wide. On a narrower one, run /blind-spots once, and from then on it opens by itself from 110 columns.
After each of Claude's replies in the main conversation, the mod forks the conversation once in the background and asks for both lists as JSON. Turns run by subagents don't trigger a refresh.
Each request includes the lists currently on screen. That lets assumptions you've confirmed or corrected drop off, while scenarios that still matter stay put. Only the newest refresh can write to the pane, so a slow request never overwrites a newer one.
The background fork reads your conversation, the same context Claude already has, and returns only the two lists. Nothing goes anywhere else.
The mod submits a prompt only when you press an item, and it never submits anything on its own. What it submits is the one-sentence message shown for that item, which the fork wrote, such as "Should the pane really refresh after every reply?" It goes into the conversation as your next message, exactly as if you had typed it. The full privacy policy has the details.
| Hook | What it does |
|---|---|
session.start | Opens the pane and registers /blind-spots |
command.run | Answers /blind-spots by opening the pane and refreshing it. It handles no other command. |
turn.complete | Passes the finished turn through unchanged, then schedules a refresh after main conversation replies |
ui.render | Draws the pane |
Every refresh is one extra model call that reads your conversation, so it adds to your token usage. If that matters, press a to turn auto refresh off and press r whenever you want a fresh read.
claude plugin test .
claude plugin validate .
Everything lives in hooks/register.tsx. The pane's state is typed in types/index.d.ts, and the tests are in tests/. Claude Code generates the typings in .claude-plugin/types/ for editor type checking, and git ignores them.
hooks/register.tsx 311 lines1// Blind spots: a side pane that, after each of Claude's replies, lists the
2// scenarios you might have missed and the assumptions Claude is making.
3// One background fork of the conversation fills both lists. Pressing an item
4// sends it as your next message.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { Assumption, Spot, SpotKind, Status } from '../types'
10
11const PANE = 'blind-spots'
12const TITLE = 'Blind spots'
13const MISSED_HEADING = 'Scenarios you might have missed'
14const ASSUMED_HEADING = 'Assumptions Claude is making'
15const MAX_SPOTS = 6
16const MAX_ASSUMPTIONS = 12
17
18const spots = atom({ plugin: 'blind-spots', key: 'spots' } as const, [])
19const assumptions = atom({ plugin: 'blind-spots', key: 'assumptions' } as const, [])
20const status = atom({ plugin: 'blind-spots', key: 'status' } as const, 'idle')
21const isAuto = atom({ plugin: 'blind-spots', key: 'isAuto' } as const, true)
22const picked = atom({ plugin: 'blind-spots', key: 'picked' } as const, [])
23
24const KINDS: readonly SpotKind[] = ['question', 'feature', 'topic']
25const TAG: Record<SpotKind, string> = { question: 'Q', feature: 'F', topic: 'T' }
26const KIND_COLOR: Record<SpotKind, string> = { question: 'suggestion', feature: 'success', topic: 'merged' }
27
28function promptFor(prior: readonly Spot[], priorAssumed: readonly Assumption[]): string {
29 const shown =
30 prior.length === 0
31 ? ''
32 : `\nAlready shown as missed (keep one only if it is still unaddressed and still among the most important): ${prior
33 .map(spot => spot.title)
34 .join('; ')}\n`
35 const assumedBefore =
36 priorAssumed.length === 0
37 ? ''
38 : `\nAlready listed as assumptions (keep each one still in force, drop any the user has since confirmed or corrected): ${priorAssumed
39 .map(one => one.text)
40 .join('; ')}\n`
41
42 return `Step outside the conversation for a moment and answer two things.
43
441. "missed": what important things has the user probably NOT considered yet? Think of questions worth asking, features or options worth adding, and topics worth looking into. Prefer high-leverage, non-obvious items specific to this conversation over generic advice. Skip anything already discussed.
45${shown}
462. "assumptions": every assumption the assistant has made so far while doing this work that the user never stated. Include inferred requirements, defaults it picked, guesses about the environment, tools, data or audience, and scope it decided to include or leave out. Write each as a plain statement of what was assumed, specific enough that the user can say yes or no to it. List all that are still in force, most consequential first, up to ${MAX_ASSUMPTIONS}. Use an empty list if there are none.
47${assumedBefore}
48Reply with ONLY one JSON object, nothing else:
49{"missed":[{"kind":"question"|"feature"|"topic","title":"at most 6 words","ask":"one sentence the user could send to start discussing it"}],"assumptions":[{"text":"at most 14 words","ask":"one sentence the user could send to confirm or correct it"}]}
50Give 3 to ${MAX_SPOTS} missed items, most important first. No em dashes.`
51}
52
53type Found = { spots: Spot[]; assumptions: Assumption[] }
54
55function toSpots(raw: unknown): Spot[] {
56 if (!Array.isArray(raw)) return []
57
58 return raw
59 .flatMap((item: unknown): Spot[] => {
60 if (typeof item !== 'object' || item === null) return []
61 const { kind, title, ask } = item as Record<string, unknown>
62 if (typeof title !== 'string' || title.trim() === '') return []
63 const spotKind = KINDS.find(one => one === kind) ?? 'topic'
64 const spotAsk = typeof ask === 'string' && ask.trim() !== '' ? ask.trim() : `Let's discuss: ${title.trim()}`
65
66 return [{ kind: spotKind, title: title.trim(), ask: spotAsk }]
67 })
68 .slice(0, MAX_SPOTS)
69}
70
71function toAssumptions(raw: unknown): Assumption[] {
72 if (!Array.isArray(raw)) return []
73
74 return raw
75 .flatMap((item: unknown): Assumption[] => {
76 const { text, ask } =
77 typeof item === 'string' ? { text: item, ask: undefined } : ((item ?? {}) as Record<string, unknown>)
78 if (typeof text !== 'string' || text.trim() === '') return []
79 const oneAsk =
80 typeof ask === 'string' && ask.trim() !== ''
81 ? ask.trim()
82 : `You assumed this, so let's check it. ${text.trim()}`
83
84 return [{ text: text.trim(), ask: oneAsk }]
85 })
86 .slice(0, MAX_ASSUMPTIONS)
87}
88
89// The reply is one object with both lists. A bare array is the older shape
90// and still reads as missed items only.
91function parse(text: string): Found | undefined {
92 const brace = text.indexOf('{')
93 const bracket = text.indexOf('[')
94 const isArray = bracket >= 0 && (brace < 0 || bracket < brace)
95 const start = isArray ? bracket : brace
96 const end = text.lastIndexOf(isArray ? ']' : '}')
97 if (start < 0 || end <= start) return undefined
98
99 let raw: unknown
100 try {
101 raw = JSON.parse(text.slice(start, end + 1))
102 } catch {
103 return undefined
104 }
105
106 const { missed, assumptions: assumed } = isArray ? { missed: raw, assumptions: [] } : ((raw ?? {}) as Record<string, unknown>)
107 const found: Found = { spots: toSpots(missed), assumptions: toAssumptions(assumed) }
108
109 return found.spots.length === 0 && found.assumptions.length === 0 ? undefined : found
110}
111
112// Only the newest refresh may write its answer.
113let generation = 0
114
115async function refresh($: EngineInterface): Promise<void> {
116 const mine = ++generation
117 await update($, status, (): Status => 'thinking')
118
119 try {
120 const prior = await read($, spots)
121 const priorAssumed = await read($, assumptions)
122 const reply = await $.model.fork({ prompt: promptFor(prior, priorAssumed) })
123 if (mine !== generation) return
124
125 if (!reply.isAnswered) {
126 await update($, status, (): Status => (reply.reason === 'nothing-to-fork' ? 'idle' : 'failed'))
127 return
128 }
129 const found = parse(reply.text)
130 if (found === undefined) {
131 await update($, status, (): Status => 'failed')
132 return
133 }
134 const shownNow = [...found.spots.map(spot => spot.title), ...found.assumptions.map(one => one.text)]
135 await update($, spots, () => found.spots)
136 await update($, assumptions, () => found.assumptions)
137 await update($, picked, list => list.filter(label => shownNow.includes(label)))
138 await update($, status, (): Status => 'idle')
139 } catch {
140 if (mine === generation) await update($, status, (): Status => 'failed')
141 }
142}
143
144// What pressing an item does: send its text as the user's next message and
145// remember its `label` so the pane can dim it. Takes `$` alone, the shape the
146// plugin directory's checks can follow.
147function asker($: EngineInterface) {
148 return async (label: string, text: string): Promise<void> => {
149 await update($, picked, list => (list.includes(label) ? list : [...list, label]))
150 await $.prompt.submit({ text, asUser: true })
151 }
152}
153
154export const register: Register = on => {
155 on('session.start', async ($, e, next) => {
156 await update($, status, (): Status => 'idle')
157 await $.command.register({
158 name: 'blind-spots',
159 description: 'Show scenarios you might have missed and the assumptions Claude is making',
160 })
161 void $.ui.open({ id: PANE, title: TITLE })
162
163 return next(e)
164 })
165
166 on('command.run', { command: 'blind-spots' }, async $ => {
167 await $.ui.open({ id: PANE, title: TITLE })
168 $.clock.after(1, () => void refresh($))
169
170 return { text: 'Blind spots pane opened. Refreshing now.' }
171 })
172
173 on('turn.complete', async ($, e, next) => {
174 const done = await next(e)
175 const isMainAnswer = e.agentId === undefined && e.reason === 'answer'
176
177 if (isMainAnswer && (await read($, isAuto))) {
178 $.clock.after(1, () => void refresh($))
179 }
180
181 return done
182 })
183
184 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
185 const { Box, Button, Text } = $.ui.resolve(e)
186 const ask = asker($)
187 const list = await read($, spots)
188 const assumed = await read($, assumptions)
189 const now = await read($, status)
190 const auto = await read($, isAuto)
191 const done = await read($, picked)
192
193 const isTerminal = e.surface === 'terminal'
194 const isEmpty = list.length === 0 && assumed.length === 0
195
196 // A heading, then on the terminal a rule out to the pane's edge with the
197 // item count at its end. A desktop draws a larger heading instead.
198 const heading = (glyph: string, color: string, label: string, count: number) => {
199 const tail = count > 0 ? ` ${count}` : ''
200 const rule = '─'.repeat(Math.max(2, e.props.bodyColumns - label.length - tail.length - 4))
201
202 return (
203 <Box>
204 <Text size="heading" weight="bold">
205 <Text color={color}>{glyph}</Text> {label}
206 </Text>
207 {isTerminal && (
208 <Text dimColor wrap="truncate-end">
209 {' '}
210 {rule}
211 {tail}
212 </Text>
213 )}
214 </Box>
215 )
216 }
217
218 const hint = (text: string) => (
219 <Text dimColor italic>
220 {text}
221 </Text>
222 )
223
224 const asked = (label: string) => done.includes(label) && <Text color="success"> ✓</Text>
225
226 const state =
227 now === 'thinking' ? (
228 <Text color="claude">● thinking</Text>
229 ) : now === 'failed' ? (
230 <Text color="error">✕ refresh failed</Text>
231 ) : (
232 <Text> </Text>
233 )
234
235 return (
236 <Box flexDirection="column">
237 {heading('✦', 'claude', MISSED_HEADING, list.length)}
238 <Box flexDirection="column" paddingLeft={2}>
239 {isEmpty ? (
240 hint("Appears after Claude's next reply.")
241 ) : (
242 <Text dimColor>
243 <Text color={KIND_COLOR.question}>Q</Text> question · <Text color={KIND_COLOR.feature}>F</Text>{' '}
244 feature · <Text color={KIND_COLOR.topic}>T</Text> topic
245 </Text>
246 )}
247 {list.map((spot, index) => (
248 <Button
249 key={`spot:${index}`}
250 plain
251 hotkey={String(index + 1)}
252 dimColor={done.includes(spot.title)}
253 onPress={() => ask(spot.title, spot.ask)}
254 >
255 <Text color={KIND_COLOR[spot.kind]} weight="bold">
256 {TAG[spot.kind]}
257 </Text>
258 {' '}
259 {spot.title}
260 {asked(spot.title)}
261 </Button>
262 ))}
263 </Box>
264
265 <Box marginTop={1} flexDirection="column">
266 {heading('◇', 'warning', ASSUMED_HEADING, assumed.length)}
267 <Box flexDirection="column" paddingLeft={2}>
268 {isEmpty
269 ? hint("Appears after Claude's next reply.")
270 : assumed.length === 0
271 ? hint('None so far.')
272 : hint('Decided without asking. Press to check.')}
273 {/* The bullet sits outside the Button so a long line wraps under its text. */}
274 {assumed.map((one, index) => (
275 <Box key={`row:${index}`}>
276 {/* Kept from shrinking, or a long line squeezes the bullet out. */}
277 <Box flexShrink={0}>
278 <Text color="warning">{'› '}</Text>
279 </Box>
280 <Box flexShrink={1}>
281 <Button
282 key={`assumed:${index}`}
283 plain
284 dimColor={done.includes(one.text)}
285 onPress={() => ask(one.text, one.ask)}
286 >
287 {one.text}
288 {asked(one.text)}
289 </Button>
290 </Box>
291 </Box>
292 ))}
293 </Box>
294 </Box>
295
296 <Box marginTop={1} justifyContent="space-between">
297 <Box columnGap={2}>
298 <Button key="refresh" plain hotkey="r" dimColor onPress={() => refresh($)}>
299 refresh
300 </Button>
301 <Button key="auto" plain hotkey="a" dimColor onPress={() => update($, isAuto, value => !value)}>
302 {auto ? 'auto on' : 'auto off'}
303 </Button>
304 </Box>
305 {state}
306 </Box>
307 </Box>
308 )
309 })
310}
311types/index.d.ts 17 lines1export type SpotKind = 'question' | 'feature' | 'topic'
2export type Spot = { kind: SpotKind; title: string; ask: string }
3export type Assumption = { text: string; ask: string }
4export type Status = 'idle' | 'thinking' | 'failed'
5
6declare module 'claude-code' {
7 interface PluginState {
8 'blind-spots': {
9 spots: Spot[]
10 assumptions: Assumption[]
11 status: Status
12 isAuto: boolean
13 picked: string[]
14 }
15 }
16}
17