@chatbridge/vscode 0.7.0 → 0.8.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/README.md CHANGED
@@ -61,6 +61,23 @@ export const { activate, deactivate } = createExtension({
61
61
  - `baseDir` — test-only override for the base directory of the config /
62
62
  auth-state store.
63
63
 
64
+ ## Composer
65
+
66
+ - **Enter** sends. While a turn is in flight the message is queued instead
67
+ and drains in order once the turn finishes; Shift+Enter inserts a newline.
68
+ - **Up** on an empty composer takes the last queued message back for
69
+ editing; queued entries can also be removed individually.
70
+ - **`/` commands** — `/login`, `/logout`, `/new`, `/reopen`, `/help`, the
71
+ same table the TUI uses. Only the bare form on its own counts, so
72
+ anything else (including `/usr/bin` style paths) is sent verbatim.
73
+ - **Drop files** onto the composer to add them as attachment chips. Hold
74
+ **Shift** while dropping: without it VSCode keeps the drag for itself and
75
+ opens the file in an editor instead (the same rule as dropping into a text
76
+ editor).
77
+ - **Paste** text copied from an editor selection and it becomes a selection
78
+ chip (`path:L2-L3`) instead of inline text; unrelated clipboard text is
79
+ pasted as usual.
80
+
64
81
  ## Manifest
65
82
 
66
83
  The vendor's `package.json` must contribute, with `<id>` replaced by the
@@ -68,7 +85,7 @@ The vendor's `package.json` must contribute, with `<id>` replaced by the
68
85
 
69
86
  - View `<id>.chat` (a webview, typically under its own `viewsContainers`
70
87
  entry)
71
- - Commands `<id>.login`, `<id>.logout`, `<id>.newChat`,
88
+ - Commands `<id>.login`, `<id>.logout`, `<id>.newChat`, `<id>.reopen`,
72
89
  `<id>.installBrowser`, `<id>.sendSelection`, `<id>.sendFile`, `<id>.focus`
73
90
  - Settings `<id>.headless` (boolean) and `<id>.timeoutSec` (number)
74
91
 
@@ -78,7 +95,22 @@ missing ID:
78
95
  - `contributes.viewsContainers.activitybar[]` contains an entry with
79
96
  `id === <id>`
80
97
  - `contributes.views.<id>[]` contains an entry with `id === <id>.chat`
81
- - `contributes.commands[]` contains all seven `<id>.*` commands above
98
+ - `contributes.commands[]` contains all eight `<id>.*` commands above
99
+
100
+ A `keybindings` entry is recommended so Ctrl+R (Cmd+R on macOS) reopens the
101
+ browser while the chat view is focused; it is not validated. The webview
102
+ also handles the shortcut itself when the composer has focus.
103
+
104
+ ```json
105
+ "keybindings": [
106
+ {
107
+ "command": "<id>.reopen",
108
+ "key": "ctrl+r",
109
+ "mac": "cmd+r",
110
+ "when": "focusedView == <id>.chat"
111
+ }
112
+ ]
113
+ ```
82
114
 
83
115
  `contributes.configuration` is not validated: a missing `<id>.headless` or
84
116
  `<id>.timeoutSec` setting simply falls back to the `createExtension`
@@ -90,11 +122,32 @@ Bundle with esbuild as CJS, with `vscode` and `playwright` marked external.
90
122
  `examples/vscode-dummy-chat` keeps `"type": "module"` in its manifest, so
91
123
  its bundle is `dist/extension.cjs`, not `.js` — match that if you copy the
92
124
  example's `esbuild.mjs`. Its `package` script runs
93
- `vsce package --no-dependencies`, which is only a CI packaging smoke test.
94
- A real vendor `.vsix` must ship `node_modules/playwright` so the Install
95
- Browser button works for end users: drop `--no-dependencies` when
96
- packaging for distribution, or vendors get "playwright is not bundled with
97
- this extension" from the Install button.
125
+ `vsce package --no-dependencies`, which is only a CI packaging smoke test
126
+ (its `workspace:*` dependencies cannot be npm-installed). A distributable
127
+ `.vsix` must ship `node_modules/playwright` so the Install Browser button
128
+ works for end users; without it the button reports "playwright is not
129
+ bundled with this extension".
130
+
131
+ ### Building a distributable `.vsix` (vendor repo)
132
+
133
+ 1. In the extension's `package.json`, keep only `playwright` under
134
+ `dependencies`. `@chatbridge/vscode` (and everything it pulls in) is
135
+ inlined by esbuild, so it belongs in `devDependencies` together with
136
+ `esbuild` and `@vscode/vsce`.
137
+ 2. Build the bundle: `bun run build` (esbuild → `dist/extension.cjs` and
138
+ `dist/webview/`).
139
+ 3. Create a plain production `node_modules` with npm, not bun:
140
+ `rm -rf node_modules && npm install --omit=dev`. vsce discovers
141
+ dependencies by walking npm's layout; bun's symlinked workspace tree
142
+ makes that walk escape the folder. The result is just `playwright` and
143
+ `playwright-core`.
144
+ 4. `npx @vscode/vsce package` (no `--no-dependencies`). Expect roughly 4 MB
145
+ and about 185 files.
146
+ 5. Verify: `unzip -l *.vsix | grep node_modules/playwright/cli.js`. Then
147
+ restore the dev tree with `bun install`.
148
+ 6. Install locally with `code --install-extension <file>.vsix`. Chromium is
149
+ never inside the `.vsix`: end users get it from the Install Browser
150
+ button (or `npx playwright install chromium`).
98
151
 
99
152
  `examples/vscode-dummy-chat` in the framework repo is the reference
100
153
  implementation to copy into a vendor repo.
@@ -1,4 +1,4 @@
1
- import type { State, ToHost, ToWebview, UiConfig } from "./protocol.js";
1
+ import type { State, ToHost, ToWebview, UiConfig, WebviewCommand } from "./protocol.js";
2
2
  /** The slice of vscode.Webview the bridge uses; a fake in tests. */
3
3
  export interface WebviewLike {
4
4
  postMessage(message: ToWebview): Thenable<boolean>;
@@ -9,7 +9,11 @@ export interface WebviewLike {
9
9
  export interface ChatViewHandlers {
10
10
  send(text: string): void;
11
11
  removeAttachment(index: number): void;
12
- command(name: "login" | "newChat" | "installBrowser"): void;
12
+ takeBack(): void;
13
+ removeQueued(index: number): void;
14
+ command(name: WebviewCommand): void;
15
+ attachUris(uris: string[]): void;
16
+ pasted(id: number, text: string): void;
13
17
  }
14
18
  /** Translates webview messages into handler calls and pushes state to
15
19
  * whichever webview is currently attached (VSCode recreates it). */
@@ -22,5 +26,6 @@ export declare class ChatViewBridge {
22
26
  dispose(): void;
23
27
  };
24
28
  pushState(state: State): void;
29
+ pushPasteResult(id: number, attached: boolean): void;
25
30
  pushProgress(text: string): void;
26
31
  }
@@ -1,3 +1,10 @@
1
+ const COMMANDS = new Set([
2
+ "login",
3
+ "logout",
4
+ "newChat",
5
+ "installBrowser",
6
+ "reopen",
7
+ ]);
1
8
  function isToHost(m) {
2
9
  if (typeof m !== "object" || m === null)
3
10
  return false;
@@ -9,10 +16,16 @@ function isToHost(m) {
9
16
  return typeof msg.text === "string";
10
17
  case "removeAttachment":
11
18
  return typeof msg.index === "number";
19
+ case "takeBack":
20
+ return true;
21
+ case "removeQueued":
22
+ return typeof msg.index === "number";
12
23
  case "command":
13
- return (msg.name === "login" ||
14
- msg.name === "newChat" ||
15
- msg.name === "installBrowser");
24
+ return typeof msg.name === "string" && COMMANDS.has(msg.name);
25
+ case "attachUris":
26
+ return (Array.isArray(msg.uris) && msg.uris.every((u) => typeof u === "string"));
27
+ case "pasted":
28
+ return typeof msg.id === "number" && typeof msg.text === "string";
16
29
  default:
17
30
  return false;
18
31
  }
@@ -44,9 +57,21 @@ export class ChatViewBridge {
44
57
  case "removeAttachment":
45
58
  this.handlers.removeAttachment(raw.index);
46
59
  break;
60
+ case "takeBack":
61
+ this.handlers.takeBack();
62
+ break;
63
+ case "removeQueued":
64
+ this.handlers.removeQueued(raw.index);
65
+ break;
47
66
  case "command":
48
67
  this.handlers.command(raw.name);
49
68
  break;
69
+ case "attachUris":
70
+ this.handlers.attachUris(raw.uris);
71
+ break;
72
+ case "pasted":
73
+ this.handlers.pasted(raw.id, raw.text);
74
+ break;
50
75
  }
51
76
  });
52
77
  return {
@@ -60,6 +85,9 @@ export class ChatViewBridge {
60
85
  pushState(state) {
61
86
  void this.webview?.postMessage({ type: "state", ...state });
62
87
  }
88
+ pushPasteResult(id, attached) {
89
+ void this.webview?.postMessage({ type: "pasteResult", id, attached });
90
+ }
63
91
  pushProgress(text) {
64
92
  void this.webview?.postMessage({ type: "progress", text });
65
93
  }
@@ -17,11 +17,16 @@ export interface CommandHandlers {
17
17
  login(): Promise<void>;
18
18
  logout(): Promise<void>;
19
19
  newChat(): Promise<void>;
20
+ reopen(): Promise<void>;
20
21
  installBrowser(): Promise<void>;
21
22
  sendSelection(): Promise<void>;
22
23
  sendFile(uri: unknown): Promise<void>;
23
24
  focus(): void;
24
25
  /** From the webview's input box. */
25
26
  send(text: string): Promise<void>;
27
+ /** Files dropped on the webview; unreadable URIs are reported together. */
28
+ attachUris(uris: string[]): Promise<void>;
29
+ /** A paste into the input box; true when it became a selection chip. */
30
+ pasted(text: string): boolean;
26
31
  }
27
32
  export declare function createCommands(deps: CommandDeps): CommandHandlers;
package/dist/commands.js CHANGED
@@ -9,7 +9,7 @@ export function createCommands(deps) {
9
9
  const { controller, ui } = deps;
10
10
  function isBusy() {
11
11
  const status = controller.getState().status;
12
- return status === "busy" || status === "opening";
12
+ return status === "busy" || status === "opening" || status === "reopening";
13
13
  }
14
14
  async function runInstall() {
15
15
  try {
@@ -66,13 +66,27 @@ export function createCommands(deps) {
66
66
  }
67
67
  },
68
68
  async logout() {
69
- if (!(await controller.discard("Logged out"))) {
69
+ if (isBusy()) {
70
70
  ui.showWarningMessage("Wait for the current reply to finish, then log out.");
71
71
  return;
72
72
  }
73
+ // The auth state goes first: `discard` drains the queue, and a queued
74
+ // entry would otherwise reopen the browser — and send — under the
75
+ // credentials the user just asked to delete.
73
76
  await deps.clearAuth();
77
+ if (!(await controller.discard("Logged out"))) {
78
+ // A turn started while the auth state was being deleted. The file
79
+ // is gone either way; only the session is still open, so warn about
80
+ // that alone.
81
+ ui.showWarningMessage("Wait for the current reply to finish, then log out.");
82
+ }
83
+ },
84
+ async newChat() {
85
+ if (!(await controller.newChat())) {
86
+ ui.showWarningMessage("Wait for the current reply to finish, or press Ctrl+R to reopen.");
87
+ }
74
88
  },
75
- newChat: () => controller.newChat(),
89
+ reopen: () => controller.reopen(),
76
90
  async installBrowser() {
77
91
  if (await runInstall())
78
92
  ui.showInformationMessage("Chromium installed.");
@@ -99,6 +113,32 @@ export function createCommands(deps) {
99
113
  attachEditor(editor, false);
100
114
  },
101
115
  focus: () => ui.focusView(),
116
+ async attachUris(uris) {
117
+ const skipped = [];
118
+ for (const raw of uris) {
119
+ try {
120
+ const doc = await ui.openDocument(ui.parseUri(raw));
121
+ attach(doc.path, doc.text);
122
+ }
123
+ catch {
124
+ skipped.push(raw);
125
+ }
126
+ }
127
+ if (skipped.length > 0) {
128
+ ui.showWarningMessage(`Skipped: ${skipped.join(", ")}`);
129
+ }
130
+ },
131
+ pasted(text) {
132
+ const editor = ui.activeEditor();
133
+ const selection = editor?.selection;
134
+ if (!editor || !selection)
135
+ return false;
136
+ const normalise = (s) => s.replace(/\r\n/g, "\n");
137
+ if (normalise(text) !== normalise(selection.text))
138
+ return false;
139
+ attachEditor(editor, true);
140
+ return true;
141
+ },
102
142
  async send(text) {
103
143
  const result = await controller.send(text);
104
144
  if (result.ok || result.code !== "BROWSER_UNAVAILABLE")
@@ -1,5 +1,6 @@
1
1
  import { type Provider } from "@chatbridge/core";
2
2
  import * as vscode from "vscode";
3
+ import { type CommandHandlers } from "./commands.js";
3
4
  import { SessionController } from "./session-controller.js";
4
5
  import { type ExtensionUiOptions } from "./ui-config.js";
5
6
  export interface CreateExtensionOptions {
@@ -26,6 +27,8 @@ export interface CreateExtensionOptions {
26
27
  /** What `activate` returns: the E2E drives the controller directly. */
27
28
  export interface ExtensionApi {
28
29
  controller: SessionController;
30
+ /** The E2E drives the command handlers directly. */
31
+ handlers: CommandHandlers;
29
32
  }
30
33
  export declare function createExtension(opts: CreateExtensionOptions): {
31
34
  activate: (context: vscode.ExtensionContext) => Promise<ExtensionApi>;
@@ -30,7 +30,11 @@ export function createExtension(opts) {
30
30
  const bridge = new ChatViewBridge(() => controller.getState(), {
31
31
  send: (text) => void handlers.send(text),
32
32
  removeAttachment: (i) => controller?.removeAttachment(i),
33
+ takeBack: () => controller?.takeBack(),
34
+ removeQueued: (i) => controller?.removeQueued(i),
33
35
  command: (name) => void handlers[name](),
36
+ attachUris: (uris) => void handlers.attachUris(uris),
37
+ pasted: (id, text) => bridge.pushPasteResult(id, handlers.pasted(text)),
34
38
  });
35
39
  function settings() {
36
40
  const cfg = vscode.workspace.getConfiguration(opts.id);
@@ -56,7 +60,9 @@ export function createExtension(opts) {
56
60
  },
57
61
  onChange: (state) => {
58
62
  bridge.pushState(state);
59
- if (state.status === "busy" || state.status === "opening")
63
+ if (state.status === "busy" ||
64
+ state.status === "opening" ||
65
+ state.status === "reopening")
60
66
  statusBar.show();
61
67
  else
62
68
  statusBar.hide();
@@ -83,7 +89,7 @@ export function createExtension(opts) {
83
89
  for (const name of COMMAND_NAMES) {
84
90
  context.subscriptions.push(vscode.commands.registerCommand(`${opts.id}.${name}`, (arg) => name === "sendFile" ? handlers.sendFile(arg) : handlers[name]()));
85
91
  }
86
- return { controller };
92
+ return { controller, handlers };
87
93
  }
88
94
  async function deactivate() {
89
95
  await controller?.close();
@@ -1,4 +1,4 @@
1
- export declare const COMMAND_NAMES: readonly ["login", "logout", "newChat", "installBrowser", "sendSelection", "sendFile", "focus"];
1
+ export declare const COMMAND_NAMES: readonly ["login", "logout", "newChat", "reopen", "installBrowser", "sendSelection", "sendFile", "focus"];
2
2
  export type CommandName = (typeof COMMAND_NAMES)[number];
3
3
  /** The `contributes` IDs a vendor extension must declare for `id`. */
4
4
  export declare function expectedContributions(id: string): {
package/dist/manifest.js CHANGED
@@ -2,6 +2,7 @@ export const COMMAND_NAMES = [
2
2
  "login",
3
3
  "logout",
4
4
  "newChat",
5
+ "reopen",
5
6
  "installBrowser",
6
7
  "sendSelection",
7
8
  "sendFile",
@@ -7,15 +7,24 @@ export interface Message {
7
7
  attachments?: Attachment[];
8
8
  }
9
9
  /** closed: no browser. opening: ChatSession.open in flight. idle: ready.
10
- * busy: a turn is in flight. dead: fatal error; New chat or Log in recover. */
11
- export type Status = "closed" | "opening" | "idle" | "busy" | "dead";
10
+ * busy: a turn is in flight. reopening: Ctrl+R is replacing the browser.
11
+ * dead: fatal error; New chat, Log in or Reopen recover. */
12
+ export type Status = "closed" | "opening" | "idle" | "busy" | "reopening" | "dead";
13
+ /** A message waiting for its turn: sent while the controller was not idle. */
14
+ export interface QueueEntry {
15
+ text: string;
16
+ attachments: Attachment[];
17
+ }
12
18
  export interface State {
13
19
  status: Status;
14
20
  messages: Message[];
15
21
  pendingAttachments: Attachment[];
22
+ /** Oldest first; drained one entry per turn end. */
23
+ queue: QueueEntry[];
16
24
  /** `ChatBridgeError.code` of the error that made the status `dead`. */
17
25
  lastError?: string;
18
26
  }
27
+ export type WebviewCommand = "login" | "logout" | "newChat" | "installBrowser" | "reopen";
19
28
  /** webview → host */
20
29
  export type ToHost = {
21
30
  type: "ready";
@@ -25,9 +34,25 @@ export type ToHost = {
25
34
  } | {
26
35
  type: "removeAttachment";
27
36
  index: number;
37
+ } | {
38
+ type: "takeBack";
39
+ } | {
40
+ type: "removeQueued";
41
+ index: number;
28
42
  } | {
29
43
  type: "command";
30
- name: "login" | "newChat" | "installBrowser";
44
+ name: WebviewCommand;
45
+ }
46
+ /** Files dropped on the webview, as URI strings. */
47
+ | {
48
+ type: "attachUris";
49
+ uris: string[];
50
+ }
51
+ /** A paste into the input box; `id` pairs it with its `pasteResult`. */
52
+ | {
53
+ type: "pasted";
54
+ id: number;
55
+ text: string;
31
56
  };
32
57
  /** Vendor UI customisation, as the webview receives it. */
33
58
  export interface UiConfig {
@@ -51,4 +76,10 @@ export type ToWebview = ({
51
76
  text: string;
52
77
  } | ({
53
78
  type: "config";
54
- } & UiConfig);
79
+ } & UiConfig)
80
+ /** Answer to `pasted`: when attached, the webview drops the pasted text. */
81
+ | {
82
+ type: "pasteResult";
83
+ id: number;
84
+ attached: boolean;
85
+ };
@@ -1,5 +1,5 @@
1
1
  import { type Attachment } from "@chatbridge/core";
2
- import type { State } from "./protocol.js";
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>;
@@ -12,11 +12,14 @@ export interface PendingAttachment extends Attachment {
12
12
  }
13
13
  export type SendResult = {
14
14
  ok: true;
15
+ queued?: true;
15
16
  } | {
16
17
  ok: false;
17
18
  code: string;
18
19
  message: string;
19
20
  };
21
+ /** The separator pushed into the history by `reopen()`. */
22
+ export declare const REOPENED_SEPARATOR = "reopened";
20
23
  export type AddResult = {
21
24
  ok: true;
22
25
  } | {
@@ -47,6 +50,12 @@ export declare class SessionController {
47
50
  private session;
48
51
  /** The full prompt of the last send, for retryLast(). */
49
52
  private lastPrompt;
53
+ /** Turns sent while the controller was not ready, oldest first. */
54
+ private queue;
55
+ /** Bumped by every reopen; a send from an older generation is stale. */
56
+ private generation;
57
+ /** The reopen in flight, so a second Ctrl+R joins it instead of racing. */
58
+ private reopening;
50
59
  private readonly closeTimeoutMs;
51
60
  constructor(opts: SessionControllerOptions);
52
61
  getState(): State;
@@ -55,9 +64,25 @@ export declare class SessionController {
55
64
  private emit;
56
65
  addAttachment(a: PendingAttachment): AddResult;
57
66
  removeAttachment(index: number): void;
58
- /** Sends the text plus the pending attachments as one turn. Never
59
- * throws: the outcome is the result and the history. */
67
+ /** True when a turn can start right now. `dead` counts: an explicit
68
+ * send is the user retrying, and only automatic draining must stop
69
+ * while the controller is dead. */
70
+ private get canStartTurn();
71
+ /** Sends the text plus the pending attachments as one turn, or queues it
72
+ * when a turn is already running. Never throws: the outcome is the
73
+ * result and the history. */
60
74
  send(text: string): Promise<SendResult>;
75
+ /** Pushes the user entry and runs the turn. Shared by send and drain. */
76
+ private startTurn;
77
+ /** Starts the oldest queued entry, if any, when the controller is ready
78
+ * for a turn. Called at every transition back to a ready state, before
79
+ * the caller emits, so the webview never sees an idle frame with a
80
+ * queue still waiting. */
81
+ private drain;
82
+ /** Empties the queue back into the composer: the entries are returned
83
+ * and their attachments become pending again. */
84
+ takeBack(): QueueEntry[];
85
+ removeQueued(index: number): void;
61
86
  /** Re-runs the last prompt after a recoverable fatal error (a missing
62
87
  * browser that was just installed). Drops the trailing error entry so
63
88
  * the history reads user → assistant. */
@@ -65,8 +90,14 @@ export declare class SessionController {
65
90
  private runTurn;
66
91
  private fail;
67
92
  private dropSession;
68
- /** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily. */
69
- newChat(): Promise<void>;
93
+ /** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
94
+ * False when a turn is in flight, so the caller can warn. */
95
+ newChat(): Promise<boolean>;
96
+ /** Replaces the browser in every state. The in-flight turn, if any, is
97
+ * abandoned: its result is dropped by the generation check. A second
98
+ * call while one is running joins the first. */
99
+ reopen(): Promise<void>;
100
+ private runReopen;
70
101
  /** Closes the session (if any) and pushes `separator`. Refused (returns
71
102
  * false) while a turn is in flight. Clears a dead state. */
72
103
  discard(separator: string): Promise<boolean>;
@@ -1,4 +1,6 @@
1
1
  import { ChatBridgeError, MAX_FILE_BYTES, MAX_TOTAL_BYTES, closeOrKill, formatAttachment, formatSize, } from "@chatbridge/core";
2
+ /** The separator pushed into the history by `reopen()`. */
3
+ export const REOPENED_SEPARATOR = "reopened";
2
4
  export const CLOSE_TIMEOUT_MS = 5_000;
3
5
  const EMPTY = {
4
6
  ok: false,
@@ -16,6 +18,12 @@ export class SessionController {
16
18
  session;
17
19
  /** The full prompt of the last send, for retryLast(). */
18
20
  lastPrompt;
21
+ /** Turns sent while the controller was not ready, oldest first. */
22
+ queue = [];
23
+ /** Bumped by every reopen; a send from an older generation is stale. */
24
+ generation = 0;
25
+ /** The reopen in flight, so a second Ctrl+R joins it instead of racing. */
26
+ reopening;
19
27
  closeTimeoutMs;
20
28
  constructor(opts) {
21
29
  this.opts = opts;
@@ -32,6 +40,10 @@ export class SessionController {
32
40
  path,
33
41
  bytes,
34
42
  })),
43
+ queue: this.queue.map((q) => ({
44
+ text: q.text,
45
+ attachments: q.attachments.map(({ path, bytes }) => ({ path, bytes })),
46
+ })),
35
47
  };
36
48
  if (this.lastError !== undefined)
37
49
  state.lastError = this.lastError;
@@ -72,37 +84,89 @@ export class SessionController {
72
84
  this.pending.splice(index, 1);
73
85
  this.emit();
74
86
  }
75
- /** Sends the text plus the pending attachments as one turn. Never
76
- * throws: the outcome is the result and the history. */
87
+ /** True when a turn can start right now. `dead` counts: an explicit
88
+ * send is the user retrying, and only automatic draining must stop
89
+ * while the controller is dead. */
90
+ get canStartTurn() {
91
+ return (this.status === "idle" ||
92
+ this.status === "closed" ||
93
+ this.status === "dead");
94
+ }
95
+ /** Sends the text plus the pending attachments as one turn, or queues it
96
+ * when a turn is already running. Never throws: the outcome is the
97
+ * result and the history. */
77
98
  async send(text) {
78
- if (this.status === "busy" || this.status === "opening") {
79
- return {
80
- ok: false,
81
- code: "INVALID_STATE",
82
- message: "A send is already in progress.",
83
- };
84
- }
85
99
  const body = text.trim() === "" ? "" : text;
86
100
  if (body === "" && this.pending.length === 0)
87
101
  return EMPTY;
88
- const sections = this.pending.map((a) => formatAttachment(a.path, a.content));
89
- const prompt = [body, ...sections].filter((s) => s !== "").join("\n\n");
90
- const attachments = this.pending.map(({ path, bytes }) => ({
91
- path,
92
- bytes,
93
- }));
102
+ const attachments = this.pending;
94
103
  this.pending = [];
95
- this.push({ role: "user", text: body, attachments });
104
+ // A non-empty queue means earlier entries are waiting (the controller
105
+ // is `dead`, where draining stops): keep FIFO by queueing behind them
106
+ // and letting drain start the oldest, which reopens the browser.
107
+ if (!this.canStartTurn || this.queue.length > 0) {
108
+ this.queue.push({ text: body, attachments });
109
+ this.drain();
110
+ this.emit();
111
+ return { ok: true, queued: true };
112
+ }
113
+ return this.startTurn({ text: body, attachments });
114
+ }
115
+ /** Pushes the user entry and runs the turn. Shared by send and drain. */
116
+ startTurn(turn) {
117
+ const sections = turn.attachments.map((a) => formatAttachment(a.path, a.content));
118
+ const prompt = [turn.text, ...sections]
119
+ .filter((s) => s !== "")
120
+ .join("\n\n");
121
+ this.push({
122
+ role: "user",
123
+ text: turn.text,
124
+ attachments: turn.attachments.map(({ path, bytes }) => ({ path, bytes })),
125
+ });
96
126
  this.lastPrompt = prompt;
97
127
  return this.runTurn(prompt);
98
128
  }
129
+ /** Starts the oldest queued entry, if any, when the controller is ready
130
+ * for a turn. Called at every transition back to a ready state, before
131
+ * the caller emits, so the webview never sees an idle frame with a
132
+ * queue still waiting. */
133
+ drain() {
134
+ // markLoggedIn() can arrive in any status; never start a second turn
135
+ // on top of a running one. The other call sites are already ready.
136
+ if (!this.canStartTurn)
137
+ return;
138
+ const next = this.queue.shift();
139
+ if (next === undefined)
140
+ return;
141
+ void this.startTurn(next);
142
+ }
143
+ /** Empties the queue back into the composer: the entries are returned
144
+ * and their attachments become pending again. */
145
+ takeBack() {
146
+ if (this.queue.length === 0)
147
+ return [];
148
+ const entries = this.queue.splice(0);
149
+ for (const e of entries)
150
+ this.pending.push(...e.attachments);
151
+ this.emit();
152
+ return entries.map((e) => ({
153
+ text: e.text,
154
+ attachments: e.attachments.map(({ path, bytes }) => ({ path, bytes })),
155
+ }));
156
+ }
157
+ removeQueued(index) {
158
+ if (index < 0 || index >= this.queue.length)
159
+ return;
160
+ this.queue.splice(index, 1);
161
+ this.emit();
162
+ }
99
163
  /** Re-runs the last prompt after a recoverable fatal error (a missing
100
164
  * browser that was just installed). Drops the trailing error entry so
101
165
  * the history reads user → assistant. */
102
166
  async retryLast() {
103
167
  if (this.lastPrompt === undefined)
104
168
  return EMPTY;
105
- if (this.status === "busy" || this.status === "opening") {
169
+ if (!this.canStartTurn) {
106
170
  return {
107
171
  ok: false,
108
172
  code: "INVALID_STATE",
@@ -122,18 +186,32 @@ export class SessionController {
122
186
  // `lastError` would keep the webview's error banner up after a
123
187
  // successful send from `dead`.
124
188
  this.lastError = undefined;
189
+ const generation = this.generation;
125
190
  try {
126
191
  if (this.session === undefined) {
127
192
  this.setStatus("opening");
128
- this.session = await this.opts.openSession();
193
+ const session = await this.opts.openSession();
194
+ // A reopen ran while we were opening: this browser is an orphan.
195
+ if (generation !== this.generation) {
196
+ await closeOrKill(session, this.closeTimeoutMs);
197
+ return { ok: true };
198
+ }
199
+ this.session = session;
129
200
  }
130
201
  this.setStatus("busy");
131
202
  const reply = await this.session.send(prompt);
203
+ if (generation !== this.generation)
204
+ return { ok: true }; // stale
132
205
  this.messages.push({ role: "assistant", text: reply });
133
- this.setStatus("idle");
206
+ // Claim the next turn before emitting, so no idle frame is shown.
207
+ this.status = "idle";
208
+ this.drain();
209
+ this.emit();
134
210
  return { ok: true };
135
211
  }
136
212
  catch (err) {
213
+ if (generation !== this.generation)
214
+ return { ok: true }; // stale
137
215
  return this.fail(err);
138
216
  }
139
217
  }
@@ -148,7 +226,9 @@ export class SessionController {
148
226
  // Show the error before `dropSession` (up to `closeTimeoutMs`) runs.
149
227
  this.emit();
150
228
  if (code === "RESPONSE_TIMEOUT") {
151
- this.setStatus("idle");
229
+ this.status = "idle";
230
+ this.drain();
231
+ this.emit();
152
232
  }
153
233
  else {
154
234
  this.lastError = code;
@@ -163,21 +243,68 @@ export class SessionController {
163
243
  if (old !== undefined)
164
244
  await closeOrKill(old, this.closeTimeoutMs);
165
245
  }
166
- /** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily. */
246
+ /** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
247
+ * False when a turn is in flight, so the caller can warn. */
167
248
  async newChat() {
168
- await this.discard("New chat");
249
+ return this.discard("New chat");
250
+ }
251
+ /** Replaces the browser in every state. The in-flight turn, if any, is
252
+ * abandoned: its result is dropped by the generation check. A second
253
+ * call while one is running joins the first. */
254
+ reopen() {
255
+ if (this.reopening)
256
+ return this.reopening;
257
+ const run = this.runReopen().finally(() => {
258
+ this.reopening = undefined;
259
+ });
260
+ this.reopening = run;
261
+ return run;
262
+ }
263
+ async runReopen() {
264
+ const generation = ++this.generation;
265
+ this.setStatus("reopening");
266
+ await this.dropSession();
267
+ try {
268
+ const session = await this.opts.openSession();
269
+ // `close()` (deactivate) ran while the browser was opening: this one
270
+ // is an orphan nobody would ever close, and the controller is closed.
271
+ if (generation !== this.generation) {
272
+ await closeOrKill(session, this.closeTimeoutMs);
273
+ return;
274
+ }
275
+ this.session = session;
276
+ this.lastError = undefined;
277
+ this.messages.push({ role: "separator", text: REOPENED_SEPARATOR });
278
+ this.status = "idle";
279
+ this.drain();
280
+ this.emit();
281
+ }
282
+ catch (err) {
283
+ const code = err instanceof ChatBridgeError ? err.code : "UNKNOWN";
284
+ const message = err instanceof Error ? err.message : String(err);
285
+ const hint = this.opts.hints?.[code];
286
+ this.messages.push({
287
+ role: "error",
288
+ text: hint === undefined ? message : `${message}\n${hint}`,
289
+ });
290
+ this.lastError = code;
291
+ this.setStatus("dead");
292
+ }
169
293
  }
170
294
  /** Closes the session (if any) and pushes `separator`. Refused (returns
171
295
  * false) while a turn is in flight. Clears a dead state. */
172
296
  async discard(separator) {
173
- if (this.status === "busy" || this.status === "opening")
297
+ if (!this.canStartTurn)
174
298
  return false;
175
299
  await this.dropSession();
176
300
  this.lastError = undefined;
177
301
  // A fresh chat must not re-send a prompt from before the break.
178
302
  this.lastPrompt = undefined;
179
303
  this.status = "closed";
180
- this.push({ role: "separator", text: separator });
304
+ this.messages.push({ role: "separator", text: separator });
305
+ // The queue outlives the break: drain it into the new chat.
306
+ this.drain();
307
+ this.emit();
181
308
  return true;
182
309
  }
183
310
  /** After a successful login command: a dead controller may try again. */
@@ -186,10 +313,15 @@ export class SessionController {
186
313
  this.lastError = undefined;
187
314
  this.status = "closed";
188
315
  }
189
- this.push({ role: "separator", text: "Logged in" });
316
+ this.messages.push({ role: "separator", text: "Logged in" });
317
+ this.drain();
318
+ this.emit();
190
319
  }
191
320
  /** deactivate: close the browser, keep the history. Idempotent. */
192
321
  async close() {
322
+ // A reopen in flight is stale from here on: its new browser must be
323
+ // closed rather than adopted by a controller the user has shut down.
324
+ this.generation++;
193
325
  await this.dropSession();
194
326
  if (this.status !== "dead")
195
327
  this.status = "closed";
@@ -20,6 +20,8 @@ export interface VscodeUi {
20
20
  showInformationMessage(message: string): void;
21
21
  withProgress<T>(title: string, cancellable: boolean, task: (progress: ProgressReporter, signal: AbortSignal) => Promise<T>): Promise<T>;
22
22
  activeEditor(): EditorSnapshot | undefined;
23
+ /** Parses a URI string; throws when it is not a valid URI. */
24
+ parseUri(uri: string): unknown;
23
25
  /** Opens the document behind an explorer Uri (or any Uri) read-only. */
24
26
  openDocument(uri: unknown): Promise<{
25
27
  path: string;
package/dist/vscode-ui.js CHANGED
@@ -29,6 +29,7 @@ export function createVscodeUi(api, id) {
29
29
  }
30
30
  return snap;
31
31
  },
32
+ parseUri: (uri) => api.Uri.parse(uri, true),
32
33
  openDocument: async (uri) => {
33
34
  const doc = await api.workspace.openTextDocument(uri);
34
35
  return { path: relPath(doc.uri), text: doc.getText() };
@@ -1,5 +1,30 @@
1
1
  "use strict";
2
2
  (() => {
3
+ // ../core/dist/slash-commands.js
4
+ var SLASH_COMMANDS = [
5
+ { name: "login", description: "Log in in a browser window" },
6
+ { name: "logout", description: "Delete the saved login and close the chat" },
7
+ { name: "new", description: "Start a new chat" },
8
+ { name: "reopen", description: "Reopen the browser (also Ctrl+R)" },
9
+ { name: "help", description: "List these commands" }
10
+ ];
11
+ var NAMES = new Set(SLASH_COMMANDS.map((c) => c.name));
12
+ var PATTERN = /^\/([a-z]+)$/;
13
+ function parseSlashCommand(text) {
14
+ const m = PATTERN.exec(text.trim());
15
+ if (!m || m[1] === void 0)
16
+ return void 0;
17
+ const word = m[1];
18
+ return NAMES.has(word) ? { command: word } : { unknown: word };
19
+ }
20
+ function unknownCommandMessage(word) {
21
+ return `Unknown command: /${word}. Type /help.`;
22
+ }
23
+ function helpText() {
24
+ const width = Math.max(...SLASH_COMMANDS.map((c) => c.name.length)) + 1;
25
+ return SLASH_COMMANDS.map((c) => `/${c.name.padEnd(width)} ${c.description}`).join("\n");
26
+ }
27
+
3
28
  // src/webview/main.ts
4
29
  var vscode = acquireVsCodeApi();
5
30
  var history = document.getElementById("history");
@@ -12,7 +37,10 @@
12
37
  var welcomeText = document.getElementById("welcome-text");
13
38
  var banner = document.getElementById("banner");
14
39
  var footer = document.getElementById("footer");
40
+ var queue = document.getElementById("queue");
41
+ var inlineError = document.getElementById("inline-error");
15
42
  var config = {};
43
+ var lastState;
16
44
  function applyConfig(c) {
17
45
  config = c;
18
46
  if (c.sendButton?.background) {
@@ -68,17 +96,19 @@
68
96
  }
69
97
  return box;
70
98
  }
99
+ function waitingText(status2) {
100
+ if (status2 === "reopening") return "Reopening browser...";
101
+ if (status2 === "opening") return "Opening browser...";
102
+ return "Waiting...";
103
+ }
71
104
  function renderStatus(s) {
72
105
  status.replaceChildren();
73
106
  status.hidden = false;
74
- if (s.status === "busy" || s.status === "opening") {
107
+ if (s.status === "busy" || s.status === "opening" || s.status === "reopening") {
75
108
  status.appendChild(el("span", "spinner"));
109
+ const queued = s.queue.length > 0 ? ` \xB7 ${s.queue.length} queued` : "";
76
110
  status.appendChild(
77
- el(
78
- "span",
79
- "progress-text",
80
- s.status === "opening" ? "Opening browser..." : "Waiting..."
81
- )
111
+ el("span", "progress-text", `${waitingText(s.status)}${queued}`)
82
112
  );
83
113
  return;
84
114
  }
@@ -99,6 +129,12 @@
99
129
  )
100
130
  );
101
131
  }
132
+ status.appendChild(
133
+ button(
134
+ "Reopen",
135
+ () => vscode.postMessage({ type: "command", name: "reopen" })
136
+ )
137
+ );
102
138
  status.appendChild(
103
139
  button(
104
140
  "New chat",
@@ -109,6 +145,23 @@
109
145
  }
110
146
  status.hidden = true;
111
147
  }
148
+ function renderQueue(s) {
149
+ queue.replaceChildren();
150
+ queue.hidden = s.queue.length === 0;
151
+ s.queue.forEach((entry, index) => {
152
+ const li = document.createElement("li");
153
+ const firstLine = entry.text.split("\n")[0] ?? "";
154
+ const label = entry.attachments.length > 0 ? `${firstLine} \u{1F4CE} ${entry.attachments.length}` : firstLine;
155
+ li.appendChild(el("span", "queue-text", `\u25B9 ${label}`));
156
+ const x = button(
157
+ "\xD7",
158
+ () => vscode.postMessage({ type: "removeQueued", index })
159
+ );
160
+ x.className = "chip-remove";
161
+ li.appendChild(x);
162
+ queue.appendChild(li);
163
+ });
164
+ }
112
165
  function renderAttachments(s) {
113
166
  attachments.replaceChildren();
114
167
  s.pendingAttachments.forEach((a, index) => {
@@ -126,17 +179,45 @@
126
179
  history.replaceChildren(...s.messages.map(renderMessage));
127
180
  history.scrollTop = history.scrollHeight;
128
181
  renderStatus(s);
182
+ renderQueue(s);
129
183
  renderAttachments(s);
130
184
  welcome.hidden = s.messages.length > 0 || !config.welcome && !config.bannerUri;
131
185
  history.hidden = !welcome.hidden;
132
- const locked = s.status === "busy" || s.status === "opening";
133
- input.disabled = locked;
134
- sendButton.disabled = locked;
135
- if (!locked) input.focus();
186
+ const active = s.status === "busy" || s.status === "opening" || s.status === "reopening";
187
+ input.disabled = false;
188
+ sendButton.disabled = false;
189
+ sendButton.textContent = active ? "Queue" : "Send";
190
+ if (document.activeElement === null || document.activeElement === document.body) {
191
+ input.focus();
192
+ }
193
+ lastState = s;
194
+ }
195
+ function showInlineError(text) {
196
+ inlineError.textContent = text ?? "";
197
+ inlineError.hidden = text === void 0;
136
198
  }
137
199
  function submit() {
138
200
  const text = input.value;
139
201
  if (text.trim() === "" && attachments.childElementCount === 0) return;
202
+ const slash = parseSlashCommand(text);
203
+ if (slash && "unknown" in slash) {
204
+ showInlineError(unknownCommandMessage(slash.unknown));
205
+ return;
206
+ }
207
+ showInlineError(void 0);
208
+ if (slash) {
209
+ input.value = "";
210
+ if (slash.command === "help") {
211
+ welcome.hidden = true;
212
+ history.hidden = false;
213
+ history.appendChild(el("div", "message help", helpText()));
214
+ history.scrollTop = history.scrollHeight;
215
+ return;
216
+ }
217
+ const name = slash.command === "new" ? "newChat" : slash.command;
218
+ vscode.postMessage({ type: "command", name });
219
+ return;
220
+ }
140
221
  vscode.postMessage({ type: "send", text });
141
222
  input.value = "";
142
223
  }
@@ -148,6 +229,63 @@
148
229
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
149
230
  e.preventDefault();
150
231
  submit();
232
+ return;
233
+ }
234
+ if (e.key === "ArrowUp" && input.value === "" && (lastState?.queue.length ?? 0) > 0) {
235
+ e.preventDefault();
236
+ const entries = lastState?.queue ?? [];
237
+ input.value = entries.map((q) => q.text).join("\n\n");
238
+ showInlineError(void 0);
239
+ vscode.postMessage({ type: "takeBack" });
240
+ }
241
+ });
242
+ input.addEventListener("input", () => showInlineError(void 0));
243
+ function urisFromDrop(dt) {
244
+ const list = dt?.getData("text/uri-list") ?? "";
245
+ return list.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== "" && !l.startsWith("#"));
246
+ }
247
+ document.addEventListener("dragover", (e) => {
248
+ e.preventDefault();
249
+ document.body.classList.add("drop-target");
250
+ });
251
+ document.addEventListener(
252
+ "dragleave",
253
+ () => document.body.classList.remove("drop-target")
254
+ );
255
+ document.addEventListener("drop", (e) => {
256
+ e.preventDefault();
257
+ document.body.classList.remove("drop-target");
258
+ const uris = urisFromDrop(e.dataTransfer);
259
+ if (uris.length > 0) vscode.postMessage({ type: "attachUris", uris });
260
+ });
261
+ var PASTE_TIMEOUT_MS = 500;
262
+ var pasteSeq = 0;
263
+ var pendingPastes = /* @__PURE__ */ new Map();
264
+ function insertAtCaret(text) {
265
+ input.focus();
266
+ if (!document.execCommand("insertText", false, text)) {
267
+ const { selectionStart, selectionEnd, value } = input;
268
+ input.value = value.slice(0, selectionStart) + text + value.slice(selectionEnd);
269
+ const pos = selectionStart + text.length;
270
+ input.setSelectionRange(pos, pos);
271
+ }
272
+ }
273
+ input.addEventListener("paste", (e) => {
274
+ const text = e.clipboardData?.getData("text/plain") ?? "";
275
+ if (!text.includes("\n")) return;
276
+ e.preventDefault();
277
+ const id = ++pasteSeq;
278
+ const timer = setTimeout(() => {
279
+ pendingPastes.delete(id);
280
+ insertAtCaret(text);
281
+ }, PASTE_TIMEOUT_MS);
282
+ pendingPastes.set(id, { text, timer });
283
+ vscode.postMessage({ type: "pasted", id, text });
284
+ });
285
+ document.addEventListener("keydown", (e) => {
286
+ if (e.key.toLowerCase() === "r" && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
287
+ e.preventDefault();
288
+ vscode.postMessage({ type: "command", name: "reopen" });
151
289
  }
152
290
  });
153
291
  window.addEventListener("message", (event) => {
@@ -161,6 +299,12 @@
161
299
  } else if (m.type === "progress") {
162
300
  const t = status.querySelector(".progress-text");
163
301
  if (t) t.textContent = m.text;
302
+ } else if (m.type === "pasteResult") {
303
+ const p = pendingPastes.get(m.id);
304
+ if (!p) return;
305
+ clearTimeout(p.timer);
306
+ pendingPastes.delete(m.id);
307
+ if (!m.attached) insertAtCaret(p.text);
164
308
  }
165
309
  });
166
310
  vscode.postMessage({ type: "ready" });
@@ -137,3 +137,38 @@ body {
137
137
  #input:disabled {
138
138
  opacity: 0.5;
139
139
  }
140
+ #queue {
141
+ list-style: none;
142
+ margin: 0;
143
+ padding: 0 8px;
144
+ font-size: 90%;
145
+ opacity: 0.8;
146
+ }
147
+ #queue li {
148
+ display: flex;
149
+ align-items: center;
150
+ gap: 6px;
151
+ padding: 2px 0;
152
+ white-space: nowrap;
153
+ overflow: hidden;
154
+ text-overflow: ellipsis;
155
+ }
156
+ #queue li .queue-text {
157
+ flex: 1;
158
+ overflow: hidden;
159
+ text-overflow: ellipsis;
160
+ }
161
+ #inline-error {
162
+ padding: 2px 8px;
163
+ color: var(--vscode-errorForeground);
164
+ font-size: 90%;
165
+ }
166
+ .message.help {
167
+ font-family: var(--vscode-editor-font-family);
168
+ white-space: pre;
169
+ opacity: 0.8;
170
+ }
171
+ body.drop-target {
172
+ outline: 2px dashed var(--vscode-focusBorder);
173
+ outline-offset: -2px;
174
+ }
@@ -20,9 +20,11 @@ export function buildHtml(i) {
20
20
  <div id="welcome" hidden><img id="banner" alt="" hidden><p id="welcome-text"></p></div>
21
21
  <main id="history" aria-live="polite"></main>
22
22
  <div id="status" hidden></div>
23
+ <ul id="queue" hidden></ul>
23
24
  <div id="attachments"></div>
25
+ <div id="inline-error" hidden></div>
24
26
  <form id="composer">
25
- <textarea id="input" rows="3" placeholder="Message (Enter to send, Shift+Enter for a newline)"></textarea>
27
+ <textarea id="input" rows="3" placeholder="Message (Enter to send, Shift+Enter for a newline, / for commands)"></textarea>
26
28
  <button id="send" type="submit">Send</button>
27
29
  </form>
28
30
  <footer id="footer" hidden></footer>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatbridge/vscode",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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.7.0"
31
+ "@chatbridge/core": "0.8.1"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/vscode": "1.138.0",