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
exportconstDefault={
4747
INIT: "init",
4848
REVIEW: "review",
49+
BTW: "btw",
4950
}asconst
5051

5152
exportinterfaceInterface{
@@ -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+
gettemplate(){
95+
return"$ARGUMENTS"
96+
},
97+
hints: ["$ARGUMENTS"],
98+
}
8999

90100
for(const[name,command]ofObject.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+
exporttypeDialogBtwProps={
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+
exportfunctiontoggleBtwDialog(dialog: ReturnType<typeofuseDialog>,parentSessionID?: string){
32+
if(isBtwVisible()){
33+
dialog.clear()
34+
}else{
35+
constcurrent=lastBtwState()
36+
dialog.replace(()=>(
37+
<DialogBtw
38+
question={current?.question}
39+
sessionIDOverride={current?.sessionID}
40+
parentSessionID={parentSessionID}
41+
/>
42+
))
43+
}
44+
}
45+
46+
exportfunctionDialogBtw(props: DialogBtwProps){
47+
constdialog=useDialog()
48+
constsync=useSync()
49+
constsdk=useSDK()
50+
const{ theme }=useTheme()
51+
consttuiConfig=useTuiConfig()
52+
constscrollAcceleration=createMemo(()=>getScrollAcceleration(tuiConfig))
53+
54+
const[sessionID,setSessionID]=createSignal<string>("")
55+
const[error,setError]=createSignal<string>("")
56+
const[loading,setLoading]=createSignal(true)
57+
constisInterrupted=createMemo(()=>!!lastBtwState()?.interrupted)
58+
const[modelLabel,setModelLabel]=createSignal<string>("")
59+
constlocal=useLocal()
60+
letscrollBox: 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+
constparentSession=props.parentSessionID ? sync.session.get(props.parentSessionID) : undefined
86+
constdirectory=parentSession?.directory
87+
constworkspaceID=parentSession?.workspaceID
88+
89+
// We will find a suitable agent and model
90+
constcurrentModel=local.model.current()
91+
constagentName=parentSession?.agent??local.agent.current()?.name??"opencode"
92+
constmodel=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+
constlabel=`${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+
constres=awaitsdk.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+
constnewSessionID=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+
awaitsdk.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+
constmessages=createMemo(()=>{
172+
constsId=sessionID()
173+
if(!sId)return[]
174+
returnsync.data.message[sId]??[]
175+
})
176+
177+
constassistantMessageText=createMemo(()=>{
178+
constmsgs=messages()
179+
constassistantMsgs=msgs.filter(m=>m.role==="assistant")
180+
letfullText=""
181+
for(constmsgofassistantMsgs){
182+
constparts=sync.data.part[msg.id]??[]
183+
for(constpartofparts){
184+
if(part.type==="text"){
185+
fullText+=part.text
186+
}
187+
}
188+
}
189+
returnfullText
190+
})
191+
192+
// Check if AI is currently streaming/generating response
193+
constisStreaming=createMemo(()=>{
194+
constsId=sessionID()
195+
if(!sId)return!props.sessionIDOverride
196+
conststatus=sync.data.session_status[sId]
197+
returnstatus?.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+
conststreaming=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+
<boxpaddingLeft={2}paddingRight={2}gap={1}minHeight={15}>
264+
{/* Header */}
265+
<boxflexDirection="row"justifyContent="space-between"border={["bottom"]}borderColor={theme.border}paddingBottom={1}>
266+
<boxflexDirection="row"gap={1}alignItems="flex-end">
267+
<textattributes={TextAttributes.BOLD}fg={theme.accent}>
268+
[BTW]
269+
</text>
270+
<textattributes={TextAttributes.BOLD}fg={theme.text}>
271+
Quick Side Query
272+
</text>
273+
<Showwhen={modelLabel()}>
274+
<boxpaddingLeft={2}>
275+
<textfg={theme.textMuted}attributes={TextAttributes.DIM}>
276+
Agent: {modelLabel()}
277+
</text>
278+
</box>
279+
</Show>
280+
</box>
281+
<textfg={theme.textMuted}onMouseUp={()=>dialog.clear()}>
282+
esc to close
283+
</text>
284+
</box>
285+
286+
{/* Guide screen when no query exists */}
287+
<Showwhen={!props.question&&!props.sessionIDOverride&&!lastBtwState()}>
288+
<boxpadding={2}gap={1}flexGrow={1}justifyContent="center"alignItems="center">
289+
<textfg={theme.textMuted}wrapMode="word"attributes={TextAttributes.ITALIC}>
290+
No previous side query.
291+
</text>
292+
<textfg={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+
<Showwhen={props.question||props.sessionIDOverride||lastBtwState()}>
300+
<boxpaddingLeft={1}paddingRight={1}paddingTop={1}paddingBottom={1}backgroundColor={theme.backgroundElement}>
301+
<textfg={theme.textMuted}attributes={TextAttributes.ITALIC}wrapMode="word">
302+
Q: {props.question??lastBtwState()?.question}
303+
</text>
304+
</box>
305+
</Show>
306+
307+
{/* Response content */}
308+
<Showwhen={error()}>
309+
<boxpadding={1}>
310+
<textfg={theme.error}wrapMode="word">{error()}</text>
311+
</box>
312+
</Show>
313+
314+
<Showwhen={!error()&&(props.question||props.sessionIDOverride||lastBtwState())}>
315+
<boxflexGrow={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+
<Showwhen={loading()||isStreaming()}>
334+
<boxflexDirection="row"gap={1}padding={1}>
335+
<Spinner/>
336+
<textfg={theme.textMuted}>{assistantMessageText() ? "Streaming response..." : "Thinking..."}</text>
337+
</box>
338+
</Show>
339+
<Showwhen={assistantMessageText()}>
340+
<boxpaddingLeft={1}paddingRight={1}>
341+
<textfg={theme.text}wrapMode="word">
342+
{assistantMessageText()}
343+
</text>
344+
</box>
345+
</Show>
346+
</scrollbox>
347+
</box>
348+
</Show>
349+
350+
{/* Interrupted message */}
351+
<Showwhen={isInterrupted()}>
352+
<boxpaddingLeft={1}paddingRight={1}paddingBottom={1}>
353+
<textfg={theme.error}attributes={TextAttributes.BOLD}>
354+
Agent stream cancelled
355+
</text>
356+
</box>
357+
</Show>
358+
359+
{/* Footer */}
360+
<boxflexDirection="column"border={["top"]}borderColor={theme.border}paddingTop={1}paddingBottom={1}gap={1}>
361+
<boxflexDirection="row"justifyContent="space-between">
362+
<textfg={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+
<textfg={theme.selectedListItemText}>Close</text>
370+
</box>
371+
</box>
372+
</box>
373+
</box>
374+
)
375+
}

0 commit comments

Comments
 (0)