@chatbridge/vscode 0.8.1 → 0.8.3

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,4 +1,4 @@
1
- import type { State, ToHost, ToWebview, UiConfig, WebviewCommand } 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>;
@@ -15,6 +15,8 @@ export interface ChatViewHandlers {
15
15
  attachUris(uris: string[]): void;
16
16
  pasted(id: number, text: string): void;
17
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")[];
18
20
  /** Translates webview messages into handler calls and pushes state to
19
21
  * whichever webview is currently attached (VSCode recreates it). */
20
22
  export declare class ChatViewBridge {
@@ -27,5 +29,6 @@ export declare class ChatViewBridge {
27
29
  };
28
30
  pushState(state: State): void;
29
31
  pushPasteResult(id: number, attached: boolean): void;
32
+ pushTookBack(entries: QueueEntry[]): void;
30
33
  pushProgress(text: string): void;
31
34
  }
@@ -1,10 +1,13 @@
1
- const COMMANDS = new Set([
1
+ /** Every command the webview may post; the bridge rejects the rest. */
2
+ export const COMMAND_LIST = [
2
3
  "login",
3
4
  "logout",
4
5
  "newChat",
5
6
  "installBrowser",
6
7
  "reopen",
7
- ]);
8
+ "help",
9
+ ];
10
+ const COMMANDS = new Set(COMMAND_LIST);
8
11
  function isToHost(m) {
9
12
  if (typeof m !== "object" || m === null)
10
13
  return false;
@@ -88,6 +91,9 @@ export class ChatViewBridge {
88
91
  pushPasteResult(id, attached) {
89
92
  void this.webview?.postMessage({ type: "pasteResult", id, attached });
90
93
  }
94
+ pushTookBack(entries) {
95
+ void this.webview?.postMessage({ type: "tookBack", entries });
96
+ }
91
97
  pushProgress(text) {
92
98
  void this.webview?.postMessage({ type: "progress", text });
93
99
  }
@@ -19,6 +19,8 @@ export interface CommandHandlers {
19
19
  newChat(): Promise<void>;
20
20
  reopen(): Promise<void>;
21
21
  installBrowser(): Promise<void>;
22
+ /** From the webview's `/help`: the listing joins the history. */
23
+ help(): void;
22
24
  sendSelection(): Promise<void>;
23
25
  sendFile(uri: unknown): Promise<void>;
24
26
  focus(): void;
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
  }
@@ -87,6 +91,7 @@ export function createCommands(deps) {
87
91
  }
88
92
  },
89
93
  reopen: () => controller.reopen(),
94
+ help: () => controller.pushHelp(helpText()),
90
95
  async installBrowser() {
91
96
  if (await runInstall())
92
97
  ui.showInformationMessage("Chromium installed.");
@@ -101,6 +106,10 @@ export function createCommands(deps) {
101
106
  },
102
107
  async sendFile(uri) {
103
108
  if (uri !== undefined && uri !== null) {
109
+ if (!ui.isUri(uri)) {
110
+ ui.showWarningMessage("Nothing to attach.");
111
+ return;
112
+ }
104
113
  const doc = await ui.openDocument(uri);
105
114
  attach(doc.path, doc.text);
106
115
  return;
@@ -115,7 +124,7 @@ export function createCommands(deps) {
115
124
  focus: () => ui.focusView(),
116
125
  async attachUris(uris) {
117
126
  const skipped = [];
118
- for (const raw of uris) {
127
+ for (const raw of new Set(uris)) {
119
128
  try {
120
129
  const doc = await ui.openDocument(ui.parseUri(raw));
121
130
  attach(doc.path, doc.text);
@@ -131,9 +140,8 @@ export function createCommands(deps) {
131
140
  pasted(text) {
132
141
  const editor = ui.activeEditor();
133
142
  const selection = editor?.selection;
134
- if (!editor || !selection)
143
+ if (!editor || !selection || selection.text.trim() === "")
135
144
  return false;
136
- const normalise = (s) => s.replace(/\r\n/g, "\n");
137
145
  if (normalise(text) !== normalise(selection.text))
138
146
  return false;
139
147
  attachEditor(editor, true);
@@ -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,17 +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),
33
- takeBack: () => controller?.takeBack(),
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
+ },
34
46
  removeQueued: (i) => controller?.removeQueued(i),
35
47
  command: (name) => void handlers[name](),
36
48
  attachUris: (uris) => void handlers.attachUris(uris),
37
49
  pasted: (id, text) => bridge.pushPasteResult(id, handlers.pasted(text)),
38
50
  });
51
+ let warnedTimeout = false;
39
52
  function settings() {
40
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
+ }
41
60
  return {
42
61
  headless: cfg.get("headless", opts.headless ?? true),
43
- timeoutMs: cfg.get("timeoutSec", (opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000) * 1000,
62
+ timeoutMs,
44
63
  };
45
64
  }
46
65
  function progress(message) {
@@ -57,6 +76,7 @@ export function createExtension(opts) {
57
76
  }),
58
77
  hints: {
59
78
  BLOCKED: `Set the "${opts.id}.headless" setting to false and try again.`,
79
+ BROWSER_UNAVAILABLE: `Run "${opts.displayName}: Install Browser" and send again.`,
60
80
  },
61
81
  onChange: (state) => {
62
82
  bridge.pushState(state);
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,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;
@@ -24,7 +24,9 @@ export interface State {
24
24
  /** `ChatBridgeError.code` of the error that made the status `dead`. */
25
25
  lastError?: string;
26
26
  }
27
- export type WebviewCommand = "login" | "logout" | "newChat" | "installBrowser" | "reopen";
27
+ export type WebviewCommand = "login" | "logout" | "newChat" | "installBrowser" | "reopen"
28
+ /** Webview only: the host answers with a `help` history entry. */
29
+ | "help";
28
30
  /** webview → host */
29
31
  export type ToHost = {
30
32
  type: "ready";
@@ -82,4 +84,9 @@ export type ToWebview = ({
82
84
  type: "pasteResult";
83
85
  id: number;
84
86
  attached: boolean;
87
+ }
88
+ /** Answer to `takeBack`: the entries removed from the queue. */
89
+ | {
90
+ type: "tookBack";
91
+ entries: QueueEntry[];
85
92
  };
@@ -26,6 +26,16 @@ export type AddResult = {
26
26
  ok: false;
27
27
  reason: string;
28
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
+ }
29
39
  export interface SessionControllerOptions {
30
40
  /** Opens a ChatSession; called lazily on the first send after `closed`. */
31
41
  openSession: () => Promise<ChatSessionLike>;
@@ -34,9 +44,9 @@ export interface SessionControllerOptions {
34
44
  /** Called with the full state after every change. */
35
45
  onChange?: (state: State) => void;
36
46
  /** Extra line appended to the history entry of a failed turn, keyed by
37
- * `ChatBridgeError.code`. Core's messages are CLI-flavoured (`Try
38
- * --headful.`); the extension adds the VSCode-side remedy. */
39
- 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>>;
40
50
  }
41
51
  export declare const CLOSE_TIMEOUT_MS = 5000;
42
52
  /** Owns the history, the pending attachments and the ChatSession. No
@@ -56,10 +66,15 @@ export declare class SessionController {
56
66
  private generation;
57
67
  /** The reopen in flight, so a second Ctrl+R joins it instead of racing. */
58
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;
59
72
  private readonly closeTimeoutMs;
60
73
  constructor(opts: SessionControllerOptions);
61
74
  getState(): State;
62
75
  private setStatus;
76
+ /** A `/help` listing, as a history entry. */
77
+ pushHelp(text: string): void;
63
78
  private push;
64
79
  private emit;
65
80
  addAttachment(a: PendingAttachment): AddResult;
@@ -81,14 +96,19 @@ export declare class SessionController {
81
96
  private drain;
82
97
  /** Empties the queue back into the composer: the entries are returned
83
98
  * and their attachments become pending again. */
84
- takeBack(): QueueEntry[];
99
+ takeBack(): TakeBackResult;
85
100
  removeQueued(index: number): void;
86
101
  /** Re-runs the last prompt after a recoverable fatal error (a missing
87
102
  * browser that was just installed). Drops the trailing error entry so
88
103
  * the history reads user → assistant. */
89
104
  retryLast(): Promise<SendResult>;
90
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;
91
109
  private fail;
110
+ /** Tracks an openSession so close()/dropSession can wait for it. */
111
+ private trackOpen;
92
112
  private dropSession;
93
113
  /** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
94
114
  * False when a turn is in flight, so the caller can warn. */
@@ -24,6 +24,9 @@ export class SessionController {
24
24
  generation = 0;
25
25
  /** The reopen in flight, so a second Ctrl+R joins it instead of racing. */
26
26
  reopening;
27
+ /** The openSession in flight (first send or reopen), so close() can wait
28
+ * for it instead of orphaning the browser it is about to produce. */
29
+ opening;
27
30
  closeTimeoutMs;
28
31
  constructor(opts) {
29
32
  this.opts = opts;
@@ -53,6 +56,10 @@ export class SessionController {
53
56
  this.status = status;
54
57
  this.emit();
55
58
  }
59
+ /** A `/help` listing, as a history entry. */
60
+ pushHelp(text) {
61
+ this.push({ role: "help", text });
62
+ }
56
63
  push(message) {
57
64
  this.messages.push(message);
58
65
  this.emit();
@@ -101,9 +108,10 @@ export class SessionController {
101
108
  return EMPTY;
102
109
  const attachments = this.pending;
103
110
  this.pending = [];
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.
111
+ // A non-empty queue means the controller was not ready when the last
112
+ // entry arrived and has not become ready since every ready transition
113
+ // drains first; `closed` after deactivate can still hold a queue, which
114
+ // the next send queues behind. Either way, keep FIFO by queueing.
107
115
  if (!this.canStartTurn || this.queue.length > 0) {
108
116
  this.queue.push({ text: body, attachments });
109
117
  this.drain();
@@ -144,15 +152,28 @@ export class SessionController {
144
152
  * and their attachments become pending again. */
145
153
  takeBack() {
146
154
  if (this.queue.length === 0)
147
- return [];
155
+ return { entries: [], droppedAttachments: 0 };
148
156
  const entries = this.queue.splice(0);
149
- for (const e of entries)
150
- this.pending.push(...e.attachments);
157
+ let total = this.pending.reduce((n, p) => n + p.bytes, 0);
158
+ let dropped = 0;
159
+ for (const e of entries) {
160
+ for (const a of e.attachments) {
161
+ if (total + a.bytes > MAX_TOTAL_BYTES) {
162
+ dropped++;
163
+ continue;
164
+ }
165
+ total += a.bytes;
166
+ this.pending.push(a);
167
+ }
168
+ }
151
169
  this.emit();
152
- return entries.map((e) => ({
153
- text: e.text,
154
- attachments: e.attachments.map(({ path, bytes }) => ({ path, bytes })),
155
- }));
170
+ return {
171
+ entries: entries.map((e) => ({
172
+ text: e.text,
173
+ attachments: e.attachments.map(({ path, bytes }) => ({ path, bytes })),
174
+ })),
175
+ droppedAttachments: dropped,
176
+ };
156
177
  }
157
178
  removeQueued(index) {
158
179
  if (index < 0 || index >= this.queue.length)
@@ -190,8 +211,9 @@ export class SessionController {
190
211
  try {
191
212
  if (this.session === undefined) {
192
213
  this.setStatus("opening");
193
- const session = await this.opts.openSession();
214
+ const session = await this.trackOpen(this.opts.openSession());
194
215
  // A reopen ran while we were opening: this browser is an orphan.
216
+ // dropSession may have closed it already; close/kill are idempotent.
195
217
  if (generation !== this.generation) {
196
218
  await closeOrKill(session, this.closeTimeoutMs);
197
219
  return { ok: true };
@@ -215,7 +237,9 @@ export class SessionController {
215
237
  return this.fail(err);
216
238
  }
217
239
  }
218
- async fail(err) {
240
+ /** Pushes the error entry (message plus the extension's remedy, if any)
241
+ * and reports the code back to the caller. */
242
+ pushError(err) {
219
243
  const code = err instanceof ChatBridgeError ? err.code : "UNKNOWN";
220
244
  const message = err instanceof Error ? err.message : String(err);
221
245
  const hint = this.opts.hints?.[code];
@@ -223,6 +247,10 @@ export class SessionController {
223
247
  role: "error",
224
248
  text: hint === undefined ? message : `${message}\n${hint}`,
225
249
  });
250
+ return { code, message };
251
+ }
252
+ async fail(err) {
253
+ const { code, message } = this.pushError(err);
226
254
  // Show the error before `dropSession` (up to `closeTimeoutMs`) runs.
227
255
  this.emit();
228
256
  if (code === "RESPONSE_TIMEOUT") {
@@ -237,11 +265,31 @@ export class SessionController {
237
265
  }
238
266
  return { ok: false, code, message };
239
267
  }
268
+ /** Tracks an openSession so close()/dropSession can wait for it. */
269
+ async trackOpen(p) {
270
+ this.opening = p;
271
+ try {
272
+ return await p;
273
+ }
274
+ finally {
275
+ if (this.opening === p)
276
+ this.opening = undefined;
277
+ }
278
+ }
240
279
  async dropSession() {
280
+ // An open still in flight would assign `this.session` after we return,
281
+ // so wait for it and close the browser it produced here: the caller
282
+ // (close(), discard()) must not return with one still running.
283
+ const opened = await this.opening?.catch(() => undefined);
241
284
  const old = this.session;
242
285
  this.session = undefined;
243
286
  if (old !== undefined)
244
287
  await closeOrKill(old, this.closeTimeoutMs);
288
+ // The opener's own generation check will close this one too; close()
289
+ // and kill() are idempotent, so closing it twice is harmless.
290
+ else if (opened !== undefined) {
291
+ await closeOrKill(opened, this.closeTimeoutMs);
292
+ }
245
293
  }
246
294
  /** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
247
295
  * False when a turn is in flight, so the caller can warn. */
@@ -265,9 +313,10 @@ export class SessionController {
265
313
  this.setStatus("reopening");
266
314
  await this.dropSession();
267
315
  try {
268
- const session = await this.opts.openSession();
316
+ const session = await this.trackOpen(this.opts.openSession());
269
317
  // `close()` (deactivate) ran while the browser was opening: this one
270
- // is an orphan nobody would ever close, and the controller is closed.
318
+ // is an orphan, and the controller is closed. dropSession may have
319
+ // closed it already; close/kill are idempotent.
271
320
  if (generation !== this.generation) {
272
321
  await closeOrKill(session, this.closeTimeoutMs);
273
322
  return;
@@ -280,13 +329,7 @@ export class SessionController {
280
329
  this.emit();
281
330
  }
282
331
  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
- });
332
+ const { code } = this.pushError(err);
290
333
  this.lastError = code;
291
334
  this.setStatus("dead");
292
335
  }
@@ -0,0 +1,7 @@
1
+ /** Mirrors the CLI's parseTimeoutMs: finite and > 0, else the default.
2
+ * `invalid` is true when a value was set but unusable, so the caller can
3
+ * warn once. */
4
+ export declare function parseTimeoutSec(raw: unknown, fallbackMs: number): {
5
+ timeoutMs: number;
6
+ invalid: boolean;
7
+ };
@@ -0,0 +1,12 @@
1
+ /** Mirrors the CLI's parseTimeoutMs: finite and > 0, else the default.
2
+ * `invalid` is true when a value was set but unusable, so the caller can
3
+ * warn once. */
4
+ export function parseTimeoutSec(raw, fallbackMs) {
5
+ if (raw === undefined)
6
+ return { timeoutMs: fallbackMs, invalid: false };
7
+ const seconds = typeof raw === "number" ? raw : Number(raw);
8
+ if (!Number.isFinite(seconds) || seconds <= 0) {
9
+ return { timeoutMs: fallbackMs, invalid: true };
10
+ }
11
+ return { timeoutMs: seconds * 1000, invalid: false };
12
+ }
@@ -20,6 +20,9 @@ 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
+ /** True when the value is a real vscode.Uri: a command argument from an
24
+ * unexpected caller is not. */
25
+ isUri(value: unknown): boolean;
23
26
  /** Parses a URI string; throws when it is not a valid URI. */
24
27
  parseUri(uri: string): unknown;
25
28
  /** Opens the document behind an explorer Uri (or any Uri) read-only. */
package/dist/vscode-ui.js CHANGED
@@ -29,6 +29,7 @@ export function createVscodeUi(api, id) {
29
29
  }
30
30
  return snap;
31
31
  },
32
+ isUri: (v) => v instanceof api.Uri,
32
33
  parseUri: (uri) => api.Uri.parse(uri, true),
33
34
  openDocument: async (uri) => {
34
35
  const doc = await api.workspace.openTextDocument(uri);
@@ -11,19 +11,14 @@
11
11
  var NAMES = new Set(SLASH_COMMANDS.map((c) => c.name));
12
12
  var PATTERN = /^\/([a-z]+)$/;
13
13
  function parseSlashCommand(text) {
14
- const m = PATTERN.exec(text.trim());
15
- if (!m || m[1] === void 0)
14
+ const word = PATTERN.exec(text.trim())?.[1];
15
+ if (word === void 0)
16
16
  return void 0;
17
- const word = m[1];
18
17
  return NAMES.has(word) ? { command: word } : { unknown: word };
19
18
  }
20
19
  function unknownCommandMessage(word) {
21
20
  return `Unknown command: /${word}. Type /help.`;
22
21
  }
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
22
 
28
23
  // src/webview/main.ts
29
24
  var vscode = acquireVsCodeApi();
@@ -39,8 +34,15 @@
39
34
  var footer = document.getElementById("footer");
40
35
  var queue = document.getElementById("queue");
41
36
  var inlineError = document.getElementById("inline-error");
37
+ function fitComposer() {
38
+ const atBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 2;
39
+ input.style.height = "auto";
40
+ input.style.height = `${input.scrollHeight + input.offsetHeight - input.clientHeight}px`;
41
+ if (atBottom) history.scrollTop = history.scrollHeight;
42
+ }
42
43
  var config = {};
43
44
  var lastState;
45
+ var lastProgress;
44
46
  function applyConfig(c) {
45
47
  config = c;
46
48
  if (c.sendButton?.background) {
@@ -71,8 +73,8 @@
71
73
  if (text !== void 0) e.textContent = text;
72
74
  return e;
73
75
  }
74
- function button(label, onClick) {
75
- const b = el("button", "action", label);
76
+ function button(label, onClick, className = "action") {
77
+ const b = el("button", className, label);
76
78
  b.setAttribute("type", "button");
77
79
  b.addEventListener("click", onClick);
78
80
  return b;
@@ -88,6 +90,10 @@
88
90
  box.textContent = `\u2014 ${m.text} \u2014`;
89
91
  return box;
90
92
  }
93
+ if (m.role === "help") {
94
+ box.textContent = m.text;
95
+ return box;
96
+ }
91
97
  box.appendChild(el("div", "text", m.text));
92
98
  for (const a of m.attachments ?? []) {
93
99
  box.appendChild(
@@ -108,7 +114,11 @@
108
114
  status.appendChild(el("span", "spinner"));
109
115
  const queued = s.queue.length > 0 ? ` \xB7 ${s.queue.length} queued` : "";
110
116
  status.appendChild(
111
- el("span", "progress-text", `${waitingText(s.status)}${queued}`)
117
+ el(
118
+ "span",
119
+ "progress-text",
120
+ `${lastProgress ?? waitingText(s.status)}${queued}`
121
+ )
112
122
  );
113
123
  return;
114
124
  }
@@ -155,9 +165,9 @@
155
165
  li.appendChild(el("span", "queue-text", `\u25B9 ${label}`));
156
166
  const x = button(
157
167
  "\xD7",
158
- () => vscode.postMessage({ type: "removeQueued", index })
168
+ () => vscode.postMessage({ type: "removeQueued", index }),
169
+ "chip-remove"
159
170
  );
160
- x.className = "chip-remove";
161
171
  li.appendChild(x);
162
172
  queue.appendChild(li);
163
173
  });
@@ -168,14 +178,16 @@
168
178
  const chip = el("span", "chip", `\u{1F4CE} ${a.path} (${formatSize(a.bytes)})`);
169
179
  const x = button(
170
180
  "\xD7",
171
- () => vscode.postMessage({ type: "removeAttachment", index })
181
+ () => vscode.postMessage({ type: "removeAttachment", index }),
182
+ "chip-remove"
172
183
  );
173
- x.className = "chip-remove";
174
184
  chip.appendChild(x);
175
185
  attachments.appendChild(chip);
176
186
  });
177
187
  }
178
188
  function render(s) {
189
+ const active = s.status === "busy" || s.status === "opening" || s.status === "reopening";
190
+ if (!active) lastProgress = void 0;
179
191
  history.replaceChildren(...s.messages.map(renderMessage));
180
192
  history.scrollTop = history.scrollHeight;
181
193
  renderStatus(s);
@@ -183,12 +195,13 @@
183
195
  renderAttachments(s);
184
196
  welcome.hidden = s.messages.length > 0 || !config.welcome && !config.bannerUri;
185
197
  history.hidden = !welcome.hidden;
186
- const active = s.status === "busy" || s.status === "opening" || s.status === "reopening";
187
198
  input.disabled = false;
188
199
  sendButton.disabled = false;
189
200
  sendButton.textContent = active ? "Queue" : "Send";
190
- if (document.activeElement === null || document.activeElement === document.body) {
191
- input.focus();
201
+ if (!lastState || lastState.status !== s.status) {
202
+ if (document.activeElement === null || document.activeElement === document.body) {
203
+ input.focus();
204
+ }
192
205
  }
193
206
  lastState = s;
194
207
  }
@@ -207,19 +220,14 @@
207
220
  showInlineError(void 0);
208
221
  if (slash) {
209
222
  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
- }
223
+ fitComposer();
217
224
  const name = slash.command === "new" ? "newChat" : slash.command;
218
225
  vscode.postMessage({ type: "command", name });
219
226
  return;
220
227
  }
221
228
  vscode.postMessage({ type: "send", text });
222
229
  input.value = "";
230
+ fitComposer();
223
231
  }
224
232
  form.addEventListener("submit", (e) => {
225
233
  e.preventDefault();
@@ -233,27 +241,40 @@
233
241
  }
234
242
  if (e.key === "ArrowUp" && input.value === "" && (lastState?.queue.length ?? 0) > 0) {
235
243
  e.preventDefault();
236
- const entries = lastState?.queue ?? [];
237
- input.value = entries.map((q) => q.text).join("\n\n");
238
- showInlineError(void 0);
239
244
  vscode.postMessage({ type: "takeBack" });
240
245
  }
241
246
  });
242
- input.addEventListener("input", () => showInlineError(void 0));
247
+ input.addEventListener("input", () => {
248
+ showInlineError(void 0);
249
+ fitComposer();
250
+ });
243
251
  function urisFromDrop(dt) {
244
252
  const list = dt?.getData("text/uri-list") ?? "";
245
253
  return list.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== "" && !l.startsWith("#"));
246
254
  }
247
- document.addEventListener("dragover", (e) => {
248
- e.preventDefault();
255
+ function carriesFiles(dt) {
256
+ return dt?.types.includes("text/uri-list") ?? false;
257
+ }
258
+ var dragDepth = 0;
259
+ document.addEventListener("dragenter", (e) => {
260
+ if (!carriesFiles(e.dataTransfer)) return;
261
+ dragDepth++;
249
262
  document.body.classList.add("drop-target");
250
263
  });
251
- document.addEventListener(
252
- "dragleave",
253
- () => document.body.classList.remove("drop-target")
254
- );
264
+ document.addEventListener("dragover", (e) => {
265
+ if (carriesFiles(e.dataTransfer)) e.preventDefault();
266
+ });
267
+ document.addEventListener("dragleave", (e) => {
268
+ if (!carriesFiles(e.dataTransfer)) return;
269
+ if (--dragDepth <= 0) {
270
+ dragDepth = 0;
271
+ document.body.classList.remove("drop-target");
272
+ }
273
+ });
255
274
  document.addEventListener("drop", (e) => {
275
+ if (!carriesFiles(e.dataTransfer)) return;
256
276
  e.preventDefault();
277
+ dragDepth = 0;
257
278
  document.body.classList.remove("drop-target");
258
279
  const uris = urisFromDrop(e.dataTransfer);
259
280
  if (uris.length > 0) vscode.postMessage({ type: "attachUris", uris });
@@ -268,7 +289,9 @@
268
289
  input.value = value.slice(0, selectionStart) + text + value.slice(selectionEnd);
269
290
  const pos = selectionStart + text.length;
270
291
  input.setSelectionRange(pos, pos);
292
+ input.dispatchEvent(new Event("input", { bubbles: true }));
271
293
  }
294
+ fitComposer();
272
295
  }
273
296
  input.addEventListener("paste", (e) => {
274
297
  const text = e.clipboardData?.getData("text/plain") ?? "";
@@ -277,7 +300,7 @@
277
300
  const id = ++pasteSeq;
278
301
  const timer = setTimeout(() => {
279
302
  pendingPastes.delete(id);
280
- insertAtCaret(text);
303
+ if (!input.disabled) insertAtCaret(text);
281
304
  }, PASTE_TIMEOUT_MS);
282
305
  pendingPastes.set(id, { text, timer });
283
306
  vscode.postMessage({ type: "pasted", id, text });
@@ -290,6 +313,7 @@
290
313
  });
291
314
  window.addEventListener("message", (event) => {
292
315
  const m = event.data;
316
+ if (!m || typeof m !== "object" || typeof m.type !== "string") return;
293
317
  if (m.type === "state") {
294
318
  const { type: _type, ...state } = m;
295
319
  render(state);
@@ -297,6 +321,7 @@
297
321
  const { type: _type, ...rest } = m;
298
322
  applyConfig(rest);
299
323
  } else if (m.type === "progress") {
324
+ lastProgress = m.text;
300
325
  const t = status.querySelector(".progress-text");
301
326
  if (t) t.textContent = m.text;
302
327
  } else if (m.type === "pasteResult") {
@@ -305,7 +330,13 @@
305
330
  clearTimeout(p.timer);
306
331
  pendingPastes.delete(m.id);
307
332
  if (!m.attached) insertAtCaret(p.text);
333
+ } else if (m.type === "tookBack") {
334
+ input.value = m.entries.map((q) => q.text).join("\n\n");
335
+ showInlineError(void 0);
336
+ fitComposer();
337
+ input.focus();
308
338
  }
309
339
  });
340
+ fitComposer();
310
341
  vscode.postMessage({ type: "ready" });
311
342
  })();
@@ -85,6 +85,7 @@ body {
85
85
  }
86
86
  #composer {
87
87
  display: flex;
88
+ align-items: flex-end;
88
89
  gap: 6px;
89
90
  padding: 8px;
90
91
  border-top: 1px solid var(--vscode-panel-border);
@@ -92,6 +93,13 @@ body {
92
93
  #input {
93
94
  flex: 1;
94
95
  resize: none;
96
+ /* `height = scrollHeight` and the row-based max-height are only exact
97
+ when padding and border are inside the box. */
98
+ box-sizing: border-box;
99
+ overflow-y: auto;
100
+ line-height: 1.4;
101
+ /* 8 rows + padding: beyond this the textarea scrolls internally. */
102
+ max-height: calc(1.4em * 8 + 8px);
95
103
  background: var(--vscode-input-background);
96
104
  color: var(--vscode-input-foreground);
97
105
  border: 1px solid var(--vscode-input-border, transparent);
@@ -24,7 +24,7 @@ export function buildHtml(i) {
24
24
  <div id="attachments"></div>
25
25
  <div id="inline-error" hidden></div>
26
26
  <form id="composer">
27
- <textarea id="input" rows="3" placeholder="Message (Enter to send, Shift+Enter for a newline, / for commands)"></textarea>
27
+ <textarea id="input" rows="1" placeholder="Message (Enter to send, Shift+Enter for a newline, / for commands)"></textarea>
28
28
  <button id="send" type="submit">Send</button>
29
29
  </form>
30
30
  <footer id="footer" hidden></footer>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatbridge/vscode",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
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.1"
31
+ "@chatbridge/core": "0.8.3"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/vscode": "1.138.0",