@chatbridge/vscode 0.8.0 → 0.8.2

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`
@@ -1,4 +1,4 @@
1
- import type { State, ToHost, ToWebview, UiConfig } from "./protocol.js";
1
+ import type { QueueEntry, 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,8 +9,14 @@ 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
  }
18
+ /** Every command the webview may post; the bridge rejects the rest. */
19
+ export declare const COMMAND_LIST: ("help" | "login" | "logout" | "newChat" | "installBrowser" | "reopen")[];
14
20
  /** Translates webview messages into handler calls and pushes state to
15
21
  * whichever webview is currently attached (VSCode recreates it). */
16
22
  export declare class ChatViewBridge {
@@ -22,5 +28,7 @@ export declare class ChatViewBridge {
22
28
  dispose(): void;
23
29
  };
24
30
  pushState(state: State): void;
31
+ pushPasteResult(id: number, attached: boolean): void;
32
+ pushTookBack(entries: QueueEntry[]): void;
25
33
  pushProgress(text: string): void;
26
34
  }
@@ -1,3 +1,13 @@
1
+ /** Every command the webview may post; the bridge rejects the rest. */
2
+ export const COMMAND_LIST = [
3
+ "login",
4
+ "logout",
5
+ "newChat",
6
+ "installBrowser",
7
+ "reopen",
8
+ "help",
9
+ ];
10
+ const COMMANDS = new Set(COMMAND_LIST);
1
11
  function isToHost(m) {
2
12
  if (typeof m !== "object" || m === null)
3
13
  return false;
@@ -9,10 +19,16 @@ function isToHost(m) {
9
19
  return typeof msg.text === "string";
10
20
  case "removeAttachment":
11
21
  return typeof msg.index === "number";
22
+ case "takeBack":
23
+ return true;
24
+ case "removeQueued":
25
+ return typeof msg.index === "number";
12
26
  case "command":
13
- return (msg.name === "login" ||
14
- msg.name === "newChat" ||
15
- msg.name === "installBrowser");
27
+ return typeof msg.name === "string" && COMMANDS.has(msg.name);
28
+ case "attachUris":
29
+ return (Array.isArray(msg.uris) && msg.uris.every((u) => typeof u === "string"));
30
+ case "pasted":
31
+ return typeof msg.id === "number" && typeof msg.text === "string";
16
32
  default:
17
33
  return false;
18
34
  }
@@ -44,9 +60,21 @@ export class ChatViewBridge {
44
60
  case "removeAttachment":
45
61
  this.handlers.removeAttachment(raw.index);
46
62
  break;
63
+ case "takeBack":
64
+ this.handlers.takeBack();
65
+ break;
66
+ case "removeQueued":
67
+ this.handlers.removeQueued(raw.index);
68
+ break;
47
69
  case "command":
48
70
  this.handlers.command(raw.name);
49
71
  break;
72
+ case "attachUris":
73
+ this.handlers.attachUris(raw.uris);
74
+ break;
75
+ case "pasted":
76
+ this.handlers.pasted(raw.id, raw.text);
77
+ break;
50
78
  }
51
79
  });
52
80
  return {
@@ -60,6 +88,12 @@ export class ChatViewBridge {
60
88
  pushState(state) {
61
89
  void this.webview?.postMessage({ type: "state", ...state });
62
90
  }
91
+ pushPasteResult(id, attached) {
92
+ void this.webview?.postMessage({ type: "pasteResult", id, attached });
93
+ }
94
+ pushTookBack(entries) {
95
+ void this.webview?.postMessage({ type: "tookBack", entries });
96
+ }
63
97
  pushProgress(text) {
64
98
  void this.webview?.postMessage({ type: "progress", text });
65
99
  }
@@ -17,11 +17,18 @@ 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>;
22
+ /** From the webview's `/help`: the listing joins the history. */
23
+ help(): void;
21
24
  sendSelection(): Promise<void>;
22
25
  sendFile(uri: unknown): Promise<void>;
23
26
  focus(): void;
24
27
  /** From the webview's input box. */
25
28
  send(text: string): Promise<void>;
29
+ /** Files dropped on the webview; unreadable URIs are reported together. */
30
+ attachUris(uris: string[]): Promise<void>;
31
+ /** A paste into the input box; true when it became a selection chip. */
32
+ pasted(text: string): boolean;
26
33
  }
27
34
  export declare function createCommands(deps: CommandDeps): CommandHandlers;
package/dist/commands.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { LoginAbortedError } from "@chatbridge/core";
2
+ import { helpText } from "@chatbridge/core/slash-commands";
2
3
  function utf8Bytes(text) {
3
4
  return new TextEncoder().encode(text).byteLength;
4
5
  }
6
+ /** Pasted text arrives with the platform's line endings; the editor's
7
+ * selection text does not. Compare them on the same footing. */
8
+ const normalise = (s) => s.replace(/\r\n/g, "\n");
5
9
  function message(err) {
6
10
  return err instanceof Error ? err.message : String(err);
7
11
  }
@@ -9,7 +13,7 @@ export function createCommands(deps) {
9
13
  const { controller, ui } = deps;
10
14
  function isBusy() {
11
15
  const status = controller.getState().status;
12
- return status === "busy" || status === "opening";
16
+ return status === "busy" || status === "opening" || status === "reopening";
13
17
  }
14
18
  async function runInstall() {
15
19
  try {
@@ -66,13 +70,28 @@ export function createCommands(deps) {
66
70
  }
67
71
  },
68
72
  async logout() {
69
- if (!(await controller.discard("Logged out"))) {
73
+ if (isBusy()) {
70
74
  ui.showWarningMessage("Wait for the current reply to finish, then log out.");
71
75
  return;
72
76
  }
77
+ // The auth state goes first: `discard` drains the queue, and a queued
78
+ // entry would otherwise reopen the browser — and send — under the
79
+ // credentials the user just asked to delete.
73
80
  await deps.clearAuth();
81
+ if (!(await controller.discard("Logged out"))) {
82
+ // A turn started while the auth state was being deleted. The file
83
+ // is gone either way; only the session is still open, so warn about
84
+ // that alone.
85
+ ui.showWarningMessage("Wait for the current reply to finish, then log out.");
86
+ }
87
+ },
88
+ async newChat() {
89
+ if (!(await controller.newChat())) {
90
+ ui.showWarningMessage("Wait for the current reply to finish, or press Ctrl+R to reopen.");
91
+ }
74
92
  },
75
- newChat: () => controller.newChat(),
93
+ reopen: () => controller.reopen(),
94
+ help: () => controller.pushHelp(helpText()),
76
95
  async installBrowser() {
77
96
  if (await runInstall())
78
97
  ui.showInformationMessage("Chromium installed.");
@@ -87,6 +106,10 @@ export function createCommands(deps) {
87
106
  },
88
107
  async sendFile(uri) {
89
108
  if (uri !== undefined && uri !== null) {
109
+ if (!ui.isUri(uri)) {
110
+ ui.showWarningMessage("Nothing to attach.");
111
+ return;
112
+ }
90
113
  const doc = await ui.openDocument(uri);
91
114
  attach(doc.path, doc.text);
92
115
  return;
@@ -99,6 +122,31 @@ export function createCommands(deps) {
99
122
  attachEditor(editor, false);
100
123
  },
101
124
  focus: () => ui.focusView(),
125
+ async attachUris(uris) {
126
+ const skipped = [];
127
+ for (const raw of new Set(uris)) {
128
+ try {
129
+ const doc = await ui.openDocument(ui.parseUri(raw));
130
+ attach(doc.path, doc.text);
131
+ }
132
+ catch {
133
+ skipped.push(raw);
134
+ }
135
+ }
136
+ if (skipped.length > 0) {
137
+ ui.showWarningMessage(`Skipped: ${skipped.join(", ")}`);
138
+ }
139
+ },
140
+ pasted(text) {
141
+ const editor = ui.activeEditor();
142
+ const selection = editor?.selection;
143
+ if (!editor || !selection || selection.text.trim() === "")
144
+ return false;
145
+ if (normalise(text) !== normalise(selection.text))
146
+ return false;
147
+ attachEditor(editor, true);
148
+ return true;
149
+ },
102
150
  async send(text) {
103
151
  const result = await controller.send(text);
104
152
  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>;
@@ -8,6 +8,7 @@ import { createCommands } from "./commands.js";
8
8
  import { installBrowser } from "./install-browser.js";
9
9
  import { COMMAND_NAMES, missingContributions } from "./manifest.js";
10
10
  import { SessionController } from "./session-controller.js";
11
+ import { parseTimeoutSec } from "./timeout-setting.js";
11
12
  import { resolveUiConfig } from "./ui-config.js";
12
13
  import { createVscodeUi } from "./vscode-ui.js";
13
14
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -30,13 +31,35 @@ export function createExtension(opts) {
30
31
  const bridge = new ChatViewBridge(() => controller.getState(), {
31
32
  send: (text) => void handlers.send(text),
32
33
  removeAttachment: (i) => controller?.removeAttachment(i),
34
+ takeBack: () => {
35
+ const r = controller?.takeBack();
36
+ if (!r)
37
+ return;
38
+ // `takeBack()` has already emitted a `state` with the queue
39
+ // empty; the webview fills the composer from `tookBack` alone,
40
+ // so this arriving second does not matter.
41
+ bridge.pushTookBack(r.entries);
42
+ if (r.droppedAttachments > 0) {
43
+ void vscode.window.showWarningMessage(`${r.droppedAttachments} attachment(s) left out: total size limit.`);
44
+ }
45
+ },
46
+ removeQueued: (i) => controller?.removeQueued(i),
33
47
  command: (name) => void handlers[name](),
48
+ attachUris: (uris) => void handlers.attachUris(uris),
49
+ pasted: (id, text) => bridge.pushPasteResult(id, handlers.pasted(text)),
34
50
  });
51
+ let warnedTimeout = false;
35
52
  function settings() {
36
53
  const cfg = vscode.workspace.getConfiguration(opts.id);
54
+ const fallbackMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
55
+ const { timeoutMs, invalid } = parseTimeoutSec(cfg.get("timeoutSec"), fallbackMs);
56
+ if (invalid && !warnedTimeout) {
57
+ warnedTimeout = true;
58
+ void vscode.window.showWarningMessage(`${opts.displayName}: "${opts.id}.timeoutSec" must be a positive number; using ${fallbackMs / 1000} s.`);
59
+ }
37
60
  return {
38
61
  headless: cfg.get("headless", opts.headless ?? true),
39
- timeoutMs: cfg.get("timeoutSec", (opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000) * 1000,
62
+ timeoutMs,
40
63
  };
41
64
  }
42
65
  function progress(message) {
@@ -53,10 +76,13 @@ export function createExtension(opts) {
53
76
  }),
54
77
  hints: {
55
78
  BLOCKED: `Set the "${opts.id}.headless" setting to false and try again.`,
79
+ BROWSER_UNAVAILABLE: `Run "${opts.displayName}: Install Browser" and send again.`,
56
80
  },
57
81
  onChange: (state) => {
58
82
  bridge.pushState(state);
59
- if (state.status === "busy" || state.status === "opening")
83
+ if (state.status === "busy" ||
84
+ state.status === "opening" ||
85
+ state.status === "reopening")
60
86
  statusBar.show();
61
87
  else
62
88
  statusBar.hide();
@@ -83,7 +109,7 @@ export function createExtension(opts) {
83
109
  for (const name of COMMAND_NAMES) {
84
110
  context.subscriptions.push(vscode.commands.registerCommand(`${opts.id}.${name}`, (arg) => name === "sendFile" ? handlers.sendFile(arg) : handlers[name]()));
85
111
  }
86
- return { controller };
112
+ return { controller, handlers };
87
113
  }
88
114
  async function deactivate() {
89
115
  await controller?.close();
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from "./protocol.js";
2
- export { type AddResult, type ChatSessionLike, type PendingAttachment, type SendResult, SessionController, type SessionControllerOptions, } from "./session-controller.js";
2
+ export { type AddResult, type ChatSessionLike, type HintCode, type PendingAttachment, type SendResult, SessionController, type SessionControllerOptions, type TakeBackResult, } from "./session-controller.js";
3
3
  export { type ChildLike, installBrowser, type InstallBrowserOptions, type SpawnFn, splitProgressLines, } from "./install-browser.js";
4
4
  export { COMMAND_NAMES, type CommandName, expectedContributions, missingContributions, } from "./manifest.js";
5
5
  export { ChatViewBridge, type ChatViewHandlers, type WebviewLike, } from "./chat-view-bridge.js";
@@ -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",
@@ -1,5 +1,5 @@
1
1
  import type { Attachment } from "@chatbridge/core";
2
- export type Role = "user" | "assistant" | "error" | "separator";
2
+ export type Role = "user" | "assistant" | "error" | "separator" | "help";
3
3
  export interface Message {
4
4
  role: Role;
5
5
  text: string;
@@ -7,15 +7,26 @@ 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"
28
+ /** Webview only: the host answers with a `help` history entry. */
29
+ | "help";
19
30
  /** webview → host */
20
31
  export type ToHost = {
21
32
  type: "ready";
@@ -25,9 +36,25 @@ export type ToHost = {
25
36
  } | {
26
37
  type: "removeAttachment";
27
38
  index: number;
39
+ } | {
40
+ type: "takeBack";
41
+ } | {
42
+ type: "removeQueued";
43
+ index: number;
28
44
  } | {
29
45
  type: "command";
30
- name: "login" | "newChat" | "installBrowser";
46
+ name: WebviewCommand;
47
+ }
48
+ /** Files dropped on the webview, as URI strings. */
49
+ | {
50
+ type: "attachUris";
51
+ uris: string[];
52
+ }
53
+ /** A paste into the input box; `id` pairs it with its `pasteResult`. */
54
+ | {
55
+ type: "pasted";
56
+ id: number;
57
+ text: string;
31
58
  };
32
59
  /** Vendor UI customisation, as the webview receives it. */
33
60
  export interface UiConfig {
@@ -51,4 +78,15 @@ export type ToWebview = ({
51
78
  text: string;
52
79
  } | ({
53
80
  type: "config";
54
- } & UiConfig);
81
+ } & UiConfig)
82
+ /** Answer to `pasted`: when attached, the webview drops the pasted text. */
83
+ | {
84
+ type: "pasteResult";
85
+ id: number;
86
+ attached: boolean;
87
+ }
88
+ /** Answer to `takeBack`: the entries removed from the queue. */
89
+ | {
90
+ type: "tookBack";
91
+ entries: QueueEntry[];
92
+ };
@@ -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,17 +12,30 @@ 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
  } | {
23
26
  ok: false;
24
27
  reason: string;
25
28
  };
29
+ /** Error codes the extension can attach a remedy to. */
30
+ export type HintCode = "BLOCKED" | "BROWSER_UNAVAILABLE" | "AUTH_REQUIRED" | "AUTH_EXPIRED" | "RESPONSE_TIMEOUT";
31
+ export interface TakeBackResult {
32
+ /** Every entry that was queued. `entries[i].attachments` lists everything
33
+ * that entry carried, including attachments that were *not* restored to
34
+ * pending: only their number is reported, in `droppedAttachments`. */
35
+ entries: QueueEntry[];
36
+ /** Attachments left out because restoring them would exceed MAX_TOTAL_BYTES. */
37
+ droppedAttachments: number;
38
+ }
26
39
  export interface SessionControllerOptions {
27
40
  /** Opens a ChatSession; called lazily on the first send after `closed`. */
28
41
  openSession: () => Promise<ChatSessionLike>;
@@ -31,9 +44,9 @@ export interface SessionControllerOptions {
31
44
  /** Called with the full state after every change. */
32
45
  onChange?: (state: State) => void;
33
46
  /** Extra line appended to the history entry of a failed turn, keyed by
34
- * `ChatBridgeError.code`. Core's messages are CLI-flavoured (`Try
35
- * --headful.`); the extension adds the VSCode-side remedy. */
36
- hints?: Partial<Record<string, string>>;
47
+ * `ChatBridgeError.code`. Core's messages carry no UI-specific remedy;
48
+ * the extension adds the VSCode-side one. */
49
+ hints?: Partial<Record<HintCode, string>>;
37
50
  }
38
51
  export declare const CLOSE_TIMEOUT_MS = 5000;
39
52
  /** Owns the history, the pending attachments and the ChatSession. No
@@ -47,26 +60,64 @@ export declare class SessionController {
47
60
  private session;
48
61
  /** The full prompt of the last send, for retryLast(). */
49
62
  private lastPrompt;
63
+ /** Turns sent while the controller was not ready, oldest first. */
64
+ private queue;
65
+ /** Bumped by every reopen; a send from an older generation is stale. */
66
+ private generation;
67
+ /** The reopen in flight, so a second Ctrl+R joins it instead of racing. */
68
+ private reopening;
69
+ /** The openSession in flight (first send or reopen), so close() can wait
70
+ * for it instead of orphaning the browser it is about to produce. */
71
+ private opening;
50
72
  private readonly closeTimeoutMs;
51
73
  constructor(opts: SessionControllerOptions);
52
74
  getState(): State;
53
75
  private setStatus;
76
+ /** A `/help` listing, as a history entry. */
77
+ pushHelp(text: string): void;
54
78
  private push;
55
79
  private emit;
56
80
  addAttachment(a: PendingAttachment): AddResult;
57
81
  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. */
82
+ /** True when a turn can start right now. `dead` counts: an explicit
83
+ * send is the user retrying, and only automatic draining must stop
84
+ * while the controller is dead. */
85
+ private get canStartTurn();
86
+ /** Sends the text plus the pending attachments as one turn, or queues it
87
+ * when a turn is already running. Never throws: the outcome is the
88
+ * result and the history. */
60
89
  send(text: string): Promise<SendResult>;
90
+ /** Pushes the user entry and runs the turn. Shared by send and drain. */
91
+ private startTurn;
92
+ /** Starts the oldest queued entry, if any, when the controller is ready
93
+ * for a turn. Called at every transition back to a ready state, before
94
+ * the caller emits, so the webview never sees an idle frame with a
95
+ * queue still waiting. */
96
+ private drain;
97
+ /** Empties the queue back into the composer: the entries are returned
98
+ * and their attachments become pending again. */
99
+ takeBack(): TakeBackResult;
100
+ removeQueued(index: number): void;
61
101
  /** Re-runs the last prompt after a recoverable fatal error (a missing
62
102
  * browser that was just installed). Drops the trailing error entry so
63
103
  * the history reads user → assistant. */
64
104
  retryLast(): Promise<SendResult>;
65
105
  private runTurn;
106
+ /** Pushes the error entry (message plus the extension's remedy, if any)
107
+ * and reports the code back to the caller. */
108
+ private pushError;
66
109
  private fail;
110
+ /** Tracks an openSession so close()/dropSession can wait for it. */
111
+ private trackOpen;
67
112
  private dropSession;
68
- /** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily. */
69
- newChat(): Promise<void>;
113
+ /** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
114
+ * False when a turn is in flight, so the caller can warn. */
115
+ newChat(): Promise<boolean>;
116
+ /** Replaces the browser in every state. The in-flight turn, if any, is
117
+ * abandoned: its result is dropped by the generation check. A second
118
+ * call while one is running joins the first. */
119
+ reopen(): Promise<void>;
120
+ private runReopen;
70
121
  /** Closes the session (if any) and pushes `separator`. Refused (returns
71
122
  * false) while a turn is in flight. Clears a dead state. */
72
123
  discard(separator: string): Promise<boolean>;