Captures ideas per folder with /idea while Claude keeps working, and hands them to Claude later

Captures ideas while Claude is busy, without interrupting it. Type /idea mid-turn, write the thought down, and Claude keeps working on its task; later you hand the idea to Claude or let it work through the list.
Ideas belong to the folder Claude Code was started in. Every folder has its own list, whether it is a git repository or not.
| When | What you see |
|---|---|
/idea <text>, also while Claude is working | the idea is saved, a toast confirms it; Claude sees nothing |
/idea | a pane with one text field: Enter saves and empties it for the next idea, Shift+Enter adds a line in the terminal, Esc closes |
| Ideas exist for this folder | 💡 3 in the footer under the prompt, terminal and desktop app |
/ideas | a pane listing the folder's ideas: pressing one puts it into the prompt and removes it from the list, delete drops it |
| You ask Claude to work through the ideas | Claude reads them with its ideas_list tool and removes each with idea_remove once it is done |
No setup needed.
One Markdown file per folder under ~/.claude/ideas/, mirroring the folder's path: ideas for ~/Projects/app live in ~/.claude/ideas/home/Projects/app.md, ideas for a folder outside your home directory under ~/.claude/ideas/root/…. A file holds nothing but the ideas, one bullet each, so reading it costs Claude only the ideas themselves. The file is deleted when its last idea is gone.
config-snapshots records ~/.claude/ideas/ with every snapshot, which carries the ideas to other machines that share the snapshot repository.
/idea runs immediately, even while a turn is in flight, and leaves no row in the conversation: neither the command nor the idea reaches Claude's context./ideas waits for the turn to end, since handing an idea to Claude only makes sense once it is free.cd inside a shell command does not change it, /cd does. A subfolder or a worktree has a list of its own./idea <text> works there.Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. Captured ideas stay in ~/.claude/ideas/ until you delete them.
hooks/register.tsx 238 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { formatIdeas, ideasFileOf, normalizeIdea, numberedList, parseIdeas, previewOf } from './ideas'
5
6const CAPTURE_PANE = 'idea'
7const LIST_PANE = 'ideas'
8const LIST_TOOL = 'mcp__idea-shell__ideas_list'
9const REMOVE_TOOL = 'mcp__idea-shell__idea_remove'
10
11const ideas = atom({ plugin: 'idea-shell', key: 'ideas' } as const, [])
12
13async function ideasFile($: EngineInterface): Promise<string> {
14 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
15 const configDirectory = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
16 const folder = await $.session.root()
17
18 return ideasFileOf(folder, home, `${configDirectory}/ideas`)
19}
20
21// Every change starts from the file, not from the drawn list, so ideas another
22// session or Claude added in the meantime are never overwritten.
23async function load($: EngineInterface): Promise<string[]> {
24 const path = await ideasFile($)
25 const text = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
26 const list = parseIdeas(text)
27 await update($, ideas, () => list)
28
29 return list
30}
31
32async function save($: EngineInterface, list: string[]): Promise<void> {
33 const path = await ideasFile($)
34 if (list.length > 0) {
35 await $.fs.write(path, formatIdeas(list))
36 } else if (await $.fs.exists(path)) {
37 await $.process.run(['rm', '-f', path])
38 }
39 await update($, ideas, () => list)
40}
41
42async function addIdea($: EngineInterface, text: string): Promise<boolean> {
43 const idea = normalizeIdea(text)
44 if (idea.length === 0) {
45 return false
46 }
47 const list = await load($)
48 await save($, [...list, idea])
49
50 return true
51}
52
53async function removeIdea($: EngineInterface, idea: string): Promise<boolean> {
54 const list = await load($)
55 const index = list.indexOf(idea)
56 if (index === -1) {
57 return false
58 }
59 await save($, list.filter((_, position) => position !== index))
60
61 return true
62}
63
64async function workOn($: EngineInterface, idea: string): Promise<void> {
65 const draft = await $.prompt.read()
66 const isEmpty = draft.text.trim().length === 0
67 const filled = await $.prompt.fill({ text: isEmpty ? idea : `\n${idea}`, mode: isEmpty ? 'replace' : 'append' })
68 if (!filled.isFilled) {
69 $.ui.toast('The prompt box is busy; the idea stays in the list.')
70 return
71 }
72 await removeIdea($, idea)
73 await $.ui.close({ id: LIST_PANE })
74}
75
76function textResult(text: string) {
77 return { result: text }
78}
79
80export const register: Register = on => {
81 on('session.start', async ($, e, next) => {
82 await $.command.register({
83 name: 'idea',
84 description: 'Capture an idea for this folder without interrupting Claude',
85 argumentHint: '[idea]',
86 immediate: true,
87 })
88 await $.command.register({
89 name: 'ideas',
90 description: 'Show the ideas captured for this folder and hand one to Claude',
91 })
92 await $.tool.register({
93 name: 'ideas_list',
94 description:
95 'Lists the ideas the user captured with /idea for the folder this session runs in, numbered. Use it when the user asks to work through, review or pick from their ideas.',
96 inputSchema: { type: 'object', properties: {} },
97 })
98 await $.tool.register({
99 name: 'idea_remove',
100 description:
101 'Removes one idea from this folder\'s list. Call it only once the idea is done or the user dropped it. `number` comes from the latest ideas_list; list again before removing another, since the numbers shift.',
102 inputSchema: {
103 type: 'object',
104 properties: { number: { type: 'integer', minimum: 1 } },
105 required: ['number'],
106 },
107 })
108 await load($)
109
110 return next(e)
111 })
112
113 on('turn.complete', async ($, e, next) => {
114 const result = await next(e)
115 await load($)
116
117 return result
118 })
119
120 on('command.run', { command: 'idea' }, async ($, e) => {
121 if (e.args.trim().length > 0) {
122 if (await addIdea($, e.args)) {
123 $.ui.toast('Idea saved')
124 }
125
126 return {}
127 }
128 await load($)
129 await $.ui.open({ id: CAPTURE_PANE, title: 'New idea', focus: true, closeOnEscape: true, rows: 4 })
130
131 return {}
132 })
133
134 on('command.run', { command: 'ideas' }, async $ => {
135 await load($)
136 await $.ui.open({ id: LIST_PANE, title: 'Ideas', focus: true, closeOnEscape: true })
137
138 return {}
139 })
140
141 on('tool.call', { tool: LIST_TOOL }, async $ => {
142 const list = await load($)
143
144 return textResult(list.length === 0 ? 'No ideas captured for this folder.' : numberedList(list))
145 }).catch(() => ({ deny: 'idea-shell could not read the ideas file.' }))
146
147 on('tool.call', { tool: REMOVE_TOOL }, async ($, e) => {
148 const number = (e as { number?: unknown }).number
149 const list = await load($)
150 const idea = typeof number === 'number' ? list[number - 1] : undefined
151 if (idea === undefined) {
152 return textResult(`No idea number ${String(number)}; the list has ${list.length}.`)
153 }
154 await removeIdea($, idea)
155
156 return textResult(`Removed: ${idea}`)
157 }).catch(() => ({ deny: 'idea-shell could not update the ideas file.' }))
158
159 on('ui.render', { component: 'Pane', requestId: CAPTURE_PANE }, async ($, e) => {
160 // Asked by surface, not by `'Input' in` the table: the mobile table carries an
161 // Input that draws nothing.
162 if (e.surface === 'mobile') {
163 const { Text } = $.ui.resolve(e)
164
165 return <Text dimColor>This app draws no text field. /idea followed by the idea saves it.</Text>
166 }
167 const { Box, Input, Text } = $.ui.resolve(e)
168 // Reading the list makes every save redraw the pane, which empties the field.
169 const count = (await read($, ideas)).length
170 // The desktop app's field is a single line, where Shift+Enter saves like Enter.
171 const lineBreak = e.surface === 'terminal' ? ' · Shift+Enter adds a line' : ''
172 const hint = `${count} ${count === 1 ? 'idea' : 'ideas'} for this folder${lineBreak} · Esc closes`
173
174 return (
175 <Box flexDirection="column">
176 <Input
177 key="idea"
178 value=""
179 placeholder="New idea"
180 submitLabel="save"
181 autoFocus
182 onSubmit={value => {
183 void addIdea($, value)
184 }}
185 />
186 <Text dimColor>{hint}</Text>
187 </Box>
188 )
189 })
190
191 on('ui.render', { component: 'Pane', requestId: LIST_PANE }, async ($, e) => {
192 const { Box, Text, Button } = $.ui.resolve(e)
193 const list = await read($, ideas)
194 if (list.length === 0) {
195 return <Text dimColor>No ideas for this folder. /idea captures one.</Text>
196 }
197
198 return (
199 <Box flexDirection="column">
200 {list.map((idea, index) => (
201 <Box key={`row-${index}`}>
202 <Button key={`work-${index}`} plain label={previewOf(idea)} onPress={() => workOn($, idea)} />
203 <Button key={`delete-${index}`} plain dimColor label="delete" onPress={() => removeIdea($, idea)} />
204 </Box>
205 ))}
206 <Box marginTop={1}>
207 <Text dimColor>Pressing an idea puts it into the prompt and removes it from the list.</Text>
208 </Box>
209 </Box>
210 )
211 })
212
213 // The terminal draws the mode labels itself, so the count joins them there.
214 // On the desktop, status-band draws the footer from the labels it was handed,
215 // whichever order the mods load in; only a drawn tree survives that.
216 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
217 const count = (await read($, ideas)).length
218 if (count === 0) {
219 return next(e)
220 }
221 const label = `💡 ${count}`
222 if (e.surface === 'terminal') {
223 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
224 }
225 const below = await next(e)
226 const { Box, Text } = $.ui.resolve(e)
227 const rest = below.type !== 'engine' ? below : e.props.modes.length === 0 ? null : <Text dimColor>{e.props.modes.join(' & ')}</Text>
228
229 return (
230 <Box flexDirection="row">
231 <Text dimColor>{label}</Text>
232 {rest !== null && <Text dimColor> · </Text>}
233 {rest}
234 </Box>
235 )
236 })
237}
238hooks/ideas.ts 72 lines1// One Markdown bullet per idea, continuation lines indented by two spaces.
2// Nothing else goes into the file, so reading it costs Claude only the ideas.
3export function parseIdeas(text: string): string[] {
4 const ideas: string[][] = []
5 for (const line of text.replace(/\r\n?/g, '\n').split('\n')) {
6 if (line.startsWith('- ')) {
7 ideas.push([line.slice(2)])
8 continue
9 }
10 const current = ideas.at(-1)
11 if (current !== undefined) {
12 current.push(line.startsWith(' ') ? line.slice(2) : line)
13 continue
14 }
15 // Text written by hand above the first bullet is kept as an idea of its own
16 // instead of being dropped on the next save.
17 if (line.trim().length > 0) {
18 ideas.push([line])
19 }
20 }
21
22 return ideas.map(i => i.join('\n').trim()).filter(i => i.length > 0)
23}
24
25export function formatIdeas(ideas: readonly string[]): string {
26 if (ideas.length === 0) {
27 return ''
28 }
29 const blocks = ideas.map(idea => {
30 const [first = '', ...rest] = idea.split('\n')
31 const continued = rest.map(line => (line.length === 0 ? '' : ` ${line}`))
32
33 return [`- ${first}`, ...continued].join('\n')
34 })
35
36 return `${blocks.join('\n')}\n`
37}
38
39export function normalizeIdea(text: string): string {
40 return text.replace(/\r\n?/g, '\n').trim()
41}
42
43// The file mirrors the folder's path, so every folder keeps its own list and the
44// same folder maps to the same file on every machine that shares the home layout.
45export function ideasFileOf(folder: string, home: string, ideasDirectory: string): string {
46 const path = trimSeparators(folder)
47 const homePath = trimSeparators(home)
48 if (homePath.length > 0 && path === homePath) {
49 return `${ideasDirectory}/home.md`
50 }
51 if (homePath.length > 0 && path.startsWith(`${homePath}/`)) {
52 return `${ideasDirectory}/home/${path.slice(homePath.length + 1)}.md`
53 }
54 const relative = path.replace(/^\/+/, '').replace(/:/g, '')
55
56 return relative.length === 0 ? `${ideasDirectory}/root.md` : `${ideasDirectory}/root/${relative}.md`
57}
58
59function trimSeparators(path: string): string {
60 return path.replace(/\\/g, '/').replace(/\/+$/, '')
61}
62
63export function previewOf(idea: string): string {
64 const [first = ''] = idea.split('\n')
65
66 return idea.includes('\n') ? `${first} …` : first
67}
68
69export function numberedList(ideas: readonly string[]): string {
70 return ideas.map((idea, index) => `${index + 1}. ${idea.split('\n').join('\n ')}`).join('\n')
71}
72types/index.d.ts 10 lines1export type Idea = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'idea-shell': {
6 ideas: Idea[]
7 }
8 }
9}
10