opencode-sidechat 1.1.0 → 1.1.1
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/package.json +1 -1
- package/src/components/SideChat.tsx +127 -47
- package/src/config.ts +6 -2
- package/src/constants.ts +0 -1
- package/src/index.tsx +12 -4
- package/src/session.ts +6 -6
- package/src/types.ts +3 -0
package/package.json
CHANGED
|
@@ -6,8 +6,12 @@ import type { OverlayState } from "../types";
|
|
|
6
6
|
|
|
7
7
|
const MAX_VISIBLE_MESSAGES = 20;
|
|
8
8
|
|
|
9
|
+
type InlinePart =
|
|
10
|
+
| { type: "text" | "code" | "bold" | "italic"; text: string }
|
|
11
|
+
| { type: "link"; text: string; url: string };
|
|
12
|
+
|
|
9
13
|
function renderInlineMarkdown(text: string) {
|
|
10
|
-
const parts:
|
|
14
|
+
const parts: InlinePart[] = [];
|
|
11
15
|
let remaining = text;
|
|
12
16
|
|
|
13
17
|
while (remaining.length > 0) {
|
|
@@ -42,7 +46,9 @@ function renderInlineMarkdown(text: string) {
|
|
|
42
46
|
parts.push({ type: "text", text: remaining });
|
|
43
47
|
break;
|
|
44
48
|
}
|
|
45
|
-
|
|
49
|
+
const linkText = remaining.slice(1, closeBracket);
|
|
50
|
+
const linkUrl = remaining.slice(closeBracket + 2, closeParen);
|
|
51
|
+
parts.push({ type: "link", text: linkText, url: linkUrl });
|
|
46
52
|
remaining = remaining.slice(closeParen + 1);
|
|
47
53
|
} else {
|
|
48
54
|
const nextSpecial = searchSpecialChar(remaining);
|
|
@@ -70,6 +76,14 @@ function searchSpecialChar(text: string): number {
|
|
|
70
76
|
return -1;
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
function formatKeybind(kb: string | false): string | false {
|
|
80
|
+
if (kb === false) return false;
|
|
81
|
+
return kb
|
|
82
|
+
.split(/[+]/)
|
|
83
|
+
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
|
|
84
|
+
.join("+");
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
export function SideChat(props: OverlayState & { width: number; transcriptHeight: number; tokenLimit: number }) {
|
|
74
88
|
const theme = props.api.theme.current;
|
|
75
89
|
let input: InputRenderable | undefined;
|
|
@@ -159,6 +173,7 @@ export function SideChat(props: OverlayState & { width: number; transcriptHeight
|
|
|
159
173
|
position="absolute"
|
|
160
174
|
bottom={0}
|
|
161
175
|
right={0}
|
|
176
|
+
onMouseDown={() => input?.focus()}
|
|
162
177
|
>
|
|
163
178
|
<box
|
|
164
179
|
width={panelWidth}
|
|
@@ -181,9 +196,7 @@ export function SideChat(props: OverlayState & { width: number; transcriptHeight
|
|
|
181
196
|
<b>{"OpenCode-SideChat"}</b>
|
|
182
197
|
</text>
|
|
183
198
|
</box>
|
|
184
|
-
|
|
185
|
-
<b>{"[f]"}</b>
|
|
186
|
-
</text>
|
|
199
|
+
|
|
187
200
|
</box>
|
|
188
201
|
<box flexDirection="row" gap={1} alignItems="center">
|
|
189
202
|
<text fg={theme.textMuted}>{shortModelName()}</text>
|
|
@@ -205,21 +218,23 @@ export function SideChat(props: OverlayState & { width: number; transcriptHeight
|
|
|
205
218
|
>
|
|
206
219
|
<box flexDirection="column" gap={1} paddingTop={1} paddingBottom={1} width={contentWidth - 2}>
|
|
207
220
|
{msgs().length > 0 ? (
|
|
208
|
-
msgs()
|
|
209
|
-
|
|
210
|
-
<
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
221
|
+
<For each={msgs()}>
|
|
222
|
+
{(msg) => (
|
|
223
|
+
<box flexDirection="column" gap={0}>
|
|
224
|
+
<text fg={msg.role === "assistant" ? theme.secondary : theme.text}>
|
|
225
|
+
<b>{msg.role === "assistant" ? "Agent:" : "You:"}</b>
|
|
226
|
+
</text>
|
|
227
|
+
{msg.reasoning.map((r) => renderThinking(r))}
|
|
228
|
+
{msg.text ? (
|
|
229
|
+
<box flexDirection="column">
|
|
230
|
+
<RenderMarkdown text={msg.text} theme={theme} />
|
|
231
|
+
</box>
|
|
232
|
+
) : (
|
|
233
|
+
<text>{""}</text>
|
|
234
|
+
)}
|
|
235
|
+
</box>
|
|
236
|
+
)}
|
|
237
|
+
</For>
|
|
223
238
|
) : props.state.loading ? (
|
|
224
239
|
<text fg={theme.textMuted}>{THINKING_TEXT}</text>
|
|
225
240
|
) : (
|
|
@@ -267,13 +282,21 @@ export function SideChat(props: OverlayState & { width: number; transcriptHeight
|
|
|
267
282
|
paddingRight={1}
|
|
268
283
|
alignItems="center"
|
|
269
284
|
>
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
285
|
+
{formatKeybind(props.clearKeybind) && (
|
|
286
|
+
<box flexDirection="row" gap={1} alignItems="center">
|
|
287
|
+
<text fg={theme.secondary}><b>{formatKeybind(props.clearKeybind)}</b></text>
|
|
288
|
+
<text fg={theme.primary}>{"Clear"}</text>
|
|
289
|
+
<text fg={theme.textMuted}>{"·"}</text>
|
|
290
|
+
</box>
|
|
291
|
+
)}
|
|
292
|
+
{formatKeybind(props.thinkToggleKeybind) && (
|
|
293
|
+
<box flexDirection="row" gap={1} alignItems="center">
|
|
294
|
+
<text fg={theme.secondary}><b>{formatKeybind(props.thinkToggleKeybind)}</b></text>
|
|
295
|
+
<text fg={theme.primary}>{"Thinking"}</text>
|
|
296
|
+
<text fg={theme.textMuted}>{props.thinkCollapsed ? "" : "(on)"}</text>
|
|
297
|
+
<text fg={theme.textMuted}>{"·"}</text>
|
|
298
|
+
</box>
|
|
299
|
+
)}
|
|
277
300
|
<text fg={theme.secondary}><b>{"Tab"}</b></text>
|
|
278
301
|
<text fg={theme.primary}>{"Model"}</text>
|
|
279
302
|
</box>
|
|
@@ -282,40 +305,65 @@ export function SideChat(props: OverlayState & { width: number; transcriptHeight
|
|
|
282
305
|
);
|
|
283
306
|
}
|
|
284
307
|
|
|
308
|
+
type RenderBlock =
|
|
309
|
+
| { type: "line"; style?: "heading" | "blockquote" | "listitem" | "hr"; parts: InlinePart[] }
|
|
310
|
+
| { type: "codeblock"; codeText: string };
|
|
311
|
+
|
|
285
312
|
function RenderMarkdown(props: { text: string; theme: import("@opencode-ai/plugin/tui").TuiThemeCurrent }) {
|
|
286
|
-
const
|
|
313
|
+
const t = props.theme;
|
|
314
|
+
|
|
315
|
+
const lines = createMemo((): RenderBlock[] => {
|
|
287
316
|
const text = props.text;
|
|
288
|
-
const result:
|
|
317
|
+
const result: RenderBlock[] = [];
|
|
318
|
+
|
|
289
319
|
let inCodeBlock = false;
|
|
290
320
|
let codeBuffer = "";
|
|
291
321
|
|
|
292
|
-
for (const
|
|
293
|
-
if (
|
|
322
|
+
for (const rawLine of text.split("\n")) {
|
|
323
|
+
if (rawLine.startsWith("```")) {
|
|
294
324
|
if (inCodeBlock) {
|
|
295
|
-
result.push({ type: "codeblock",
|
|
325
|
+
result.push({ type: "codeblock", codeText: codeBuffer });
|
|
296
326
|
codeBuffer = "";
|
|
297
327
|
}
|
|
298
328
|
inCodeBlock = !inCodeBlock;
|
|
299
329
|
continue;
|
|
300
330
|
}
|
|
301
331
|
if (inCodeBlock) {
|
|
302
|
-
codeBuffer += (codeBuffer ? "\n" : "") +
|
|
332
|
+
codeBuffer += (codeBuffer ? "\n" : "") + rawLine;
|
|
303
333
|
continue;
|
|
304
334
|
}
|
|
305
335
|
|
|
306
|
-
|
|
307
|
-
|
|
336
|
+
// Detect block-level styles
|
|
337
|
+
let line = rawLine;
|
|
338
|
+
let style: "heading" | "blockquote" | "listitem" | "hr" | undefined;
|
|
339
|
+
|
|
340
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
341
|
+
style = "heading";
|
|
342
|
+
line = line.replace(/^#{1,6}\s+/, "");
|
|
343
|
+
} else if (line.startsWith("> ")) {
|
|
344
|
+
style = "blockquote";
|
|
345
|
+
line = line.slice(2);
|
|
346
|
+
} else if (/^[-*]\s/.test(line)) {
|
|
347
|
+
style = "listitem";
|
|
348
|
+
line = line.replace(/^[-*]\s/, "");
|
|
349
|
+
} else if (/^\d+\.\s/.test(line)) {
|
|
350
|
+
style = "listitem";
|
|
351
|
+
// Keep number prefix (1., 2. etc.) visible
|
|
352
|
+
} else if (/^[-*=_]{3,}$/.test(line)) {
|
|
353
|
+
style = "hr";
|
|
354
|
+
line = "";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
result.push({ type: "line", style, parts: renderInlineMarkdown(line) });
|
|
308
358
|
}
|
|
309
359
|
|
|
310
360
|
if (inCodeBlock && codeBuffer) {
|
|
311
|
-
result.push({ type: "codeblock",
|
|
361
|
+
result.push({ type: "codeblock", codeText: codeBuffer });
|
|
312
362
|
}
|
|
313
363
|
|
|
314
364
|
return result;
|
|
315
365
|
});
|
|
316
366
|
|
|
317
|
-
const t = props.theme;
|
|
318
|
-
|
|
319
367
|
return (
|
|
320
368
|
<For each={lines()}>
|
|
321
369
|
{(block) => {
|
|
@@ -326,20 +374,52 @@ function RenderMarkdown(props: { text: string; theme: import("@opencode-ai/plugi
|
|
|
326
374
|
paddingLeft={1}
|
|
327
375
|
paddingRight={1}
|
|
328
376
|
>
|
|
329
|
-
<text fg={t.
|
|
377
|
+
<text fg={t.markdownCodeBlock}>{block.codeText}</text>
|
|
330
378
|
</box>
|
|
331
379
|
);
|
|
332
380
|
}
|
|
381
|
+
|
|
382
|
+
const s = block.style;
|
|
383
|
+
let textColor: import("@opentui/core").RGBA;
|
|
384
|
+
if (s === "heading") {
|
|
385
|
+
textColor = t.markdownHeading;
|
|
386
|
+
} else if (s === "blockquote") {
|
|
387
|
+
textColor = t.markdownBlockQuote;
|
|
388
|
+
} else if (s === "listitem") {
|
|
389
|
+
textColor = t.markdownListItem;
|
|
390
|
+
} else if (s === "hr") {
|
|
391
|
+
textColor = t.markdownHorizontalRule;
|
|
392
|
+
} else {
|
|
393
|
+
textColor = t.markdownText;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (s === "hr") {
|
|
397
|
+
return <text fg={t.markdownHorizontalRule}>{"―".repeat(8)}</text>;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Render inline parts with appropriate colors
|
|
333
401
|
return (
|
|
334
402
|
<box flexDirection="row" flexWrap="wrap" gap={0}>
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
403
|
+
<For each={block.parts}>
|
|
404
|
+
{(part) => {
|
|
405
|
+
switch (part.type) {
|
|
406
|
+
case "bold":
|
|
407
|
+
return <text fg={t.markdownStrong}><b>{part.text}</b></text>;
|
|
408
|
+
case "italic":
|
|
409
|
+
return <text fg={t.markdownEmph}><i>{part.text}</i></text>;
|
|
410
|
+
case "code":
|
|
411
|
+
return <text fg={t.markdownCode}>{part.text}</text>;
|
|
412
|
+
case "link":
|
|
413
|
+
return (
|
|
414
|
+
<text fg={t.markdownLinkText}>
|
|
415
|
+
{part.text}<text fg={t.markdownLink}>{"(" + part.url + ")"}</text>
|
|
416
|
+
</text>
|
|
417
|
+
);
|
|
418
|
+
default:
|
|
419
|
+
return <text fg={textColor}>{part.text}</text>;
|
|
420
|
+
}
|
|
421
|
+
}}
|
|
422
|
+
</For>
|
|
343
423
|
</box>
|
|
344
424
|
);
|
|
345
425
|
}}
|
package/src/config.ts
CHANGED
|
@@ -132,7 +132,9 @@ function ensureConfigFile(): void {
|
|
|
132
132
|
try {
|
|
133
133
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
134
134
|
if (!existsSync(path)) writeFileSync(path, generateDefaultConfig(), "utf-8");
|
|
135
|
-
} catch {
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(`[SideChat] Failed to create config:`, err);
|
|
137
|
+
}
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
export function loadConfig(): SideConfig {
|
|
@@ -144,7 +146,9 @@ export function loadConfig(): SideConfig {
|
|
|
144
146
|
const json = stripTrailingCommas(stripJsoncComments(text));
|
|
145
147
|
const parsed = JSON.parse(json);
|
|
146
148
|
if (parsed && typeof parsed === "object") raw = parsed as Record<string, unknown>;
|
|
147
|
-
} catch {
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.warn(`[SideChat] Failed to parse config, using defaults:`, err);
|
|
151
|
+
}
|
|
148
152
|
|
|
149
153
|
return {
|
|
150
154
|
model: parseStringOrNull(raw.model),
|
package/src/constants.ts
CHANGED
package/src/index.tsx
CHANGED
|
@@ -50,6 +50,9 @@ const tui: TuiPlugin = async (api, _options) => {
|
|
|
50
50
|
let sessionInitPromise: Promise<string | undefined> | undefined;
|
|
51
51
|
let promptTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
52
52
|
let cachedToolIDs: string[] | undefined;
|
|
53
|
+
let cachedPromptResult:
|
|
54
|
+
| { system: string; tools: Record<string, boolean>; permission: any[] }
|
|
55
|
+
| undefined;
|
|
53
56
|
|
|
54
57
|
const getModelName = () =>
|
|
55
58
|
formatPreference(
|
|
@@ -88,13 +91,15 @@ const tui: TuiPlugin = async (api, _options) => {
|
|
|
88
91
|
}
|
|
89
92
|
const toolIDs = cachedToolIDs;
|
|
90
93
|
const resolvedTools = resolveAllowedTools(config.allowedTools, toolIDs);
|
|
91
|
-
|
|
94
|
+
const result = {
|
|
92
95
|
system: buildSideSystemPrompt(config.systemPrompt, resolvedTools),
|
|
93
96
|
toolIDs,
|
|
94
97
|
resolvedTools,
|
|
95
98
|
tools: buildToolSelection(toolIDs, resolvedTools),
|
|
96
99
|
permission: buildPermissionRules(toolIDs, resolvedTools),
|
|
97
100
|
};
|
|
101
|
+
cachedPromptResult = { system: result.system, tools: result.tools, permission: result.permission };
|
|
102
|
+
return result;
|
|
98
103
|
};
|
|
99
104
|
|
|
100
105
|
const initSession = async (): Promise<string | undefined> => {
|
|
@@ -227,7 +232,7 @@ const tui: TuiPlugin = async (api, _options) => {
|
|
|
227
232
|
|
|
228
233
|
void (async () => {
|
|
229
234
|
try {
|
|
230
|
-
const { system, tools } = await buildSystemPrompt();
|
|
235
|
+
const { system, tools } = cachedPromptResult ?? await buildSystemPrompt();
|
|
231
236
|
const resolved =
|
|
232
237
|
selectedModel() ??
|
|
233
238
|
resolveModel(config.model, state().entries, api).model;
|
|
@@ -238,8 +243,8 @@ const tui: TuiPlugin = async (api, _options) => {
|
|
|
238
243
|
system,
|
|
239
244
|
tools,
|
|
240
245
|
parts: [{ type: "text", text }],
|
|
241
|
-
...(resolved
|
|
242
|
-
...(resolved
|
|
246
|
+
...(resolved?.model ? { model: resolved.model } : {}),
|
|
247
|
+
...(resolved?.variant ? { variant: resolved.variant } : {}),
|
|
243
248
|
},
|
|
244
249
|
{ throwOnError: true },
|
|
245
250
|
);
|
|
@@ -324,6 +329,9 @@ const tui: TuiPlugin = async (api, _options) => {
|
|
|
324
329
|
tokenLimit={config.tokenLimit}
|
|
325
330
|
thinkCollapsed={thinkCollapsed()}
|
|
326
331
|
thinkConfig={config.think}
|
|
332
|
+
keybind={config.keybind}
|
|
333
|
+
clearKeybind={config.clearKeybind}
|
|
334
|
+
thinkToggleKeybind={config.thinkToggleKeybind}
|
|
327
335
|
onInput={(node) => { overlayInput = node; }}
|
|
328
336
|
onChangeModel={handleChangeModel}
|
|
329
337
|
onSubmit={handleSubmit}
|
package/src/session.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { SideConfig, SessionEntry, ResolvedModel, ModelPreference } from ".
|
|
|
6
6
|
export type ModelSource = "config" | "session" | "unknown";
|
|
7
7
|
|
|
8
8
|
export type ResolvedModelWithSource = {
|
|
9
|
-
model
|
|
9
|
+
model?: ResolvedModel;
|
|
10
10
|
source: ModelSource;
|
|
11
11
|
};
|
|
12
12
|
|
|
@@ -58,7 +58,7 @@ export function resolveModel(
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
return {
|
|
61
|
+
return { source: "unknown" };
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export function parseModelOverride(value: string) {
|
|
@@ -179,7 +179,7 @@ export function openModelPicker(
|
|
|
179
179
|
|
|
180
180
|
function buildModelOptions(
|
|
181
181
|
api: TuiPluginApi,
|
|
182
|
-
defaultModel: ResolvedModel,
|
|
182
|
+
defaultModel: ResolvedModel | undefined,
|
|
183
183
|
defaultSource: ModelSource,
|
|
184
184
|
): TuiDialogSelectOption<
|
|
185
185
|
{ type: "default" } | { type: "model"; model: NonNullable<ResolvedModel["model"]>; variant?: string }
|
|
@@ -194,7 +194,7 @@ function buildModelOptions(
|
|
|
194
194
|
left.name.localeCompare(right.name),
|
|
195
195
|
);
|
|
196
196
|
|
|
197
|
-
const defaultModelName = defaultModel
|
|
197
|
+
const defaultModelName = defaultModel?.model
|
|
198
198
|
? providers
|
|
199
199
|
.find((p) => p.id === defaultModel.model!.providerID)
|
|
200
200
|
?.models[defaultModel.model!.modelID]?.name ||
|
|
@@ -211,9 +211,9 @@ function buildModelOptions(
|
|
|
211
211
|
{ type: "default" } | { type: "model"; model: NonNullable<ResolvedModel["model"]>; variant?: string }
|
|
212
212
|
>[] = [
|
|
213
213
|
{
|
|
214
|
-
title: defaultModelName + (defaultModel
|
|
214
|
+
title: defaultModelName + (defaultModel?.variant ? ` (${defaultModel.variant})` : ""),
|
|
215
215
|
value: { type: "default" },
|
|
216
|
-
description: `${formatResolvedModel(defaultModel)}`,
|
|
216
|
+
description: `${defaultModel ? formatResolvedModel(defaultModel) : "default"}`,
|
|
217
217
|
category: `Default [${sourceLabel[defaultSource]}]`,
|
|
218
218
|
},
|
|
219
219
|
];
|
package/src/types.ts
CHANGED
|
@@ -48,6 +48,9 @@ export type OverlayState = {
|
|
|
48
48
|
streamingAnswer: string;
|
|
49
49
|
thinkCollapsed: boolean;
|
|
50
50
|
thinkConfig: ThinkConfig;
|
|
51
|
+
keybind: string | false;
|
|
52
|
+
clearKeybind: string | false;
|
|
53
|
+
thinkToggleKeybind: string | false;
|
|
51
54
|
onInput?: (input: { focus: () => void } | undefined) => void;
|
|
52
55
|
onChangeModel: () => void;
|
|
53
56
|
onSubmit: (value: string) => boolean;
|