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

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.
Click Settings in the tab or run /btw settings:
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.
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.
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.
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.
hooks/btw-chat.mjs 270 lines1const 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