@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 +34 -2
- package/dist/chat-view-bridge.d.ts +10 -2
- package/dist/chat-view-bridge.js +37 -3
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +51 -3
- package/dist/create-extension.d.ts +3 -0
- package/dist/create-extension.js +29 -3
- package/dist/index.d.ts +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +1 -0
- package/dist/protocol.d.ts +43 -5
- package/dist/session-controller.d.ts +59 -8
- package/dist/session-controller.js +201 -26
- package/dist/timeout-setting.d.ts +7 -0
- package/dist/timeout-setting.js +12 -0
- package/dist/vscode-ui.d.ts +5 -0
- package/dist/vscode-ui.js +2 -0
- package/dist/webview/main.js +185 -10
- package/dist/webview/style.css +43 -0
- package/dist/webview-html.js +3 -1
- package/package.json +2 -2
|
@@ -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,15 @@ 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;
|
|
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;
|
|
19
30
|
closeTimeoutMs;
|
|
20
31
|
constructor(opts) {
|
|
21
32
|
this.opts = opts;
|
|
@@ -32,6 +43,10 @@ export class SessionController {
|
|
|
32
43
|
path,
|
|
33
44
|
bytes,
|
|
34
45
|
})),
|
|
46
|
+
queue: this.queue.map((q) => ({
|
|
47
|
+
text: q.text,
|
|
48
|
+
attachments: q.attachments.map(({ path, bytes }) => ({ path, bytes })),
|
|
49
|
+
})),
|
|
35
50
|
};
|
|
36
51
|
if (this.lastError !== undefined)
|
|
37
52
|
state.lastError = this.lastError;
|
|
@@ -41,6 +56,10 @@ export class SessionController {
|
|
|
41
56
|
this.status = status;
|
|
42
57
|
this.emit();
|
|
43
58
|
}
|
|
59
|
+
/** A `/help` listing, as a history entry. */
|
|
60
|
+
pushHelp(text) {
|
|
61
|
+
this.push({ role: "help", text });
|
|
62
|
+
}
|
|
44
63
|
push(message) {
|
|
45
64
|
this.messages.push(message);
|
|
46
65
|
this.emit();
|
|
@@ -72,37 +91,103 @@ export class SessionController {
|
|
|
72
91
|
this.pending.splice(index, 1);
|
|
73
92
|
this.emit();
|
|
74
93
|
}
|
|
75
|
-
/**
|
|
76
|
-
*
|
|
94
|
+
/** True when a turn can start right now. `dead` counts: an explicit
|
|
95
|
+
* send is the user retrying, and only automatic draining must stop
|
|
96
|
+
* while the controller is dead. */
|
|
97
|
+
get canStartTurn() {
|
|
98
|
+
return (this.status === "idle" ||
|
|
99
|
+
this.status === "closed" ||
|
|
100
|
+
this.status === "dead");
|
|
101
|
+
}
|
|
102
|
+
/** Sends the text plus the pending attachments as one turn, or queues it
|
|
103
|
+
* when a turn is already running. Never throws: the outcome is the
|
|
104
|
+
* result and the history. */
|
|
77
105
|
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
106
|
const body = text.trim() === "" ? "" : text;
|
|
86
107
|
if (body === "" && this.pending.length === 0)
|
|
87
108
|
return EMPTY;
|
|
88
|
-
const
|
|
89
|
-
const prompt = [body, ...sections].filter((s) => s !== "").join("\n\n");
|
|
90
|
-
const attachments = this.pending.map(({ path, bytes }) => ({
|
|
91
|
-
path,
|
|
92
|
-
bytes,
|
|
93
|
-
}));
|
|
109
|
+
const attachments = this.pending;
|
|
94
110
|
this.pending = [];
|
|
95
|
-
|
|
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.
|
|
115
|
+
if (!this.canStartTurn || this.queue.length > 0) {
|
|
116
|
+
this.queue.push({ text: body, attachments });
|
|
117
|
+
this.drain();
|
|
118
|
+
this.emit();
|
|
119
|
+
return { ok: true, queued: true };
|
|
120
|
+
}
|
|
121
|
+
return this.startTurn({ text: body, attachments });
|
|
122
|
+
}
|
|
123
|
+
/** Pushes the user entry and runs the turn. Shared by send and drain. */
|
|
124
|
+
startTurn(turn) {
|
|
125
|
+
const sections = turn.attachments.map((a) => formatAttachment(a.path, a.content));
|
|
126
|
+
const prompt = [turn.text, ...sections]
|
|
127
|
+
.filter((s) => s !== "")
|
|
128
|
+
.join("\n\n");
|
|
129
|
+
this.push({
|
|
130
|
+
role: "user",
|
|
131
|
+
text: turn.text,
|
|
132
|
+
attachments: turn.attachments.map(({ path, bytes }) => ({ path, bytes })),
|
|
133
|
+
});
|
|
96
134
|
this.lastPrompt = prompt;
|
|
97
135
|
return this.runTurn(prompt);
|
|
98
136
|
}
|
|
137
|
+
/** Starts the oldest queued entry, if any, when the controller is ready
|
|
138
|
+
* for a turn. Called at every transition back to a ready state, before
|
|
139
|
+
* the caller emits, so the webview never sees an idle frame with a
|
|
140
|
+
* queue still waiting. */
|
|
141
|
+
drain() {
|
|
142
|
+
// markLoggedIn() can arrive in any status; never start a second turn
|
|
143
|
+
// on top of a running one. The other call sites are already ready.
|
|
144
|
+
if (!this.canStartTurn)
|
|
145
|
+
return;
|
|
146
|
+
const next = this.queue.shift();
|
|
147
|
+
if (next === undefined)
|
|
148
|
+
return;
|
|
149
|
+
void this.startTurn(next);
|
|
150
|
+
}
|
|
151
|
+
/** Empties the queue back into the composer: the entries are returned
|
|
152
|
+
* and their attachments become pending again. */
|
|
153
|
+
takeBack() {
|
|
154
|
+
if (this.queue.length === 0)
|
|
155
|
+
return { entries: [], droppedAttachments: 0 };
|
|
156
|
+
const entries = this.queue.splice(0);
|
|
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
|
+
}
|
|
169
|
+
this.emit();
|
|
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
|
+
};
|
|
177
|
+
}
|
|
178
|
+
removeQueued(index) {
|
|
179
|
+
if (index < 0 || index >= this.queue.length)
|
|
180
|
+
return;
|
|
181
|
+
this.queue.splice(index, 1);
|
|
182
|
+
this.emit();
|
|
183
|
+
}
|
|
99
184
|
/** Re-runs the last prompt after a recoverable fatal error (a missing
|
|
100
185
|
* browser that was just installed). Drops the trailing error entry so
|
|
101
186
|
* the history reads user → assistant. */
|
|
102
187
|
async retryLast() {
|
|
103
188
|
if (this.lastPrompt === undefined)
|
|
104
189
|
return EMPTY;
|
|
105
|
-
if (this.
|
|
190
|
+
if (!this.canStartTurn) {
|
|
106
191
|
return {
|
|
107
192
|
ok: false,
|
|
108
193
|
code: "INVALID_STATE",
|
|
@@ -122,22 +207,39 @@ export class SessionController {
|
|
|
122
207
|
// `lastError` would keep the webview's error banner up after a
|
|
123
208
|
// successful send from `dead`.
|
|
124
209
|
this.lastError = undefined;
|
|
210
|
+
const generation = this.generation;
|
|
125
211
|
try {
|
|
126
212
|
if (this.session === undefined) {
|
|
127
213
|
this.setStatus("opening");
|
|
128
|
-
|
|
214
|
+
const session = await this.trackOpen(this.opts.openSession());
|
|
215
|
+
// A reopen ran while we were opening: this browser is an orphan.
|
|
216
|
+
// dropSession may have closed it already; close/kill are idempotent.
|
|
217
|
+
if (generation !== this.generation) {
|
|
218
|
+
await closeOrKill(session, this.closeTimeoutMs);
|
|
219
|
+
return { ok: true };
|
|
220
|
+
}
|
|
221
|
+
this.session = session;
|
|
129
222
|
}
|
|
130
223
|
this.setStatus("busy");
|
|
131
224
|
const reply = await this.session.send(prompt);
|
|
225
|
+
if (generation !== this.generation)
|
|
226
|
+
return { ok: true }; // stale
|
|
132
227
|
this.messages.push({ role: "assistant", text: reply });
|
|
133
|
-
|
|
228
|
+
// Claim the next turn before emitting, so no idle frame is shown.
|
|
229
|
+
this.status = "idle";
|
|
230
|
+
this.drain();
|
|
231
|
+
this.emit();
|
|
134
232
|
return { ok: true };
|
|
135
233
|
}
|
|
136
234
|
catch (err) {
|
|
235
|
+
if (generation !== this.generation)
|
|
236
|
+
return { ok: true }; // stale
|
|
137
237
|
return this.fail(err);
|
|
138
238
|
}
|
|
139
239
|
}
|
|
140
|
-
|
|
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) {
|
|
141
243
|
const code = err instanceof ChatBridgeError ? err.code : "UNKNOWN";
|
|
142
244
|
const message = err instanceof Error ? err.message : String(err);
|
|
143
245
|
const hint = this.opts.hints?.[code];
|
|
@@ -145,10 +247,16 @@ export class SessionController {
|
|
|
145
247
|
role: "error",
|
|
146
248
|
text: hint === undefined ? message : `${message}\n${hint}`,
|
|
147
249
|
});
|
|
250
|
+
return { code, message };
|
|
251
|
+
}
|
|
252
|
+
async fail(err) {
|
|
253
|
+
const { code, message } = this.pushError(err);
|
|
148
254
|
// Show the error before `dropSession` (up to `closeTimeoutMs`) runs.
|
|
149
255
|
this.emit();
|
|
150
256
|
if (code === "RESPONSE_TIMEOUT") {
|
|
151
|
-
this.
|
|
257
|
+
this.status = "idle";
|
|
258
|
+
this.drain();
|
|
259
|
+
this.emit();
|
|
152
260
|
}
|
|
153
261
|
else {
|
|
154
262
|
this.lastError = code;
|
|
@@ -157,27 +265,89 @@ export class SessionController {
|
|
|
157
265
|
}
|
|
158
266
|
return { ok: false, code, message };
|
|
159
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
|
+
}
|
|
160
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);
|
|
161
284
|
const old = this.session;
|
|
162
285
|
this.session = undefined;
|
|
163
286
|
if (old !== undefined)
|
|
164
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
|
+
}
|
|
165
293
|
}
|
|
166
|
-
/** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
|
|
294
|
+
/** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
|
|
295
|
+
* False when a turn is in flight, so the caller can warn. */
|
|
167
296
|
async newChat() {
|
|
168
|
-
|
|
297
|
+
return this.discard("New chat");
|
|
298
|
+
}
|
|
299
|
+
/** Replaces the browser in every state. The in-flight turn, if any, is
|
|
300
|
+
* abandoned: its result is dropped by the generation check. A second
|
|
301
|
+
* call while one is running joins the first. */
|
|
302
|
+
reopen() {
|
|
303
|
+
if (this.reopening)
|
|
304
|
+
return this.reopening;
|
|
305
|
+
const run = this.runReopen().finally(() => {
|
|
306
|
+
this.reopening = undefined;
|
|
307
|
+
});
|
|
308
|
+
this.reopening = run;
|
|
309
|
+
return run;
|
|
310
|
+
}
|
|
311
|
+
async runReopen() {
|
|
312
|
+
const generation = ++this.generation;
|
|
313
|
+
this.setStatus("reopening");
|
|
314
|
+
await this.dropSession();
|
|
315
|
+
try {
|
|
316
|
+
const session = await this.trackOpen(this.opts.openSession());
|
|
317
|
+
// `close()` (deactivate) ran while the browser was opening: this one
|
|
318
|
+
// is an orphan, and the controller is closed. dropSession may have
|
|
319
|
+
// closed it already; close/kill are idempotent.
|
|
320
|
+
if (generation !== this.generation) {
|
|
321
|
+
await closeOrKill(session, this.closeTimeoutMs);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this.session = session;
|
|
325
|
+
this.lastError = undefined;
|
|
326
|
+
this.messages.push({ role: "separator", text: REOPENED_SEPARATOR });
|
|
327
|
+
this.status = "idle";
|
|
328
|
+
this.drain();
|
|
329
|
+
this.emit();
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
const { code } = this.pushError(err);
|
|
333
|
+
this.lastError = code;
|
|
334
|
+
this.setStatus("dead");
|
|
335
|
+
}
|
|
169
336
|
}
|
|
170
337
|
/** Closes the session (if any) and pushes `separator`. Refused (returns
|
|
171
338
|
* false) while a turn is in flight. Clears a dead state. */
|
|
172
339
|
async discard(separator) {
|
|
173
|
-
if (this.
|
|
340
|
+
if (!this.canStartTurn)
|
|
174
341
|
return false;
|
|
175
342
|
await this.dropSession();
|
|
176
343
|
this.lastError = undefined;
|
|
177
344
|
// A fresh chat must not re-send a prompt from before the break.
|
|
178
345
|
this.lastPrompt = undefined;
|
|
179
346
|
this.status = "closed";
|
|
180
|
-
this.push({ role: "separator", text: separator });
|
|
347
|
+
this.messages.push({ role: "separator", text: separator });
|
|
348
|
+
// The queue outlives the break: drain it into the new chat.
|
|
349
|
+
this.drain();
|
|
350
|
+
this.emit();
|
|
181
351
|
return true;
|
|
182
352
|
}
|
|
183
353
|
/** After a successful login command: a dead controller may try again. */
|
|
@@ -186,10 +356,15 @@ export class SessionController {
|
|
|
186
356
|
this.lastError = undefined;
|
|
187
357
|
this.status = "closed";
|
|
188
358
|
}
|
|
189
|
-
this.push({ role: "separator", text: "Logged in" });
|
|
359
|
+
this.messages.push({ role: "separator", text: "Logged in" });
|
|
360
|
+
this.drain();
|
|
361
|
+
this.emit();
|
|
190
362
|
}
|
|
191
363
|
/** deactivate: close the browser, keep the history. Idempotent. */
|
|
192
364
|
async close() {
|
|
365
|
+
// A reopen in flight is stale from here on: its new browser must be
|
|
366
|
+
// closed rather than adopted by a controller the user has shut down.
|
|
367
|
+
this.generation++;
|
|
193
368
|
await this.dropSession();
|
|
194
369
|
if (this.status !== "dead")
|
|
195
370
|
this.status = "closed";
|
|
@@ -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
|
+
}
|
package/dist/vscode-ui.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ 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;
|
|
26
|
+
/** Parses a URI string; throws when it is not a valid URI. */
|
|
27
|
+
parseUri(uri: string): unknown;
|
|
23
28
|
/** Opens the document behind an explorer Uri (or any Uri) read-only. */
|
|
24
29
|
openDocument(uri: unknown): Promise<{
|
|
25
30
|
path: string;
|
package/dist/vscode-ui.js
CHANGED
|
@@ -29,6 +29,8 @@ export function createVscodeUi(api, id) {
|
|
|
29
29
|
}
|
|
30
30
|
return snap;
|
|
31
31
|
},
|
|
32
|
+
isUri: (v) => v instanceof api.Uri,
|
|
33
|
+
parseUri: (uri) => api.Uri.parse(uri, true),
|
|
32
34
|
openDocument: async (uri) => {
|
|
33
35
|
const doc = await api.workspace.openTextDocument(uri);
|
|
34
36
|
return { path: relPath(doc.uri), text: doc.getText() };
|
package/dist/webview/main.js
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
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 word = PATTERN.exec(text.trim())?.[1];
|
|
15
|
+
if (word === void 0)
|
|
16
|
+
return void 0;
|
|
17
|
+
return NAMES.has(word) ? { command: word } : { unknown: word };
|
|
18
|
+
}
|
|
19
|
+
function unknownCommandMessage(word) {
|
|
20
|
+
return `Unknown command: /${word}. Type /help.`;
|
|
21
|
+
}
|
|
22
|
+
|
|
3
23
|
// src/webview/main.ts
|
|
4
24
|
var vscode = acquireVsCodeApi();
|
|
5
25
|
var history = document.getElementById("history");
|
|
@@ -12,7 +32,17 @@
|
|
|
12
32
|
var welcomeText = document.getElementById("welcome-text");
|
|
13
33
|
var banner = document.getElementById("banner");
|
|
14
34
|
var footer = document.getElementById("footer");
|
|
35
|
+
var queue = document.getElementById("queue");
|
|
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
|
+
}
|
|
15
43
|
var config = {};
|
|
44
|
+
var lastState;
|
|
45
|
+
var lastProgress;
|
|
16
46
|
function applyConfig(c) {
|
|
17
47
|
config = c;
|
|
18
48
|
if (c.sendButton?.background) {
|
|
@@ -43,8 +73,8 @@
|
|
|
43
73
|
if (text !== void 0) e.textContent = text;
|
|
44
74
|
return e;
|
|
45
75
|
}
|
|
46
|
-
function button(label, onClick) {
|
|
47
|
-
const b = el("button",
|
|
76
|
+
function button(label, onClick, className = "action") {
|
|
77
|
+
const b = el("button", className, label);
|
|
48
78
|
b.setAttribute("type", "button");
|
|
49
79
|
b.addEventListener("click", onClick);
|
|
50
80
|
return b;
|
|
@@ -60,6 +90,10 @@
|
|
|
60
90
|
box.textContent = `\u2014 ${m.text} \u2014`;
|
|
61
91
|
return box;
|
|
62
92
|
}
|
|
93
|
+
if (m.role === "help") {
|
|
94
|
+
box.textContent = m.text;
|
|
95
|
+
return box;
|
|
96
|
+
}
|
|
63
97
|
box.appendChild(el("div", "text", m.text));
|
|
64
98
|
for (const a of m.attachments ?? []) {
|
|
65
99
|
box.appendChild(
|
|
@@ -68,16 +102,22 @@
|
|
|
68
102
|
}
|
|
69
103
|
return box;
|
|
70
104
|
}
|
|
105
|
+
function waitingText(status2) {
|
|
106
|
+
if (status2 === "reopening") return "Reopening browser...";
|
|
107
|
+
if (status2 === "opening") return "Opening browser...";
|
|
108
|
+
return "Waiting...";
|
|
109
|
+
}
|
|
71
110
|
function renderStatus(s) {
|
|
72
111
|
status.replaceChildren();
|
|
73
112
|
status.hidden = false;
|
|
74
|
-
if (s.status === "busy" || s.status === "opening") {
|
|
113
|
+
if (s.status === "busy" || s.status === "opening" || s.status === "reopening") {
|
|
75
114
|
status.appendChild(el("span", "spinner"));
|
|
115
|
+
const queued = s.queue.length > 0 ? ` \xB7 ${s.queue.length} queued` : "";
|
|
76
116
|
status.appendChild(
|
|
77
117
|
el(
|
|
78
118
|
"span",
|
|
79
119
|
"progress-text",
|
|
80
|
-
s.status
|
|
120
|
+
`${lastProgress ?? waitingText(s.status)}${queued}`
|
|
81
121
|
)
|
|
82
122
|
);
|
|
83
123
|
return;
|
|
@@ -99,6 +139,12 @@
|
|
|
99
139
|
)
|
|
100
140
|
);
|
|
101
141
|
}
|
|
142
|
+
status.appendChild(
|
|
143
|
+
button(
|
|
144
|
+
"Reopen",
|
|
145
|
+
() => vscode.postMessage({ type: "command", name: "reopen" })
|
|
146
|
+
)
|
|
147
|
+
);
|
|
102
148
|
status.appendChild(
|
|
103
149
|
button(
|
|
104
150
|
"New chat",
|
|
@@ -109,36 +155,79 @@
|
|
|
109
155
|
}
|
|
110
156
|
status.hidden = true;
|
|
111
157
|
}
|
|
158
|
+
function renderQueue(s) {
|
|
159
|
+
queue.replaceChildren();
|
|
160
|
+
queue.hidden = s.queue.length === 0;
|
|
161
|
+
s.queue.forEach((entry, index) => {
|
|
162
|
+
const li = document.createElement("li");
|
|
163
|
+
const firstLine = entry.text.split("\n")[0] ?? "";
|
|
164
|
+
const label = entry.attachments.length > 0 ? `${firstLine} \u{1F4CE} ${entry.attachments.length}` : firstLine;
|
|
165
|
+
li.appendChild(el("span", "queue-text", `\u25B9 ${label}`));
|
|
166
|
+
const x = button(
|
|
167
|
+
"\xD7",
|
|
168
|
+
() => vscode.postMessage({ type: "removeQueued", index }),
|
|
169
|
+
"chip-remove"
|
|
170
|
+
);
|
|
171
|
+
li.appendChild(x);
|
|
172
|
+
queue.appendChild(li);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
112
175
|
function renderAttachments(s) {
|
|
113
176
|
attachments.replaceChildren();
|
|
114
177
|
s.pendingAttachments.forEach((a, index) => {
|
|
115
178
|
const chip = el("span", "chip", `\u{1F4CE} ${a.path} (${formatSize(a.bytes)})`);
|
|
116
179
|
const x = button(
|
|
117
180
|
"\xD7",
|
|
118
|
-
() => vscode.postMessage({ type: "removeAttachment", index })
|
|
181
|
+
() => vscode.postMessage({ type: "removeAttachment", index }),
|
|
182
|
+
"chip-remove"
|
|
119
183
|
);
|
|
120
|
-
x.className = "chip-remove";
|
|
121
184
|
chip.appendChild(x);
|
|
122
185
|
attachments.appendChild(chip);
|
|
123
186
|
});
|
|
124
187
|
}
|
|
125
188
|
function render(s) {
|
|
189
|
+
const active = s.status === "busy" || s.status === "opening" || s.status === "reopening";
|
|
190
|
+
if (!active) lastProgress = void 0;
|
|
126
191
|
history.replaceChildren(...s.messages.map(renderMessage));
|
|
127
192
|
history.scrollTop = history.scrollHeight;
|
|
128
193
|
renderStatus(s);
|
|
194
|
+
renderQueue(s);
|
|
129
195
|
renderAttachments(s);
|
|
130
196
|
welcome.hidden = s.messages.length > 0 || !config.welcome && !config.bannerUri;
|
|
131
197
|
history.hidden = !welcome.hidden;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
sendButton.
|
|
135
|
-
if (!
|
|
198
|
+
input.disabled = false;
|
|
199
|
+
sendButton.disabled = false;
|
|
200
|
+
sendButton.textContent = active ? "Queue" : "Send";
|
|
201
|
+
if (!lastState || lastState.status !== s.status) {
|
|
202
|
+
if (document.activeElement === null || document.activeElement === document.body) {
|
|
203
|
+
input.focus();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
lastState = s;
|
|
207
|
+
}
|
|
208
|
+
function showInlineError(text) {
|
|
209
|
+
inlineError.textContent = text ?? "";
|
|
210
|
+
inlineError.hidden = text === void 0;
|
|
136
211
|
}
|
|
137
212
|
function submit() {
|
|
138
213
|
const text = input.value;
|
|
139
214
|
if (text.trim() === "" && attachments.childElementCount === 0) return;
|
|
215
|
+
const slash = parseSlashCommand(text);
|
|
216
|
+
if (slash && "unknown" in slash) {
|
|
217
|
+
showInlineError(unknownCommandMessage(slash.unknown));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
showInlineError(void 0);
|
|
221
|
+
if (slash) {
|
|
222
|
+
input.value = "";
|
|
223
|
+
fitComposer();
|
|
224
|
+
const name = slash.command === "new" ? "newChat" : slash.command;
|
|
225
|
+
vscode.postMessage({ type: "command", name });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
140
228
|
vscode.postMessage({ type: "send", text });
|
|
141
229
|
input.value = "";
|
|
230
|
+
fitComposer();
|
|
142
231
|
}
|
|
143
232
|
form.addEventListener("submit", (e) => {
|
|
144
233
|
e.preventDefault();
|
|
@@ -148,10 +237,83 @@
|
|
|
148
237
|
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
149
238
|
e.preventDefault();
|
|
150
239
|
submit();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (e.key === "ArrowUp" && input.value === "" && (lastState?.queue.length ?? 0) > 0) {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
vscode.postMessage({ type: "takeBack" });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
input.addEventListener("input", () => {
|
|
248
|
+
showInlineError(void 0);
|
|
249
|
+
fitComposer();
|
|
250
|
+
});
|
|
251
|
+
function urisFromDrop(dt) {
|
|
252
|
+
const list = dt?.getData("text/uri-list") ?? "";
|
|
253
|
+
return list.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== "" && !l.startsWith("#"));
|
|
254
|
+
}
|
|
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++;
|
|
262
|
+
document.body.classList.add("drop-target");
|
|
263
|
+
});
|
|
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
|
+
});
|
|
274
|
+
document.addEventListener("drop", (e) => {
|
|
275
|
+
if (!carriesFiles(e.dataTransfer)) return;
|
|
276
|
+
e.preventDefault();
|
|
277
|
+
dragDepth = 0;
|
|
278
|
+
document.body.classList.remove("drop-target");
|
|
279
|
+
const uris = urisFromDrop(e.dataTransfer);
|
|
280
|
+
if (uris.length > 0) vscode.postMessage({ type: "attachUris", uris });
|
|
281
|
+
});
|
|
282
|
+
var PASTE_TIMEOUT_MS = 500;
|
|
283
|
+
var pasteSeq = 0;
|
|
284
|
+
var pendingPastes = /* @__PURE__ */ new Map();
|
|
285
|
+
function insertAtCaret(text) {
|
|
286
|
+
input.focus();
|
|
287
|
+
if (!document.execCommand("insertText", false, text)) {
|
|
288
|
+
const { selectionStart, selectionEnd, value } = input;
|
|
289
|
+
input.value = value.slice(0, selectionStart) + text + value.slice(selectionEnd);
|
|
290
|
+
const pos = selectionStart + text.length;
|
|
291
|
+
input.setSelectionRange(pos, pos);
|
|
292
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
293
|
+
}
|
|
294
|
+
fitComposer();
|
|
295
|
+
}
|
|
296
|
+
input.addEventListener("paste", (e) => {
|
|
297
|
+
const text = e.clipboardData?.getData("text/plain") ?? "";
|
|
298
|
+
if (!text.includes("\n")) return;
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
const id = ++pasteSeq;
|
|
301
|
+
const timer = setTimeout(() => {
|
|
302
|
+
pendingPastes.delete(id);
|
|
303
|
+
if (!input.disabled) insertAtCaret(text);
|
|
304
|
+
}, PASTE_TIMEOUT_MS);
|
|
305
|
+
pendingPastes.set(id, { text, timer });
|
|
306
|
+
vscode.postMessage({ type: "pasted", id, text });
|
|
307
|
+
});
|
|
308
|
+
document.addEventListener("keydown", (e) => {
|
|
309
|
+
if (e.key.toLowerCase() === "r" && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
vscode.postMessage({ type: "command", name: "reopen" });
|
|
151
312
|
}
|
|
152
313
|
});
|
|
153
314
|
window.addEventListener("message", (event) => {
|
|
154
315
|
const m = event.data;
|
|
316
|
+
if (!m || typeof m !== "object" || typeof m.type !== "string") return;
|
|
155
317
|
if (m.type === "state") {
|
|
156
318
|
const { type: _type, ...state } = m;
|
|
157
319
|
render(state);
|
|
@@ -159,9 +321,22 @@
|
|
|
159
321
|
const { type: _type, ...rest } = m;
|
|
160
322
|
applyConfig(rest);
|
|
161
323
|
} else if (m.type === "progress") {
|
|
324
|
+
lastProgress = m.text;
|
|
162
325
|
const t = status.querySelector(".progress-text");
|
|
163
326
|
if (t) t.textContent = m.text;
|
|
327
|
+
} else if (m.type === "pasteResult") {
|
|
328
|
+
const p = pendingPastes.get(m.id);
|
|
329
|
+
if (!p) return;
|
|
330
|
+
clearTimeout(p.timer);
|
|
331
|
+
pendingPastes.delete(m.id);
|
|
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();
|
|
164
338
|
}
|
|
165
339
|
});
|
|
340
|
+
fitComposer();
|
|
166
341
|
vscode.postMessage({ type: "ready" });
|
|
167
342
|
})();
|