SLOPSHOPPER

decision-box

Claude asks for your call in the built-in question box, with an everyday story and a recommendation for every option.

newrowsprompt
v0.1.0no licenseupdated 2026-10-26screamyx/claude-code-mods/decision-box
A shopper browsing a rack in a slop shop
README

Claude Code mods

Mods for the Claude Code desktop app and terminal.

decision-box

When Claude needs your call, it asks in Claude Code's own question box instead of in plain text, and it writes every option the same way:

  • The option it recommends comes first, marked "(Recommended)", with one sentence on why.
  • Each option has a short everyday story: who is affected and what they see on a normal day, and what goes wrong in the bad case.

In the desktop app the stories sit in a panel just above the box, so you can compare all the options at once. In the terminal, your pick shows above the box and each story shows when you move to that option. You click an option, or type your own answer under Other, and Claude carries on with it right away.

The mod also tells Claude to use the box whenever it needs your input, and to hand questions up to the main agent when a subagent hits one.

Install

In the desktop app: click + next to the prompt box, then Plugins, Add marketplace, enter screamyx/claude-code-mods, then Add plugin and choose decision-box.

At the prompt of a terminal session:

/plugin install decision-box --marketplace screamyx/claude-code-mods

From a shell:

claude plugin install decision-box --marketplace screamyx/claude-code-mods

Answer y to add the marketplace, then pick the user scope so it applies to every session.

If this repository is private, your machine needs a git login for github.com first (gh auth login, then gh auth setup-git).

Develop

claude plugin validate decision-box
claude plugin test decision-box
Source 2 files
hooks/register.tsx 82 lines
1import type { Register } from 'claude-code'
2
3import { panelFor, withoutStories } from './stories'
4
5export const RULES = `# Asking the person to decide (decision-box mod)
6
7Whenever you need the person's input to go on, ask with the AskUserQuestion tool, not in plain text. That covers a choice between approaches, a call on direction, a confirmation before a step that is hard to undo, and a clarifying question about what they expect. Use it whenever you can: the person would rather click an answer than type one. Do not ask about things you can reasonably decide yourself, and never ask the same thing twice.
8
9Write every question like this:
10- question: plain words, no jargon, ending in "?".
11- Always recommend one option. Put it first and end its label with " (Recommended)".
12- label: 1 to 5 words. A technical term is fine as a label, but the scenario below comes first in the person's mind.
13- description: one short line on what happens if it is picked.
14- preview: the everyday scenario, exactly in these lines:
15  Normal day: who is affected (a staff member, a customer, the person themselves), and what they see or do.
16  Bad case: what goes wrong, concretely.
17  and on the recommended option only:
18  Why: one sentence on why you recommend it.
19- multiSelect only when picks really combine.
20- No "Other" option: the box already has a field for typing.
21
22When the answer comes back, act on it straight away. Free text the person typed wins over the options you offered.
23
24Subagents cannot reach the person. If you are a subagent, hand the question back to the agent that started you, written the same way.`
25
26const TOOL_NOTE = `
27
28Write the options as the decision-box rules in the system prompt say: the recommended option first with " (Recommended)" in its label, and each option's preview holding "Normal day:" and "Bad case:" lines, plus "Why:" on the recommended one.`
29
30export const register: Register = on => {
31  on('prompt.compose', async ($, e, next) => {
32    const composed = await next(e)
33    if (!e.tools.includes('AskUserQuestion')) return composed
34
35    return {
36      sections: [
37        ...composed.sections,
38        { id: 'decision-box:rules', text: RULES, scope: 'session' },
39      ],
40    }
41  })
42
43  on('tool.describe', { tool: 'AskUserQuestion' }, async ($, e, next) => {
44    const described = await next(e)
45
46    return { ...described, description: described.description + TOOL_NOTE }
47  })
48
49  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
50    const panel = panelFor(e.props.questions, e.surface)
51    if (panel === undefined) return next(e)
52
53    const box = await next(
54      panel.isStripping
55        ? { ...e, props: { ...e.props, questions: withoutStories(e.props.questions) } }
56        : e,
57    )
58
59    if (e.surface === 'terminal') {
60      const { Box, Text } = $.ui.resolve(e)
61
62      return (
63        <Box flexDirection="column" gap={1}>
64          <Text dimColor>{panel.text}</Text>
65          {box}
66        </Box>
67      )
68    }
69
70    const { Box, Markdown } = $.ui.resolve(e)
71
72    return (
73      <Box flexDirection="column" gap="sm">
74        <Box flexDirection="column" padding="sm" borderStyle="round" borderDimColor>
75          <Markdown text={panel.text} />
76        </Box>
77        {box}
78      </Box>
79    )
80  })
81}
82
hooks/stories.ts 176 lines
1// Reads the everyday story Claude writes into each option's `preview`:
2//   Normal day: ...
3//   Bad case: ...
4//   Why: ...        (the recommended option only)
5
6export type Story = {
7  label: string
8  isRecommended: boolean
9  day?: string
10  bad?: string
11  why?: string
12}
13
14export type Asked = {
15  header: string
16  question: string
17  stories: Story[]
18}
19
20type RawOption = { label?: unknown; description?: unknown; preview?: unknown }
21type RawQuestion = { question?: unknown; header?: unknown; options?: unknown }
22
23const RECOMMENDED = /\s*\((recommended|my pick)\)\s*$/i
24const LINE = /^\s*(normal day|bad case|why)\s*:\s*(.+)$/i
25
26const text = (value: unknown): string => (typeof value === 'string' ? value : '')
27
28export function cleanLabel(label: string): string {
29  return label.replace(RECOMMENDED, '')
30}
31
32export function readStory(option: RawOption): Story {
33  const label = text(option.label)
34  const story: Story = {
35    label: cleanLabel(label),
36    isRecommended: RECOMMENDED.test(label),
37  }
38
39  for (const line of text(option.preview).split('\n')) {
40    const match = LINE.exec(line)
41    if (match === null) continue
42    const kind = (match[1] ?? '').toLowerCase()
43    const body = (match[2] ?? '').trim()
44    if (kind === 'normal day') story.day = body
45    else if (kind === 'bad case') story.bad = body
46    else story.why = body
47  }
48  if (story.why !== undefined) story.isRecommended = true
49
50  return story
51}
52
53export function hasStory(story: Story): boolean {
54  return story.day !== undefined || story.bad !== undefined
55}
56
57export function readQuestions(questions: readonly unknown[]): Asked[] {
58  return questions.map(raw => {
59    const q = (raw ?? {}) as RawQuestion
60    const options = Array.isArray(q.options) ? (q.options as RawOption[]) : []
61
62    return {
63      header: text(q.header),
64      question: text(q.question),
65      stories: options.map(readStory),
66    }
67  })
68}
69
70// The previews the mod draws itself come off the options, so the box does
71// not show the same story twice. Previews that hold no story stay.
72export function withoutStories(questions: readonly unknown[]): unknown[] {
73  return questions.map(raw => {
74    const q = (raw ?? {}) as RawQuestion
75    if (!Array.isArray(q.options)) return raw
76
77    return {
78      ...q,
79      options: (q.options as RawOption[]).map(option => {
80        if (!hasStory(readStory(option))) return option
81        const { preview: _, ...rest } = option
82
83        return rest
84      }),
85    }
86  })
87}
88
89export function pickLine(asked: Asked): string | undefined {
90  const pick = asked.stories.find(story => story.isRecommended)
91  if (pick === undefined) return undefined
92
93  return pick.why === undefined
94    ? `My pick: ${pick.label}.`
95    : `My pick: ${pick.label}. ${pick.why}`
96}
97
98export function storyMarkdown(asked: readonly Asked[]): string {
99  const blocks: string[] = []
100
101  for (const one of asked) {
102    if (!one.stories.some(hasStory)) continue
103    const lines: string[] = []
104    if (asked.length > 1 && one.header !== '') lines.push(`**${one.header}**`)
105    const pick = pickLine(one)
106    if (pick !== undefined) lines.push(pick, '')
107    for (const story of one.stories) {
108      if (!hasStory(story)) continue
109      lines.push(`**${story.label}**`)
110      if (story.day !== undefined) lines.push(`Normal day: ${story.day}`)
111      if (story.bad !== undefined) lines.push(`*Bad case:* ${story.bad}`)
112      lines.push('')
113    }
114    blocks.push(lines.join('\n').trim())
115  }
116
117  return blocks.join('\n\n---\n\n')
118}
119
120// Rows as the question dialog counts them: one per line, one more for every
121// 40 columns a line runs to.
122export function countRows(markdown: string): number {
123  return markdown
124    .split('\n')
125    .reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / 40)), 0)
126}
127
128export function picksMarkdown(asked: readonly Asked[]): string {
129  return asked
130    .map(one => {
131      const pick = pickLine(one)
132      if (pick === undefined) return undefined
133
134      return asked.length > 1 && one.header !== '' ? `**${one.header}:** ${pick}` : pick
135    })
136    .filter((line): line is string => line !== undefined)
137    .join('\n\n')
138}
139
140// The desktop, VS Code and mobile frame above the box holds 48 rows; a few
141// are kept spare for the gap and wrapping the count cannot see.
142const ROWS = 42
143// The terminal frame holds 12 and draws no Markdown.
144const TERMINAL_ROWS = 4
145
146export type Panel = {
147  // What goes above the box.
148  text: string
149  // Whether the stories come off the options' previews, shown above instead.
150  isStripping: boolean
151}
152
153export function panelFor(questions: readonly unknown[], surface: string): Panel | undefined {
154  const asked = readQuestions(questions)
155
156  if (surface === 'terminal') {
157    // The terminal shows each option's preview as the box's own, so the
158    // stories stay there and only the picks go above.
159    const picks = picksMarkdown(asked).replace(/\*\*/g, '')
160    if (picks === '' || countRows(picks) > TERMINAL_ROWS) return undefined
161
162    return { text: picks, isStripping: false }
163  }
164
165  const story = storyMarkdown(asked)
166  if (story === '') return undefined
167  if (countRows(story) <= ROWS) return { text: story, isStripping: true }
168
169  // Too long to show whole: the picks go above and each story stays in its
170  // option's preview, where the box shows it on focus.
171  const picks = picksMarkdown(asked)
172  if (picks === '' || countRows(picks) > ROWS) return undefined
173
174  return { text: picks, isStripping: false }
175}
176