SLOPSHOPPER

idea-shell

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

newpanespinnerguardcommandtoast
★ 2v0.1.0MITupdated 2026-10-09MichaelP17/claude-mods/idea-shell
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · idea-shell
│ ┃ New idea ✕ › fix the failing auth test and add an audit log call │ ┃ New idea ⏎ save │ ┃ 0 ideas for this folder · Shift+Enter adds a ⏺ Read(src/auth.ts) │ ┃ line · Esc closes ⎿ 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 │ │ › /idea │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · New idea
New idea ⏎ save 0 ideas for this folder · Shift+Enter adds a line · Esc closes
Pane · Ideas
No ideas for this folder. /idea captures one.
README

idea-shell

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.

WhenWhat you see
/idea <text>, also while Claude is workingthe idea is saved, a toast confirms it; Claude sees nothing
/ideaa 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
/ideasa 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 ideasClaude reads them with its ideas_list tool and removes each with idea_remove once it is done

No setup needed.

Where ideas are kept

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.

Details and limits

  • /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.
  • The folder is the one the session started in; a cd inside a shell command does not change it, /cd does. A subfolder or a worktree has a list of its own.
  • Every change re-reads the file first, so two sessions in the same folder do not overwrite each other's ideas.
  • The desktop app's text field is a single line: Shift+Enter saves there like Enter.
  • The mobile app draws no text field; /idea <text> works there.

Uninstall

Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. Captured ideas stay in ~/.claude/ideas/ until you delete them.

Source 3 files
hooks/register.tsx 238 lines
1import { 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}
238
hooks/ideas.ts 72 lines
1// 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}
72
types/index.d.ts 10 lines
1export type Idea = string
2
3declare module 'claude-code' {
4  interface PluginState {
5    'idea-shell': {
6      ideas: Idea[]
7    }
8  }
9}
10