@chatbridge/vscode 0.8.1 → 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/dist/chat-view-bridge.d.ts +4 -1
- package/dist/chat-view-bridge.js +8 -2
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +11 -3
- package/dist/create-extension.js +22 -2
- package/dist/index.d.ts +1 -1
- package/dist/protocol.d.ts +9 -2
- package/dist/session-controller.d.ts +24 -4
- package/dist/session-controller.js +64 -21
- package/dist/timeout-setting.d.ts +7 -0
- package/dist/timeout-setting.js +12 -0
- package/dist/vscode-ui.d.ts +3 -0
- package/dist/vscode-ui.js +1 -0
- package/dist/webview/main.js +66 -35
- package/dist/webview/style.css +8 -0
- package/dist/webview-html.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
}
|
package/dist/chat-view-bridge.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
|
|
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
|
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -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);
|
package/dist/create-extension.js
CHANGED
|
@@ -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: () =>
|
|
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
|
|
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";
|
package/dist/protocol.d.ts
CHANGED
|
@@ -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
|
|
38
|
-
*
|
|
39
|
-
hints?: Partial<Record<
|
|
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():
|
|
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
|
|
105
|
-
//
|
|
106
|
-
//
|
|
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
|
-
|
|
150
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/vscode-ui.d.ts
CHANGED
|
@@ -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
package/dist/webview/main.js
CHANGED
|
@@ -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
|
|
15
|
-
if (
|
|
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",
|
|
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(
|
|
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 (
|
|
191
|
-
|
|
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
|
-
|
|
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", () =>
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
})();
|
package/dist/webview/style.css
CHANGED
|
@@ -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);
|
package/dist/webview-html.js
CHANGED
|
@@ -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="
|
|
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.
|
|
3
|
+
"version": "0.8.2",
|
|
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.
|
|
31
|
+
"@chatbridge/core": "0.8.2"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/vscode": "1.138.0",
|