opencode-sidechat 1.0.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/src/session.ts CHANGED
@@ -1,257 +1,260 @@
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
- }
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, SYSTEM_PROMPT_OVERRIDE } 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" && info.model) {
28
+ return {
29
+ model: {
30
+ model: {
31
+ providerID: info.model.providerID,
32
+ modelID: info.model.modelID,
33
+ },
34
+ },
35
+ source: "session",
36
+ };
37
+ }
38
+ if (info.role === "assistant" && info.providerID && info.modelID && !assistantFallback) {
39
+ assistantFallback = {
40
+ model: {
41
+ providerID: info.providerID,
42
+ modelID: info.modelID,
43
+ },
44
+ };
45
+ }
46
+ }
47
+
48
+ if (assistantFallback) return { model: assistantFallback, source: "session" };
49
+
50
+ const sessionModel = api.state.config.model;
51
+ if (sessionModel) {
52
+ const parts = sessionModel.split("/");
53
+ if (parts.length >= 2) {
54
+ return {
55
+ model: { model: { providerID: parts[0], modelID: parts.slice(1).join("/") } },
56
+ source: "session",
57
+ };
58
+ }
59
+ }
60
+
61
+ return { source: "unknown" };
62
+ }
63
+
64
+ export function parseModelOverride(value: string) {
65
+ const [providerID, ...rest] = value.split("/");
66
+ const modelID = rest.join("/");
67
+ if (!providerID || !modelID) return undefined;
68
+ return { providerID, modelID };
69
+ }
70
+
71
+ export function formatResolvedModel(resolved: ResolvedModel) {
72
+ if (!resolved.model) return "default";
73
+ const base = `${resolved.model.providerID}/${resolved.model.modelID}`;
74
+ return resolved.variant ? `${base} (${resolved.variant})` : base;
75
+ }
76
+
77
+ export function formatPreference(preference: ModelPreference): string {
78
+ if (!preference) return "default";
79
+ return formatResolvedModel(preference);
80
+ }
81
+
82
+ export async function getAvailableToolIDs(api: TuiPluginApi): Promise<string[]> {
83
+ try {
84
+ const result = await api.client.tool.ids(
85
+ { directory: api.state.path.directory },
86
+ { throwOnError: true },
87
+ );
88
+ if (
89
+ Array.isArray(result.data) &&
90
+ result.data.every((item: unknown) => typeof item === "string")
91
+ ) {
92
+ return result.data;
93
+ }
94
+ } catch {}
95
+
96
+ return DEFAULT_ALLOWED_TOOLS;
97
+ }
98
+
99
+ export function resolveAllowedTools(
100
+ allowedTools: string[] | null,
101
+ availableToolIDs: string[],
102
+ ): string[] {
103
+ if (allowedTools === null) return DEFAULT_ALLOWED_TOOLS;
104
+ if (allowedTools.includes("*")) return [...availableToolIDs];
105
+ return allowedTools;
106
+ }
107
+
108
+ export function buildToolSelection(toolIDs: string[], allowedTools: string[]) {
109
+ return Object.fromEntries(
110
+ toolIDs.map((toolID) => [toolID, allowedTools.includes(toolID)]),
111
+ );
112
+ }
113
+
114
+ export function buildPermissionRules(
115
+ toolIDs: string[],
116
+ allowedTools: string[],
117
+ ): PermissionRuleset {
118
+ const permissionIDs = [
119
+ ...new Set([...toolIDs, ...ADDITIONAL_PERMISSION_IDS]),
120
+ ];
121
+ return permissionIDs.map((permission) => ({
122
+ permission,
123
+ pattern: "*",
124
+ action: allowedTools.includes(permission) ? "allow" : "deny",
125
+ }));
126
+ }
127
+
128
+ export function buildSideSystemPrompt(systemPrompt: string, allowedTools: string[]) {
129
+ const toolsNote = allowedTools.length === 0
130
+ ? "No tools are available."
131
+ : `Available tools: ${allowedTools.join(", ")}.`;
132
+ return `${SYSTEM_PROMPT_OVERRIDE}\n\n${systemPrompt} ${toolsNote}`;
133
+ }
134
+
135
+ export function openModelPicker(
136
+ api: TuiPluginApi,
137
+ config: SideConfig,
138
+ currentPreference: ModelPreference,
139
+ onSelect: (model: ModelPreference) => void,
140
+ ) {
141
+ const { model: defaultModel, source: defaultSource } = resolveModel(
142
+ config.model,
143
+ [],
144
+ api,
145
+ );
146
+ const options = buildModelOptions(api, defaultModel, defaultSource);
147
+
148
+ api.ui.dialog.setSize("large");
149
+ api.ui.dialog.replace(() =>
150
+ api.ui.DialogSelect<{ type: "default" } | { type: "model"; model: NonNullable<ResolvedModel["model"]>; variant?: string }>({
151
+ title: "side chat model",
152
+ placeholder: "Select model for side chat",
153
+ options,
154
+ onSelect: (option) => {
155
+ if (option.value.type === "default") {
156
+ onSelect(undefined);
157
+ api.ui.toast({
158
+ variant: "success",
159
+ message: "side chat model reset to default.",
160
+ });
161
+ } else {
162
+ onSelect({
163
+ model: option.value.model,
164
+ variant: option.value.variant,
165
+ });
166
+ api.ui.toast({
167
+ variant: "success",
168
+ message: `side chat model set to ${formatResolvedModel({
169
+ model: option.value.model,
170
+ variant: option.value.variant,
171
+ })}.`,
172
+ });
173
+ }
174
+ api.ui.dialog.clear();
175
+ },
176
+ }),
177
+ );
178
+ }
179
+
180
+ function buildModelOptions(
181
+ api: TuiPluginApi,
182
+ defaultModel: ResolvedModel | undefined,
183
+ defaultSource: ModelSource,
184
+ ): TuiDialogSelectOption<
185
+ { type: "default" } | { type: "model"; model: NonNullable<ResolvedModel["model"]>; variant?: string }
186
+ >[] {
187
+ const providers = api.state.provider ? [...api.state.provider] : [];
188
+ if (providers.length === 0) {
189
+ api.ui.toast({ variant: "error", message: "No model providers available." });
190
+ api.ui.dialog.clear();
191
+ return [];
192
+ }
193
+ providers.sort((left, right) =>
194
+ left.name.localeCompare(right.name),
195
+ );
196
+
197
+ const defaultModelName = defaultModel?.model
198
+ ? providers
199
+ .find((p) => p.id === defaultModel.model!.providerID)
200
+ ?.models[defaultModel.model!.modelID]?.name ||
201
+ defaultModel.model!.modelID
202
+ : "default";
203
+
204
+ const sourceLabel: Record<ModelSource, string> = {
205
+ config: "config",
206
+ session: "main session",
207
+ unknown: "unknown",
208
+ };
209
+
210
+ const options: TuiDialogSelectOption<
211
+ { type: "default" } | { type: "model"; model: NonNullable<ResolvedModel["model"]>; variant?: string }
212
+ >[] = [
213
+ {
214
+ title: defaultModelName + (defaultModel?.variant ? ` (${defaultModel.variant})` : ""),
215
+ value: { type: "default" },
216
+ description: `${defaultModel ? formatResolvedModel(defaultModel) : "default"}`,
217
+ category: `Default [${sourceLabel[defaultSource]}]`,
218
+ },
219
+ ];
220
+
221
+ for (const provider of providers) {
222
+ const models = Object.values(provider.models).sort((left, right) =>
223
+ left.name.localeCompare(right.name),
224
+ );
225
+ for (const model of models) {
226
+ const resolved = {
227
+ providerID: model.providerID,
228
+ modelID: model.id,
229
+ };
230
+ options.push({
231
+ title: model.name || model.id,
232
+ value: { type: "model", model: resolved },
233
+ description: `${provider.id}/${model.id}`,
234
+ category: provider.name,
235
+ });
236
+
237
+ for (const variant of Object.keys(model.variants ?? {}).sort()) {
238
+ options.push({
239
+ title: `${model.name || model.id} (${variant})`,
240
+ value: { type: "model", model: resolved, variant },
241
+ description: `${provider.id}/${model.id}`,
242
+ category: provider.name,
243
+ });
244
+ }
245
+ }
246
+ }
247
+
248
+ return options;
249
+ }
250
+
251
+ export function getErrorMessage(cause: unknown): string {
252
+ if (cause instanceof Error && cause.message) return cause.message;
253
+ if (cause && typeof cause === "object") {
254
+ const data = "data" in cause
255
+ ? (cause as { data?: { message?: unknown } }).data
256
+ : undefined;
257
+ if (data && typeof data.message === "string" && data.message) return data.message;
258
+ }
259
+ return "An error occurred.";
260
+ }
package/src/types.ts CHANGED
@@ -1,54 +1,57 @@
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
- };
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
+ loading: boolean;
40
+ error?: string;
41
+ tokenCount: number;
42
+ };
43
+
44
+ export type OverlayState = {
45
+ api: TuiPluginApi;
46
+ modelName: string;
47
+ state: SideDialogState;
48
+ streamingAnswer: string;
49
+ thinkCollapsed: boolean;
50
+ thinkConfig: ThinkConfig;
51
+ keybind: string | false;
52
+ clearKeybind: string | false;
53
+ thinkToggleKeybind: string | false;
54
+ onInput?: (input: { focus: () => void } | undefined) => void;
55
+ onChangeModel: () => void;
56
+ onSubmit: (value: string) => boolean;
57
+ };