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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sidechat",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Floating side-chat panel for quick queries alongside your main opencode session",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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: Array<{ type: string; text: string }> = [];
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
- parts.push({ type: "text", text: remaining.slice(1, closeBracket) });
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
- <text fg={theme.success}>
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().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
- ))
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
- <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>
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 lines = createMemo(() => {
313
+ const t = props.theme;
314
+
315
+ const lines = createMemo((): RenderBlock[] => {
287
316
  const text = props.text;
288
- const result: Array<{ type: "line" | "codeblock"; parts: Array<{ type: string; text: string }>; codeText?: string }> = [];
317
+ const result: RenderBlock[] = [];
318
+
289
319
  let inCodeBlock = false;
290
320
  let codeBuffer = "";
291
321
 
292
- for (const line of text.split("\n")) {
293
- if (line.startsWith("```")) {
322
+ for (const rawLine of text.split("\n")) {
323
+ if (rawLine.startsWith("```")) {
294
324
  if (inCodeBlock) {
295
- result.push({ type: "codeblock", parts: [], codeText: codeBuffer });
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" : "") + line;
332
+ codeBuffer += (codeBuffer ? "\n" : "") + rawLine;
303
333
  continue;
304
334
  }
305
335
 
306
- const parts = renderInlineMarkdown(line);
307
- result.push({ type: "line", parts });
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", parts: [], codeText: codeBuffer });
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.markdownCode}>{block.codeText}</text>
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
- <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>
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
@@ -38,7 +38,6 @@ export const ADDITIONAL_PERMISSION_IDS = [
38
38
  "external_directory",
39
39
  "todowrite",
40
40
  "question",
41
- "websearch",
42
41
  "codesearch",
43
42
  "repo_clone",
44
43
  "repo_overview",
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
- return {
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.model ? { model: resolved.model } : {}),
242
- ...(resolved.variant ? { variant: resolved.variant } : {}),
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: ResolvedModel;
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 { model: {}, source: "unknown" };
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.model
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.variant ? ` (${defaultModel.variant})` : ""),
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;