SLOPSHOPPER

decision-tree

When Claude ends a reply with a question, explains the choice in a side pane and lets you explore any option, or your own, in a real forked session

newpanecommandtoastmodelprocess
v0.2.4MITupdated 2026-10-23whykothari/whystack/mods/decision-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decision-tree
│ ┃ question ✕ › fix the failing auth test and add an audit log call │ ┃ No question yet. The pane fills when Claude │ ┃ asks you to choose. ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /explain │ ⎿ decision-tree: No question to explain yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · question
No question yet. The pane fills when Claude asks you to choose.
README

decision-tree

A Claude Code mod that turns the question at the end of Claude's reply into a side pane you can explore: every option with what it leads to, and a real forked session for any answer you want to try before you commit.

decision-tree in a session: Claude's reply lists three storage options and asks which one to use, and the Decision tree pane beside it shows the question, one box per option with what it leads to and its pros and cons, SQLite in green as recommended, and an Other box for your own answer, above the four steps from Claude asking to you exploring

After every turn:

  1. A regex looks at the end of the reply. A question mark in the last paragraph, or two or more numbered or lettered options. Most turns stop here, with no model call.
  2. Haiku confirms it. It answers YES if you have to decide something before work can go on, NO for "anything else?" and other pleasantries.
  3. A fork explains the choice. $.model.fork replays the session's own transcript (served from the prompt cache), so it knows everything Claude knew. It has no tools and replies with JSON: the question, the context you need, each option with what it leads to and its pros and cons, and a recommendation.
  4. The pane draws it. One box per option, the recommended one in green, and an Explore button on each. The last box is Other: type your own answer and press Enter.

You answer Claude in the prompt as usual. Nothing in the pane goes back to Claude.

Does nothing in -p / SDK sessions, unless DECISION_TREE_WAIT=1 is set (see below).

Exploring

The pane on a branch: "If you pick SQLite" with the steps the forked session would take and its session id, under it the map of every question and option explored, and below the window a graph of the main session with two forks off its question, one for SQLite and one for your own "use Redis" answer, each reaching its own next question

Explore forks the real session at the message that asked the question:

claude -p --resume <session> --resume-session-at <that message> --fork-session

The fork is told you picked that option and carries on with read-only tools (Read, Grep, Glob). In 12 to 20 seconds the pane shows what it would do, its steps, and the next question it would ask you. Explore again from there to go deeper: each level forks the branch above it.

  • Your own answer: the Other box, or /explain other <answer>, adds it as an option and explores it like the rest.
  • Map: every question and option you explored as one tree. Press a row to jump to it.
  • Continue branch: opens the branch as a live session with claude --resume <branch> --fork-session, in a new Herdr pane when you're inside Herdr, or copied to your clipboard otherwise.

Every branch is its own session file, so the main conversation never sees any of it.

Commands

CommandWhat it does
/explainOpen the pane for the latest question
/explain bExplore option B (A to H)
/explain other <answer>Explore your own answer
/explain mapShow the map
/explain back, /explain topMove up the tree
/explain continueOpen the current branch as a live session

Install

/plugin install decision-tree --marketplace whykothari/whystack

Answer y to add the marketplace, then pick a scope.

Settings

OptionDefaultWhat it does
gateModelhaikuThe model that answers YES or NO

Limits

  • Claude Code only opens a pane nobody asked for in a terminal at least 144 columns wide. In a narrower one you get a toast; run /explain and the pane opens above the prompt.
  • The tree lives in the mod's memory. A new question, a restart or a reload clears it; the branch sessions stay on disk and can still be resumed by id.
  • --resume-session-at is an undocumented Claude Code flag (tested on 2.1.296). If the mod can't find the message to cut at, it forks from the latest point and says so.
  • Each branch is a full claude -p run with a turn's cost, and its session file stays in the project's history.

Develop

claude --plugin-dir ./decision-tree
claude plugin validate decision-tree

In claude -p, set DECISION_TREE_WAIT=1 so the run waits for the fork before it exits.

Credits and changes

Based on decision-tree by Ray Amjad, from ray-amjad/claude-code-mods, under the MIT licence (see LICENSE).

Changes from the original, by Yash Kothari:

  • The pane puts the recommendation first, under the question.
  • Fewer colors, one per meaning: green for the recommended option and pros, magenta for your own answers and the branch you try, red for cons and errors.
  • Plain, short pane text, and the forks are told to write short, plain sentences.
  • Buttons say "Try it" and "Wait…".
  • plugin.json, this README and LICENSE name whystack.

The screenshots show the original pane.

Source 1 files
hooks/register.jsx 448 lines
1// decision-tree: when Claude's reply ends by asking you something, a fork of the
2// conversation explains the choice in a side pane. Then you can explore
3// any option: the mod forks the real session at that question, plays the answer out in
4// the fork, and shows the next question. Each branch is its own session, so nothing you
5// explore reaches the main conversation.
6
7const PANE = 'question'
8
9// The answer every explanation is asked for, so the pane can draw it
10const SHAPE = '{"question": "the question, 12 words at most", ' +
11  '"context": "at most two short sentences: what I must know to decide", ' +
12  '"options": [{"label": "2 to 4 words", "detail": "one short sentence", ' +
13  '"leadsTo": "one short sentence: what happens next if I pick it", "pros": ["6 words at most"], "cons": ["6 words at most"]}], ' +
14  '"recommendation": {"label": "the label of one option", "why": "one short sentence"}}'
15
16// The pane is read at a glance, so every text the forks write follows these rules
17const STYLE = 'Write plain, simple English: short sentences, common words, active voice, ' +
18  'no jargon and no filler. No em or en dashes.'
19
20// The tree for the latest question. A node is { sid, msgId, answer, status, data, children }:
21// sid and msgId say which session, cut at which message, a branch below it forks from
22let root = null
23// Option indexes from the root to the node the pane shows
24let path = []
25// What the pane shows: 'node' (one question) or 'map' (the whole tree)
26let view = 'node'
27// Where the session transcripts live, from the Stop hook
28let transcriptPath = ''
29// True while no surface can draw the pane: a -p run, or an SDK session before its app attaches
30let headless = false
31
32// Cheap check, no model: does the end of the reply ask something?
33// A question mark in the last paragraph, or a numbered / lettered / bulleted list of options.
34function looksLikeQuestion(answer) {
35  const tail = answer.trim().slice(-1500)
36  const paragraphs = tail.split(/\n\s*\n/)
37  const last = paragraphs[paragraphs.length - 1] || ''
38  if (/\?\s*(\*\*|_|`)?\s*$/.test(last) || /\?\s*$/m.test(last)) return true
39  const options = tail.match(/^\s*(\d+[.)]|[A-D][.):]|[-*]\s+\*\*Option|Option [A-D1-9])/gm) || []
40  return options.length >= 2 && /\b(which|prefer|choose|pick|want|should I|let me know)\b/i.test(tail)
41}
42
43// The gate: a small model confirms the question needs a real decision from the user
44async function needsDecision($, answer, model) {
45  const r = await $.model.complete({
46    model,
47    system: 'You read the end of an AI coding assistant reply. Answer YES if it ends by asking ' +
48      'the user to decide, choose, or supply something before work can go on. Answer NO for ' +
49      'rhetorical questions, pleasantries such as "anything else?", or a reply that just reports. ' +
50      'Reply with YES or NO only.',
51    prompt: answer.trim().slice(-2000),
52    maxTokens: 5,
53    timeoutMs: 20000,
54  })
55  return r.isAnswered && /^\s*yes/i.test(r.text)
56}
57
58// The first explanation: a model fork replays the conversation and returns the choice as JSON
59async function explainRoot($, answer) {
60  const node = { sid: await $.session.id(), answer, status: 'thinking', children: [] }
61  root = node
62  path = []
63  view = 'node'
64  await openPane($, false)
65  $.ui.invalidate('ui.render')
66
67  const r = await $.model.fork({
68    prompt: 'Your last reply ended by asking me a question. Do not answer it and do not use tools. ' +
69      'Help me answer it. Reply with JSON only, no code fence, in this shape: ' + SHAPE + '. ' +
70      'Use the options you offered; if you offered none, give the 2 to 4 answers I would most ' +
71      'likely pick. At most 2 pros and 2 cons each. ' + STYLE,
72  })
73  settle(node, r.isAnswered ? r.text : '', r.isAnswered ? 'the fork did not return JSON' : r.reason)
74  if (root !== node) return
75  $.ui.invalidate('ui.render')
76}
77
78// Explore option i of the node the pane shows: fork the real session at that node's
79// question, tell the fork we picked i, and let it play that out with read-only tools.
80// With own, the answer is yours: it joins the question's options first, then is explored
81async function explore($, i, own) {
82  const tree = root
83  const node = nodeAt(path)
84  if (own && node && node.data && node.data.options) {
85    const options = node.data.options
86    i = options.findIndex(o => o.label.toLowerCase() === own.toLowerCase())
87    if (i < 0) i = options.push({ label: own, mine: true }) - 1
88  }
89  const option = node && node.data && node.data.options && node.data.options[i]
90  if (!option) return $.ui.toast('decision-tree: no option ' + String.fromCharCode(65 + i) + ' here')
91  path = [...path, i]
92  view = 'node'
93  $.ui.invalidate('ui.render')
94  if (node.children[i]) return
95
96  const child = { label: option.label, status: 'exploring', children: [], startedAt: Date.now() }
97  node.children[i] = child
98
99  // The message to cut the parent session at: the reply that asked this question
100  if (!node.msgId) node.msgId = await lastAssistantId($, node.sid, node.answer)
101  if (!node.msgId) $.ui.log('decision-tree: message id not found, forking from the latest point')
102  const args = ['claude', '-p', whatIf(option),
103    '--resume', node.sid, '--fork-session', '--output-format', 'json',
104    '--permission-mode', 'default', '--allowedTools=Read,Grep,Glob',
105    '--disallowedTools=Bash,Edit,Write,NotebookEdit']
106  if (node.msgId) args.push('--resume-session-at', node.msgId)
107
108  let out = null
109  try {
110    const run = await $.process.run(args, {
111      cwd: await $.session.cwd(),
112      env: { DECISION_TREE_CHILD: '1' },
113      timeoutMs: 300000,
114    })
115    out = resultOf(run.stdout)
116    if (!out) child.reason = (run.stderr || run.stdout || 'no output').slice(0, 300)
117  } catch (err) {
118    child.reason = String(err)
119  }
120  if (root !== tree) return
121  if (out) {
122    child.sid = out.session_id
123    child.answer = out.result
124    settle(child, out.result || '', 'the branch did not return JSON')
125  } else {
126    child.status = 'failed'
127  }
128  child.ms = Date.now() - child.startedAt
129  $.ui.invalidate('ui.render')
130}
131
132// The prompt a branch gets: our answer, then the same JSON as the root plus what happened
133function whatIf(option) {
134  return 'WHAT-IF BRANCH: this is a forked copy of the session for exploring one answer. ' +
135    'The real session is not affected. My answer to your question: "' + option.label + '"' +
136    (option.detail ? ' (' + option.detail + ')' : '') + '. Carry on as if I picked it, but change no files and run no ' +
137    'commands; you may Read, Grep and Glob to make the plan concrete. Then reply with JSON only, ' +
138    'no code fence, in this shape, plus two more fields: "outcome" (two or three short sentences: what ' +
139    'you would do and how things stand after) and "steps" (up to 4 short steps, 8 words at most each). ' +
140    'The question and options are the NEXT decision you would need from me after this; if there ' +
141    'is none, give an empty question and options []. Shape: ' + SHAPE + '. ' + STYLE
142}
143
144// Turn a reply into the node's data, or mark the node failed
145function settle(node, text, reason) {
146  const data = parseJson(text)
147  if (data && Array.isArray(data.options)) {
148    node.data = data
149    node.status = 'ready'
150  } else {
151    node.status = 'failed'
152    node.reason = node.reason || reason
153  }
154}
155
156// claude -p --output-format json prints the result object, or the whole event list
157function resultOf(stdout) {
158  try {
159    const out = JSON.parse(stdout)
160    const res = Array.isArray(out) ? out.find(x => x.type === 'result') : out
161    return res && res.session_id ? res : null
162  } catch {
163    return null
164  }
165}
166
167// Find the id of the assistant message that asked the question, in the session's transcript
168async function lastAssistantId($, sid, answer) {
169  // The Stop hook's transcript path names the folder; without it, rebuild it from the cwd
170  // (a Windows path uses \, and a native Windows terminal sets USERPROFILE, not HOME)
171  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
172  const folder = transcriptPath ? transcriptPath.replace(/[\\/][^\\/]+$/, '') :
173    home + '/.claude/projects/' + (await $.session.cwd()).replace(/[^a-zA-Z0-9]/g, '-')
174  const file = folder + '/' + sid + '.jsonl'
175  if (!(await $.fs.exists(file))) return undefined
176  // A transcript past $.fs.read's 4 MiB cannot be searched: fork from the latest point
177  const lines = (await $.fs.read(file).catch(() => '')).split('\n')
178  const want = (answer || '').trim().slice(-60)
179  for (let n = lines.length - 1; n >= 0; n--) {
180    let row
181    try { row = JSON.parse(lines[n]) } catch { continue }
182    if (row.type !== 'assistant' || row.isSidechain) continue
183    const text = (row.message.content || []).filter(b => b.type === 'text').map(b => b.text).join('')
184    if (text && text.trim().endsWith(want)) return row.uuid
185  }
186  return undefined
187}
188
189// The map: one row per question and per option, drawn as a tree with the path to each row
190// Question: Which storage?
191// ├ A JSON file on disk ▸
192// │ └ Q: How should I build it?
193// └ B SQLite ★
194function mapRows(node, p, prefix, rows) {
195  const data = node.data
196  if (!data || !data.question) return rows
197  rows.push({ text: prefix + 'Q: ' + data.question, path: p, kind: 'question' })
198  const pick = (data.recommendation && data.recommendation.label) || ''
199  data.options.forEach((o, i) => {
200    const last = i === data.options.length - 1
201    const child = node.children[i]
202    const mark = !child ? '' : child.status === 'exploring' ? '  ...' : child.status === 'failed' ? '  ✗' : '  ▸'
203    rows.push({
204      text: prefix + (last ? '└ ' : '├ ') + String.fromCharCode(65 + i) + ' ' + o.label + (o.label === pick ? ' ★' : '') + (o.mine ? ' ✎' : '') + mark,
205      path: child ? [...p, i] : null,
206      kind: child ? 'explored' : 'option',
207    })
208    if (child && child.status === 'ready') mapRows(child, [...p, i], prefix + (last ? '  ' : '│ '), rows)
209  })
210  return rows
211}
212
213function nodeAt(p) {
214  let node = root
215  for (const i of p) node = node && node.children[i]
216  return node
217}
218
219// Continue a branch for real: a new Claude session forked from it, in a Herdr pane if there is one
220async function continueBranch($, node) {
221  if (!node || !node.sid) return
222  const command = 'claude --resume ' + node.sid + ' --fork-session'
223  if ((await $.env.get('HERDR_ENV')) === '1') {
224    try {
225      const split = await $.process.run(['herdr', 'pane', 'split', '--current', '--direction', 'down',
226        '--cwd', await $.session.cwd(), '--no-focus'])
227      const id = JSON.parse(split.stdout).result.pane.pane_id
228      await $.process.run(['herdr', 'pane', 'run', id, command])
229      return $.ui.toast('decision-tree: branch opened in pane ' + id)
230    } catch (err) {
231      $.ui.log('could not open a Herdr pane: ' + err)
232    }
233  }
234  await $.ui.copy({ text: command })
235  $.ui.toast('decision-tree: copied ' + command)
236}
237
238// Open the side pane. Unasked, Claude Code only places it on a wide terminal.
239async function openPane($, asked) {
240  // A -p run with DECISION_TREE_WAIT still builds the tree, but has no pane to open
241  if (headless) return
242  // focus takes only true, so leave it out for an unasked open
243  const pane = { id: PANE, title: 'Decision tree' }
244  // Asked from /explain: take the keys, Escape closes it, and ask for room above the prompt
245  const opened = await $.ui.open(asked ? { ...pane, focus: true, closeOnEscape: true, rows: 30 } : pane)
246  if (!opened.isPlaced) $.ui.toast('decision-tree: no room for the pane. Run /explain.')
247}
248
249// The fork may wrap its JSON in prose or a fence; take the outermost braces
250function parseJson(text) {
251  const start = text.indexOf('{')
252  const end = text.lastIndexOf('}')
253  if (start < 0 || end <= start) return null
254  try {
255    return JSON.parse(text.slice(start, end + 1))
256  } catch {
257    return null
258  }
259}
260
261async function setUp($) {
262  headless = false
263  await $.command.register({
264    name: 'explain',
265    description: 'Show the decision tree. A to H tries an option. other <answer> tries your own.',
266    argumentHint: '[A-H | other <answer> | map | back | top | continue]',
267    immediate: true,
268  })
269}
270
271export function register(on, options) {
272  // The desktop app starts an SDK session (isInteractive false) and attaches its surface
273  // later, so a session with no surface yet waits for session.attach.
274  on('session.start', async ($, e, next) => {
275    headless = e.isInteractive === false && (await $.session.surfaces()).length === 0
276    if (!headless) await setUp($)
277    return next(e)
278  })
279
280  on('session.attach', async ($, e, next) => {
281    if (headless) await setUp($)
282    return next(e)
283  })
284
285  // Remember where transcripts are written, to find message ids to fork at
286  on('classic.Stop', async ($, e, next) => {
287    if (e.transcript_path) transcriptPath = e.transcript_path
288    return next(e)
289  }).catch(($, e, next) => (next.called ? undefined : next(e)))
290
291  // After each of Claude's replies: regex check, then the gate, then the fork
292  on('turn.complete', async ($, e, next) => {
293    const result = await next(e)
294    // Headless: nothing would be shown, so skip the gate and the fork, unless DECISION_TREE_WAIT asks for it
295    const wait = (await $.env.get('DECISION_TREE_WAIT')) === '1'
296    if (headless && !wait) return result
297    // A branch's own claude -p run never explains its own replies
298    if ((await $.env.get('DECISION_TREE_CHILD')) === '1') return result
299    if (e.agentId || e.reason !== 'answer' || !looksLikeQuestion(e.answer)) return result
300    if (!(await needsDecision($, e.answer, options.gateModel || 'haiku'))) return result
301    // Run the fork without holding up the prompt; in a -p run, wait so the process lives on
302    const work = explainRoot($, e.answer).catch(err => $.ui.log('decision-tree failed: ' + err))
303    if (wait) await work
304    return result
305  })
306
307  // /explain opens the pane; /explain B explores option B; back, top and continue move around
308  on('command.run', { command: 'explain' }, async ($, e) => {
309    if (!root) return { text: 'No question to explain yet.' }
310    const raw = (e.args || '').trim()
311    const arg = raw.toLowerCase()
312    view = arg === 'map' ? 'map' : 'node'
313    if (arg === 'back') path = path.slice(0, -1)
314    else if (arg === 'top') path = []
315    else if (arg === 'continue') await continueBranch($, nodeAt(path))
316    else if (/^[a-h]$/.test(arg) || /^other\s+\S/.test(arg)) {
317      const own = /^other\s/.test(arg) ? raw.replace(/^other\s+/i, '') : undefined
318      const work = explore($, own ? -1 : arg.charCodeAt(0) - 97, own).catch(err => $.ui.log('explore failed: ' + err))
319      if ((await $.env.get('DECISION_TREE_WAIT')) === '1') await work
320    }
321    $.ui.invalidate('ui.render')
322    await openPane($, true)
323    return {}
324  })
325
326  // The pane: where you are in the tree, what the branch did, then the question and its options
327  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
328    const { Box, Text, Button, Input } = $.ui.resolve(e)
329    const width = Math.max(30, (e.props.bodyColumns || 60) - 2)
330    const node = nodeAt(path)
331
332    if (!root || !node) return <Text dimColor>No question yet. The pane fills when Claude asks you to choose.</Text>
333
334    // The breadcrumb: Question › B SQLite › A WAL mode
335    const crumbs = ['Question']
336    let walk = root
337    for (const i of path) {
338      crumbs.push(String.fromCharCode(65 + i) + ' ' + walk.children[i].label)
339      walk = walk.children[i]
340    }
341    const nav = (
342      <Box gap={2} marginTop={1}>
343        {path.length ? <Button key="back" label="Back" onPress={() => { path = path.slice(0, -1); $.ui.invalidate('ui.render') }} /> : null}
344        {path.length > 1 ? <Button key="top" label="Top" onPress={() => { path = []; $.ui.invalidate('ui.render') }} /> : null}
345        {node.sid && path.length ? <Button key="cont" label="Continue branch" onPress={() => continueBranch($, node)} /> : null}
346        <Button key="map" label="Map" onPress={() => { view = 'map'; $.ui.invalidate('ui.render') }} />
347        <Button key="close" label="Close" onPress={() => $.ui.close({ id: PANE })} />
348      </Box>
349    )
350
351    if (node.status === 'thinking') return <Text dimColor>Claude reads the chat to explain the question. Wait a moment.</Text>
352
353    // The map: the whole tree at once. Press a question or an explored option to open it.
354    if (view === 'map') {
355      const here = path.join('.')
356      return (
357        <Box flexDirection="column" width={width}>
358          <Text bold>Map · ★ recommended · ▸ explored · ✎ yours</Text>
359          <Text>{' '}</Text>
360          {mapRows(root, [], '', []).map((row, n) => {
361            // Mark the option you are on, or the first question when you are at the top
362            const isHere = row.path && row.path.join('.') === here && (row.kind !== 'question' || !row.path.length)
363            const text = (isHere ? '● ' : '  ') + row.text
364            if (!row.path) return <Text key={'r' + n} dimColor>{text}</Text>
365            return <Button key={'r' + n} plain label={text}
366              onPress={() => { path = row.path; view = 'node'; $.ui.invalidate('ui.render') }} />
367          })}
368          <Box gap={2} marginTop={1}>
369            <Button key="node" label="Back to question" onPress={() => { view = 'node'; $.ui.invalidate('ui.render') }} />
370            <Button key="close" label="Close" onPress={() => $.ui.close({ id: PANE })} />
371          </Box>
372        </Box>
373      )
374    }
375    if (node.status === 'exploring') {
376      return (
377        <Box flexDirection="column" width={width}>
378          <Text dimColor>{crumbs.join(' › ')}</Text>
379          <Text color="magenta">{'A copy of this chat tries "' + node.label + '". It changes no files. This takes 20 to 60 seconds.'}</Text>
380          {nav}
381        </Box>
382      )
383    }
384    if (node.status === 'failed') {
385      return (
386        <Box flexDirection="column" width={width}>
387          <Text dimColor>{crumbs.join(' › ')}</Text>
388          <Text color="red">Could not explain it: {String(node.reason)}</Text>
389          {nav}
390        </Box>
391      )
392    }
393
394    const data = node.data
395    const pick = (data.recommendation && data.recommendation.label) || ''
396    return (
397      <Box flexDirection="column" width={width}>
398        <Text dimColor>{crumbs.join(' › ')}</Text>
399        {path.length ? (
400          <Box borderStyle="quote" borderColor="magenta" paddingLeft={1} flexDirection="column" marginBottom={1}>
401            <Text bold color="magenta">{'If you pick ' + node.label}</Text>
402            {data.outcome ? <Text>{data.outcome}</Text> : null}
403            {(data.steps || []).map((s, j) => <Text key={'s' + j}>{(j + 1) + '. ' + s}</Text>)}
404            <Text dimColor>{'branch session ' + String(node.sid).slice(0, 8) + (node.ms ? ' · ' + Math.round(node.ms / 1000) + 's' : '')}</Text>
405          </Box>
406        ) : null}
407        {data.question ? (
408          <Box borderStyle="double" paddingX={1} flexDirection="column">
409            <Text bold>{data.question}</Text>
410            {data.context ? <Text dimColor>{data.context}</Text> : null}
411          </Box>
412        ) : <Text dimColor>This branch needs no more choices.</Text>}
413        {pick ? <Text color="green" bold>{'★ Pick ' + pick + '. ' + data.recommendation.why}</Text> : null}
414        {data.options.map((o, i) => {
415          const isPick = o.label === pick && !o.mine
416          const child = node.children[i]
417          const color = isPick ? 'green' : o.mine ? 'magenta' : 'gray'
418          return (
419            <Box key={'opt' + i} flexDirection="column">
420              <Text dimColor>{'  │'}</Text>
421              <Box borderStyle={isPick ? 'bold' : 'round'} borderColor={color} paddingX={1} flexDirection="column">
422                <Text bold color={color === 'gray' ? 'white' : color}>{String.fromCharCode(65 + i) + '  ' + o.label + (isPick ? '   ★ recommended' : '') + (o.mine ? '   ✎ your answer' : '')}</Text>
423                {o.detail ? <Text>{o.detail}</Text> : null}
424                {o.leadsTo ? <Text>{'→ ' + o.leadsTo}</Text> : null}
425                {(o.pros || []).map((p, j) => <Text key={'p' + j} color="green">{'+ ' + p}</Text>)}
426                {(o.cons || []).map((c, j) => <Text key={'c' + j} color="red">{'- ' + c}</Text>)}
427                <Button key={'x' + i} label={child ? (child.status === 'exploring' ? 'Wait…' : 'Open branch ▸') : 'Try it ▸'}
428                  onPress={() => explore($, i)} />
429              </Box>
430            </Box>
431          )
432        })}
433        {data.question ? (
434          <Box flexDirection="column">
435            <Text dimColor>{'  │'}</Text>
436            <Box borderStyle="round" borderColor="gray" borderDimColor paddingX={1} flexDirection="column">
437              <Text dimColor bold>{String.fromCharCode(65 + data.options.length) + '  Other'}</Text>
438              <Input key={'other.' + path.join('.')} placeholder="type your own answer" submitLabel="try it"
439                onSubmit={value => { if (value.trim()) explore($, -1, value.trim()) }} />
440            </Box>
441          </Box>
442        ) : null}
443        {nav}
444      </Box>
445    )
446  })
447}
448