|
| 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 <question>" 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