@chatbridge/vscode 0.8.2 → 0.9.0
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 +3 -1
- package/dist/chat-view-bridge.js +15 -3
- package/dist/chat-view-provider.d.ts +3 -1
- package/dist/chat-view-provider.js +4 -2
- package/dist/commands.d.ts +9 -4
- package/dist/commands.js +14 -3
- package/dist/create-extension.js +15 -3
- package/dist/protocol.d.ts +12 -2
- package/dist/session-controller.d.ts +25 -2
- package/dist/session-controller.js +156 -22
- package/dist/webview/main.js +29 -6
- package/package.json +2 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CommandInfo } from "@chatbridge/core";
|
|
1
2
|
import type { QueueEntry, State, ToHost, ToWebview, UiConfig, WebviewCommand } from "./protocol.js";
|
|
2
3
|
/** The slice of vscode.Webview the bridge uses; a fake in tests. */
|
|
3
4
|
export interface WebviewLike {
|
|
@@ -12,6 +13,7 @@ export interface ChatViewHandlers {
|
|
|
12
13
|
takeBack(): void;
|
|
13
14
|
removeQueued(index: number): void;
|
|
14
15
|
command(name: WebviewCommand): void;
|
|
16
|
+
customCommand(name: string, args: string, text: string): void;
|
|
15
17
|
attachUris(uris: string[]): void;
|
|
16
18
|
pasted(id: number, text: string): void;
|
|
17
19
|
}
|
|
@@ -24,7 +26,7 @@ export declare class ChatViewBridge {
|
|
|
24
26
|
private readonly handlers;
|
|
25
27
|
private webview;
|
|
26
28
|
constructor(getState: () => State, handlers: ChatViewHandlers);
|
|
27
|
-
attach(webview: WebviewLike, uiConfig?: UiConfig): {
|
|
29
|
+
attach(webview: WebviewLike, uiConfig?: UiConfig, commands?: CommandInfo[]): {
|
|
28
30
|
dispose(): void;
|
|
29
31
|
};
|
|
30
32
|
pushState(state: State): void;
|
package/dist/chat-view-bridge.js
CHANGED
|
@@ -25,6 +25,10 @@ function isToHost(m) {
|
|
|
25
25
|
return typeof msg.index === "number";
|
|
26
26
|
case "command":
|
|
27
27
|
return typeof msg.name === "string" && COMMANDS.has(msg.name);
|
|
28
|
+
case "customCommand":
|
|
29
|
+
return (typeof msg.name === "string" &&
|
|
30
|
+
typeof msg.args === "string" &&
|
|
31
|
+
typeof msg.text === "string");
|
|
28
32
|
case "attachUris":
|
|
29
33
|
return (Array.isArray(msg.uris) && msg.uris.every((u) => typeof u === "string"));
|
|
30
34
|
case "pasted":
|
|
@@ -43,15 +47,20 @@ export class ChatViewBridge {
|
|
|
43
47
|
this.getState = getState;
|
|
44
48
|
this.handlers = handlers;
|
|
45
49
|
}
|
|
46
|
-
attach(webview, uiConfig) {
|
|
50
|
+
attach(webview, uiConfig, commands) {
|
|
47
51
|
this.webview = webview;
|
|
48
52
|
const sub = webview.onDidReceiveMessage((raw) => {
|
|
49
53
|
if (!isToHost(raw))
|
|
50
54
|
return;
|
|
51
55
|
switch (raw.type) {
|
|
52
56
|
case "ready":
|
|
53
|
-
if (uiConfig)
|
|
54
|
-
void webview.postMessage({
|
|
57
|
+
if (uiConfig || commands) {
|
|
58
|
+
void webview.postMessage({
|
|
59
|
+
type: "config",
|
|
60
|
+
...uiConfig,
|
|
61
|
+
...(commands ? { commands } : {}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
55
64
|
this.pushState(this.getState());
|
|
56
65
|
break;
|
|
57
66
|
case "send":
|
|
@@ -69,6 +78,9 @@ export class ChatViewBridge {
|
|
|
69
78
|
case "command":
|
|
70
79
|
this.handlers.command(raw.name);
|
|
71
80
|
break;
|
|
81
|
+
case "customCommand":
|
|
82
|
+
this.handlers.customCommand(raw.name, raw.args, raw.text);
|
|
83
|
+
break;
|
|
72
84
|
case "attachUris":
|
|
73
85
|
this.handlers.attachUris(raw.uris);
|
|
74
86
|
break;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CommandInfo } from "@chatbridge/core";
|
|
1
2
|
import * as vscode from "vscode";
|
|
2
3
|
import type { ChatViewBridge } from "./chat-view-bridge.js";
|
|
3
4
|
import type { ResolvedUiConfig } from "./ui-config.js";
|
|
@@ -6,6 +7,7 @@ export declare class ChatViewProvider implements vscode.WebviewViewProvider {
|
|
|
6
7
|
private readonly title;
|
|
7
8
|
private readonly bridge;
|
|
8
9
|
private readonly ui?;
|
|
9
|
-
|
|
10
|
+
private readonly commands?;
|
|
11
|
+
constructor(extensionUri: vscode.Uri, title: string, bridge: ChatViewBridge, ui?: ResolvedUiConfig | undefined, commands?: CommandInfo[] | undefined);
|
|
10
12
|
resolveWebviewView(view: vscode.WebviewView): void;
|
|
11
13
|
}
|
|
@@ -6,11 +6,13 @@ export class ChatViewProvider {
|
|
|
6
6
|
title;
|
|
7
7
|
bridge;
|
|
8
8
|
ui;
|
|
9
|
-
|
|
9
|
+
commands;
|
|
10
|
+
constructor(extensionUri, title, bridge, ui, commands) {
|
|
10
11
|
this.extensionUri = extensionUri;
|
|
11
12
|
this.title = title;
|
|
12
13
|
this.bridge = bridge;
|
|
13
14
|
this.ui = ui;
|
|
15
|
+
this.commands = commands;
|
|
14
16
|
}
|
|
15
17
|
resolveWebviewView(view) {
|
|
16
18
|
const root = vscode.Uri.joinPath(this.extensionUri, "dist", "webview");
|
|
@@ -44,7 +46,7 @@ export class ChatViewProvider {
|
|
|
44
46
|
}
|
|
45
47
|
: rest;
|
|
46
48
|
}
|
|
47
|
-
const sub = this.bridge.attach(view.webview, uiConfig);
|
|
49
|
+
const sub = this.bridge.attach(view.webview, uiConfig, this.commands);
|
|
48
50
|
view.onDidDispose(() => sub.dispose());
|
|
49
51
|
}
|
|
50
52
|
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { LoginOptions } from "@chatbridge/core";
|
|
1
|
+
import type { CommandInfo, LoginOptions } from "@chatbridge/core";
|
|
2
2
|
import type { InstallBrowserOptions } from "./install-browser.js";
|
|
3
|
-
import type { SessionController } from "./session-controller.js";
|
|
3
|
+
import type { SendResult, SessionController } from "./session-controller.js";
|
|
4
4
|
import type { VscodeUi } from "./vscode-ui.js";
|
|
5
5
|
export interface CommandDeps {
|
|
6
6
|
displayName: string;
|
|
@@ -12,6 +12,8 @@ export interface CommandDeps {
|
|
|
12
12
|
loginOptions: () => Omit<LoginOptions, "signal" | "onProgress">;
|
|
13
13
|
installOptions: () => Omit<InstallBrowserOptions, "onProgress">;
|
|
14
14
|
clearAuth: () => Promise<void>;
|
|
15
|
+
/** The provider's own commands, listed by `/help`. */
|
|
16
|
+
commands?: readonly CommandInfo[];
|
|
15
17
|
}
|
|
16
18
|
export interface CommandHandlers {
|
|
17
19
|
login(): Promise<void>;
|
|
@@ -21,11 +23,14 @@ export interface CommandHandlers {
|
|
|
21
23
|
installBrowser(): Promise<void>;
|
|
22
24
|
/** From the webview's `/help`: the listing joins the history. */
|
|
23
25
|
help(): void;
|
|
26
|
+
/** From the webview's `/name args`. */
|
|
27
|
+
customCommand(name: string, args: string, text: string): Promise<void>;
|
|
24
28
|
sendSelection(): Promise<void>;
|
|
25
29
|
sendFile(uri: unknown): Promise<void>;
|
|
26
30
|
focus(): void;
|
|
27
|
-
/** From the webview's input box.
|
|
28
|
-
|
|
31
|
+
/** From the webview's input box. The result is returned so the caller can
|
|
32
|
+
* hand the text back to the composer when the turn was refused. */
|
|
33
|
+
send(text: string): Promise<SendResult>;
|
|
29
34
|
/** Files dropped on the webview; unreadable URIs are reported together. */
|
|
30
35
|
attachUris(uris: string[]): Promise<void>;
|
|
31
36
|
/** A paste into the input box; true when it became a selection chip. */
|
package/dist/commands.js
CHANGED
|
@@ -91,7 +91,17 @@ export function createCommands(deps) {
|
|
|
91
91
|
}
|
|
92
92
|
},
|
|
93
93
|
reopen: () => controller.reopen(),
|
|
94
|
-
help: () => controller.pushHelp(helpText()),
|
|
94
|
+
help: () => controller.pushHelp(helpText(deps.commands ?? [])),
|
|
95
|
+
async customCommand(name, args, text) {
|
|
96
|
+
const result = await controller.runCommand(name, args, text);
|
|
97
|
+
if (result.ok || result.code !== "BROWSER_UNAVAILABLE")
|
|
98
|
+
return;
|
|
99
|
+
const choice = await ui.showErrorMessage(result.message, "Install");
|
|
100
|
+
if (choice !== "Install")
|
|
101
|
+
return;
|
|
102
|
+
if (await runInstall())
|
|
103
|
+
await controller.retryLast();
|
|
104
|
+
},
|
|
95
105
|
async installBrowser() {
|
|
96
106
|
if (await runInstall())
|
|
97
107
|
ui.showInformationMessage("Chromium installed.");
|
|
@@ -150,12 +160,13 @@ export function createCommands(deps) {
|
|
|
150
160
|
async send(text) {
|
|
151
161
|
const result = await controller.send(text);
|
|
152
162
|
if (result.ok || result.code !== "BROWSER_UNAVAILABLE")
|
|
153
|
-
return;
|
|
163
|
+
return result;
|
|
154
164
|
const choice = await ui.showErrorMessage(result.message, "Install");
|
|
155
165
|
if (choice !== "Install")
|
|
156
|
-
return;
|
|
166
|
+
return result;
|
|
157
167
|
if (await runInstall())
|
|
158
168
|
await controller.retryLast();
|
|
169
|
+
return result;
|
|
159
170
|
},
|
|
160
171
|
};
|
|
161
172
|
}
|
package/dist/create-extension.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { ChatSession, createAuthStore, runLogin, } from "@chatbridge/core";
|
|
3
|
+
import { ChatSession, commandInfoOf, createAuthStore, resolveUrlHooks, runLogin, } from "@chatbridge/core";
|
|
4
4
|
import * as vscode from "vscode";
|
|
5
5
|
import { ChatViewBridge } from "./chat-view-bridge.js";
|
|
6
6
|
import { ChatViewProvider } from "./chat-view-provider.js";
|
|
@@ -28,8 +28,15 @@ export function createExtension(opts) {
|
|
|
28
28
|
const output = vscode.window.createOutputChannel(opts.displayName);
|
|
29
29
|
const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
|
|
30
30
|
context.subscriptions.push(output, statusBar);
|
|
31
|
+
const commands = commandInfoOf(opts.provider);
|
|
31
32
|
const bridge = new ChatViewBridge(() => controller.getState(), {
|
|
32
|
-
send: (text) => void handlers.send(text)
|
|
33
|
+
send: (text) => void handlers.send(text).then((r) => {
|
|
34
|
+
// The webview empties the composer as it posts `send`; a hook
|
|
35
|
+
// refusal sends nothing, so give the text back to be fixed.
|
|
36
|
+
if (!r.ok && r.code === "URL_HOOK") {
|
|
37
|
+
bridge.pushTookBack([{ text, attachments: [] }]);
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
33
40
|
removeAttachment: (i) => controller?.removeAttachment(i),
|
|
34
41
|
takeBack: () => {
|
|
35
42
|
const r = controller?.takeBack();
|
|
@@ -45,6 +52,7 @@ export function createExtension(opts) {
|
|
|
45
52
|
},
|
|
46
53
|
removeQueued: (i) => controller?.removeQueued(i),
|
|
47
54
|
command: (name) => void handlers[name](),
|
|
55
|
+
customCommand: (name, args, text) => void handlers.customCommand(name, args, text),
|
|
48
56
|
attachUris: (uris) => void handlers.attachUris(uris),
|
|
49
57
|
pasted: (id, text) => bridge.pushPasteResult(id, handlers.pasted(text)),
|
|
50
58
|
});
|
|
@@ -74,6 +82,9 @@ export function createExtension(opts) {
|
|
|
74
82
|
...settings(),
|
|
75
83
|
onProgress: progress,
|
|
76
84
|
}),
|
|
85
|
+
expandUrls: (text) => resolveUrlHooks(text, opts.provider.urlHooks ?? [], {
|
|
86
|
+
timeoutMs: settings().timeoutMs,
|
|
87
|
+
}),
|
|
77
88
|
hints: {
|
|
78
89
|
BLOCKED: `Set the "${opts.id}.headless" setting to false and try again.`,
|
|
79
90
|
BROWSER_UNAVAILABLE: `Run "${opts.displayName}: Install Browser" and send again.`,
|
|
@@ -104,8 +115,9 @@ export function createExtension(opts) {
|
|
|
104
115
|
return { cliPath };
|
|
105
116
|
},
|
|
106
117
|
clearAuth: () => authStore.clear(),
|
|
118
|
+
commands,
|
|
107
119
|
});
|
|
108
|
-
context.subscriptions.push(vscode.window.registerWebviewViewProvider(`${opts.id}.chat`, new ChatViewProvider(context.extensionUri, opts.displayName, bridge, uiConfig)));
|
|
120
|
+
context.subscriptions.push(vscode.window.registerWebviewViewProvider(`${opts.id}.chat`, new ChatViewProvider(context.extensionUri, opts.displayName, bridge, uiConfig, commands)));
|
|
109
121
|
for (const name of COMMAND_NAMES) {
|
|
110
122
|
context.subscriptions.push(vscode.commands.registerCommand(`${opts.id}.${name}`, (arg) => name === "sendFile" ? handlers.sendFile(arg) : handlers[name]()));
|
|
111
123
|
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Attachment } from "@chatbridge/core";
|
|
1
|
+
import type { Attachment, CommandInfo } from "@chatbridge/core";
|
|
2
2
|
export type Role = "user" | "assistant" | "error" | "separator" | "help";
|
|
3
3
|
export interface Message {
|
|
4
4
|
role: Role;
|
|
@@ -45,6 +45,13 @@ export type ToHost = {
|
|
|
45
45
|
type: "command";
|
|
46
46
|
name: WebviewCommand;
|
|
47
47
|
}
|
|
48
|
+
/** A provider `/command`; `text` is the line as typed, for the history. */
|
|
49
|
+
| {
|
|
50
|
+
type: "customCommand";
|
|
51
|
+
name: string;
|
|
52
|
+
args: string;
|
|
53
|
+
text: string;
|
|
54
|
+
}
|
|
48
55
|
/** Files dropped on the webview, as URI strings. */
|
|
49
56
|
| {
|
|
50
57
|
type: "attachUris";
|
|
@@ -78,7 +85,10 @@ export type ToWebview = ({
|
|
|
78
85
|
text: string;
|
|
79
86
|
} | ({
|
|
80
87
|
type: "config";
|
|
81
|
-
} & UiConfig
|
|
88
|
+
} & UiConfig & {
|
|
89
|
+
/** The provider's commands, so the webview can parse `/name args`. */
|
|
90
|
+
commands?: CommandInfo[];
|
|
91
|
+
})
|
|
82
92
|
/** Answer to `pasted`: when attached, the webview drops the pasted text. */
|
|
83
93
|
| {
|
|
84
94
|
type: "pasteResult";
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { type Attachment } from "@chatbridge/core";
|
|
1
|
+
import { type Attachment, type ProviderCommandResult, type ResolvedUrl } from "@chatbridge/core";
|
|
2
2
|
import type { QueueEntry, State } from "./protocol.js";
|
|
3
3
|
/** What the controller needs from a ChatSession; lets tests inject a fake. */
|
|
4
4
|
export interface ChatSessionLike {
|
|
5
5
|
send(prompt: string): Promise<string>;
|
|
6
|
+
runCommand?(name: string, args: string): Promise<ProviderCommandResult>;
|
|
6
7
|
close(): Promise<void>;
|
|
7
8
|
kill(): Promise<void>;
|
|
8
9
|
}
|
|
@@ -47,6 +48,9 @@ export interface SessionControllerOptions {
|
|
|
47
48
|
* `ChatBridgeError.code`. Core's messages carry no UI-specific remedy;
|
|
48
49
|
* the extension adds the VSCode-side one. */
|
|
49
50
|
hints?: Partial<Record<HintCode, string>>;
|
|
51
|
+
/** URL hook expansion for the text of a turn; the extension builds it from
|
|
52
|
+
* the provider's hooks and the timeout setting. Default: none. */
|
|
53
|
+
expandUrls?: (text: string) => Promise<ResolvedUrl[]>;
|
|
50
54
|
}
|
|
51
55
|
export declare const CLOSE_TIMEOUT_MS = 5000;
|
|
52
56
|
/** Owns the history, the pending attachments and the ChatSession. No
|
|
@@ -69,6 +73,10 @@ export declare class SessionController {
|
|
|
69
73
|
/** The openSession in flight (first send or reopen), so close() can wait
|
|
70
74
|
* for it instead of orphaning the browser it is about to produce. */
|
|
71
75
|
private opening;
|
|
76
|
+
/** True while a turn's URL hooks are being resolved: the turn is claimed
|
|
77
|
+
* but no status change shows it yet, so a send arriving meanwhile must
|
|
78
|
+
* queue rather than start a second turn. */
|
|
79
|
+
private expanding;
|
|
72
80
|
private readonly closeTimeoutMs;
|
|
73
81
|
constructor(opts: SessionControllerOptions);
|
|
74
82
|
getState(): State;
|
|
@@ -87,8 +95,23 @@ export declare class SessionController {
|
|
|
87
95
|
* when a turn is already running. Never throws: the outcome is the
|
|
88
96
|
* result and the history. */
|
|
89
97
|
send(text: string): Promise<SendResult>;
|
|
90
|
-
/** Pushes the user entry and runs the turn. Shared by
|
|
98
|
+
/** Pushes the user entry and runs the turn (or the command). Shared by
|
|
99
|
+
* send, runCommand and drain. */
|
|
91
100
|
private startTurn;
|
|
101
|
+
/** A provider `/command` from the webview. Queued like a message when a
|
|
102
|
+
* turn is running; `text` is the line as typed, which is what the
|
|
103
|
+
* history shows. */
|
|
104
|
+
runCommand(name: string, args: string, text: string): Promise<SendResult>;
|
|
105
|
+
/** Runs the command on the session (opening one lazily, like a turn).
|
|
106
|
+
* `show` prints; `send` continues as an ordinary turn with the prompt. */
|
|
107
|
+
private runProviderCommand;
|
|
108
|
+
/** Leaves a ready status synchronously, before the first await, so a
|
|
109
|
+
* send arriving in the same tick queues instead of starting a second
|
|
110
|
+
* turn on the session. */
|
|
111
|
+
private claimTurn;
|
|
112
|
+
/** The open session, opening one when there is none. Undefined when a
|
|
113
|
+
* reopen ran meanwhile (the caller's generation is stale). */
|
|
114
|
+
private ensureSession;
|
|
92
115
|
/** Starts the oldest queued entry, if any, when the controller is ready
|
|
93
116
|
* for a turn. Called at every transition back to a ready state, before
|
|
94
117
|
* the caller emits, so the webview never sees an idle frame with a
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChatBridgeError, MAX_FILE_BYTES, MAX_TOTAL_BYTES, closeOrKill, formatAttachment, formatSize, } from "@chatbridge/core";
|
|
1
|
+
import { ChatBridgeError, MAX_FILE_BYTES, MAX_TOTAL_BYTES, UrlHookError, closeOrKill, formatAttachment, formatSize, } from "@chatbridge/core";
|
|
2
2
|
/** The separator pushed into the history by `reopen()`. */
|
|
3
3
|
export const REOPENED_SEPARATOR = "reopened";
|
|
4
4
|
export const CLOSE_TIMEOUT_MS = 5_000;
|
|
@@ -27,6 +27,10 @@ export class SessionController {
|
|
|
27
27
|
/** The openSession in flight (first send or reopen), so close() can wait
|
|
28
28
|
* for it instead of orphaning the browser it is about to produce. */
|
|
29
29
|
opening;
|
|
30
|
+
/** True while a turn's URL hooks are being resolved: the turn is claimed
|
|
31
|
+
* but no status change shows it yet, so a send arriving meanwhile must
|
|
32
|
+
* queue rather than start a second turn. */
|
|
33
|
+
expanding = false;
|
|
30
34
|
closeTimeoutMs;
|
|
31
35
|
constructor(opts) {
|
|
32
36
|
this.opts = opts;
|
|
@@ -95,6 +99,8 @@ export class SessionController {
|
|
|
95
99
|
* send is the user retrying, and only automatic draining must stop
|
|
96
100
|
* while the controller is dead. */
|
|
97
101
|
get canStartTurn() {
|
|
102
|
+
if (this.expanding)
|
|
103
|
+
return false;
|
|
98
104
|
return (this.status === "idle" ||
|
|
99
105
|
this.status === "closed" ||
|
|
100
106
|
this.status === "dead");
|
|
@@ -120,19 +126,151 @@ export class SessionController {
|
|
|
120
126
|
}
|
|
121
127
|
return this.startTurn({ text: body, attachments });
|
|
122
128
|
}
|
|
123
|
-
/** Pushes the user entry and runs the turn. Shared by
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
/** Pushes the user entry and runs the turn (or the command). Shared by
|
|
130
|
+
* send, runCommand and drain. */
|
|
131
|
+
async startTurn(turn) {
|
|
132
|
+
// URL expansion is a network wait; a reopen or a close during it makes
|
|
133
|
+
// this turn stale, and it must not open a browser of its own.
|
|
134
|
+
const generation = this.generation;
|
|
135
|
+
if (turn.command) {
|
|
136
|
+
this.push({ role: "user", text: turn.text, attachments: [] });
|
|
137
|
+
return this.runProviderCommand(turn.command, generation);
|
|
138
|
+
}
|
|
139
|
+
let attachments = turn.attachments;
|
|
140
|
+
if (this.opts.expandUrls) {
|
|
141
|
+
this.expanding = true;
|
|
142
|
+
try {
|
|
143
|
+
const urls = await this.opts.expandUrls(turn.text);
|
|
144
|
+
if (generation !== this.generation) {
|
|
145
|
+
// Stale: the reopen/close owns the state now. The turn is not
|
|
146
|
+
// sent, but `send` already emptied the composer, so give the
|
|
147
|
+
// attachments back and leave the text in the history.
|
|
148
|
+
this.expanding = false;
|
|
149
|
+
this.pending = [...turn.attachments, ...this.pending];
|
|
150
|
+
this.messages.push({
|
|
151
|
+
role: "error",
|
|
152
|
+
text: `Reopened while resolving URLs; message not sent: ${turn.text}`,
|
|
153
|
+
});
|
|
154
|
+
// Nothing else will run the entries queued behind this one. Only
|
|
155
|
+
// from `idle`: a stale turn from `close()` must not open a browser
|
|
156
|
+
// after deactivate.
|
|
157
|
+
if (this.status === "idle")
|
|
158
|
+
this.drain();
|
|
159
|
+
this.emit();
|
|
160
|
+
return { ok: true };
|
|
161
|
+
}
|
|
162
|
+
attachments = [
|
|
163
|
+
...attachments,
|
|
164
|
+
...urls.map((u) => ({
|
|
165
|
+
path: u.label,
|
|
166
|
+
bytes: u.bytes,
|
|
167
|
+
content: u.content,
|
|
168
|
+
})),
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (!(err instanceof UrlHookError))
|
|
173
|
+
throw err;
|
|
174
|
+
// The user's to fix: report, send nothing, leave the status alone.
|
|
175
|
+
const message = err.message;
|
|
176
|
+
// Cleared here rather than only in `finally`, which runs after the
|
|
177
|
+
// push and the drain below: both must see the turn released.
|
|
178
|
+
this.expanding = false;
|
|
179
|
+
// The composer was emptied by `send`; give the attachments back so
|
|
180
|
+
// the user only has to re-type the text.
|
|
181
|
+
this.pending = [...turn.attachments, ...this.pending];
|
|
182
|
+
this.push({ role: "error", text: message });
|
|
183
|
+
// Nothing else will run the entries that queued behind this one.
|
|
184
|
+
this.drain();
|
|
185
|
+
return { ok: false, code: "URL_HOOK", message };
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
this.expanding = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const sections = attachments.map((a) => formatAttachment(a.path, a.content));
|
|
126
192
|
const prompt = [turn.text, ...sections]
|
|
127
193
|
.filter((s) => s !== "")
|
|
128
194
|
.join("\n\n");
|
|
129
195
|
this.push({
|
|
130
196
|
role: "user",
|
|
131
197
|
text: turn.text,
|
|
132
|
-
attachments:
|
|
198
|
+
attachments: attachments.map(({ path, bytes }) => ({ path, bytes })),
|
|
133
199
|
});
|
|
134
200
|
this.lastPrompt = prompt;
|
|
135
|
-
return this.runTurn(prompt);
|
|
201
|
+
return this.runTurn(prompt, generation);
|
|
202
|
+
}
|
|
203
|
+
/** A provider `/command` from the webview. Queued like a message when a
|
|
204
|
+
* turn is running; `text` is the line as typed, which is what the
|
|
205
|
+
* history shows. */
|
|
206
|
+
async runCommand(name, args, text) {
|
|
207
|
+
const turn = { text, attachments: [], command: { name, args } };
|
|
208
|
+
if (!this.canStartTurn || this.queue.length > 0) {
|
|
209
|
+
this.queue.push(turn);
|
|
210
|
+
this.drain();
|
|
211
|
+
this.emit();
|
|
212
|
+
return { ok: true, queued: true };
|
|
213
|
+
}
|
|
214
|
+
return this.startTurn(turn);
|
|
215
|
+
}
|
|
216
|
+
/** Runs the command on the session (opening one lazily, like a turn).
|
|
217
|
+
* `show` prints; `send` continues as an ordinary turn with the prompt. */
|
|
218
|
+
async runProviderCommand(command, generation) {
|
|
219
|
+
this.lastError = undefined;
|
|
220
|
+
// A `show` sets no prompt; leaving the previous turn's would make a
|
|
221
|
+
// retryLast after a failure resend that message instead. A `send`
|
|
222
|
+
// result sets it again below.
|
|
223
|
+
this.lastPrompt = undefined;
|
|
224
|
+
this.claimTurn();
|
|
225
|
+
try {
|
|
226
|
+
const session = await this.ensureSession(generation);
|
|
227
|
+
if (session === undefined)
|
|
228
|
+
return { ok: true }; // stale
|
|
229
|
+
if (session.runCommand === undefined) {
|
|
230
|
+
throw new Error(`/${command.name} is not available in this session.`);
|
|
231
|
+
}
|
|
232
|
+
if (this.status !== "busy")
|
|
233
|
+
this.setStatus("busy");
|
|
234
|
+
const result = await session.runCommand(command.name, command.args);
|
|
235
|
+
if (generation !== this.generation)
|
|
236
|
+
return { ok: true }; // stale
|
|
237
|
+
if (result.kind === "show") {
|
|
238
|
+
this.messages.push({ role: "help", text: result.text });
|
|
239
|
+
// Claim the next turn before emitting, so no idle frame is shown.
|
|
240
|
+
this.status = "idle";
|
|
241
|
+
this.drain();
|
|
242
|
+
this.emit();
|
|
243
|
+
return { ok: true };
|
|
244
|
+
}
|
|
245
|
+
this.lastPrompt = result.prompt;
|
|
246
|
+
return this.runTurn(result.prompt, generation);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
if (generation !== this.generation)
|
|
250
|
+
return { ok: true }; // stale
|
|
251
|
+
return this.fail(err);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** Leaves a ready status synchronously, before the first await, so a
|
|
255
|
+
* send arriving in the same tick queues instead of starting a second
|
|
256
|
+
* turn on the session. */
|
|
257
|
+
claimTurn() {
|
|
258
|
+
this.setStatus(this.session === undefined ? "opening" : "busy");
|
|
259
|
+
}
|
|
260
|
+
/** The open session, opening one when there is none. Undefined when a
|
|
261
|
+
* reopen ran meanwhile (the caller's generation is stale). */
|
|
262
|
+
async ensureSession(generation) {
|
|
263
|
+
if (this.session !== undefined)
|
|
264
|
+
return this.session;
|
|
265
|
+
const session = await this.trackOpen(this.opts.openSession());
|
|
266
|
+
// A reopen ran while we were opening: this browser is an orphan.
|
|
267
|
+
// dropSession may have closed it already; close/kill are idempotent.
|
|
268
|
+
if (generation !== this.generation) {
|
|
269
|
+
await closeOrKill(session, this.closeTimeoutMs);
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
this.session = session;
|
|
273
|
+
return session;
|
|
136
274
|
}
|
|
137
275
|
/** Starts the oldest queued entry, if any, when the controller is ready
|
|
138
276
|
* for a turn. Called at every transition back to a ready state, before
|
|
@@ -146,6 +284,9 @@ export class SessionController {
|
|
|
146
284
|
const next = this.queue.shift();
|
|
147
285
|
if (next === undefined)
|
|
148
286
|
return;
|
|
287
|
+
// A URL_HOOK failure here pushes its error entry and drops the entry:
|
|
288
|
+
// the queue is not a composer, so there is nowhere to hand the text
|
|
289
|
+
// back to — the user reads the error and re-types.
|
|
149
290
|
void this.startTurn(next);
|
|
150
291
|
}
|
|
151
292
|
/** Empties the queue back into the composer: the entries are returned
|
|
@@ -200,28 +341,21 @@ export class SessionController {
|
|
|
200
341
|
if (this.status === "dead")
|
|
201
342
|
this.status = "closed";
|
|
202
343
|
this.emit();
|
|
203
|
-
return this.runTurn(this.lastPrompt);
|
|
344
|
+
return this.runTurn(this.lastPrompt, this.generation);
|
|
204
345
|
}
|
|
205
|
-
async runTurn(prompt) {
|
|
346
|
+
async runTurn(prompt, generation) {
|
|
206
347
|
// A new turn supersedes whatever killed the previous one: a stale
|
|
207
348
|
// `lastError` would keep the webview's error banner up after a
|
|
208
349
|
// successful send from `dead`.
|
|
209
350
|
this.lastError = undefined;
|
|
210
|
-
|
|
351
|
+
this.claimTurn();
|
|
211
352
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
await closeOrKill(session, this.closeTimeoutMs);
|
|
219
|
-
return { ok: true };
|
|
220
|
-
}
|
|
221
|
-
this.session = session;
|
|
222
|
-
}
|
|
223
|
-
this.setStatus("busy");
|
|
224
|
-
const reply = await this.session.send(prompt);
|
|
353
|
+
const session = await this.ensureSession(generation);
|
|
354
|
+
if (session === undefined)
|
|
355
|
+
return { ok: true }; // stale
|
|
356
|
+
if (this.status !== "busy")
|
|
357
|
+
this.setStatus("busy");
|
|
358
|
+
const reply = await session.send(prompt);
|
|
225
359
|
if (generation !== this.generation)
|
|
226
360
|
return { ok: true }; // stale
|
|
227
361
|
this.messages.push({ role: "assistant", text: reply });
|
package/dist/webview/main.js
CHANGED
|
@@ -9,12 +9,20 @@
|
|
|
9
9
|
{ name: "help", description: "List these commands" }
|
|
10
10
|
];
|
|
11
11
|
var NAMES = new Set(SLASH_COMMANDS.map((c) => c.name));
|
|
12
|
-
var
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
var EMPTY = /* @__PURE__ */ new Set();
|
|
13
|
+
var PATTERN = /^\/([a-z]+)(?:\s+([\s\S]*))?$/;
|
|
14
|
+
function parseSlashCommand(text, custom = EMPTY) {
|
|
15
|
+
const m = PATTERN.exec(text.trim());
|
|
16
|
+
if (m === null)
|
|
16
17
|
return void 0;
|
|
17
|
-
|
|
18
|
+
const word = m[1];
|
|
19
|
+
const args = (m[2] ?? "").trim();
|
|
20
|
+
if (NAMES.has(word)) {
|
|
21
|
+
return args === "" ? { command: word } : { error: `/${word} takes no arguments.` };
|
|
22
|
+
}
|
|
23
|
+
if (custom.has(word))
|
|
24
|
+
return { custom: word, args };
|
|
25
|
+
return { unknown: word };
|
|
18
26
|
}
|
|
19
27
|
function unknownCommandMessage(word) {
|
|
20
28
|
return `Unknown command: /${word}. Type /help.`;
|
|
@@ -41,10 +49,12 @@
|
|
|
41
49
|
if (atBottom) history.scrollTop = history.scrollHeight;
|
|
42
50
|
}
|
|
43
51
|
var config = {};
|
|
52
|
+
var commandNames = /* @__PURE__ */ new Set();
|
|
44
53
|
var lastState;
|
|
45
54
|
var lastProgress;
|
|
46
55
|
function applyConfig(c) {
|
|
47
56
|
config = c;
|
|
57
|
+
commandNames = new Set((c.commands ?? []).map((x) => x.name));
|
|
48
58
|
if (c.sendButton?.background) {
|
|
49
59
|
sendButton.style.setProperty("--cb-send-bg", c.sendButton.background);
|
|
50
60
|
}
|
|
@@ -212,15 +222,28 @@
|
|
|
212
222
|
function submit() {
|
|
213
223
|
const text = input.value;
|
|
214
224
|
if (text.trim() === "" && attachments.childElementCount === 0) return;
|
|
215
|
-
const slash = parseSlashCommand(text);
|
|
225
|
+
const slash = parseSlashCommand(text, commandNames);
|
|
216
226
|
if (slash && "unknown" in slash) {
|
|
217
227
|
showInlineError(unknownCommandMessage(slash.unknown));
|
|
218
228
|
return;
|
|
219
229
|
}
|
|
230
|
+
if (slash && "error" in slash) {
|
|
231
|
+
showInlineError(slash.error);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
220
234
|
showInlineError(void 0);
|
|
221
235
|
if (slash) {
|
|
222
236
|
input.value = "";
|
|
223
237
|
fitComposer();
|
|
238
|
+
if ("custom" in slash) {
|
|
239
|
+
vscode.postMessage({
|
|
240
|
+
type: "customCommand",
|
|
241
|
+
name: slash.custom,
|
|
242
|
+
args: slash.args,
|
|
243
|
+
text: text.trim()
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
224
247
|
const name = slash.command === "new" ? "newChat" : slash.command;
|
|
225
248
|
vscode.postMessage({ type: "command", name });
|
|
226
249
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatbridge/vscode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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.
|
|
31
|
+
"@chatbridge/core": "0.9.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/vscode": "1.138.0",
|