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

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.
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.
/btw <question> opens the pane and asks the first question.↑ / ↓ 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.Up to 20 side questions are remembered per session. /clear forgets them.
/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./plugin install line works in the terminal. Once installed at the user scope, the mod also runs in the desktop app's Code tab.claude plugin validate .
claude plugin test .
claude --plugin-dir .
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 質問 で画面が開き、最初の質問に答えます。/btw 質問 は新しく始まり、前回までの質問は上に 1 行ずつ畳んで並びます。/btw で、前回の画面を閉じたときのまま開き直します。copy で選んでいる回答(選んでいなければ最後の回答)をコピー、clear history で全部忘れます。覚えておける質問はセッションごとに 20 件までで、/clear で消えます。
/btw はすべてこの mod の動きになります。mod の読み込みに失敗したときは、組み込みの /btw が動きます。hooks/register.tsx 350 lines1import { 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 <your question>.</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}
350hooks/tables.ts 98 lines1// 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}
98types/index.d.ts 19 lines1/** 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