opencode-sidechat 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -0
- package/package.json +33 -0
- package/src/components/SideChat.tsx +348 -0
- package/src/config.ts +189 -0
- package/src/constants.ts +45 -0
- package/src/index.tsx +379 -0
- package/src/session.ts +257 -0
- package/src/types.ts +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# OpenCode SideChat
|
|
2
|
+
|
|
3
|
+
Floating side-chat panel for quick queries while your main session runs. Opens at the bottom-right corner of the TUI via `Alt+N`. Uses a separate agent with read-only tools and no access to main-session context.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Add the path to your `~/.config/opencode/tui.json`:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
["E:/CodeProjects/opencode-sidechat"]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
On first launch, the plugin creates `~/.config/opencode/sidechat.jsonc` with defaults. Edit that file to change settings.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
| Key | Action |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `Alt+N` | Toggle panel |
|
|
20
|
+
| `Alt+C` | Clear chat / new session |
|
|
21
|
+
| `Alt+T` | Toggle thinking blocks |
|
|
22
|
+
| `Tab` | Change model |
|
|
23
|
+
|
|
24
|
+
Commands: `/side`, `/side-clear`, `/side-model`.
|
|
25
|
+
|
|
26
|
+
## Configuration
|
|
27
|
+
|
|
28
|
+
All settings live in `~/.config/opencode/sidechat.jsonc`:
|
|
29
|
+
|
|
30
|
+
```jsonc
|
|
31
|
+
{
|
|
32
|
+
"model": "opencode/deepseek-v4-flash-free",
|
|
33
|
+
"systemPrompt": "You are a casual side assistant. Answer concisely and directly.",
|
|
34
|
+
"keybind": "alt+n",
|
|
35
|
+
"clearKeybind": "alt+c",
|
|
36
|
+
"thinkToggleKeybind": "alt+t",
|
|
37
|
+
"allowedTools": ["glob", "grep", "list", "read", "webfetch", "websearch"],
|
|
38
|
+
"width": 70,
|
|
39
|
+
"transcriptHeight": 20,
|
|
40
|
+
"tokenLimit": 45000,
|
|
41
|
+
"think": {
|
|
42
|
+
"defaultState": "collapsed",
|
|
43
|
+
"showSummary": false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Security
|
|
49
|
+
|
|
50
|
+
The side agent is deny-by-default for tool access. Only tools listed in `allowedTools` are granted. The side agent cannot see your main session, its files, or its conversation history. It operates as an isolated session with a minimal system prompt.
|
|
51
|
+
|
|
52
|
+
## Build
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
npm install
|
|
56
|
+
npx tsc --noEmit # typecheck
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Files
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
src/
|
|
63
|
+
├── index.tsx # plugin entry, slot/event wiring
|
|
64
|
+
├── config.ts # sidechat.jsonc read + defaults
|
|
65
|
+
├── constants.ts # command names, tool allowlist
|
|
66
|
+
├── session.ts # session lifecycle, model picker
|
|
67
|
+
├── types.ts # config + state types
|
|
68
|
+
└── components/
|
|
69
|
+
└── SideChat.tsx # UI: transcript, input, hint bar
|
|
70
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-sidechat",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Floating side-chat panel for quick queries alongside your main opencode session",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/SquirrelRat/OpenCode-SideChat.git"
|
|
9
|
+
},
|
|
10
|
+
"author": "SquirrelRat",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"exports": {
|
|
13
|
+
"./tui": "./src/index.tsx"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@opencode-ai/plugin": ">=1.14.48",
|
|
23
|
+
"@opencode-ai/sdk": ">=1.14.48",
|
|
24
|
+
"@opentui/core": "^0.2.6",
|
|
25
|
+
"@opentui/keymap": "^0.2.6",
|
|
26
|
+
"@opentui/solid": "^0.2.6",
|
|
27
|
+
"solid-js": "^1.9.12"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^25.9.1",
|
|
31
|
+
"typescript": "^5.9.3"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { createMemo, For } from "solid-js";
|
|
3
|
+
import type { InputRenderable } from "@opentui/core";
|
|
4
|
+
import { THINKING_TEXT } from "../constants";
|
|
5
|
+
import type { OverlayState } from "../types";
|
|
6
|
+
|
|
7
|
+
const MAX_VISIBLE_MESSAGES = 20;
|
|
8
|
+
|
|
9
|
+
function renderInlineMarkdown(text: string) {
|
|
10
|
+
const parts: Array<{ type: string; text: string }> = [];
|
|
11
|
+
let remaining = text;
|
|
12
|
+
|
|
13
|
+
while (remaining.length > 0) {
|
|
14
|
+
if (remaining.startsWith("`")) {
|
|
15
|
+
const end = remaining.indexOf("`", 1);
|
|
16
|
+
if (end === -1) {
|
|
17
|
+
parts.push({ type: "text", text: remaining });
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
parts.push({ type: "code", text: remaining.slice(1, end) });
|
|
21
|
+
remaining = remaining.slice(end + 1);
|
|
22
|
+
} else if (remaining.startsWith("**")) {
|
|
23
|
+
const end = remaining.indexOf("**", 2);
|
|
24
|
+
if (end === -1) {
|
|
25
|
+
parts.push({ type: "text", text: remaining });
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
parts.push({ type: "bold", text: remaining.slice(2, end) });
|
|
29
|
+
remaining = remaining.slice(end + 2);
|
|
30
|
+
} else if (remaining.startsWith("*") && !remaining.startsWith("**")) {
|
|
31
|
+
const end = remaining.indexOf("*", 1);
|
|
32
|
+
if (end === -1) {
|
|
33
|
+
parts.push({ type: "text", text: remaining });
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
parts.push({ type: "italic", text: remaining.slice(1, end) });
|
|
37
|
+
remaining = remaining.slice(end + 1);
|
|
38
|
+
} else if (remaining.startsWith("[") && remaining.includes("](")) {
|
|
39
|
+
const closeBracket = remaining.indexOf("](");
|
|
40
|
+
const closeParen = remaining.indexOf(")", closeBracket);
|
|
41
|
+
if (closeParen === -1) {
|
|
42
|
+
parts.push({ type: "text", text: remaining });
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
parts.push({ type: "text", text: remaining.slice(1, closeBracket) });
|
|
46
|
+
remaining = remaining.slice(closeParen + 1);
|
|
47
|
+
} else {
|
|
48
|
+
const nextSpecial = searchSpecialChar(remaining);
|
|
49
|
+
if (nextSpecial === -1) {
|
|
50
|
+
parts.push({ type: "text", text: remaining });
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (nextSpecial > 0) {
|
|
54
|
+
parts.push({ type: "text", text: remaining.slice(0, nextSpecial) });
|
|
55
|
+
}
|
|
56
|
+
remaining = remaining.slice(nextSpecial);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return parts;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function searchSpecialChar(text: string): number {
|
|
64
|
+
for (let i = 0; i < text.length; i++) {
|
|
65
|
+
const c = text[i];
|
|
66
|
+
if (c === "`" || c === "*" || (c === "[" && text.includes("](", i))) {
|
|
67
|
+
return i;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return -1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function SideChat(props: OverlayState & { width: number; transcriptHeight: number; tokenLimit: number }) {
|
|
74
|
+
const theme = props.api.theme.current;
|
|
75
|
+
let input: InputRenderable | undefined;
|
|
76
|
+
let inputValue = "";
|
|
77
|
+
|
|
78
|
+
const panelWidth = props.width;
|
|
79
|
+
const contentWidth = props.width - 4;
|
|
80
|
+
|
|
81
|
+
const msgs = createMemo(() => {
|
|
82
|
+
const messages = props.state.entries
|
|
83
|
+
.map((entry) => {
|
|
84
|
+
const textParts: string[] = [];
|
|
85
|
+
const reasoning: Array<{ id: string; text: string }> = [];
|
|
86
|
+
|
|
87
|
+
for (const p of entry.parts) {
|
|
88
|
+
if (p.type === "text") {
|
|
89
|
+
if (p.text.trim()) textParts.push(p.text.trim());
|
|
90
|
+
} else if (p.type === "reasoning") {
|
|
91
|
+
if (p.text) reasoning.push({ id: p.id, text: p.text });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (textParts.length === 0 && reasoning.length === 0) return null;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
id: entry.info.id,
|
|
99
|
+
role: entry.info.role as "user" | "assistant",
|
|
100
|
+
text: textParts.join("\n"),
|
|
101
|
+
reasoning,
|
|
102
|
+
};
|
|
103
|
+
})
|
|
104
|
+
.filter((m): m is NonNullable<typeof m> => m !== null)
|
|
105
|
+
.slice(-MAX_VISIBLE_MESSAGES);
|
|
106
|
+
|
|
107
|
+
if (props.state.loading && props.state.streamingAnswer) {
|
|
108
|
+
const streaming = props.state.streamingAnswer.trim();
|
|
109
|
+
const last = messages[messages.length - 1];
|
|
110
|
+
const lastText = last?.role === "assistant" ? last.text : "";
|
|
111
|
+
if (streaming && streaming !== lastText) {
|
|
112
|
+
messages.push({
|
|
113
|
+
id: "__streaming__",
|
|
114
|
+
role: "assistant",
|
|
115
|
+
text: streaming,
|
|
116
|
+
reasoning: [],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return messages;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const ctxLabel = createMemo(() => {
|
|
125
|
+
const n = props.state.tokenCount ?? 0;
|
|
126
|
+
if (n <= 0) return "";
|
|
127
|
+
const current = n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
|
|
128
|
+
const limit = props.tokenLimit >= 1000 ? (props.tokenLimit / 1000).toFixed(0) + "k" : String(props.tokenLimit);
|
|
129
|
+
return current + "/" + limit + " ctx";
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const shortModelName = createMemo(() => {
|
|
133
|
+
const name = props.modelName;
|
|
134
|
+
const parts = name.split("/");
|
|
135
|
+
return parts.length >= 2 ? parts[parts.length - 1] : name;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const renderThinking = (r: { id: string; text: string }) => {
|
|
139
|
+
if (!props.thinkCollapsed) {
|
|
140
|
+
return (
|
|
141
|
+
<box flexDirection="column">
|
|
142
|
+
<text fg={theme.textMuted}>
|
|
143
|
+
{"▼ thinking:"}
|
|
144
|
+
</text>
|
|
145
|
+
<text fg={theme.textMuted}>{r.text}</text>
|
|
146
|
+
</box>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const label = props.thinkConfig.showSummary
|
|
151
|
+
? "▶ thinking: " + r.text.slice(0, 60).replace(/\n/g, " ") + (r.text.length > 60 ? "..." : "")
|
|
152
|
+
: "▶ thinking (" + r.text.length + " chars)";
|
|
153
|
+
|
|
154
|
+
return <text fg={theme.textMuted}>{label}</text>;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<box
|
|
159
|
+
position="absolute"
|
|
160
|
+
bottom={0}
|
|
161
|
+
right={0}
|
|
162
|
+
>
|
|
163
|
+
<box
|
|
164
|
+
width={panelWidth}
|
|
165
|
+
flexDirection="column"
|
|
166
|
+
border={true}
|
|
167
|
+
borderColor={theme.borderActive}
|
|
168
|
+
backgroundColor={theme.backgroundPanel}
|
|
169
|
+
>
|
|
170
|
+
<box
|
|
171
|
+
flexDirection="row"
|
|
172
|
+
justifyContent="space-between"
|
|
173
|
+
alignItems="center"
|
|
174
|
+
paddingTop={1}
|
|
175
|
+
paddingLeft={1}
|
|
176
|
+
paddingRight={1}
|
|
177
|
+
>
|
|
178
|
+
<box flexDirection="row" gap={1} alignItems="center">
|
|
179
|
+
<box paddingLeft={1} paddingRight={1} backgroundColor={theme.accent}>
|
|
180
|
+
<text fg={theme.background}>
|
|
181
|
+
<b>{"SideChat"}</b>
|
|
182
|
+
</text>
|
|
183
|
+
</box>
|
|
184
|
+
<text fg={theme.success}>
|
|
185
|
+
<b>{"[f]"}</b>
|
|
186
|
+
</text>
|
|
187
|
+
</box>
|
|
188
|
+
<box flexDirection="row" gap={1} alignItems="center">
|
|
189
|
+
<text fg={theme.textMuted}>{shortModelName()}</text>
|
|
190
|
+
{ctxLabel() ? (
|
|
191
|
+
<text fg={theme.textMuted}>{ctxLabel()}</text>
|
|
192
|
+
) : (
|
|
193
|
+
<text>{""}</text>
|
|
194
|
+
)}
|
|
195
|
+
</box>
|
|
196
|
+
</box>
|
|
197
|
+
|
|
198
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
199
|
+
<scrollbox
|
|
200
|
+
scrollY={true}
|
|
201
|
+
stickyScroll={true}
|
|
202
|
+
stickyStart="bottom"
|
|
203
|
+
height={props.transcriptHeight}
|
|
204
|
+
width={contentWidth}
|
|
205
|
+
>
|
|
206
|
+
<box flexDirection="column" gap={1} paddingTop={1} paddingBottom={1} width={contentWidth - 2}>
|
|
207
|
+
{msgs().length > 0 ? (
|
|
208
|
+
msgs().map((msg) => (
|
|
209
|
+
<box flexDirection="column" gap={0}>
|
|
210
|
+
<text fg={msg.role === "assistant" ? theme.secondary : theme.text}>
|
|
211
|
+
<b>{msg.role === "assistant" ? "A:" : "You:"}</b>
|
|
212
|
+
</text>
|
|
213
|
+
{msg.reasoning.map((r) => renderThinking(r))}
|
|
214
|
+
{msg.text ? (
|
|
215
|
+
<box flexDirection="column">
|
|
216
|
+
<RenderMarkdown text={msg.text} theme={theme} />
|
|
217
|
+
</box>
|
|
218
|
+
) : (
|
|
219
|
+
<text>{""}</text>
|
|
220
|
+
)}
|
|
221
|
+
</box>
|
|
222
|
+
))
|
|
223
|
+
) : props.state.loading ? (
|
|
224
|
+
<text fg={theme.textMuted}>{THINKING_TEXT}</text>
|
|
225
|
+
) : (
|
|
226
|
+
<text>{""}</text>
|
|
227
|
+
)}
|
|
228
|
+
{props.state.error ? (
|
|
229
|
+
<text fg={theme.error}>{"Error: " + String(props.state.error)}</text>
|
|
230
|
+
) : (
|
|
231
|
+
<text>{""}</text>
|
|
232
|
+
)}
|
|
233
|
+
</box>
|
|
234
|
+
</scrollbox>
|
|
235
|
+
</box>
|
|
236
|
+
|
|
237
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
238
|
+
<input
|
|
239
|
+
ref={(node) => { input = node; props.onInput?.(node); }}
|
|
240
|
+
width={contentWidth}
|
|
241
|
+
placeholder={props.state.loading ? "..." : ">"}
|
|
242
|
+
textColor={theme.text}
|
|
243
|
+
placeholderColor={theme.textMuted}
|
|
244
|
+
backgroundColor={theme.backgroundElement}
|
|
245
|
+
focusedTextColor={theme.text}
|
|
246
|
+
cursorColor={theme.primary}
|
|
247
|
+
focusedBackgroundColor={theme.backgroundElement}
|
|
248
|
+
onInput={(value) => {
|
|
249
|
+
inputValue = value;
|
|
250
|
+
}}
|
|
251
|
+
onSubmit={() => {
|
|
252
|
+
const submitted = (input?.value ?? inputValue).trim();
|
|
253
|
+
if (!submitted || props.state.loading) return;
|
|
254
|
+
if (!props.onSubmit(submitted)) return;
|
|
255
|
+
inputValue = "";
|
|
256
|
+
if (input) input.value = "";
|
|
257
|
+
}}
|
|
258
|
+
/>
|
|
259
|
+
</box>
|
|
260
|
+
|
|
261
|
+
<box
|
|
262
|
+
flexDirection="row"
|
|
263
|
+
gap={1}
|
|
264
|
+
paddingTop={0}
|
|
265
|
+
paddingBottom={1}
|
|
266
|
+
paddingLeft={1}
|
|
267
|
+
paddingRight={1}
|
|
268
|
+
alignItems="center"
|
|
269
|
+
>
|
|
270
|
+
<text fg={theme.secondary}><b>{"Alt+C"}</b></text>
|
|
271
|
+
<text fg={theme.primary}>{"Clear"}</text>
|
|
272
|
+
<text fg={theme.textMuted}>{"·"}</text>
|
|
273
|
+
<text fg={theme.secondary}><b>{"Alt+T"}</b></text>
|
|
274
|
+
<text fg={theme.primary}>{"Thinking"}</text>
|
|
275
|
+
<text fg={theme.textMuted}>{props.thinkCollapsed ? "" : "(on)"}</text>
|
|
276
|
+
<text fg={theme.textMuted}>{"·"}</text>
|
|
277
|
+
<text fg={theme.secondary}><b>{"Tab"}</b></text>
|
|
278
|
+
<text fg={theme.primary}>{"Model"}</text>
|
|
279
|
+
</box>
|
|
280
|
+
</box>
|
|
281
|
+
</box>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function RenderMarkdown(props: { text: string; theme: import("@opencode-ai/plugin/tui").TuiThemeCurrent }) {
|
|
286
|
+
const lines = createMemo(() => {
|
|
287
|
+
const text = props.text;
|
|
288
|
+
const result: Array<{ type: "line" | "codeblock"; parts: Array<{ type: string; text: string }>; codeText?: string }> = [];
|
|
289
|
+
let inCodeBlock = false;
|
|
290
|
+
let codeBuffer = "";
|
|
291
|
+
|
|
292
|
+
for (const line of text.split("\n")) {
|
|
293
|
+
if (line.startsWith("```")) {
|
|
294
|
+
if (inCodeBlock) {
|
|
295
|
+
result.push({ type: "codeblock", parts: [], codeText: codeBuffer });
|
|
296
|
+
codeBuffer = "";
|
|
297
|
+
}
|
|
298
|
+
inCodeBlock = !inCodeBlock;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (inCodeBlock) {
|
|
302
|
+
codeBuffer += (codeBuffer ? "\n" : "") + line;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const parts = renderInlineMarkdown(line);
|
|
307
|
+
result.push({ type: "line", parts });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (inCodeBlock && codeBuffer) {
|
|
311
|
+
result.push({ type: "codeblock", parts: [], codeText: codeBuffer });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return result;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const t = props.theme;
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<For each={lines()}>
|
|
321
|
+
{(block) => {
|
|
322
|
+
if (block.type === "codeblock") {
|
|
323
|
+
return (
|
|
324
|
+
<box
|
|
325
|
+
backgroundColor={t.backgroundElement}
|
|
326
|
+
paddingLeft={1}
|
|
327
|
+
paddingRight={1}
|
|
328
|
+
>
|
|
329
|
+
<text fg={t.markdownCode}>{block.codeText}</text>
|
|
330
|
+
</box>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return (
|
|
334
|
+
<box flexDirection="row" flexWrap="wrap" gap={0}>
|
|
335
|
+
<For each={block.parts}>
|
|
336
|
+
{(part) => {
|
|
337
|
+
if (part.type === "bold") return <text><b>{part.text}</b></text>;
|
|
338
|
+
if (part.type === "italic") return <text><i>{part.text}</i></text>;
|
|
339
|
+
if (part.type === "code") return <text fg={t.markdownCode}>{part.text}</text>;
|
|
340
|
+
return <text>{part.text}</text>;
|
|
341
|
+
}}
|
|
342
|
+
</For>
|
|
343
|
+
</box>
|
|
344
|
+
);
|
|
345
|
+
}}
|
|
346
|
+
</For>
|
|
347
|
+
);
|
|
348
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_ALLOWED_TOOLS,
|
|
6
|
+
DEFAULT_TOKEN_LIMIT,
|
|
7
|
+
DEFAULT_KEYBIND,
|
|
8
|
+
DEFAULT_CLEAR_KEYBIND,
|
|
9
|
+
DEFAULT_THINK_TOGGLE_KEYBIND,
|
|
10
|
+
DEFAULT_WIDTH,
|
|
11
|
+
DEFAULT_TRANSCRIPT_HEIGHT,
|
|
12
|
+
DEFAULT_SYSTEM_PROMPT,
|
|
13
|
+
} from "./constants";
|
|
14
|
+
import type { SideConfig, ThinkConfig } from "./types";
|
|
15
|
+
|
|
16
|
+
const CONFIG_FILENAME = "sidechat.jsonc";
|
|
17
|
+
|
|
18
|
+
function configDir(): string {
|
|
19
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
20
|
+
if (xdg) return join(xdg, "opencode");
|
|
21
|
+
return join(homedir(), ".config", "opencode");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function configPath(): string {
|
|
25
|
+
return join(configDir(), CONFIG_FILENAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stripJsoncComments(text: string): string {
|
|
29
|
+
let result = "";
|
|
30
|
+
let i = 0;
|
|
31
|
+
let inString = false;
|
|
32
|
+
while (i < text.length) {
|
|
33
|
+
const ch = text[i];
|
|
34
|
+
if (inString) {
|
|
35
|
+
result += ch;
|
|
36
|
+
if (ch === "\\" && i + 1 < text.length) {
|
|
37
|
+
i += 1;
|
|
38
|
+
result += text[i];
|
|
39
|
+
} else if (ch === '"') {
|
|
40
|
+
inString = false;
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
if (ch === '"') {
|
|
44
|
+
inString = true;
|
|
45
|
+
result += ch;
|
|
46
|
+
} else if (ch === "/" && i + 1 < text.length && text[i + 1] === "/") {
|
|
47
|
+
while (i < text.length && text[i] !== "\n") i += 1;
|
|
48
|
+
continue;
|
|
49
|
+
} else if (ch === "/" && i + 1 < text.length && text[i + 1] === "*") {
|
|
50
|
+
i += 2;
|
|
51
|
+
while (i + 1 < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
|
|
52
|
+
i += 2;
|
|
53
|
+
continue;
|
|
54
|
+
} else {
|
|
55
|
+
result += ch;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
i += 1;
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stripTrailingCommas(text: string): string {
|
|
64
|
+
let result = "";
|
|
65
|
+
let i = 0;
|
|
66
|
+
let inString = false;
|
|
67
|
+
while (i < text.length) {
|
|
68
|
+
const ch = text[i];
|
|
69
|
+
if (inString) {
|
|
70
|
+
result += ch;
|
|
71
|
+
if (ch === "\\" && i + 1 < text.length) {
|
|
72
|
+
i += 1;
|
|
73
|
+
result += text[i];
|
|
74
|
+
} else if (ch === '"') {
|
|
75
|
+
inString = false;
|
|
76
|
+
}
|
|
77
|
+
i += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (ch === '"') {
|
|
82
|
+
inString = true;
|
|
83
|
+
result += ch;
|
|
84
|
+
i += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (ch === ",") {
|
|
89
|
+
let j = i + 1;
|
|
90
|
+
while (j < text.length && /\s/.test(text[j])) j += 1;
|
|
91
|
+
if (text[j] === "}" || text[j] === "]") {
|
|
92
|
+
i += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
result += ch;
|
|
98
|
+
i += 1;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function generateDefaultConfig(): string {
|
|
104
|
+
const defaultAllowedTools = JSON.stringify(DEFAULT_ALLOWED_TOOLS);
|
|
105
|
+
return `{
|
|
106
|
+
// OpenCode SideChat Configuration
|
|
107
|
+
"model": "opencode/deepseek-v4-flash-free",
|
|
108
|
+
"systemPrompt": ${JSON.stringify(DEFAULT_SYSTEM_PROMPT)},
|
|
109
|
+
"keybind": "alt+n",
|
|
110
|
+
"clearKeybind": "alt+c",
|
|
111
|
+
"thinkToggleKeybind": "alt+t",
|
|
112
|
+
"allowedTools": ${defaultAllowedTools},
|
|
113
|
+
"width": ${DEFAULT_WIDTH},
|
|
114
|
+
"transcriptHeight": ${DEFAULT_TRANSCRIPT_HEIGHT},
|
|
115
|
+
"tokenLimit": ${DEFAULT_TOKEN_LIMIT},
|
|
116
|
+
"think": {
|
|
117
|
+
"defaultState": "collapsed",
|
|
118
|
+
"showSummary": false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ensureConfigFile(): void {
|
|
125
|
+
const dir = configDir();
|
|
126
|
+
const path = configPath();
|
|
127
|
+
try {
|
|
128
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
129
|
+
if (!existsSync(path)) writeFileSync(path, generateDefaultConfig(), "utf-8");
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function loadConfig(): SideConfig {
|
|
134
|
+
ensureConfigFile();
|
|
135
|
+
|
|
136
|
+
let raw: Record<string, unknown> = {};
|
|
137
|
+
try {
|
|
138
|
+
const text = readFileSync(configPath(), "utf-8");
|
|
139
|
+
const json = stripTrailingCommas(stripJsoncComments(text));
|
|
140
|
+
const parsed = JSON.parse(json);
|
|
141
|
+
if (parsed && typeof parsed === "object") raw = parsed as Record<string, unknown>;
|
|
142
|
+
} catch {}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
model: parseStringOrNull(raw.model),
|
|
146
|
+
systemPrompt: parseString(raw.systemPrompt, DEFAULT_SYSTEM_PROMPT),
|
|
147
|
+
tokenLimit: parsePositiveNumber(raw.tokenLimit, DEFAULT_TOKEN_LIMIT),
|
|
148
|
+
keybind: parseKeybind(raw.keybind, DEFAULT_KEYBIND),
|
|
149
|
+
clearKeybind: parseKeybind(raw.clearKeybind, DEFAULT_CLEAR_KEYBIND),
|
|
150
|
+
thinkToggleKeybind: parseKeybind(raw.thinkToggleKeybind, DEFAULT_THINK_TOGGLE_KEYBIND),
|
|
151
|
+
allowedTools: parseAllowedTools(raw.allowedTools),
|
|
152
|
+
width: parsePositiveNumber(raw.width, DEFAULT_WIDTH),
|
|
153
|
+
transcriptHeight: parsePositiveNumber(raw.transcriptHeight, DEFAULT_TRANSCRIPT_HEIGHT),
|
|
154
|
+
think: parseThinkConfig(raw.think),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseStringOrNull(value: unknown): string | null {
|
|
159
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseString(value: unknown, fallback: string): string {
|
|
163
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parsePositiveNumber(value: unknown, fallback: number) {
|
|
167
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
168
|
+
? Math.floor(value)
|
|
169
|
+
: fallback;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseKeybind(value: unknown, fallback: string): string | false {
|
|
173
|
+
if (value === false || value === "none") return false;
|
|
174
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseAllowedTools(value: unknown): string[] | null {
|
|
178
|
+
if (!Array.isArray(value)) return null;
|
|
179
|
+
return value.every((item) => typeof item === "string") ? value : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseThinkConfig(value: unknown): ThinkConfig {
|
|
183
|
+
if (!value || typeof value !== "object") return { defaultState: "collapsed", showSummary: false };
|
|
184
|
+
const obj = value as Record<string, unknown>;
|
|
185
|
+
return {
|
|
186
|
+
defaultState: obj.defaultState === "expanded" ? "expanded" : "collapsed",
|
|
187
|
+
showSummary: obj.showSummary === true,
|
|
188
|
+
};
|
|
189
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const PLUGIN_ID = "local.opencode-sidechat";
|
|
2
|
+
|
|
3
|
+
export const CMD_TOGGLE_FOCUS = "sidechat.toggle-focus";
|
|
4
|
+
export const CMD_CLEAR = "sidechat.clear";
|
|
5
|
+
export const CMD_CHANGE_MODEL = "sidechat.change-model";
|
|
6
|
+
export const CMD_TOGGLE_THINK = "sidechat.toggle-think";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_KEYBIND = "alt+n";
|
|
9
|
+
export const DEFAULT_CLEAR_KEYBIND = "alt+c";
|
|
10
|
+
export const DEFAULT_THINK_TOGGLE_KEYBIND = "alt+t";
|
|
11
|
+
export const DEFAULT_TOKEN_LIMIT = 45_000;
|
|
12
|
+
export const DEFAULT_WIDTH = 70;
|
|
13
|
+
export const DEFAULT_TRANSCRIPT_HEIGHT = 20;
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_SYSTEM_PROMPT =
|
|
16
|
+
"You are a casual side assistant. Answer concisely and directly. Use tools only when helpful.";
|
|
17
|
+
|
|
18
|
+
export const THINKING_TEXT = "...";
|
|
19
|
+
|
|
20
|
+
export const SAFE_TOOLS: Record<string, true> = {
|
|
21
|
+
glob: true,
|
|
22
|
+
grep: true,
|
|
23
|
+
list: true,
|
|
24
|
+
read: true,
|
|
25
|
+
websearch: true,
|
|
26
|
+
webfetch: true,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_ALLOWED_TOOLS = Object.keys(SAFE_TOOLS);
|
|
30
|
+
|
|
31
|
+
export const ADDITIONAL_PERMISSION_IDS = [
|
|
32
|
+
"edit",
|
|
33
|
+
"bash",
|
|
34
|
+
"task",
|
|
35
|
+
"external_directory",
|
|
36
|
+
"todowrite",
|
|
37
|
+
"question",
|
|
38
|
+
"websearch",
|
|
39
|
+
"codesearch",
|
|
40
|
+
"repo_clone",
|
|
41
|
+
"repo_overview",
|
|
42
|
+
"lsp",
|
|
43
|
+
"doom_loop",
|
|
44
|
+
"skill",
|
|
45
|
+
];
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
3
|
+
import { createSignal, Show } from "solid-js";
|
|
4
|
+
import { loadConfig } from "./config";
|
|
5
|
+
import { SideChat } from "./components/SideChat";
|
|
6
|
+
import {
|
|
7
|
+
CMD_TOGGLE_FOCUS,
|
|
8
|
+
CMD_CLEAR,
|
|
9
|
+
CMD_CHANGE_MODEL,
|
|
10
|
+
CMD_TOGGLE_THINK,
|
|
11
|
+
PLUGIN_ID,
|
|
12
|
+
} from "./constants";
|
|
13
|
+
import {
|
|
14
|
+
getAvailableToolIDs,
|
|
15
|
+
resolveAllowedTools,
|
|
16
|
+
buildToolSelection,
|
|
17
|
+
buildPermissionRules,
|
|
18
|
+
buildSideSystemPrompt,
|
|
19
|
+
resolveModel,
|
|
20
|
+
formatPreference,
|
|
21
|
+
openModelPicker,
|
|
22
|
+
getErrorMessage,
|
|
23
|
+
} from "./session";
|
|
24
|
+
import type { SideDialogState, ModelPreference } from "./types";
|
|
25
|
+
|
|
26
|
+
const SIDE_AGENT = "general";
|
|
27
|
+
|
|
28
|
+
const tui: TuiPlugin = async (api, _options) => {
|
|
29
|
+
const config = loadConfig();
|
|
30
|
+
const keybind = config.keybind;
|
|
31
|
+
const clearKeybind = config.clearKeybind;
|
|
32
|
+
const thinkToggleKeybind = config.thinkToggleKeybind;
|
|
33
|
+
|
|
34
|
+
const [state, setState] = createSignal<SideDialogState>({
|
|
35
|
+
entries: [],
|
|
36
|
+
streamingAnswer: "",
|
|
37
|
+
loading: false,
|
|
38
|
+
error: undefined,
|
|
39
|
+
tokenCount: 0,
|
|
40
|
+
}, { equals: false });
|
|
41
|
+
|
|
42
|
+
const [tempSessionID, setTempSessionID] = createSignal<string | undefined>(undefined);
|
|
43
|
+
const [selectedModel, setSelectedModel] = createSignal<ModelPreference>(undefined);
|
|
44
|
+
const [visible, setVisible] = createSignal(false);
|
|
45
|
+
const [thinkCollapsed, setThinkCollapsed] = createSignal(config.think.defaultState === "collapsed");
|
|
46
|
+
|
|
47
|
+
let overlayInput: { focus: () => void } | undefined;
|
|
48
|
+
let unsubscribers: Array<() => void> = [];
|
|
49
|
+
let sessionInitPromise: Promise<string | undefined> | undefined;
|
|
50
|
+
|
|
51
|
+
const getModelName = () =>
|
|
52
|
+
formatPreference(
|
|
53
|
+
selectedModel() ?? resolveModel(config.model, state().entries, api).model,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const clearListeners = () => {
|
|
57
|
+
while (unsubscribers.length > 0) {
|
|
58
|
+
try { unsubscribers.pop()?.(); } catch {}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const refreshSession = () => {
|
|
63
|
+
const sid = tempSessionID();
|
|
64
|
+
if (!sid) return;
|
|
65
|
+
try {
|
|
66
|
+
const messages = api.state.session.messages(sid);
|
|
67
|
+
const entries: SideDialogState["entries"] = [];
|
|
68
|
+
let tokenCount = 0;
|
|
69
|
+
for (const info of messages) {
|
|
70
|
+
entries.push({ info, parts: [...api.state.part(info.id)] });
|
|
71
|
+
if (info.role === "assistant") {
|
|
72
|
+
tokenCount += (info.tokens?.input ?? 0) + (info.tokens?.output ?? 0);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
setState((s) => ({ ...s, entries, tokenCount }));
|
|
76
|
+
} catch {}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const buildSystemPrompt = async () => {
|
|
80
|
+
const toolIDs = await getAvailableToolIDs(api);
|
|
81
|
+
const resolvedTools = resolveAllowedTools(config.allowedTools, toolIDs);
|
|
82
|
+
return {
|
|
83
|
+
system: buildSideSystemPrompt(config.systemPrompt, resolvedTools),
|
|
84
|
+
toolIDs,
|
|
85
|
+
resolvedTools,
|
|
86
|
+
tools: buildToolSelection(toolIDs, resolvedTools),
|
|
87
|
+
permission: buildPermissionRules(toolIDs, resolvedTools),
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const initSession = async (): Promise<string | undefined> => {
|
|
92
|
+
clearListeners();
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const { permission } = await buildSystemPrompt();
|
|
96
|
+
|
|
97
|
+
const created = await api.client.session.create(
|
|
98
|
+
{
|
|
99
|
+
title: "side chat",
|
|
100
|
+
directory: api.state.path.directory,
|
|
101
|
+
agent: SIDE_AGENT,
|
|
102
|
+
permission,
|
|
103
|
+
},
|
|
104
|
+
{ throwOnError: true },
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const sid = created.data.id;
|
|
108
|
+
setTempSessionID(sid);
|
|
109
|
+
|
|
110
|
+
unsubscribers.push(
|
|
111
|
+
api.event.on("session.idle", (event) => {
|
|
112
|
+
if (event.properties.sessionID !== sid) return;
|
|
113
|
+
refreshSession();
|
|
114
|
+
setState((s) => ({
|
|
115
|
+
...s,
|
|
116
|
+
loading: false,
|
|
117
|
+
streamingAnswer: "",
|
|
118
|
+
}));
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
unsubscribers.push(
|
|
123
|
+
api.event.on("message.updated", (event) => {
|
|
124
|
+
if (event.properties.sessionID !== sid) return;
|
|
125
|
+
refreshSession();
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
unsubscribers.push(
|
|
130
|
+
api.event.on("message.part.delta", (event) => {
|
|
131
|
+
if (
|
|
132
|
+
event.properties.sessionID !== sid ||
|
|
133
|
+
event.properties.field !== "text"
|
|
134
|
+
) return;
|
|
135
|
+
setState((s) => ({
|
|
136
|
+
...s,
|
|
137
|
+
streamingAnswer: s.streamingAnswer + event.properties.delta,
|
|
138
|
+
}));
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
unsubscribers.push(
|
|
143
|
+
api.event.on("message.part.updated", (event) => {
|
|
144
|
+
if (event.properties.sessionID !== sid) return;
|
|
145
|
+
refreshSession();
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
unsubscribers.push(
|
|
150
|
+
api.event.on("session.error", (event) => {
|
|
151
|
+
if (event.properties.sessionID !== sid) return;
|
|
152
|
+
setState((s) => ({
|
|
153
|
+
...s,
|
|
154
|
+
error: getErrorMessage(event.properties.error),
|
|
155
|
+
loading: false,
|
|
156
|
+
}));
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
setState((s) => ({ ...s, sessionReady: true, error: undefined }));
|
|
161
|
+
return sid;
|
|
162
|
+
} catch (cause) {
|
|
163
|
+
const msg = getErrorMessage(cause);
|
|
164
|
+
setState((s) => ({ ...s, error: msg, sessionReady: false }));
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const ensureSession = (): Promise<string | undefined> => {
|
|
170
|
+
if (tempSessionID()) return Promise.resolve(tempSessionID());
|
|
171
|
+
if (!sessionInitPromise) sessionInitPromise = initSession();
|
|
172
|
+
return sessionInitPromise;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const destroySession = async () => {
|
|
176
|
+
const sid = tempSessionID();
|
|
177
|
+
if (!sid) return;
|
|
178
|
+
setTempSessionID(undefined);
|
|
179
|
+
clearListeners();
|
|
180
|
+
try {
|
|
181
|
+
await api.client.session.abort(
|
|
182
|
+
{ sessionID: sid },
|
|
183
|
+
{ throwOnError: true },
|
|
184
|
+
);
|
|
185
|
+
} catch {}
|
|
186
|
+
try {
|
|
187
|
+
await api.client.session.delete(
|
|
188
|
+
{ sessionID: sid },
|
|
189
|
+
{ throwOnError: true },
|
|
190
|
+
);
|
|
191
|
+
} catch {}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const handleSubmit = (text: string): boolean => {
|
|
195
|
+
if (state().loading) return false;
|
|
196
|
+
|
|
197
|
+
void ensureSession().then((sid) => {
|
|
198
|
+
if (!sid) {
|
|
199
|
+
setState((s) => ({
|
|
200
|
+
...s,
|
|
201
|
+
error: "Failed to create session.",
|
|
202
|
+
loading: false,
|
|
203
|
+
}));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setState((s) => ({
|
|
208
|
+
...s,
|
|
209
|
+
error: undefined,
|
|
210
|
+
loading: true,
|
|
211
|
+
streamingAnswer: "",
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
void (async () => {
|
|
215
|
+
try {
|
|
216
|
+
const { system, tools } = await buildSystemPrompt();
|
|
217
|
+
const resolved =
|
|
218
|
+
selectedModel() ??
|
|
219
|
+
resolveModel(config.model, state().entries, api).model;
|
|
220
|
+
|
|
221
|
+
await api.client.session.promptAsync(
|
|
222
|
+
{
|
|
223
|
+
sessionID: sid,
|
|
224
|
+
system,
|
|
225
|
+
agent: SIDE_AGENT,
|
|
226
|
+
tools,
|
|
227
|
+
parts: [{ type: "text", text }],
|
|
228
|
+
...(resolved.model ? { model: resolved.model } : {}),
|
|
229
|
+
...(resolved.variant ? { variant: resolved.variant } : {}),
|
|
230
|
+
},
|
|
231
|
+
{ throwOnError: true },
|
|
232
|
+
);
|
|
233
|
+
} catch (cause) {
|
|
234
|
+
setState((s) => ({
|
|
235
|
+
...s,
|
|
236
|
+
error: getErrorMessage(cause),
|
|
237
|
+
loading: false,
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
})();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return true;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const handleClear = async () => {
|
|
247
|
+
await destroySession();
|
|
248
|
+
setState({
|
|
249
|
+
entries: [],
|
|
250
|
+
streamingAnswer: "",
|
|
251
|
+
loading: false,
|
|
252
|
+
error: undefined,
|
|
253
|
+
tokenCount: 0,
|
|
254
|
+
});
|
|
255
|
+
sessionInitPromise = undefined;
|
|
256
|
+
setThinkCollapsed(config.think.defaultState === "collapsed");
|
|
257
|
+
await ensureSession();
|
|
258
|
+
setVisible(true);
|
|
259
|
+
setTimeout(() => overlayInput?.focus(), 0);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const handleToggle = () => {
|
|
263
|
+
const currentRoute = api.route.current;
|
|
264
|
+
if (currentRoute.name !== "session") return;
|
|
265
|
+
setVisible((prev) => {
|
|
266
|
+
if (!prev) setTimeout(() => overlayInput?.focus(), 0);
|
|
267
|
+
return !prev;
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const handleToggleThink = () => {
|
|
272
|
+
setThinkCollapsed((prev) => !prev);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const handleChangeModel = () => {
|
|
276
|
+
const currentRoute = api.route.current;
|
|
277
|
+
if (currentRoute.name !== "session") return;
|
|
278
|
+
openModelPicker(api, config, selectedModel(), (model) => {
|
|
279
|
+
setSelectedModel(model);
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
api.lifecycle.onDispose(() => {
|
|
284
|
+
clearListeners();
|
|
285
|
+
void destroySession();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
api.slots.register({
|
|
289
|
+
slots: {
|
|
290
|
+
app: () => (
|
|
291
|
+
<Show when={visible()}>
|
|
292
|
+
<SideChat
|
|
293
|
+
api={api}
|
|
294
|
+
modelName={getModelName()}
|
|
295
|
+
state={state()}
|
|
296
|
+
width={config.width}
|
|
297
|
+
transcriptHeight={config.transcriptHeight}
|
|
298
|
+
tokenLimit={config.tokenLimit}
|
|
299
|
+
thinkCollapsed={thinkCollapsed()}
|
|
300
|
+
thinkConfig={config.think}
|
|
301
|
+
onInput={(node) => { overlayInput = node; }}
|
|
302
|
+
onChangeModel={handleChangeModel}
|
|
303
|
+
onSubmit={handleSubmit}
|
|
304
|
+
/>
|
|
305
|
+
</Show>
|
|
306
|
+
),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
api.keymap.registerLayer({
|
|
311
|
+
commands: [
|
|
312
|
+
{
|
|
313
|
+
namespace: "palette",
|
|
314
|
+
name: CMD_TOGGLE_FOCUS,
|
|
315
|
+
title: "side",
|
|
316
|
+
desc: "Open/side chat overlay",
|
|
317
|
+
category: "Plugin",
|
|
318
|
+
slashName: "side",
|
|
319
|
+
enabled: () => api.route.current.name === "session",
|
|
320
|
+
run: () => handleToggle(),
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
namespace: "palette",
|
|
324
|
+
name: CMD_CLEAR,
|
|
325
|
+
title: "side clear",
|
|
326
|
+
desc: "Clear the side chat conversation",
|
|
327
|
+
category: "Plugin",
|
|
328
|
+
slashName: "side-clear",
|
|
329
|
+
enabled: () => api.route.current.name === "session",
|
|
330
|
+
run: () => void handleClear(),
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
namespace: "palette",
|
|
334
|
+
name: CMD_CHANGE_MODEL,
|
|
335
|
+
title: "side model",
|
|
336
|
+
desc: "Change the side chat model",
|
|
337
|
+
category: "Plugin",
|
|
338
|
+
slashName: "side-model",
|
|
339
|
+
enabled: () => api.route.current.name === "session",
|
|
340
|
+
run: () => handleChangeModel(),
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
bindings: [
|
|
344
|
+
...(keybind !== false
|
|
345
|
+
? [{
|
|
346
|
+
key: keybind,
|
|
347
|
+
cmd: CMD_TOGGLE_FOCUS,
|
|
348
|
+
desc: "Toggle side chat",
|
|
349
|
+
}]
|
|
350
|
+
: []),
|
|
351
|
+
],
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
api.keymap.registerLayer({
|
|
355
|
+
priority: 1000,
|
|
356
|
+
enabled: () => visible(),
|
|
357
|
+
commands: [
|
|
358
|
+
{ name: CMD_CLEAR, run: () => void handleClear() },
|
|
359
|
+
{ name: CMD_CHANGE_MODEL, run: () => handleChangeModel() },
|
|
360
|
+
{ name: CMD_TOGGLE_THINK, run: () => handleToggleThink() },
|
|
361
|
+
],
|
|
362
|
+
bindings: [
|
|
363
|
+
...(clearKeybind !== false
|
|
364
|
+
? [{ key: clearKeybind, cmd: CMD_CLEAR }]
|
|
365
|
+
: []),
|
|
366
|
+
...(thinkToggleKeybind !== false
|
|
367
|
+
? [{ key: thinkToggleKeybind, cmd: CMD_TOGGLE_THINK }]
|
|
368
|
+
: []),
|
|
369
|
+
{ key: "tab", cmd: CMD_CHANGE_MODEL },
|
|
370
|
+
],
|
|
371
|
+
});
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
375
|
+
id: PLUGIN_ID,
|
|
376
|
+
tui,
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
export default plugin;
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { TuiPluginApi, TuiDialogSelectOption } from "@opencode-ai/plugin/tui";
|
|
2
|
+
import type { PermissionRuleset } from "@opencode-ai/sdk/v2";
|
|
3
|
+
import { DEFAULT_ALLOWED_TOOLS, ADDITIONAL_PERMISSION_IDS } from "./constants";
|
|
4
|
+
import type { SideConfig, SessionEntry, ResolvedModel, ModelPreference } from "./types";
|
|
5
|
+
|
|
6
|
+
export type ModelSource = "config" | "session" | "unknown";
|
|
7
|
+
|
|
8
|
+
export type ResolvedModelWithSource = {
|
|
9
|
+
model: ResolvedModel;
|
|
10
|
+
source: ModelSource;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function resolveModel(
|
|
14
|
+
modelOverride: string | null,
|
|
15
|
+
entries: SessionEntry[],
|
|
16
|
+
api: TuiPluginApi,
|
|
17
|
+
): ResolvedModelWithSource {
|
|
18
|
+
if (modelOverride) {
|
|
19
|
+
const parsed = parseModelOverride(modelOverride);
|
|
20
|
+
if (parsed) return { model: { model: parsed }, source: "config" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let assistantFallback: ResolvedModel | undefined;
|
|
24
|
+
|
|
25
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
26
|
+
const { info } = entries[index];
|
|
27
|
+
if (info.role === "user") {
|
|
28
|
+
return {
|
|
29
|
+
model: {
|
|
30
|
+
model: {
|
|
31
|
+
providerID: info.model.providerID,
|
|
32
|
+
modelID: info.model.modelID,
|
|
33
|
+
},
|
|
34
|
+
variant: info.model.variant,
|
|
35
|
+
},
|
|
36
|
+
source: "session",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (!assistantFallback) {
|
|
40
|
+
assistantFallback = {
|
|
41
|
+
model: {
|
|
42
|
+
providerID: info.providerID,
|
|
43
|
+
modelID: info.modelID,
|
|
44
|
+
},
|
|
45
|
+
variant: info.variant,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (assistantFallback) return { model: assistantFallback, source: "session" };
|
|
51
|
+
|
|
52
|
+
const sessionModel = api.state.config.model;
|
|
53
|
+
if (sessionModel) {
|
|
54
|
+
const parts = sessionModel.split("/");
|
|
55
|
+
if (parts.length >= 2) {
|
|
56
|
+
return {
|
|
57
|
+
model: { model: { providerID: parts[0], modelID: parts.slice(1).join("/") } },
|
|
58
|
+
source: "session",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { model: {}, source: "unknown" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseModelOverride(value: string) {
|
|
67
|
+
const [providerID, ...rest] = value.split("/");
|
|
68
|
+
const modelID = rest.join("/");
|
|
69
|
+
if (!providerID || !modelID) return undefined;
|
|
70
|
+
return { providerID, modelID };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatResolvedModel(resolved: ResolvedModel) {
|
|
74
|
+
if (!resolved.model) return "default";
|
|
75
|
+
const base = `${resolved.model.providerID}/${resolved.model.modelID}`;
|
|
76
|
+
return resolved.variant ? `${base} (${resolved.variant})` : base;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatPreference(preference: ModelPreference): string {
|
|
80
|
+
if (!preference) return "default";
|
|
81
|
+
return formatResolvedModel(preference);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function getAvailableToolIDs(api: TuiPluginApi): Promise<string[]> {
|
|
85
|
+
try {
|
|
86
|
+
const result = await api.client.tool.ids(
|
|
87
|
+
{ directory: api.state.path.directory },
|
|
88
|
+
{ throwOnError: true },
|
|
89
|
+
);
|
|
90
|
+
if (
|
|
91
|
+
Array.isArray(result.data) &&
|
|
92
|
+
result.data.every((item: unknown) => typeof item === "string")
|
|
93
|
+
) {
|
|
94
|
+
return result.data;
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
|
|
98
|
+
return DEFAULT_ALLOWED_TOOLS;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function resolveAllowedTools(
|
|
102
|
+
allowedTools: string[] | null,
|
|
103
|
+
availableToolIDs: string[],
|
|
104
|
+
): string[] {
|
|
105
|
+
if (allowedTools === null) return DEFAULT_ALLOWED_TOOLS;
|
|
106
|
+
if (allowedTools.includes("*")) return [...availableToolIDs];
|
|
107
|
+
return allowedTools;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildToolSelection(toolIDs: string[], allowedTools: string[]) {
|
|
111
|
+
return Object.fromEntries(
|
|
112
|
+
toolIDs.map((toolID) => [toolID, allowedTools.includes(toolID)]),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildPermissionRules(
|
|
117
|
+
toolIDs: string[],
|
|
118
|
+
allowedTools: string[],
|
|
119
|
+
): PermissionRuleset {
|
|
120
|
+
const permissionIDs = [
|
|
121
|
+
...new Set([...toolIDs, ...ADDITIONAL_PERMISSION_IDS]),
|
|
122
|
+
];
|
|
123
|
+
return permissionIDs.map((permission) => ({
|
|
124
|
+
permission,
|
|
125
|
+
pattern: "*",
|
|
126
|
+
action: allowedTools.includes(permission) ? "allow" : "deny",
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function buildSideSystemPrompt(systemPrompt: string, allowedTools: string[]) {
|
|
131
|
+
if (allowedTools.length === 0) {
|
|
132
|
+
return `${systemPrompt} No tools are available.`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return `${systemPrompt} Available tools: ${allowedTools.join(", ")}.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function openModelPicker(
|
|
139
|
+
api: TuiPluginApi,
|
|
140
|
+
config: SideConfig,
|
|
141
|
+
currentPreference: ModelPreference,
|
|
142
|
+
onSelect: (model: ModelPreference) => void,
|
|
143
|
+
) {
|
|
144
|
+
const { model: defaultModel, source: defaultSource } = resolveModel(
|
|
145
|
+
config.model,
|
|
146
|
+
[],
|
|
147
|
+
api,
|
|
148
|
+
);
|
|
149
|
+
const options = buildModelOptions(api, defaultModel, defaultSource);
|
|
150
|
+
|
|
151
|
+
api.ui.dialog.setSize("large");
|
|
152
|
+
api.ui.dialog.replace(() =>
|
|
153
|
+
api.ui.DialogSelect<{ type: "default" } | { type: "model"; model: NonNullable<ResolvedModel["model"]>; variant?: string }>({
|
|
154
|
+
title: "side chat model",
|
|
155
|
+
placeholder: "Select model for side chat",
|
|
156
|
+
options,
|
|
157
|
+
onSelect: (option) => {
|
|
158
|
+
if (option.value.type === "default") {
|
|
159
|
+
onSelect(undefined);
|
|
160
|
+
api.ui.toast({
|
|
161
|
+
variant: "success",
|
|
162
|
+
message: "side chat model reset to default.",
|
|
163
|
+
});
|
|
164
|
+
} else {
|
|
165
|
+
onSelect({
|
|
166
|
+
model: option.value.model,
|
|
167
|
+
variant: option.value.variant,
|
|
168
|
+
});
|
|
169
|
+
api.ui.toast({
|
|
170
|
+
variant: "success",
|
|
171
|
+
message: `side chat model set to ${formatResolvedModel({
|
|
172
|
+
model: option.value.model,
|
|
173
|
+
variant: option.value.variant,
|
|
174
|
+
})}.`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
api.ui.dialog.clear();
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildModelOptions(
|
|
184
|
+
api: TuiPluginApi,
|
|
185
|
+
defaultModel: ResolvedModel,
|
|
186
|
+
defaultSource: ModelSource,
|
|
187
|
+
): TuiDialogSelectOption<
|
|
188
|
+
{ type: "default" } | { type: "model"; model: NonNullable<ResolvedModel["model"]>; variant?: string }
|
|
189
|
+
>[] {
|
|
190
|
+
const providers = [...api.state.provider].sort((left, right) =>
|
|
191
|
+
left.name.localeCompare(right.name),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const defaultModelName = defaultModel.model
|
|
195
|
+
? providers
|
|
196
|
+
.find((p) => p.id === defaultModel.model!.providerID)
|
|
197
|
+
?.models[defaultModel.model!.modelID]?.name ||
|
|
198
|
+
defaultModel.model!.modelID
|
|
199
|
+
: "default";
|
|
200
|
+
|
|
201
|
+
const sourceLabel: Record<ModelSource, string> = {
|
|
202
|
+
config: "config",
|
|
203
|
+
session: "main session",
|
|
204
|
+
unknown: "unknown",
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const options: TuiDialogSelectOption<
|
|
208
|
+
{ type: "default" } | { type: "model"; model: NonNullable<ResolvedModel["model"]>; variant?: string }
|
|
209
|
+
>[] = [
|
|
210
|
+
{
|
|
211
|
+
title: defaultModelName + (defaultModel.variant ? ` (${defaultModel.variant})` : ""),
|
|
212
|
+
value: { type: "default" },
|
|
213
|
+
description: `${formatResolvedModel(defaultModel)}`,
|
|
214
|
+
category: `Default [${sourceLabel[defaultSource]}]`,
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
for (const provider of providers) {
|
|
219
|
+
const models = Object.values(provider.models).sort((left, right) =>
|
|
220
|
+
left.name.localeCompare(right.name),
|
|
221
|
+
);
|
|
222
|
+
for (const model of models) {
|
|
223
|
+
const resolved = {
|
|
224
|
+
providerID: model.providerID,
|
|
225
|
+
modelID: model.id,
|
|
226
|
+
};
|
|
227
|
+
options.push({
|
|
228
|
+
title: model.name || model.id,
|
|
229
|
+
value: { type: "model", model: resolved },
|
|
230
|
+
description: `${provider.id}/${model.id}`,
|
|
231
|
+
category: provider.name,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
for (const variant of Object.keys(model.variants ?? {}).sort()) {
|
|
235
|
+
options.push({
|
|
236
|
+
title: `${model.name || model.id} (${variant})`,
|
|
237
|
+
value: { type: "model", model: resolved, variant },
|
|
238
|
+
description: `${provider.id}/${model.id}`,
|
|
239
|
+
category: provider.name,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return options;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function getErrorMessage(cause: unknown): string {
|
|
249
|
+
if (cause instanceof Error && cause.message) return cause.message;
|
|
250
|
+
if (cause && typeof cause === "object") {
|
|
251
|
+
const data = "data" in cause
|
|
252
|
+
? (cause as { data?: { message?: unknown } }).data
|
|
253
|
+
: undefined;
|
|
254
|
+
if (data && typeof data.message === "string" && data.message) return data.message;
|
|
255
|
+
}
|
|
256
|
+
return "An error occurred.";
|
|
257
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
2
|
+
import type { Message, Part } from "@opencode-ai/sdk/v2";
|
|
3
|
+
|
|
4
|
+
export type ThinkConfig = {
|
|
5
|
+
defaultState: "collapsed" | "expanded";
|
|
6
|
+
showSummary: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SideConfig = {
|
|
10
|
+
model: string | null;
|
|
11
|
+
systemPrompt: string;
|
|
12
|
+
tokenLimit: number;
|
|
13
|
+
keybind: string | false;
|
|
14
|
+
clearKeybind: string | false;
|
|
15
|
+
thinkToggleKeybind: string | false;
|
|
16
|
+
allowedTools: string[] | null;
|
|
17
|
+
width: number;
|
|
18
|
+
transcriptHeight: number;
|
|
19
|
+
think: ThinkConfig;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type SessionEntry = {
|
|
23
|
+
info: Message;
|
|
24
|
+
parts: Part[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ResolvedModel = {
|
|
28
|
+
model?: {
|
|
29
|
+
providerID: string;
|
|
30
|
+
modelID: string;
|
|
31
|
+
};
|
|
32
|
+
variant?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ModelPreference = ResolvedModel | undefined;
|
|
36
|
+
|
|
37
|
+
export type SideDialogState = {
|
|
38
|
+
entries: SessionEntry[];
|
|
39
|
+
streamingAnswer: string;
|
|
40
|
+
loading: boolean;
|
|
41
|
+
error?: string;
|
|
42
|
+
tokenCount: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type OverlayState = {
|
|
46
|
+
api: TuiPluginApi;
|
|
47
|
+
modelName: string;
|
|
48
|
+
state: SideDialogState;
|
|
49
|
+
thinkCollapsed: boolean;
|
|
50
|
+
thinkConfig: ThinkConfig;
|
|
51
|
+
onInput?: (input: { focus: () => void } | undefined) => void;
|
|
52
|
+
onChangeModel: () => void;
|
|
53
|
+
onSubmit: (value: string) => boolean;
|
|
54
|
+
};
|