@chatbridge/vscode 0.8.2 → 0.9.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.
@@ -1,3 +1,4 @@
1
+ import type { CommandInfo } from "@chatbridge/core";
1
2
  import type { QueueEntry, State, ToHost, ToWebview, UiConfig, WebviewCommand } from "./protocol.js";
2
3
  /** The slice of vscode.Webview the bridge uses; a fake in tests. */
3
4
  export interface WebviewLike {
@@ -12,6 +13,7 @@ export interface ChatViewHandlers {
12
13
  takeBack(): void;
13
14
  removeQueued(index: number): void;
14
15
  command(name: WebviewCommand): void;
16
+ customCommand(name: string, args: string, text: string): void;
15
17
  attachUris(uris: string[]): void;
16
18
  pasted(id: number, text: string): void;
17
19
  }
@@ -24,7 +26,7 @@ export declare class ChatViewBridge {
24
26
  private readonly handlers;
25
27
  private webview;
26
28
  constructor(getState: () => State, handlers: ChatViewHandlers);
27
- attach(webview: WebviewLike, uiConfig?: UiConfig): {
29
+ attach(webview: WebviewLike, uiConfig?: UiConfig, commands?: CommandInfo[]): {
28
30
  dispose(): void;
29
31
  };
30
32
  pushState(state: State): void;
@@ -25,6 +25,10 @@ function isToHost(m) {
25
25
  return typeof msg.index === "number";
26
26
  case "command":
27
27
  return typeof msg.name === "string" && COMMANDS.has(msg.name);
28
+ case "customCommand":
29
+ return (typeof msg.name === "string" &&
30
+ typeof msg.args === "string" &&
31
+ typeof msg.text === "string");
28
32
  case "attachUris":
29
33
  return (Array.isArray(msg.uris) && msg.uris.every((u) => typeof u === "string"));
30
34
  case "pasted":
@@ -43,15 +47,20 @@ export class ChatViewBridge {
43
47
  this.getState = getState;
44
48
  this.handlers = handlers;
45
49
  }
46
- attach(webview, uiConfig) {
50
+ attach(webview, uiConfig, commands) {
47
51
  this.webview = webview;
48
52
  const sub = webview.onDidReceiveMessage((raw) => {
49
53
  if (!isToHost(raw))
50
54
  return;
51
55
  switch (raw.type) {
52
56
  case "ready":
53
- if (uiConfig)
54
- void webview.postMessage({ type: "config", ...uiConfig });
57
+ if (uiConfig || commands) {
58
+ void webview.postMessage({
59
+ type: "config",
60
+ ...uiConfig,
61
+ ...(commands ? { commands } : {}),
62
+ });
63
+ }
55
64
  this.pushState(this.getState());
56
65
  break;
57
66
  case "send":
@@ -69,6 +78,9 @@ export class ChatViewBridge {
69
78
  case "command":
70
79
  this.handlers.command(raw.name);
71
80
  break;
81
+ case "customCommand":
82
+ this.handlers.customCommand(raw.name, raw.args, raw.text);
83
+ break;
72
84
  case "attachUris":
73
85
  this.handlers.attachUris(raw.uris);
74
86
  break;
@@ -1,3 +1,4 @@
1
+ import type { CommandInfo } from "@chatbridge/core";
1
2
  import * as vscode from "vscode";
2
3
  import type { ChatViewBridge } from "./chat-view-bridge.js";
3
4
  import type { ResolvedUiConfig } from "./ui-config.js";
@@ -6,6 +7,7 @@ export declare class ChatViewProvider implements vscode.WebviewViewProvider {
6
7
  private readonly title;
7
8
  private readonly bridge;
8
9
  private readonly ui?;
9
- constructor(extensionUri: vscode.Uri, title: string, bridge: ChatViewBridge, ui?: ResolvedUiConfig | undefined);
10
+ private readonly commands?;
11
+ constructor(extensionUri: vscode.Uri, title: string, bridge: ChatViewBridge, ui?: ResolvedUiConfig | undefined, commands?: CommandInfo[] | undefined);
10
12
  resolveWebviewView(view: vscode.WebviewView): void;
11
13
  }
@@ -6,11 +6,13 @@ export class ChatViewProvider {
6
6
  title;
7
7
  bridge;
8
8
  ui;
9
- constructor(extensionUri, title, bridge, ui) {
9
+ commands;
10
+ constructor(extensionUri, title, bridge, ui, commands) {
10
11
  this.extensionUri = extensionUri;
11
12
  this.title = title;
12
13
  this.bridge = bridge;
13
14
  this.ui = ui;
15
+ this.commands = commands;
14
16
  }
15
17
  resolveWebviewView(view) {
16
18
  const root = vscode.Uri.joinPath(this.extensionUri, "dist", "webview");
@@ -44,7 +46,7 @@ export class ChatViewProvider {
44
46
  }
45
47
  : rest;
46
48
  }
47
- const sub = this.bridge.attach(view.webview, uiConfig);
49
+ const sub = this.bridge.attach(view.webview, uiConfig, this.commands);
48
50
  view.onDidDispose(() => sub.dispose());
49
51
  }
50
52
  }
@@ -1,6 +1,6 @@
1
- import type { LoginOptions } from "@chatbridge/core";
1
+ import type { CommandInfo, LoginOptions } from "@chatbridge/core";
2
2
  import type { InstallBrowserOptions } from "./install-browser.js";
3
- import type { SessionController } from "./session-controller.js";
3
+ import type { SendResult, SessionController } from "./session-controller.js";
4
4
  import type { VscodeUi } from "./vscode-ui.js";
5
5
  export interface CommandDeps {
6
6
  displayName: string;
@@ -12,6 +12,8 @@ export interface CommandDeps {
12
12
  loginOptions: () => Omit<LoginOptions, "signal" | "onProgress">;
13
13
  installOptions: () => Omit<InstallBrowserOptions, "onProgress">;
14
14
  clearAuth: () => Promise<void>;
15
+ /** The provider's own commands, listed by `/help`. */
16
+ commands?: readonly CommandInfo[];
15
17
  }
16
18
  export interface CommandHandlers {
17
19
  login(): Promise<void>;
@@ -21,11 +23,14 @@ export interface CommandHandlers {
21
23
  installBrowser(): Promise<void>;
22
24
  /** From the webview's `/help`: the listing joins the history. */
23
25
  help(): void;
26
+ /** From the webview's `/name args`. */
27
+ customCommand(name: string, args: string, text: string): Promise<void>;
24
28
  sendSelection(): Promise<void>;
25
29
  sendFile(uri: unknown): Promise<void>;
26
30
  focus(): void;
27
- /** From the webview's input box. */
28
- send(text: string): Promise<void>;
31
+ /** From the webview's input box. The result is returned so the caller can
32
+ * hand the text back to the composer when the turn was refused. */
33
+ send(text: string): Promise<SendResult>;
29
34
  /** Files dropped on the webview; unreadable URIs are reported together. */
30
35
  attachUris(uris: string[]): Promise<void>;
31
36
  /** A paste into the input box; true when it became a selection chip. */
package/dist/commands.js CHANGED
@@ -91,7 +91,17 @@ export function createCommands(deps) {
91
91
  }
92
92
  },
93
93
  reopen: () => controller.reopen(),
94
- help: () => controller.pushHelp(helpText()),
94
+ help: () => controller.pushHelp(helpText(deps.commands ?? [])),
95
+ async customCommand(name, args, text) {
96
+ const result = await controller.runCommand(name, args, text);
97
+ if (result.ok || result.code !== "BROWSER_UNAVAILABLE")
98
+ return;
99
+ const choice = await ui.showErrorMessage(result.message, "Install");
100
+ if (choice !== "Install")
101
+ return;
102
+ if (await runInstall())
103
+ await controller.retryLast();
104
+ },
95
105
  async installBrowser() {
96
106
  if (await runInstall())
97
107
  ui.showInformationMessage("Chromium installed.");
@@ -150,12 +160,13 @@ export function createCommands(deps) {
150
160
  async send(text) {
151
161
  const result = await controller.send(text);
152
162
  if (result.ok || result.code !== "BROWSER_UNAVAILABLE")
153
- return;
163
+ return result;
154
164
  const choice = await ui.showErrorMessage(result.message, "Install");
155
165
  if (choice !== "Install")
156
- return;
166
+ return result;
157
167
  if (await runInstall())
158
168
  await controller.retryLast();
169
+ return result;
159
170
  },
160
171
  };
161
172
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { ChatSession, createAuthStore, runLogin, } from "@chatbridge/core";
3
+ import { ChatSession, commandInfoOf, createAuthStore, resolveUrlHooks, runLogin, } from "@chatbridge/core";
4
4
  import * as vscode from "vscode";
5
5
  import { ChatViewBridge } from "./chat-view-bridge.js";
6
6
  import { ChatViewProvider } from "./chat-view-provider.js";
@@ -28,8 +28,15 @@ export function createExtension(opts) {
28
28
  const output = vscode.window.createOutputChannel(opts.displayName);
29
29
  const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
30
30
  context.subscriptions.push(output, statusBar);
31
+ const commands = commandInfoOf(opts.provider);
31
32
  const bridge = new ChatViewBridge(() => controller.getState(), {
32
- send: (text) => void handlers.send(text),
33
+ send: (text) => void handlers.send(text).then((r) => {
34
+ // The webview empties the composer as it posts `send`; a hook
35
+ // refusal sends nothing, so give the text back to be fixed.
36
+ if (!r.ok && r.code === "URL_HOOK") {
37
+ bridge.pushTookBack([{ text, attachments: [] }]);
38
+ }
39
+ }),
33
40
  removeAttachment: (i) => controller?.removeAttachment(i),
34
41
  takeBack: () => {
35
42
  const r = controller?.takeBack();
@@ -45,6 +52,7 @@ export function createExtension(opts) {
45
52
  },
46
53
  removeQueued: (i) => controller?.removeQueued(i),
47
54
  command: (name) => void handlers[name](),
55
+ customCommand: (name, args, text) => void handlers.customCommand(name, args, text),
48
56
  attachUris: (uris) => void handlers.attachUris(uris),
49
57
  pasted: (id, text) => bridge.pushPasteResult(id, handlers.pasted(text)),
50
58
  });
@@ -74,6 +82,9 @@ export function createExtension(opts) {
74
82
  ...settings(),
75
83
  onProgress: progress,
76
84
  }),
85
+ expandUrls: (text) => resolveUrlHooks(text, opts.provider.urlHooks ?? [], {
86
+ timeoutMs: settings().timeoutMs,
87
+ }),
77
88
  hints: {
78
89
  BLOCKED: `Set the "${opts.id}.headless" setting to false and try again.`,
79
90
  BROWSER_UNAVAILABLE: `Run "${opts.displayName}: Install Browser" and send again.`,
@@ -104,8 +115,9 @@ export function createExtension(opts) {
104
115
  return { cliPath };
105
116
  },
106
117
  clearAuth: () => authStore.clear(),
118
+ commands,
107
119
  });
108
- context.subscriptions.push(vscode.window.registerWebviewViewProvider(`${opts.id}.chat`, new ChatViewProvider(context.extensionUri, opts.displayName, bridge, uiConfig)));
120
+ context.subscriptions.push(vscode.window.registerWebviewViewProvider(`${opts.id}.chat`, new ChatViewProvider(context.extensionUri, opts.displayName, bridge, uiConfig, commands)));
109
121
  for (const name of COMMAND_NAMES) {
110
122
  context.subscriptions.push(vscode.commands.registerCommand(`${opts.id}.${name}`, (arg) => name === "sendFile" ? handlers.sendFile(arg) : handlers[name]()));
111
123
  }
@@ -1,4 +1,4 @@
1
- import type { Attachment } from "@chatbridge/core";
1
+ import type { Attachment, CommandInfo } from "@chatbridge/core";
2
2
  export type Role = "user" | "assistant" | "error" | "separator" | "help";
3
3
  export interface Message {
4
4
  role: Role;
@@ -45,6 +45,13 @@ export type ToHost = {
45
45
  type: "command";
46
46
  name: WebviewCommand;
47
47
  }
48
+ /** A provider `/command`; `text` is the line as typed, for the history. */
49
+ | {
50
+ type: "customCommand";
51
+ name: string;
52
+ args: string;
53
+ text: string;
54
+ }
48
55
  /** Files dropped on the webview, as URI strings. */
49
56
  | {
50
57
  type: "attachUris";
@@ -78,7 +85,10 @@ export type ToWebview = ({
78
85
  text: string;
79
86
  } | ({
80
87
  type: "config";
81
- } & UiConfig)
88
+ } & UiConfig & {
89
+ /** The provider's commands, so the webview can parse `/name args`. */
90
+ commands?: CommandInfo[];
91
+ })
82
92
  /** Answer to `pasted`: when attached, the webview drops the pasted text. */
83
93
  | {
84
94
  type: "pasteResult";
@@ -1,8 +1,9 @@
1
- import { type Attachment } from "@chatbridge/core";
1
+ import { type Attachment, type ProviderCommandResult, type ResolvedUrl } from "@chatbridge/core";
2
2
  import type { QueueEntry, State } from "./protocol.js";
3
3
  /** What the controller needs from a ChatSession; lets tests inject a fake. */
4
4
  export interface ChatSessionLike {
5
5
  send(prompt: string): Promise<string>;
6
+ runCommand?(name: string, args: string): Promise<ProviderCommandResult>;
6
7
  close(): Promise<void>;
7
8
  kill(): Promise<void>;
8
9
  }
@@ -47,6 +48,9 @@ export interface SessionControllerOptions {
47
48
  * `ChatBridgeError.code`. Core's messages carry no UI-specific remedy;
48
49
  * the extension adds the VSCode-side one. */
49
50
  hints?: Partial<Record<HintCode, string>>;
51
+ /** URL hook expansion for the text of a turn; the extension builds it from
52
+ * the provider's hooks and the timeout setting. Default: none. */
53
+ expandUrls?: (text: string) => Promise<ResolvedUrl[]>;
50
54
  }
51
55
  export declare const CLOSE_TIMEOUT_MS = 5000;
52
56
  /** Owns the history, the pending attachments and the ChatSession. No
@@ -69,6 +73,10 @@ export declare class SessionController {
69
73
  /** The openSession in flight (first send or reopen), so close() can wait
70
74
  * for it instead of orphaning the browser it is about to produce. */
71
75
  private opening;
76
+ /** True while a turn's URL hooks are being resolved: the turn is claimed
77
+ * but no status change shows it yet, so a send arriving meanwhile must
78
+ * queue rather than start a second turn. */
79
+ private expanding;
72
80
  private readonly closeTimeoutMs;
73
81
  constructor(opts: SessionControllerOptions);
74
82
  getState(): State;
@@ -87,8 +95,23 @@ export declare class SessionController {
87
95
  * when a turn is already running. Never throws: the outcome is the
88
96
  * result and the history. */
89
97
  send(text: string): Promise<SendResult>;
90
- /** Pushes the user entry and runs the turn. Shared by send and drain. */
98
+ /** Pushes the user entry and runs the turn (or the command). Shared by
99
+ * send, runCommand and drain. */
91
100
  private startTurn;
101
+ /** A provider `/command` from the webview. Queued like a message when a
102
+ * turn is running; `text` is the line as typed, which is what the
103
+ * history shows. */
104
+ runCommand(name: string, args: string, text: string): Promise<SendResult>;
105
+ /** Runs the command on the session (opening one lazily, like a turn).
106
+ * `show` prints; `send` continues as an ordinary turn with the prompt. */
107
+ private runProviderCommand;
108
+ /** Leaves a ready status synchronously, before the first await, so a
109
+ * send arriving in the same tick queues instead of starting a second
110
+ * turn on the session. */
111
+ private claimTurn;
112
+ /** The open session, opening one when there is none. Undefined when a
113
+ * reopen ran meanwhile (the caller's generation is stale). */
114
+ private ensureSession;
92
115
  /** Starts the oldest queued entry, if any, when the controller is ready
93
116
  * for a turn. Called at every transition back to a ready state, before
94
117
  * the caller emits, so the webview never sees an idle frame with a
@@ -1,4 +1,4 @@
1
- import { ChatBridgeError, MAX_FILE_BYTES, MAX_TOTAL_BYTES, closeOrKill, formatAttachment, formatSize, } from "@chatbridge/core";
1
+ import { ChatBridgeError, MAX_FILE_BYTES, MAX_TOTAL_BYTES, UrlHookError, closeOrKill, formatAttachment, formatSize, } from "@chatbridge/core";
2
2
  /** The separator pushed into the history by `reopen()`. */
3
3
  export const REOPENED_SEPARATOR = "reopened";
4
4
  export const CLOSE_TIMEOUT_MS = 5_000;
@@ -27,6 +27,10 @@ export class SessionController {
27
27
  /** The openSession in flight (first send or reopen), so close() can wait
28
28
  * for it instead of orphaning the browser it is about to produce. */
29
29
  opening;
30
+ /** True while a turn's URL hooks are being resolved: the turn is claimed
31
+ * but no status change shows it yet, so a send arriving meanwhile must
32
+ * queue rather than start a second turn. */
33
+ expanding = false;
30
34
  closeTimeoutMs;
31
35
  constructor(opts) {
32
36
  this.opts = opts;
@@ -95,6 +99,8 @@ export class SessionController {
95
99
  * send is the user retrying, and only automatic draining must stop
96
100
  * while the controller is dead. */
97
101
  get canStartTurn() {
102
+ if (this.expanding)
103
+ return false;
98
104
  return (this.status === "idle" ||
99
105
  this.status === "closed" ||
100
106
  this.status === "dead");
@@ -120,19 +126,151 @@ export class SessionController {
120
126
  }
121
127
  return this.startTurn({ text: body, attachments });
122
128
  }
123
- /** Pushes the user entry and runs the turn. Shared by send and drain. */
124
- startTurn(turn) {
125
- const sections = turn.attachments.map((a) => formatAttachment(a.path, a.content));
129
+ /** Pushes the user entry and runs the turn (or the command). Shared by
130
+ * send, runCommand and drain. */
131
+ async startTurn(turn) {
132
+ // URL expansion is a network wait; a reopen or a close during it makes
133
+ // this turn stale, and it must not open a browser of its own.
134
+ const generation = this.generation;
135
+ if (turn.command) {
136
+ this.push({ role: "user", text: turn.text, attachments: [] });
137
+ return this.runProviderCommand(turn.command, generation);
138
+ }
139
+ let attachments = turn.attachments;
140
+ if (this.opts.expandUrls) {
141
+ this.expanding = true;
142
+ try {
143
+ const urls = await this.opts.expandUrls(turn.text);
144
+ if (generation !== this.generation) {
145
+ // Stale: the reopen/close owns the state now. The turn is not
146
+ // sent, but `send` already emptied the composer, so give the
147
+ // attachments back and leave the text in the history.
148
+ this.expanding = false;
149
+ this.pending = [...turn.attachments, ...this.pending];
150
+ this.messages.push({
151
+ role: "error",
152
+ text: `Reopened while resolving URLs; message not sent: ${turn.text}`,
153
+ });
154
+ // Nothing else will run the entries queued behind this one. Only
155
+ // from `idle`: a stale turn from `close()` must not open a browser
156
+ // after deactivate.
157
+ if (this.status === "idle")
158
+ this.drain();
159
+ this.emit();
160
+ return { ok: true };
161
+ }
162
+ attachments = [
163
+ ...attachments,
164
+ ...urls.map((u) => ({
165
+ path: u.label,
166
+ bytes: u.bytes,
167
+ content: u.content,
168
+ })),
169
+ ];
170
+ }
171
+ catch (err) {
172
+ if (!(err instanceof UrlHookError))
173
+ throw err;
174
+ // The user's to fix: report, send nothing, leave the status alone.
175
+ const message = err.message;
176
+ // Cleared here rather than only in `finally`, which runs after the
177
+ // push and the drain below: both must see the turn released.
178
+ this.expanding = false;
179
+ // The composer was emptied by `send`; give the attachments back so
180
+ // the user only has to re-type the text.
181
+ this.pending = [...turn.attachments, ...this.pending];
182
+ this.push({ role: "error", text: message });
183
+ // Nothing else will run the entries that queued behind this one.
184
+ this.drain();
185
+ return { ok: false, code: "URL_HOOK", message };
186
+ }
187
+ finally {
188
+ this.expanding = false;
189
+ }
190
+ }
191
+ const sections = attachments.map((a) => formatAttachment(a.path, a.content));
126
192
  const prompt = [turn.text, ...sections]
127
193
  .filter((s) => s !== "")
128
194
  .join("\n\n");
129
195
  this.push({
130
196
  role: "user",
131
197
  text: turn.text,
132
- attachments: turn.attachments.map(({ path, bytes }) => ({ path, bytes })),
198
+ attachments: attachments.map(({ path, bytes }) => ({ path, bytes })),
133
199
  });
134
200
  this.lastPrompt = prompt;
135
- return this.runTurn(prompt);
201
+ return this.runTurn(prompt, generation);
202
+ }
203
+ /** A provider `/command` from the webview. Queued like a message when a
204
+ * turn is running; `text` is the line as typed, which is what the
205
+ * history shows. */
206
+ async runCommand(name, args, text) {
207
+ const turn = { text, attachments: [], command: { name, args } };
208
+ if (!this.canStartTurn || this.queue.length > 0) {
209
+ this.queue.push(turn);
210
+ this.drain();
211
+ this.emit();
212
+ return { ok: true, queued: true };
213
+ }
214
+ return this.startTurn(turn);
215
+ }
216
+ /** Runs the command on the session (opening one lazily, like a turn).
217
+ * `show` prints; `send` continues as an ordinary turn with the prompt. */
218
+ async runProviderCommand(command, generation) {
219
+ this.lastError = undefined;
220
+ // A `show` sets no prompt; leaving the previous turn's would make a
221
+ // retryLast after a failure resend that message instead. A `send`
222
+ // result sets it again below.
223
+ this.lastPrompt = undefined;
224
+ this.claimTurn();
225
+ try {
226
+ const session = await this.ensureSession(generation);
227
+ if (session === undefined)
228
+ return { ok: true }; // stale
229
+ if (session.runCommand === undefined) {
230
+ throw new Error(`/${command.name} is not available in this session.`);
231
+ }
232
+ if (this.status !== "busy")
233
+ this.setStatus("busy");
234
+ const result = await session.runCommand(command.name, command.args);
235
+ if (generation !== this.generation)
236
+ return { ok: true }; // stale
237
+ if (result.kind === "show") {
238
+ this.messages.push({ role: "help", text: result.text });
239
+ // Claim the next turn before emitting, so no idle frame is shown.
240
+ this.status = "idle";
241
+ this.drain();
242
+ this.emit();
243
+ return { ok: true };
244
+ }
245
+ this.lastPrompt = result.prompt;
246
+ return this.runTurn(result.prompt, generation);
247
+ }
248
+ catch (err) {
249
+ if (generation !== this.generation)
250
+ return { ok: true }; // stale
251
+ return this.fail(err);
252
+ }
253
+ }
254
+ /** Leaves a ready status synchronously, before the first await, so a
255
+ * send arriving in the same tick queues instead of starting a second
256
+ * turn on the session. */
257
+ claimTurn() {
258
+ this.setStatus(this.session === undefined ? "opening" : "busy");
259
+ }
260
+ /** The open session, opening one when there is none. Undefined when a
261
+ * reopen ran meanwhile (the caller's generation is stale). */
262
+ async ensureSession(generation) {
263
+ if (this.session !== undefined)
264
+ return this.session;
265
+ const session = await this.trackOpen(this.opts.openSession());
266
+ // A reopen ran while we were opening: this browser is an orphan.
267
+ // dropSession may have closed it already; close/kill are idempotent.
268
+ if (generation !== this.generation) {
269
+ await closeOrKill(session, this.closeTimeoutMs);
270
+ return undefined;
271
+ }
272
+ this.session = session;
273
+ return session;
136
274
  }
137
275
  /** Starts the oldest queued entry, if any, when the controller is ready
138
276
  * for a turn. Called at every transition back to a ready state, before
@@ -146,6 +284,9 @@ export class SessionController {
146
284
  const next = this.queue.shift();
147
285
  if (next === undefined)
148
286
  return;
287
+ // A URL_HOOK failure here pushes its error entry and drops the entry:
288
+ // the queue is not a composer, so there is nowhere to hand the text
289
+ // back to — the user reads the error and re-types.
149
290
  void this.startTurn(next);
150
291
  }
151
292
  /** Empties the queue back into the composer: the entries are returned
@@ -200,28 +341,21 @@ export class SessionController {
200
341
  if (this.status === "dead")
201
342
  this.status = "closed";
202
343
  this.emit();
203
- return this.runTurn(this.lastPrompt);
344
+ return this.runTurn(this.lastPrompt, this.generation);
204
345
  }
205
- async runTurn(prompt) {
346
+ async runTurn(prompt, generation) {
206
347
  // A new turn supersedes whatever killed the previous one: a stale
207
348
  // `lastError` would keep the webview's error banner up after a
208
349
  // successful send from `dead`.
209
350
  this.lastError = undefined;
210
- const generation = this.generation;
351
+ this.claimTurn();
211
352
  try {
212
- if (this.session === undefined) {
213
- this.setStatus("opening");
214
- const session = await this.trackOpen(this.opts.openSession());
215
- // A reopen ran while we were opening: this browser is an orphan.
216
- // dropSession may have closed it already; close/kill are idempotent.
217
- if (generation !== this.generation) {
218
- await closeOrKill(session, this.closeTimeoutMs);
219
- return { ok: true };
220
- }
221
- this.session = session;
222
- }
223
- this.setStatus("busy");
224
- const reply = await this.session.send(prompt);
353
+ const session = await this.ensureSession(generation);
354
+ if (session === undefined)
355
+ return { ok: true }; // stale
356
+ if (this.status !== "busy")
357
+ this.setStatus("busy");
358
+ const reply = await session.send(prompt);
225
359
  if (generation !== this.generation)
226
360
  return { ok: true }; // stale
227
361
  this.messages.push({ role: "assistant", text: reply });
@@ -9,12 +9,20 @@
9
9
  { name: "help", description: "List these commands" }
10
10
  ];
11
11
  var NAMES = new Set(SLASH_COMMANDS.map((c) => c.name));
12
- var PATTERN = /^\/([a-z]+)$/;
13
- function parseSlashCommand(text) {
14
- const word = PATTERN.exec(text.trim())?.[1];
15
- if (word === void 0)
12
+ var EMPTY = /* @__PURE__ */ new Set();
13
+ var PATTERN = /^\/([a-z]+)(?:\s+([\s\S]*))?$/;
14
+ function parseSlashCommand(text, custom = EMPTY) {
15
+ const m = PATTERN.exec(text.trim());
16
+ if (m === null)
16
17
  return void 0;
17
- return NAMES.has(word) ? { command: word } : { unknown: word };
18
+ const word = m[1];
19
+ const args = (m[2] ?? "").trim();
20
+ if (NAMES.has(word)) {
21
+ return args === "" ? { command: word } : { error: `/${word} takes no arguments.` };
22
+ }
23
+ if (custom.has(word))
24
+ return { custom: word, args };
25
+ return { unknown: word };
18
26
  }
19
27
  function unknownCommandMessage(word) {
20
28
  return `Unknown command: /${word}. Type /help.`;
@@ -41,10 +49,12 @@
41
49
  if (atBottom) history.scrollTop = history.scrollHeight;
42
50
  }
43
51
  var config = {};
52
+ var commandNames = /* @__PURE__ */ new Set();
44
53
  var lastState;
45
54
  var lastProgress;
46
55
  function applyConfig(c) {
47
56
  config = c;
57
+ commandNames = new Set((c.commands ?? []).map((x) => x.name));
48
58
  if (c.sendButton?.background) {
49
59
  sendButton.style.setProperty("--cb-send-bg", c.sendButton.background);
50
60
  }
@@ -212,15 +222,28 @@
212
222
  function submit() {
213
223
  const text = input.value;
214
224
  if (text.trim() === "" && attachments.childElementCount === 0) return;
215
- const slash = parseSlashCommand(text);
225
+ const slash = parseSlashCommand(text, commandNames);
216
226
  if (slash && "unknown" in slash) {
217
227
  showInlineError(unknownCommandMessage(slash.unknown));
218
228
  return;
219
229
  }
230
+ if (slash && "error" in slash) {
231
+ showInlineError(slash.error);
232
+ return;
233
+ }
220
234
  showInlineError(void 0);
221
235
  if (slash) {
222
236
  input.value = "";
223
237
  fitComposer();
238
+ if ("custom" in slash) {
239
+ vscode.postMessage({
240
+ type: "customCommand",
241
+ name: slash.custom,
242
+ args: slash.args,
243
+ text: text.trim()
244
+ });
245
+ return;
246
+ }
224
247
  const name = slash.command === "new" ? "newChat" : slash.command;
225
248
  vscode.postMessage({ type: "command", name });
226
249
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatbridge/vscode",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "VSCode extension factory for chatbridge: a sidebar chat view on top of ChatSession",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "build:webview": "bun run check:webview && esbuild src/webview/main.ts --bundle --format=iife --target=es2022 --outfile=dist/webview/main.js && mkdir -p dist/webview && cp src/webview/style.css dist/webview/style.css"
29
29
  },
30
30
  "dependencies": {
31
- "@chatbridge/core": "0.8.2"
31
+ "@chatbridge/core": "0.9.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/vscode": "1.138.0",