SLOPSHOPPER

btw-chat

A fluid side conversation over the main session, without interrupting it

newpanecommandmodel
v0.1.0no licenseupdated 2026-10-19Voltaire405/claude-mods/btw-chat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · btw-chat
│ ┃ btw ✕ › fix the failing auth test and add an audit log call │ ┃ Ask anything; the main session keeps │ ┃ working. ⏺ Read(src/auth.ts) │ ┃ btw… ⏎ send ⎿ 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 │ │ › /btw-chat │ ⎿ btw-chat: btw pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · btw
Ask anything; the main session keeps working. btw… ⏎ send
README

btw-chat

A Claude Code mod for a fluid, multi-turn side conversation ("btw") that never interrupts the main session.

Use

  • /btw-chat opens a pane with its own thread and a focused input; Escape closes it.
  • /btw-chat <question> opens the pane and asks right away.
  • /btw-chat clear resets the thread.

How the context works

Each message calls $.model.fork: it resends the main thread's last API request (model, system prompt, tools, messages) with one user message after it, so the API serves that prefix from its cache. That message carries the side thread so far plus the new question. Every tool is denied in the fork, and nothing goes back to the main thread.

  • The context is pulled at send time: a later message sees whatever the main thread has sent since.
  • It does not see what the main thread is generating right now, only its last request.
  • Before the first request, or after /clear, there is nothing to fork: the pane shows no reply: nothing-to-fork.
  • The thread lives in $.state for the session, not across sessions.
  • Each message costs a request the size of the main context; the cache makes the prefix cheaper while it is warm.

Install

At the prompt of a terminal session:

/plugin install btw-chat --marketplace Voltaire405/claude-mods

Answer y to add the marketplace, then pick a scope (user scope loads it in every session and repository). From a shell: claude plugin install btw-chat --marketplace Voltaire405/claude-mods.

Check

claude plugin validate ./btw-chat
claude plugin test ./btw-chat
Source 2 files
hooks/register.tsx 84 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BtwMessage } from '../types'
5
6const PANE = 'btw'
7const thread = atom({ plugin: 'btw-chat', key: 'thread' } as const, [])
8const isThinking = atom({ plugin: 'btw-chat', key: 'isThinking' } as const, false)
9
10const openPane = ($: EngineInterface) =>
11  $.ui.open({ id: PANE, title: 'btw', focus: true, closeOnEscape: true })
12
13export const composeForkPrompt = (history: readonly BtwMessage[], question: string) =>
14  [
15    'This is a side conversation ("btw") the person is having with you while the main task goes on.',
16    'Answer from what you already know of this session. You cannot use tools here, and this exchange never enters the main thread.',
17    'Be brief and conversational.',
18    ...(history.length > 0
19      ? ['', 'The side conversation so far:', ...history.map(m => `${m.role === 'you' ? 'Person' : 'You'}: ${m.text}`)]
20      : []),
21    '',
22    `Person: ${question}`,
23  ].join('\n')
24
25const ask = async ($: EngineInterface, question: string) => {
26  const text = question.trim()
27  if (text === '' || (await read($, isThinking))) return
28
29  const history = await read($, thread)
30  await update($, thread, list => [...list, { role: 'you' as const, text }])
31  await update($, isThinking, () => true)
32
33  const reply = await $.model.fork({ prompt: composeForkPrompt(history, text) })
34  const answer = reply.isAnswered ? reply.text : `_(no reply: ${reply.reason})_`
35
36  await update($, thread, list => [...list, { role: 'claude' as const, text: answer }])
37  await update($, isThinking, () => false)
38}
39
40export const register: Register = on => {
41  on('session.start', async ($, e, next) => {
42    await $.command.register({
43      name: 'btw-chat',
44      description: 'Side conversation that never interrupts the main session (`clear` resets the thread)',
45    })
46    return next(e)
47  })
48
49  on('command.run', { command: 'btw-chat' }, async ($, e) => {
50    if (e.args.trim() === 'clear') {
51      await update($, thread, () => [])
52      return { text: 'btw thread cleared.' }
53    }
54    await openPane($)
55    if (e.args.trim() !== '') void ask($, e.args)
56    return { text: 'btw pane opened.' }
57  })
58
59  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
60    if (e.surface === 'mobile') return next(e)
61    const { Box, Text, Markdown, Input } = $.ui.resolve(e)
62    const list = await read($, thread)
63    const thinking = await read($, isThinking)
64
65    return (
66      <Box flexDirection="column">
67        {list.length === 0 && <Text dimColor>Ask anything; the main session keeps working.</Text>}
68        {list.map(m =>
69          m.role === 'you' ? <Text bold>› {m.text}</Text> : <Markdown text={m.text} />,
70        )}
71        {thinking && <Text dimColor>thinking…</Text>}
72        <Input
73          key="btw-input"
74          placeholder={thinking ? 'waiting for the reply…' : 'btw…'}
75          submitLabel="send"
76          value=""
77          autoFocus
78          onSubmit={(value: string) => void ask($, value)}
79        />
80      </Box>
81    )
82  })
83}
84
types/index.d.ts 8 lines
1export type BtwMessage = { role: 'you' | 'claude'; text: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'btw-chat': { thread: BtwMessage[]; isThinking: boolean }
6  }
7}
8