SLOPSHOPPER

better-btw

Replaces /btw with a side-question pane you can keep asking follow-ups in

newpanetoastmodeltimer
v1.0.0MITupdated 2026-10-10hamTotk/better-btw
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · better-btw
│ ┃ btw ✕ › fix the failing auth test and add an audit log call │ ┃ No side questions yet. Type /btw <your │ ┃ question>. ⏺ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · btw
No side questions yet. Type /btw <your question>.
README

better-btw

A Claude Code mod that replaces the built-in /btw with a side-question pane you can keep talking in.

The built-in /btw answers one side question and closes. better-btw keeps the pane open: type a follow-up in the field under the answer, and every question and answer of that visit stays on screen together. Like /btw, it answers from the current conversation, uses no tools, does not interrupt Claude's main work, and leaves nothing in the main conversation's history.

日本語の説明は下にあります

Install

At the prompt of a terminal Claude Code session:

/plugin install better-btw --marketplace hamTotk/better-btw

Answer y to add the marketplace, then pick a scope (the user scope is first). The mod is active at once, no restart needed.

To remove it: claude plugin uninstall better-btw. /btw is then the built-in one again.

Use

  • /btw <question> opens the pane and asks the first question.
  • Type a follow-up in the field at the bottom and press Enter. Earlier answers in the thread are passed along, so follow-ups build on them.
  • ↑ / ↓ select a question. Enter on a selected question opens or folds its answer. ↓ from the newest question returns to the field.
  • Esc closes the pane. The next /btw <question> starts a new visit; questions from earlier visits fold to one line each above it.
  • /btw with no question reopens the last visit as it was.
  • copy copies the selected answer (or the latest one); clear history forgets every side question.
  • The model is told the pane's width, and a table too wide for the pane is drawn as a list, so answers fit.

Up to 20 side questions are remembered per session. /clear forgets them.

Notes

  • It replaces /btw for you. Every /btw you type goes to this mod while it is installed. If the mod fails to load, the built-in /btw runs as before.
  • The mod API is early access. It is built on Claude Code's function-hook mods, which may change between releases. Tested on Claude Code 2.1.296.
  • Install from a terminal. The /plugin install line works in the terminal. Once installed at the user scope, the mod also runs in the desktop app's Code tab.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT


日本語

Claude Code の組み込み /btw を置き換えて、脇道の質問を続けて聞けるようにする mod です。

組み込みの /btw は質問 1 つに答えると閉じます。better-btw は画面を開いたまま、回答の下の入力欄から続けて質問できます。開いてから閉じるまでのやりとりは全部並べて表示します。組み込みと同じく、今の会話を踏まえて答え、ツールは使わず、Claude 本体の作業を止めず、メインの会話の履歴にも残りません。

入れ方

ターミナル版の Claude Code で次の 1 行を打ちます。

/plugin install better-btw --marketplace hamTotk/better-btw

マーケットプレイスを追加するか聞かれたら y、続けて入れる範囲を選びます(先頭がユーザー全体)。入れた時点で使えるようになり、再起動は要りません。

外すときは claude plugin uninstall better-btw です。外すと /btw は組み込みのものに戻ります。

使い方

  • /btw 質問 で画面が開き、最初の質問に答えます。
  • 下の入力欄に続きの質問を打って Enter。それまでの回答を踏まえて答えます。
  • ↑ / ↓ で質問を選び、Enter で回答を開いたり畳んだりします。一番新しい質問から ↓ で入力欄に戻ります。
  • Esc で閉じます。閉じた後の /btw 質問 は新しく始まり、前回までの質問は上に 1 行ずつ畳んで並びます。
  • 引数なしの /btw で、前回の画面を閉じたときのまま開き直します。
  • copy で選んでいる回答(選んでいなければ最後の回答)をコピー、clear history で全部忘れます。
  • 画面の幅を Claude に伝え、幅に収まらない表は箇条書きに直して表示します。

覚えておける質問はセッションごとに 20 件までで、/clear で消えます。

注意

  • 入れている間、/btw はすべてこの mod の動きになります。mod の読み込みに失敗したときは、組み込みの /btw が動きます。
  • Claude Code の mod の仕組みは先行公開の段階で、更新で動かなくなる可能性があります。確認済みの版は 2.1.296 です。
  • 入れる操作はターミナル版でだけできます。ユーザー全体に入れておけば、デスクトップ版のコード画面でも動きます。
Source 3 files
hooks/register.tsx 350 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelForkResult, Register } from 'claude-code'
3
4import type { BtwExchange } from '../types'
5import { fitTables } from './tables'
6
7const PANE = 'btw'
8const KEEP = 20
9/** Cells an answer loses inside the pane: the pane's padding and the answer's indent. */
10const ANSWER_INSET = 3
11
12// The answer width the pane last drew at, told to the model with each question
13// (a module value: a reload resets it until the pane draws again).
14let answerColumns = 77
15
16const exchanges = atom({ plugin: 'better-btw', key: 'exchanges' } as const, [] as BtwExchange[])
17const viewing = atom({ plugin: 'better-btw', key: 'viewing' } as const, null as number | null)
18const asked = atom({ plugin: 'better-btw', key: 'asked' } as const, 0)
19const visitStart = atom({ plugin: 'better-btw', key: 'visitStart' } as const, 0)
20const toggled = atom({ plugin: 'better-btw', key: 'toggled' } as const, [] as number[])
21
22// The built-in /btw's rules, with its "no follow-up turns" line swapped for
23// one that points the model at the earlier side questions.
24const RULES = `<system-reminder>This is a side question from the user. You must answer this question directly in a single response.
25
26IMPORTANT CONTEXT:
27- You are a separate, lightweight agent spawned to answer side questions
28- The main agent is NOT interrupted - it continues working independently in the background
29- You share the conversation context but are a completely separate instance
30- Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect
31
32CRITICAL CONSTRAINTS:
33- You have NO tools available - you cannot read files, run commands, search, or take any actions
34- Do NOT write tool calls or tool output as text - nothing you write here is executed; if answering would need reading files, running commands, or searching, say that can't be checked from a side question and suggest asking in the main conversation
35- The user may ask follow-ups: earlier side questions and your answers to them are listed below, oldest first. Build on them without repeating them
36- You can ONLY provide information based on what you already know from the conversation context
37- NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
38- If you don't know the answer, say so - do not offer to look it up or investigate
39
40Simply answer the question with the information you have.</system-reminder>`
41
42const isPending = (x: BtwExchange) => x.response === undefined && x.error === undefined
43
44const oneLine = (text: string, width: number) => {
45  const flat = text.replace(/\s+/g, ' ').trim()
46
47  return flat.length > width ? `${flat.slice(0, Math.max(1, width - 1))}…` : flat
48}
49
50function composePrompt(earlier: readonly BtwExchange[], question: string) {
51  const answered = earlier.filter(x => x.response !== undefined)
52  const thread = answered.length === 0
53    ? ''
54    : `\n\n<earlier_side_questions>\n${answered
55        .map(x => `<question>${x.question}</question>\n<answer>${x.response}</answer>`)
56        .join('\n')}\n</earlier_side_questions>`
57
58  const layout = `<display>Your answer is drawn in a narrow pane ${answerColumns} characters wide (East Asian characters take two). Use a table only if every row fits in that width; otherwise use a bulleted list.</display>`
59
60  return `${RULES}${thread}\n\n${layout}\n\nSide question: ${question}`
61}
62
63function describeFailure(reply: Exclude<ModelForkResult, { isAnswered: true }>) {
64  switch (reply.reason) {
65    case 'nothing-to-fork':
66      return 'Nothing to ask about yet: send a prompt first, then use /btw.'
67    case 'api-error':
68      return `(API error${'status' in reply && reply.status ? `: ${reply.status}` : ''})`
69    case 'empty-reply':
70      return '(No answer available for this side question. Try again, or ask in the main conversation.)'
71    case 'aborted':
72      return '(Cancelled.)'
73  }
74}
75
76const showLatest = ($: EngineInterface) => void $.ui.scroll({ in: PANE, to: 'end' }).catch(() => undefined)
77
78/**
79 * Adds the question to the thread and answers it in the background; false
80 * when one is still running. `isNewVisit` starts what the pane shows afresh.
81 */
82async function ask($: EngineInterface, question: string, isNewVisit: boolean) {
83  const before = await read($, exchanges)
84  if (before.some(isPending)) {
85    $.ui.toast('/btw: still answering the last question')
86
87    return false
88  }
89
90  const id = (await read($, asked)) + 1
91  await update($, asked, () => id)
92  if (isNewVisit) await update($, visitStart, () => id)
93  await update($, exchanges, list => [...list, { id, question }].slice(-KEEP))
94  await update($, viewing, () => null)
95
96  $.clock.after(0, () => void answer($, before, id, question))
97
98  return true
99}
100
101async function answer($: EngineInterface, earlier: readonly BtwExchange[], id: number, question: string) {
102  let settled: BtwExchange
103  try {
104    const reply = await $.model.fork({ prompt: composePrompt(earlier, question) })
105    settled = reply.isAnswered
106      ? { id, question, response: reply.text }
107      : { id, question, error: describeFailure(reply) }
108  } catch (error) {
109    settled = { id, question, error: `(Failed to get response: ${String(error)})` }
110  }
111
112  await update($, exchanges, list => list.map(x => (x.id === id ? settled : x)))
113  showLatest($)
114}
115
116const openPane = ($: EngineInterface) =>
117  $.ui.open({ id: PANE, title: 'btw', focus: true, closeOnEscape: true, rows: 40 })
118
119
120const clearAll = async ($: EngineInterface) => {
121  await update($, exchanges, () => [])
122  await update($, viewing, () => null)
123  await update($, toggled, () => [])
124  await $.ui.close({ id: PANE })
125}
126
127const EARLIER_SHOWN = 5
128
129/**
130 * What the pane lists, in order: the earlier visits folded to their last few
131 * lines (`hidden` more above them), then every exchange of this visit.
132 */
133function layout(list: readonly BtwExchange[], start: number) {
134  const earlier = list.filter(x => x.id < start)
135  const hidden = Math.max(0, earlier.length - EARLIER_SHOWN)
136
137  return { hidden, earlier: earlier.slice(hidden), current: list.filter(x => x.id >= start) }
138}
139
140const questionKey = (id: number) => `q:${id}`
141const idOf = (key: string | undefined) => (key?.startsWith('q:') ? Number(key.slice(2)) : undefined)
142
143/**
144 * Moves the selection one question up or down: up from the follow-up field
145 * picks the newest question, down from the newest goes back to the field.
146 */
147async function step($: EngineInterface, by: number) {
148  const { earlier, current } = layout(await read($, exchanges), await read($, visitStart))
149  const ids = [...earlier, ...current].map(x => x.id)
150  const at = await read($, viewing)
151  const index = at === null ? ids.length : ids.indexOf(at)
152  const to = Math.min(ids.length, Math.max(0, (index < 0 ? ids.length : index) + Math.sign(by)))
153  const id = ids[to]
154  // Selected here as well as when the ring lands, so the pick shows even
155  // where the ring cannot move (the site does not hold the keys).
156  await update($, viewing, () => id ?? null)
157
158  if (id === undefined) {
159    await $.ui.focus({ requestId: PANE, key: `ask:${await read($, asked)}` }).catch(() => undefined)
160    showLatest($)
161
162    return
163  }
164
165  await $.ui.focus({ requestId: PANE, key: questionKey(id) }).catch(() => undefined)
166  void $.ui.scroll({ in: PANE, to: { key: questionKey(id) }, block: 'start' }).catch(() => undefined)
167}
168
169export const register: Register = on => {
170  // A reload drops the module's in-flight answers: settle them so the next ask
171  // is not blocked. Rows from 0.1.0, which had no id, are dropped.
172  on('session.start', async ($, e, next) => {
173    await update($, exchanges, list =>
174      list
175        .filter(x => typeof x.id === 'number')
176        .map(x => (isPending(x) ? { ...x, error: '(Interrupted before it finished. Ask again to retry.)' } : x)),
177    )
178
179    return next(e)
180  })
181
182  on('session.end', async ($, e, next) => {
183    if (e.reason === 'clear') await clearAll($)
184
185    return next(e)
186  })
187
188  // Answers the built-in /btw in its place: the engine's own panel never opens.
189  // A question typed while the pane is closed starts a new visit; a bare /btw
190  // reopens the last one as it was.
191  on('command.run', { command: 'btw' }, async ($, e) => {
192    const question = e.args.trim()
193    if (question) {
194      const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
195      await ask($, question, !isUp)
196    } else if ((await read($, exchanges)).length === 0) {
197      $.ui.toast('Usage: /btw <your question>')
198
199      return {}
200    } else {
201      await update($, viewing, () => null)
202    }
203
204    await openPane($)
205    showLatest($)
206
207    return {}
208  })
209
210  // The selected question is the one holding the focus ring: landing on a
211  // question selects it, landing anywhere else (the field, copy) clears it.
212  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
213    const moved = await next(e)
214    if (moved.deny === undefined) {
215      const id = idOf(e.element)
216      if (id !== undefined || e.element?.startsWith('ask:')) await update($, viewing, () => id ?? null)
217    }
218
219    return moved
220  })
221
222  // Up and down select questions instead of scrolling. The wheel (it carries a
223  // pointer), the page keys and the plugin's own scrolls still move the window.
224  on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
225    if (e.origin.kind !== 'person' || e.pointer !== undefined || Math.abs(e.by) !== 1) return next(e)
226    await step($, e.by)
227
228    return {}
229  })
230
231  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
232    const table = $.ui.resolve(e)
233    const { Box, Text, Button, Markdown } = table
234    const Input = 'Input' in table ? table.Input : undefined
235
236    const list = await read($, exchanges)
237    const picked = await read($, viewing)
238    const count = await read($, asked)
239    const start = await read($, visitStart)
240    const width = Math.max(20, e.props.bodyColumns - 8)
241    answerColumns = Math.max(20, e.props.bodyColumns - ANSWER_INSET)
242
243    if (list.length === 0) {
244      return <Text dimColor>No side questions yet. Type /btw &lt;your question&gt;.</Text>
245    }
246
247    const bodyOf = (x: BtwExchange) =>
248      x.response !== undefined ? (
249        <Markdown text={fitTables(x.response, answerColumns)} />
250      ) : x.error !== undefined ? (
251        <Text color="error">{x.error}</Text>
252      ) : (
253        <Text dimColor>Answering…</Text>
254      )
255
256    // Earlier visits fold to one line each, as the built-in lists them; this
257    // visit's answers are all drawn. Enter on a question flips it: opens a
258    // folded one, folds an open one.
259    const { hidden, earlier, current } = layout(list, start)
260    const flipped = await read($, toggled)
261    const isOpen = (x: BtwExchange) => (x.id >= start) !== flipped.includes(x.id)
262    const toggle = (id: number) =>
263      update($, toggled, ids => (ids.includes(id) ? ids.filter(one => one !== id) : [...ids, id]))
264    // A Button can be neither bold nor colored, so the question is drawn as
265    // Text and a one-cell Button after it takes the focus ring and the Enter.
266    const handle = (id: number) => <Button key={questionKey(id)} plain label=" " onPress={() => toggle(id)} />
267    const copied = (list.find(x => x.id === picked) ?? current.findLast(x => x.response !== undefined))?.response
268
269    const submit = (text: string) => {
270      const question = text.trim()
271      if (!question) return
272      void ask($, question, false).then(isAsked => {
273        if (!isAsked) return
274        void $.ui.focus({ requestId: PANE, key: `ask:${count + 1}` }).catch(() => undefined)
275        showLatest($)
276      })
277    }
278
279    return (
280      <Box flexDirection="column" paddingLeft={1} rowGap={1}>
281        {hidden > 0 && <Text dimColor>(+{hidden} earlier /btw)</Text>}
282        {earlier.map(x => (
283          <Box key={`earlier:${x.id}`} flexDirection="column">
284            <Box>
285              <Text dimColor>❯ </Text>
286              <Text inverse={picked === x.id}>
287                {oneLine(x.question, width)}
288              </Text>
289              {handle(x.id)}
290            </Box>
291            {isOpen(x) && (
292              <Box marginTop={1} marginLeft={2} flexDirection="column">
293                {bodyOf(x)}
294              </Box>
295            )}
296          </Box>
297        ))}
298        {current.map(x => (
299          <Box key={`turn:${x.id}`} flexDirection="column">
300            <Box>
301              <Text color="warning" bold>
302                ❯{' '}
303              </Text>
304              <Text bold inverse={picked === x.id}>
305                {x.question}
306              </Text>
307              {handle(x.id)}
308            </Box>
309            {isOpen(x) ? (
310              <Box marginTop={1} marginLeft={2} flexDirection="column">
311                {bodyOf(x)}
312              </Box>
313            ) : (
314              <Box marginLeft={2}>
315                <Text dimColor>… (enter to open)</Text>
316              </Box>
317            )}
318          </Box>
319        ))}
320        {Input && (
321          <Box>
322            <Input
323              key={`ask:${count}`}
324              label="❯ "
325              placeholder="Ask a follow-up…"
326              submitLabel="ask"
327              autoFocus
328              onSubmit={submit}
329            />
330          </Box>
331        )}
332        <Box>
333          <Text dimColor>↑/↓ select · enter open/fold · esc close · </Text>
334          {copied !== undefined && (
335            <Button
336              key="copy"
337              plain
338              dimColor
339              label="copy"
340              onPress={() => void $.ui.copy({ text: copied, surface: e.surface })}
341            />
342          )}
343          {copied !== undefined && <Text dimColor> · </Text>}
344          <Button key="clear" plain dimColor label="clear history" onPress={() => clearAll($)} />
345        </Box>
346      </Box>
347    )
348  })
349}
350
hooks/tables.ts 98 lines
1// Markdown tables wider than the pane wrap into a mess on the terminal, so a
2// table that cannot fit is redrawn as a list: one item per row, its first
3// cell as the heading and the other cells as "header: value" beneath it.
4
5const WIDE: readonly (readonly [number, number])[] = [
6  [0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf],
7  [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
8  [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1faff],
9  [0x20000, 0x3fffd],
10]
11
12/** Terminal cells a string takes: East Asian wide characters count two. */
13export function cellWidth(text: string) {
14  let width = 0
15  for (const char of text) {
16    const code = char.codePointAt(0) ?? 0
17    width += WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
18  }
19
20  return width
21}
22
23/** The text a cell shows once its inline marks are drawn. */
24const shown = (cell: string) => cell.replace(/\*\*|__|`/g, '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
25
26const isRow = (line: string) => line.trim().startsWith('|')
27const isDivider = (line: string) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line)
28
29function cells(line: string) {
30  const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '')
31
32  return inner.split(/(?<!\\)\|/).map(cell => cell.trim())
33}
34
35/** Cells the table takes as the terminal draws it: each column, its padding and borders. */
36function tableWidth(rows: readonly string[][]) {
37  const columns = Math.max(...rows.map(row => row.length))
38  let width = 1
39  for (let column = 0; column < columns; column += 1) {
40    width += Math.max(...rows.map(row => cellWidth(shown(row[column] ?? '')))) + 3
41  }
42
43  return width
44}
45
46function asList(header: readonly string[], body: readonly string[][]) {
47  return body
48    .map(row => {
49      const [first = '', ...rest] = row
50      const details = rest
51        .map((cell, index) => (cell ? `  - ${header[index + 1] ? `${header[index + 1]}: ` : ''}${cell}` : ''))
52        .filter(Boolean)
53
54      return [`- **${first.replace(/^\*\*(.*)\*\*$/, '$1')}**`, ...details].join('\n')
55    })
56    .join('\n')
57}
58
59/**
60 * Rewrites each table in `markdown` that is wider than `width` cells as a
61 * list; narrower tables, and anything inside a code fence, stay as written.
62 */
63export function fitTables(markdown: string, width: number) {
64  const lines = markdown.split('\n')
65  const out: string[] = []
66  let fence: string | null = null
67
68  for (let index = 0; index < lines.length; index += 1) {
69    const line = lines[index] ?? ''
70    const marker = /^\s*(```|~~~)/.exec(line)?.[1]
71    if (marker !== undefined && (fence === null || fence === marker)) {
72      fence = fence === null ? marker : null
73      out.push(line)
74      continue
75    }
76
77    const next = lines[index + 1]
78    if (fence !== null || !isRow(line) || next === undefined || !isDivider(next)) {
79      out.push(line)
80      continue
81    }
82
83    let end = index + 2
84    while (end < lines.length && isRow(lines[end] ?? '')) end += 1
85    const header = cells(line)
86    const body = lines.slice(index + 2, end).map(cells)
87
88    if (tableWidth([header, ...body]) <= width) {
89      out.push(...lines.slice(index, end))
90    } else {
91      out.push(asList(header, body))
92    }
93    index = end - 1
94  }
95
96  return out.join('\n')
97}
98
types/index.d.ts 19 lines
1/** One side question and what came back; `response` and `error` both absent while it runs. */
2export type BtwExchange = { id: number; question: string; response?: string; error?: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'better-btw': {
7      exchanges: BtwExchange[]
8      /** Id of the selected question (the arrows move it); null while the field has the keys. */
9      viewing: number | null
10      /** Questions asked so far: the next exchange's id, and the follow-up field's key. */
11      asked: number
12      /** Id of the first exchange of the current visit (one /btw until its pane closes). */
13      visitStart: number
14      /** Ids whose fold Enter flipped from the default: earlier visits opened, this visit's folded. */
15      toggled: number[]
16    }
17  }
18}
19