SLOPSHOPPER

btw-chat

Keep a resettable side conversation with the latest main-session context in a sidebar tab.

panecommandmodel
★ 1v0.1.0no licenseupdated 2026-10-17AJclemendor/my-mods/plugins/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 │ ┃ Main session · Default effort │ ┃ [ Settings ] ⏺ Read(src/auth.ts) │ ┃ Main-session context refreshes with every ⎿ Read 6 lines │ ┃ question. ⏺ Update(src/auth.ts) │ ┃ Ask about what Claude is working on. This ⎿ Added 2 lines, removed 1 line │ ┃ chat stays separate. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ Reset chat ] │ ┃ Ask a follow-up… ⏎ send ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /btw-chat │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · BTW
Main session · Default effort [ Settings ] Main-session context refreshes with every question. Ask about what Claude is working on. This chat stays separate. [ Reset chat ] Ask a follow-up… ⏎ send
Pane · sidebar-controls
⟨Claude Code's own drawing⟩ [ Open BTW chat ]
README

BTW chat

An ongoing side conversation in a BTW sidebar tab. Every question uses the current main session's context plus your earlier side exchanges. Reset clears the side conversation while keeping access to the main session.

Run /btw to open and focus the tab, or /btw <question> to ask directly. Type follow-ups in the tab's input. Click Reset chat or run /btw reset to start a fresh side conversation. /btw-chat accepts the same arguments.

When enabled, this mod handles /btw in place of the native overlay. Closing the tab keeps its history; /btw reopens it. The history lives in memory until reset, plugin reload, exit, or switching/clearing the main session. It is never written into the main conversation. An answer still in flight when you reset is discarded; the underlying request may finish in the background.

The tab shares Claude's sidebar area with other panes, including Mod controls. The controls pane also gets an Open BTW chat button. The chat requests 48 columns when docked and 18 rows above the prompt in classic or narrow terminals. Escape returns keyboard focus to the main prompt without closing the tab.

Settings

Click Settings in the tab or run /btw settings:

  • Model: follow the main session, use Opus, Sonnet, or Haiku, or enter a custom model ID. Press Enter to save a custom ID. Aliases resolve through Claude Code and use the session's provider and credentials.
  • Thinking effort: Default, low, medium, high, xhigh, or max. Claude Code only sends effort to models that support it.
  • Reply style: Default, concise, balanced, or detailed.
  • Output budget: Default, 1,024, 4,096, 8,192, or 16,384 tokens. This requests a token budget for the reply. Claude Code may adjust the actual API cap for the selected model.

Selections save in the plugin's store under the current Claude profile and survive restarts and plugin reloads. They affect the next side reply; an in-flight reply keeps the choices it started with. Reset chat preserves settings. Restore defaults restores the main-model defaults and keeps the side history. Main-session settings are never changed.

Context and replies

With the main model and default effort and output budget, each ask makes a tool-free model.fork request through the session's own client and credentials, reusing its current model and cached main-conversation prefix when available. It also reads the latest transcript on every ask and supplies the newest assistant messages and subsequent tool results, since the cached request predates its own answer. Reply style can change while keeping this path. Side history is included until you reset it; no exchanges are silently dropped.

Choosing another model, effort, or output budget uses model.complete, because the fork API cannot override those settings. Every such request receives a fresh text snapshot of the main conversation and its tool inputs/results, plus side history. It does not inherit the main request's prompt cache, system prompt, or image blocks. Main session still resolves the current main model on every ask when effort or the cap alone is overridden. Default effort then means that model's default, and Default output budget is 4,096 tokens.

Context is refreshed when you send a question. It includes completed text and tool results; an answer cannot see main-session changes that arrive after its request starts, or unfinished streaming text. Newly returned images are not added by the text supplement. On the default fork path, images become part of the inherited request after the main model consumes them. The side reply appears when complete. The main task can continue while you ask and read side replies.

A new or cleared main session needs a main-model response before a side request can fork it; custom settings can answer using the transcript already available. If the request fails, the question remains in the input for retry and the error stays out of side history.

Install

From a checkout:

claude --plugin-dir ./plugins/btw-chat

For the whole collection, use claude --plugin-dir ./plugins. This mod works without sidebar-controls; when both are loaded they share the same sidebar through separate tabs.

Verify

claude plugin validate ./plugins/btw-chat --strict
claude plugin test ./plugins/btw-chat
# Requires the Python package pyte.
python3 plugins/btw-chat/tests/terminal_check.py

The runtime suite covers refreshed context, follow-ups, recent tool results, reset during an in-flight request, retryable failures, command routing, long replies, and saved settings. Terminal checks use a local mock API to verify the real pane, /btw, reset, model and effort selection, plugin reload, and separation from the main transcript without paid requests.

Source 1 files
hooks/btw-chat.mjs 270 lines
1const pane = { id: "btw-chat", title: "BTW", columns: 48, rows: 18 };
2
3export function register(on) {
4  const state = { history: [], draft: "", pending: null, error: "", settings: null, settingsOpen: false, settingsError: "" };
5
6  on("session.start", async ($, e, next) => {
7    const result = await next(e);
8    await loadSettings($, state);
9    reset($, state);
10    await $.command.register({
11      name: "btw-chat",
12      description: "Open an ongoing side chat with the latest main-session context",
13      argumentHint: "[question|reset|settings]",
14      immediate: true,
15    });
16    await $.ui.open(pane);
17    return result;
18  });
19
20  on("session.end", ($, e, next) => {
21    reset($, state);
22    return next(e);
23  });
24
25  on("command.run", { command: ["btw", "btw-chat"] }, async ($, e) => {
26    await $.ui.open({ ...pane, focus: true });
27    const question = e.args.trim();
28    if (question === "reset") reset($, state);
29    else if (question === "settings") { state.settingsOpen = true; $.ui.invalidate("ui.render"); }
30    else if (question) await ask($, state, question);
31    return {};
32  });
33
34  on("ui.render", { component: "Pane", requestId: "btw-chat" }, async ($, e, next) => {
35    if (e.surface !== "terminal") return next(e);
36    await loadSettings($, state);
37    if (state.settingsOpen) return settingsPanel($, e, state);
38    const { Box, Text, Markdown, Button, Input } = $.ui.resolve(e);
39    const rows = [
40      Text({ dimColor: true, children: settingsSummary(state.settings) }),
41      Button({ key: "btw-settings", label: "Settings", onPress: () => { state.settingsOpen = true; $.ui.invalidate("ui.render"); } }),
42      Text({ dimColor: true, children: "Main-session context refreshes with every question." }),
43    ];
44    if (!state.history.length && !state.pending) {
45      rows.push(Text({ dimColor: true, children: "Ask about what Claude is working on. This chat stays separate." }));
46    }
47    for (const message of [...state.history, ...(state.pending ? [{ role: "user", text: state.pending.question }] : [])]) {
48      const visible = cleanText(message.text);
49      rows.push(Box({
50        flexDirection: "column", marginTop: 1,
51        children: [
52          Text({ bold: true, color: message.role === "user" ? "cyan" : undefined, children: message.role === "user" ? "You" : "Claude" }),
53          ...(visible.length <= 10000
54            ? [Markdown({ text: visible })]
55            : Array.from(visible.matchAll(/[\s\S]{1,4000}/gu), match => Text({ children: match[0] }))),
56        ],
57      }));
58    }
59    if (state.pending) rows.push(Text({ dimColor: true, children: "Thinking…" }));
60    if (state.error) rows.push(Text({ color: "red", children: state.error }));
61    rows.push(Box({
62      flexDirection: "column", marginTop: 1,
63      children: [
64        Button({ key: "reset-btw", label: "Reset chat", onPress: () => reset($, state) }),
65        ...(state.pending ? [] : [Input({
66          key: "btw-question", placeholder: "Ask a follow-up…", value: state.draft,
67          submitLabel: "send", autoFocus: true,
68          onInput: value => { state.draft = value; },
69          onSubmit: value => ask($, state, value),
70        })]),
71      ],
72    }));
73    return Box({ flexDirection: "column", children: rows });
74  });
75
76  on("ui.render", { component: "Pane", requestId: "sidebar-controls" }, async ($, e, next) => {
77    const original = await next(e);
78    if (e.surface !== "terminal") return original;
79    const { Box, Button } = $.ui.resolve(e);
80    return Box({ flexDirection: "column", children: [original, Button({
81      key: "open-btw", label: "Open BTW chat", onPress: () => $.ui.open({ ...pane, focus: true }),
82    })] });
83  });
84}
85
86function reset($, state) {
87  // Discarded requests must never refill a reset chat.
88  state.pending = null;
89  state.history = [];
90  state.draft = "";
91  state.error = "";
92  state.settingsOpen = false;
93  $.ui.invalidate("ui.render");
94}
95
96async function ask($, state, question) {
97  question = question.trim();
98  if (!question || state.pending) return;
99  const request = { question };
100  state.pending = request;
101  state.draft = "";
102  state.error = "";
103  $.ui.invalidate("ui.render");
104  try {
105    await loadSettings($, state);
106    if (state.pending !== request) return;
107    const settings = { ...state.settings };
108    const customRequest = settings.model !== "main" || settings.effort !== "default" || settings.maxTokens !== "default";
109    if (settings.model === "custom" && !settings.customModel) {
110      state.draft = question;
111      state.error = "Enter a custom model ID in Settings first.";
112      return;
113    }
114    const messages = await $.session.messages();
115    if (state.pending !== request) return;
116    const prompt = [
117      "Answer the user's current question in an ongoing BTW side conversation.",
118      "Use the main conversation as context, but do not continue its task. You have no tools.",
119      customRequest
120        ? "The JSON below contains the current main-session transcript, earlier side exchanges, and the current question."
121        : "The JSON below contains recent main-session messages supplementing your inherited context, earlier side exchanges, and the current question.",
122      "Current main-session facts take precedence over earlier side answers when facts changed.",
123      "Side exchanges are separate from the main conversation. Answer only the current question.",
124      ...(settings.style === "default" ? [] : [styleInstruction(settings.style)]),
125      JSON.stringify({
126        ...(customRequest ? { mainConversation: contextMessages(messages) } : { mainSessionUpdates: latestMessages(messages) }),
127        sideConversation: state.history, question,
128      }),
129    ].join("\n\n");
130    let reply;
131    if (customRequest) {
132      const model = settings.model === "main" ? await $.session.model()
133        : settings.model === "custom" ? settings.customModel : settings.model;
134      if (state.pending !== request) return;
135      reply = await $.model.complete({
136        model, prompt,
137        maxTokens: settings.maxTokens === "default" ? 4096 : Number(settings.maxTokens),
138        ...(settings.effort === "default" ? {} : { effort: settings.effort }),
139      });
140    } else {
141      reply = await $.model.fork({ prompt });
142    }
143    if (state.pending !== request) return;
144    if (reply.isAnswered) {
145      state.history.push({ role: "user", text: question }, { role: "assistant", text: reply.text });
146    } else {
147      state.draft = question;
148      state.error = failureLabel(reply);
149    }
150  } catch {
151    if (state.pending !== request) return;
152    state.draft = question;
153    state.error = "Could not get a side reply. Try again.";
154  } finally {
155    if (state.pending === request) {
156      state.pending = null;
157      $.ui.invalidate("ui.render");
158    }
159  }
160}
161
162function latestMessages(messages) {
163  // fork inherits the last model request, which omits its answer and subsequent tool results.
164  let start = messages.length;
165  while (start > 0 && messages[start - 1].role !== "assistant") start--;
166  while (start > 0 && messages[start - 1].role === "assistant") start--;
167  return contextMessages(messages.slice(start));
168}
169
170function contextMessages(messages) {
171  return messages.map(message => ({
172    role: message.role,
173    text: message.text,
174    toolUses: message.toolUses.map(({ tool_use_id, tool, input }) => ({ tool_use_id, tool, input })),
175    toolResults: message.toolResults?.map(({ tool_use_id, text, isError }) => ({ tool_use_id, text, isError })),
176  }));
177}
178
179function styleInstruction(style) {
180  if (style === "concise") return "Keep the reply short and direct; include only the essential explanation.";
181  if (style === "detailed") return "Give a thorough explanation with relevant reasoning, examples, and caveats.";
182  return "Give a clear answer with enough explanation to be useful, without unnecessary detail.";
183}
184
185function failureLabel(reply) {
186  if (reply.reason === "nothing-to-fork") return "Ask Claude in the main conversation first, then retry here.";
187  if (reply.reason === "aborted") return "Side reply interrupted. Try again.";
188  if (reply.reason === "empty-reply") return "Claude returned no text. Try again.";
189  return `Side reply failed${reply.status ? ` (HTTP ${reply.status})` : ""}. Try again.`;
190}
191
192function cleanText(text) {
193  return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
194}
195
196const defaults = { model: "main", customModel: "", effort: "default", style: "default", maxTokens: "default" };
197
198const models = [
199  { value: "main", label: "Main session" },
200  { value: "opus", label: "Opus" },
201  { value: "sonnet", label: "Sonnet" },
202  { value: "haiku", label: "Haiku" },
203  { value: "custom", label: "Custom model ID" },
204];
205const efforts = ["default", "low", "medium", "high", "xhigh", "max"];
206const styles = ["default", "concise", "balanced", "detailed"];
207const tokenLimits = ["default", "1024", "4096", "8192", "16384"];
208
209async function loadSettings($, state) {
210  if (state.settings !== null) return;
211  const saved = await $.store.get("settings");
212  if (state.settings !== null) return;
213  state.settings = {
214    model: models.some(option => option.value === saved?.model) ? saved.model : defaults.model,
215    customModel: typeof saved?.customModel === "string" ? saved.customModel.trim() : "",
216    effort: efforts.includes(saved?.effort) ? saved.effort : defaults.effort,
217    style: styles.includes(saved?.style) ? saved.style : defaults.style,
218    maxTokens: tokenLimits.includes(saved?.maxTokens) ? saved.maxTokens : defaults.maxTokens,
219  };
220}
221
222async function saveSettings($, state, change) {
223  state.settings = { ...state.settings, ...change };
224  state.settingsError = "";
225  $.ui.invalidate("ui.render");
226  try {
227    await $.store.set("settings", state.settings);
228  } catch {
229    state.settingsError = "Settings apply now, but could not be saved. Try again.";
230    $.ui.invalidate("ui.render");
231  }
232}
233
234function settingsSummary(settings) {
235  const model = settings.model === "custom" ? settings.customModel || "Custom model (unset)"
236    : models.find(option => option.value === settings.model).label;
237  return [model, settings.effort === "default" ? "Default effort" : `${settings.effort} effort`,
238    ...(settings.style === "default" ? [] : [settings.style]),
239    ...(settings.maxTokens === "default" ? [] : [`${settings.maxTokens} tokens`]),
240  ].join(" · ");
241}
242
243function settingsPanel($, e, state) {
244  const { Box, Text, Select, Input, Button } = $.ui.resolve(e);
245  const settings = state.settings;
246  const fields = [
247    Text({ bold: true, children: "Side chat settings" }),
248    Text({ dimColor: true, children: "Changes apply to the next reply. Press Enter to save a custom ID." }),
249    Select({ key: "btw-model", label: "Model", options: models, value: settings.model,
250      onSelect: model => saveSettings($, state, { model }) }),
251  ];
252  if (settings.model === "custom") fields.push(Input({
253    key: "btw-custom-model", label: "Model ID", placeholder: "Enter a model ID", value: settings.customModel,
254    submitLabel: "save",
255    onSubmit: value => saveSettings($, state, { customModel: value.trim() }),
256  }));
257  fields.push(
258    Select({ key: "btw-effort", label: "Thinking effort", options: efforts.map(value => ({ value, label: value === "default" ? "Default" : value })), value: settings.effort,
259      onSelect: effort => saveSettings($, state, { effort }) }),
260    Select({ key: "btw-style", label: "Reply style", options: styles.map(value => ({ value, label: value === "default" ? "Default" : value })), value: settings.style,
261      onSelect: style => saveSettings($, state, { style }) }),
262    Select({ key: "btw-max-tokens", label: "Output budget", options: tokenLimits.map(value => ({ value, label: value === "default" ? "Default" : `${value} tokens` })), value: settings.maxTokens,
263      onSelect: maxTokens => saveSettings($, state, { maxTokens }) }),
264    Button({ key: "btw-settings-defaults", label: "Restore defaults", onPress: () => saveSettings($, state, defaults) }),
265    Button({ key: "btw-settings-done", label: "Back to chat", onPress: () => { state.settingsOpen = false; $.ui.invalidate("ui.render"); } }),
266  );
267  if (state.settingsError) fields.push(Text({ color: "red", children: state.settingsError }));
268  return Box({ flexDirection: "column", gap: 1, children: fields });
269}
270