Skip to content

Commit b52d39f

Browse files
committed
feat(tui): finalize btw side-query command with caching, global shortcut, centering, layout and fallback fixes
1 parent 4be1f41 commit b52d39f

6 files changed

Lines changed: 450 additions & 2 deletions

File tree

packages/opencode/src/command/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function hints(template: string) {
4646
export const Default = {
4747
INIT: "init",
4848
REVIEW: "review",
49+
BTW: "btw",
4950
} as const
5051

5152
export interface Interface {
@@ -86,6 +87,15 @@ const layer = Layer.effect(
8687
subtask: true,
8788
hints: hints(PROMPT_REVIEW),
8889
}
90+
commands[Default.BTW] = {
91+
name: Default.BTW,
92+
description: "ask a quick, side question without cluttering history",
93+
source: "command",
94+
get template() {
95+
return "$ARGUMENTS"
96+
},
97+
hints: ["$ARGUMENTS"],
98+
}
8999

90100
for (const [name, command] of Object.entries(cfg.command ?? {})) {
91101
commands[name] = {
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
import { ScrollBoxRenderable, TextAttributes } from "@opentui/core"
2+
import { useTheme } from "../context/theme"
3+
import { useDialog } from "../ui/dialog"
4+
import { useSync } from "../context/sync"
5+
import { useSDK } from "../context/sdk"
6+
import { useTuiConfig } from "../config"
7+
import { getScrollAcceleration } from "../util/scroll"
8+
import { Spinner } from "./spinner"
9+
import { createMemo, createSignal, onMount, For, Show, onCleanup } from "solid-js"
10+
import { useBindings } from "../keymap"
11+
import { useLocal } from "../context/local"
12+
13+
export type DialogBtwProps = {
14+
question?: string
15+
sessionIDOverride?: string
16+
parentSessionID?: string
17+
}
18+
19+
// Persistent states for /btw
20+
const [lastBtwState, setLastBtwState] = createSignal<{
21+
question: string
22+
sessionID: string
23+
interrupted?: boolean
24+
modelLabel?: string
25+
} | null>(null)
26+
27+
const [isBtwVisible, setIsBtwVisible] = createSignal(false)
28+
29+
export { lastBtwState, isBtwVisible }
30+
31+
export function toggleBtwDialog(dialog: ReturnType<typeof useDialog>, parentSessionID?: string) {
32+
if (isBtwVisible()) {
33+
dialog.clear()
34+
} else {
35+
const current = lastBtwState()
36+
dialog.replace(() => (
37+
<DialogBtw
38+
question={current?.question}
39+
sessionIDOverride={current?.sessionID}
40+
parentSessionID={parentSessionID}
41+
/>
42+
))
43+
}
44+
}
45+
46+
export function DialogBtw(props: DialogBtwProps) {
47+
const dialog = useDialog()
48+
const sync = useSync()
49+
const sdk = useSDK()
50+
const { theme } = useTheme()
51+
const tuiConfig = useTuiConfig()
52+
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
53+
54+
const [sessionID, setSessionID] = createSignal<string>("")
55+
const [error, setError] = createSignal<string>("")
56+
const [loading, setLoading] = createSignal(true)
57+
const isInterrupted = createMemo(() => !!lastBtwState()?.interrupted)
58+
const [modelLabel, setModelLabel] = createSignal<string>("")
59+
const local = useLocal()
60+
let scrollBox: ScrollBoxRenderable | undefined
61+
62+
onMount(async () => {
63+
dialog.setSize("large")
64+
dialog.setCenter(true)
65+
setIsBtwVisible(true)
66+
67+
onCleanup(() => {
68+
setIsBtwVisible(false)
69+
})
70+
71+
if (props.sessionIDOverride) {
72+
setSessionID(props.sessionIDOverride)
73+
setModelLabel(lastBtwState()?.modelLabel || "")
74+
setLoading(false)
75+
return
76+
}
77+
78+
if (!props.question) {
79+
setLoading(false)
80+
return
81+
}
82+
83+
try {
84+
// 1. Find the current session to inherit directory & workspace
85+
const parentSession = props.parentSessionID ? sync.session.get(props.parentSessionID) : undefined
86+
const directory = parentSession?.directory
87+
const workspaceID = parentSession?.workspaceID
88+
89+
// We will find a suitable agent and model
90+
const currentModel = local.model.current()
91+
const agentName = parentSession?.agent ?? local.agent.current()?.name ?? "opencode"
92+
const model = parentSession?.model ?? (currentModel ? {
93+
providerID: currentModel.providerID,
94+
id: currentModel.modelID,
95+
variant: local.model.variant.current(),
96+
} : {
97+
providerID: "opencode",
98+
id: "gemini-2.5-flash",
99+
})
100+
101+
const label = `${model.id}${model.variant ? ` (${model.variant})` : ""}`
102+
setModelLabel(label)
103+
104+
// Initialize global state early so it can be marked interrupted even before session creation
105+
setLastBtwState({
106+
question: props.question,
107+
sessionID: "",
108+
interrupted: false,
109+
modelLabel: label,
110+
})
111+
112+
// 2. Create the child/side-session
113+
const res = await sdk.client.session.create({
114+
directory,
115+
workspace: workspaceID,
116+
agent: agentName,
117+
parentID: props.parentSessionID,
118+
model: {
119+
providerID: model.providerID,
120+
id: model.id,
121+
variant: model.variant,
122+
},
123+
})
124+
125+
if (res.error) {
126+
setError("Failed to create side query session: " + JSON.stringify(res.error))
127+
setLoading(false)
128+
return
129+
}
130+
131+
if (lastBtwState()?.interrupted) {
132+
// The user pressed ESC before the session was even fully created.
133+
// Abort the newly created session and don't proceed with prompting.
134+
sdk.client.session.abort({ sessionID: res.data.id }).catch(() => {})
135+
setLastBtwState(prev => prev ? { ...prev, sessionID: res.data.id } : null)
136+
setLoading(false)
137+
return
138+
}
139+
140+
const newSessionID = res.data.id
141+
setSessionID(newSessionID)
142+
143+
// Update global/last BTW state with the real sessionID!
144+
setLastBtwState(prev => prev ? { ...prev, sessionID: newSessionID } : null)
145+
146+
// 3. Send the prompt to stream response!
147+
await sdk.client.session.prompt({
148+
sessionID: newSessionID,
149+
agent: agentName,
150+
model: {
151+
providerID: model.providerID,
152+
modelID: model.id,
153+
},
154+
variant: model.variant,
155+
parts: [
156+
{
157+
type: "text",
158+
text: props.question,
159+
}
160+
],
161+
}, { throwOnError: true })
162+
163+
setLoading(false)
164+
} catch (e: any) {
165+
setError(e.message || String(e))
166+
setLoading(false)
167+
}
168+
})
169+
170+
// Get assistant messages & parts reactively
171+
const messages = createMemo(() => {
172+
const sId = sessionID()
173+
if (!sId) return []
174+
return sync.data.message[sId] ?? []
175+
})
176+
177+
const assistantMessageText = createMemo(() => {
178+
const msgs = messages()
179+
const assistantMsgs = msgs.filter(m => m.role === "assistant")
180+
let fullText = ""
181+
for (const msg of assistantMsgs) {
182+
const parts = sync.data.part[msg.id] ?? []
183+
for (const part of parts) {
184+
if (part.type === "text") {
185+
fullText += part.text
186+
}
187+
}
188+
}
189+
return fullText
190+
})
191+
192+
// Check if AI is currently streaming/generating response
193+
const isStreaming = createMemo(() => {
194+
const sId = sessionID()
195+
if (!sId) return !props.sessionIDOverride
196+
const status = sync.data.session_status[sId]
197+
return status?.type === "busy" || (assistantMessageText() === "" && !props.sessionIDOverride)
198+
})
199+
200+
// Scroll bindings for the dialog so user can read with keys!
201+
useBindings(() => ({
202+
priority: 100,
203+
bindings: [
204+
{
205+
key: "escape",
206+
desc: "Interrupt and close BTW Side Query",
207+
group: "Dialog",
208+
cmd: () => {
209+
const streaming = isStreaming()
210+
setLastBtwState(prev => prev ? { ...prev, interrupted: prev.interrupted || streaming } : null)
211+
if (sessionID()) {
212+
sdk.client.session.abort({ sessionID: sessionID() }).catch(() => {})
213+
}
214+
dialog.clear()
215+
},
216+
},
217+
{
218+
key: "ctrl+b",
219+
desc: "Hide BTW Side Query (Keep running)",
220+
group: "Dialog",
221+
cmd: () => dialog.clear(),
222+
},
223+
{
224+
key: "up",
225+
desc: "Scroll up",
226+
group: "Dialog",
227+
cmd: () => scrollBox?.scrollBy(-1),
228+
},
229+
{
230+
key: "down",
231+
desc: "Scroll down",
232+
group: "Dialog",
233+
cmd: () => scrollBox?.scrollBy(1),
234+
},
235+
{
236+
key: "k",
237+
desc: "Scroll up",
238+
group: "Dialog",
239+
cmd: () => scrollBox?.scrollBy(-1),
240+
},
241+
{
242+
key: "j",
243+
desc: "Scroll down",
244+
group: "Dialog",
245+
cmd: () => scrollBox?.scrollBy(1),
246+
},
247+
{
248+
key: "pageup",
249+
desc: "Scroll page up",
250+
group: "Dialog",
251+
cmd: () => scrollBox?.scrollBy(-10),
252+
},
253+
{
254+
key: "pagedown",
255+
desc: "Scroll page down",
256+
group: "Dialog",
257+
cmd: () => scrollBox?.scrollBy(10),
258+
},
259+
]
260+
}))
261+
262+
return (
263+
<box paddingLeft={2} paddingRight={2} gap={1} minHeight={15}>
264+
{/* Header */}
265+
<box flexDirection="row" justifyContent="space-between" border={["bottom"]} borderColor={theme.border} paddingBottom={1}>
266+
<box flexDirection="row" gap={1} alignItems="flex-end">
267+
<text attributes={TextAttributes.BOLD} fg={theme.accent}>
268+
[BTW]
269+
</text>
270+
<text attributes={TextAttributes.BOLD} fg={theme.text}>
271+
Quick Side Query
272+
</text>
273+
<Show when={modelLabel()}>
274+
<box paddingLeft={2}>
275+
<text fg={theme.textMuted} attributes={TextAttributes.DIM}>
276+
Agent: {modelLabel()}
277+
</text>
278+
</box>
279+
</Show>
280+
</box>
281+
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
282+
esc to close
283+
</text>
284+
</box>
285+
286+
{/* Guide screen when no query exists */}
287+
<Show when={!props.question && !props.sessionIDOverride && !lastBtwState()}>
288+
<box padding={2} gap={1} flexGrow={1} justifyContent="center" alignItems="center">
289+
<text fg={theme.textMuted} wrapMode="word" attributes={TextAttributes.ITALIC}>
290+
No previous side query.
291+
</text>
292+
<text fg={theme.textMuted} wrapMode="word">
293+
Type "/btw &lt;question&gt;" in the main input to ask one!
294+
</text>
295+
</box>
296+
</Show>
297+
298+
{/* Question panel */}
299+
<Show when={props.question || props.sessionIDOverride || lastBtwState()}>
300+
<box paddingLeft={1} paddingRight={1} paddingTop={1} paddingBottom={1} backgroundColor={theme.backgroundElement}>
301+
<text fg={theme.textMuted} attributes={TextAttributes.ITALIC} wrapMode="word">
302+
Q: {props.question ?? lastBtwState()?.question}
303+
</text>
304+
</box>
305+
</Show>
306+
307+
{/* Response content */}
308+
<Show when={error()}>
309+
<box padding={1}>
310+
<text fg={theme.error} wrapMode="word">{error()}</text>
311+
</box>
312+
</Show>
313+
314+
<Show when={!error() && (props.question || props.sessionIDOverride || lastBtwState())}>
315+
<box flexGrow={1} minHeight={10} maxHeight={25}>
316+
<scrollbox
317+
ref={(r: ScrollBoxRenderable) => (scrollBox = r)}
318+
scrollAcceleration={scrollAcceleration()}
319+
stickyScroll={true}
320+
stickyStart="bottom"
321+
flexGrow={1}
322+
viewportOptions={{
323+
paddingRight: 1,
324+
}}
325+
verticalScrollbarOptions={{
326+
visible: true,
327+
trackOptions: {
328+
backgroundColor: theme.backgroundElement,
329+
foregroundColor: theme.borderActive,
330+
},
331+
}}
332+
>
333+
<Show when={loading() || isStreaming()}>
334+
<box flexDirection="row" gap={1} padding={1}>
335+
<Spinner />
336+
<text fg={theme.textMuted}>{assistantMessageText() ? "Streaming response..." : "Thinking..."}</text>
337+
</box>
338+
</Show>
339+
<Show when={assistantMessageText()}>
340+
<box paddingLeft={1} paddingRight={1}>
341+
<text fg={theme.text} wrapMode="word">
342+
{assistantMessageText()}
343+
</text>
344+
</box>
345+
</Show>
346+
</scrollbox>
347+
</box>
348+
</Show>
349+
350+
{/* Interrupted message */}
351+
<Show when={isInterrupted()}>
352+
<box paddingLeft={1} paddingRight={1} paddingBottom={1}>
353+
<text fg={theme.error} attributes={TextAttributes.BOLD}>
354+
Agent stream cancelled
355+
</text>
356+
</box>
357+
</Show>
358+
359+
{/* Footer */}
360+
<box flexDirection="column" border={["top"]} borderColor={theme.border} paddingTop={1} paddingBottom={1} gap={1}>
361+
<box flexDirection="row" justifyContent="space-between">
362+
<text fg={theme.textMuted}>Use ↑/↓ or j/k to scroll</text>
363+
<box
364+
paddingLeft={3}
365+
paddingRight={3}
366+
backgroundColor={theme.primary}
367+
onMouseUp={() => dialog.clear()}
368+
>
369+
<text fg={theme.selectedListItemText}>Close</text>
370+
</box>
371+
</box>
372+
</box>
373+
</box>
374+
)
375+
}

0 commit comments

Comments
 (0)