@chatbridge/vscode 0.7.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/LICENSE +21 -0
- package/README.md +104 -0
- package/dist/chat-view-bridge.d.ts +26 -0
- package/dist/chat-view-bridge.js +66 -0
- package/dist/chat-view-provider.d.ts +11 -0
- package/dist/chat-view-provider.js +50 -0
- package/dist/commands.d.ts +27 -0
- package/dist/commands.js +113 -0
- package/dist/create-extension.d.ts +33 -0
- package/dist/create-extension.js +92 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/install-browser.d.ts +33 -0
- package/dist/install-browser.js +39 -0
- package/dist/manifest.d.ts +13 -0
- package/dist/manifest.js +54 -0
- package/dist/protocol.d.ts +54 -0
- package/dist/protocol.js +1 -0
- package/dist/session-controller.d.ts +77 -0
- package/dist/session-controller.js +198 -0
- package/dist/ui-config.d.ts +38 -0
- package/dist/ui-config.js +33 -0
- package/dist/vscode-ui.d.ts +30 -0
- package/dist/vscode-ui.js +38 -0
- package/dist/webview/main.js +167 -0
- package/dist/webview/style.css +139 -0
- package/dist/webview-html.d.ts +9 -0
- package/dist/webview-html.js +32 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 7milch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# @chatbridge/vscode
|
|
2
|
+
|
|
3
|
+
VSCode extension factory for [chatbridge](https://github.com/7milch/chatbridge-cli).
|
|
4
|
+
It turns a chatbridge Provider into a sidebar chat view: a webview talks to a
|
|
5
|
+
`vscode`-free `SessionController` that owns the conversation history, the
|
|
6
|
+
pending attachments and the underlying `ChatSession` lifecycle. A provider
|
|
7
|
+
package wires it up and ships its own extension.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createExtension } from "@chatbridge/vscode";
|
|
13
|
+
import provider from "./provider.js";
|
|
14
|
+
export const { activate, deactivate } = createExtension({
|
|
15
|
+
id: "company-ai", displayName: "Company AI", provider, configDir: "company-ai",
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Options
|
|
20
|
+
|
|
21
|
+
- `id` — prefix for the view, commands and settings the manifest must
|
|
22
|
+
declare (see below). Also used as the config directory name unless
|
|
23
|
+
`configDir` is given.
|
|
24
|
+
- `displayName` — shown in progress notifications and command titles.
|
|
25
|
+
- `provider` — a chatbridge Provider, same shape as `createCli`'s.
|
|
26
|
+
- `configDir` — auth-state directory name; pass the CLI's `configDir` so one
|
|
27
|
+
`auth login` serves both.
|
|
28
|
+
- `playwrightCliPath` — override for the `playwright/cli.js` location the
|
|
29
|
+
Install Browser command spawns; only needed when Playwright is not
|
|
30
|
+
resolvable from the extension's own `node_modules`.
|
|
31
|
+
- `timeoutMs` — per-step timeout default, in milliseconds (default
|
|
32
|
+
`120000`). The user's `<id>.timeoutSec` setting overrides it.
|
|
33
|
+
- `headless` — whether sessions launch a headless browser (default `true`).
|
|
34
|
+
The user's `<id>.headless` setting overrides it.
|
|
35
|
+
- `ui` — optional vendor branding, all fields plain text (no HTML, no
|
|
36
|
+
Markdown):
|
|
37
|
+
- `welcome` — shown centred above the history while it is empty; `\n`
|
|
38
|
+
makes a line break. Hidden once the first message arrives.
|
|
39
|
+
- `banner` — image (png/svg) shown above `welcome`, same lifetime. The
|
|
40
|
+
path is relative to the extension root, must not escape it, and must
|
|
41
|
+
exist at activation — a missing file throws — so ship it in the
|
|
42
|
+
`.vsix` (keep it out of `.vscodeignore`).
|
|
43
|
+
- `footer` — one line under the composer, always visible.
|
|
44
|
+
- `sendButton` — `{ background, foreground }` CSS colour strings for the
|
|
45
|
+
Send button; each defaults to the VSCode button theme colour.
|
|
46
|
+
- `userMessage` — `{ borderColor }`, the CSS colour of the border drawn
|
|
47
|
+
around the user's own messages; defaults to the theme's focus border.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
createExtension({
|
|
51
|
+
id: "company-ai", displayName: "Company AI", provider,
|
|
52
|
+
ui: {
|
|
53
|
+
welcome: "Ask Company AI anything.",
|
|
54
|
+
banner: "media/banner.svg",
|
|
55
|
+
footer: "Conversations are not stored by this extension.",
|
|
56
|
+
sendButton: { background: "#2f6f4f", foreground: "#ffffff" },
|
|
57
|
+
userMessage: { borderColor: "#2f6f4f" },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
- `baseDir` — test-only override for the base directory of the config /
|
|
62
|
+
auth-state store.
|
|
63
|
+
|
|
64
|
+
## Manifest
|
|
65
|
+
|
|
66
|
+
The vendor's `package.json` must contribute, with `<id>` replaced by the
|
|
67
|
+
`id` passed to `createExtension`:
|
|
68
|
+
|
|
69
|
+
- View `<id>.chat` (a webview, typically under its own `viewsContainers`
|
|
70
|
+
entry)
|
|
71
|
+
- Commands `<id>.login`, `<id>.logout`, `<id>.newChat`,
|
|
72
|
+
`<id>.installBrowser`, `<id>.sendSelection`, `<id>.sendFile`, `<id>.focus`
|
|
73
|
+
- Settings `<id>.headless` (boolean) and `<id>.timeoutSec` (number)
|
|
74
|
+
|
|
75
|
+
Activation checks exactly three things and throws a message listing every
|
|
76
|
+
missing ID:
|
|
77
|
+
|
|
78
|
+
- `contributes.viewsContainers.activitybar[]` contains an entry with
|
|
79
|
+
`id === <id>`
|
|
80
|
+
- `contributes.views.<id>[]` contains an entry with `id === <id>.chat`
|
|
81
|
+
- `contributes.commands[]` contains all seven `<id>.*` commands above
|
|
82
|
+
|
|
83
|
+
`contributes.configuration` is not validated: a missing `<id>.headless` or
|
|
84
|
+
`<id>.timeoutSec` setting simply falls back to the `createExtension`
|
|
85
|
+
default.
|
|
86
|
+
|
|
87
|
+
## Packaging
|
|
88
|
+
|
|
89
|
+
Bundle with esbuild as CJS, with `vscode` and `playwright` marked external.
|
|
90
|
+
`examples/vscode-dummy-chat` keeps `"type": "module"` in its manifest, so
|
|
91
|
+
its bundle is `dist/extension.cjs`, not `.js` — match that if you copy the
|
|
92
|
+
example's `esbuild.mjs`. Its `package` script runs
|
|
93
|
+
`vsce package --no-dependencies`, which is only a CI packaging smoke test.
|
|
94
|
+
A real vendor `.vsix` must ship `node_modules/playwright` so the Install
|
|
95
|
+
Browser button works for end users: drop `--no-dependencies` when
|
|
96
|
+
packaging for distribution, or vendors get "playwright is not bundled with
|
|
97
|
+
this extension" from the Install button.
|
|
98
|
+
|
|
99
|
+
`examples/vscode-dummy-chat` in the framework repo is the reference
|
|
100
|
+
implementation to copy into a vendor repo.
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { State, ToHost, ToWebview, UiConfig } from "./protocol.js";
|
|
2
|
+
/** The slice of vscode.Webview the bridge uses; a fake in tests. */
|
|
3
|
+
export interface WebviewLike {
|
|
4
|
+
postMessage(message: ToWebview): Thenable<boolean>;
|
|
5
|
+
onDidReceiveMessage(listener: (message: ToHost) => void): {
|
|
6
|
+
dispose(): void;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export interface ChatViewHandlers {
|
|
10
|
+
send(text: string): void;
|
|
11
|
+
removeAttachment(index: number): void;
|
|
12
|
+
command(name: "login" | "newChat" | "installBrowser"): void;
|
|
13
|
+
}
|
|
14
|
+
/** Translates webview messages into handler calls and pushes state to
|
|
15
|
+
* whichever webview is currently attached (VSCode recreates it). */
|
|
16
|
+
export declare class ChatViewBridge {
|
|
17
|
+
private readonly getState;
|
|
18
|
+
private readonly handlers;
|
|
19
|
+
private webview;
|
|
20
|
+
constructor(getState: () => State, handlers: ChatViewHandlers);
|
|
21
|
+
attach(webview: WebviewLike, uiConfig?: UiConfig): {
|
|
22
|
+
dispose(): void;
|
|
23
|
+
};
|
|
24
|
+
pushState(state: State): void;
|
|
25
|
+
pushProgress(text: string): void;
|
|
26
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
function isToHost(m) {
|
|
2
|
+
if (typeof m !== "object" || m === null)
|
|
3
|
+
return false;
|
|
4
|
+
const msg = m;
|
|
5
|
+
switch (msg.type) {
|
|
6
|
+
case "ready":
|
|
7
|
+
return true;
|
|
8
|
+
case "send":
|
|
9
|
+
return typeof msg.text === "string";
|
|
10
|
+
case "removeAttachment":
|
|
11
|
+
return typeof msg.index === "number";
|
|
12
|
+
case "command":
|
|
13
|
+
return (msg.name === "login" ||
|
|
14
|
+
msg.name === "newChat" ||
|
|
15
|
+
msg.name === "installBrowser");
|
|
16
|
+
default:
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Translates webview messages into handler calls and pushes state to
|
|
21
|
+
* whichever webview is currently attached (VSCode recreates it). */
|
|
22
|
+
export class ChatViewBridge {
|
|
23
|
+
getState;
|
|
24
|
+
handlers;
|
|
25
|
+
webview;
|
|
26
|
+
constructor(getState, handlers) {
|
|
27
|
+
this.getState = getState;
|
|
28
|
+
this.handlers = handlers;
|
|
29
|
+
}
|
|
30
|
+
attach(webview, uiConfig) {
|
|
31
|
+
this.webview = webview;
|
|
32
|
+
const sub = webview.onDidReceiveMessage((raw) => {
|
|
33
|
+
if (!isToHost(raw))
|
|
34
|
+
return;
|
|
35
|
+
switch (raw.type) {
|
|
36
|
+
case "ready":
|
|
37
|
+
if (uiConfig)
|
|
38
|
+
void webview.postMessage({ type: "config", ...uiConfig });
|
|
39
|
+
this.pushState(this.getState());
|
|
40
|
+
break;
|
|
41
|
+
case "send":
|
|
42
|
+
this.handlers.send(raw.text);
|
|
43
|
+
break;
|
|
44
|
+
case "removeAttachment":
|
|
45
|
+
this.handlers.removeAttachment(raw.index);
|
|
46
|
+
break;
|
|
47
|
+
case "command":
|
|
48
|
+
this.handlers.command(raw.name);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
dispose: () => {
|
|
54
|
+
sub.dispose();
|
|
55
|
+
if (this.webview === webview)
|
|
56
|
+
this.webview = undefined;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
pushState(state) {
|
|
61
|
+
void this.webview?.postMessage({ type: "state", ...state });
|
|
62
|
+
}
|
|
63
|
+
pushProgress(text) {
|
|
64
|
+
void this.webview?.postMessage({ type: "progress", text });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as vscode from "vscode";
|
|
2
|
+
import type { ChatViewBridge } from "./chat-view-bridge.js";
|
|
3
|
+
import type { ResolvedUiConfig } from "./ui-config.js";
|
|
4
|
+
export declare class ChatViewProvider implements vscode.WebviewViewProvider {
|
|
5
|
+
private readonly extensionUri;
|
|
6
|
+
private readonly title;
|
|
7
|
+
private readonly bridge;
|
|
8
|
+
private readonly ui?;
|
|
9
|
+
constructor(extensionUri: vscode.Uri, title: string, bridge: ChatViewBridge, ui?: ResolvedUiConfig | undefined);
|
|
10
|
+
resolveWebviewView(view: vscode.WebviewView): void;
|
|
11
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import * as vscode from "vscode";
|
|
3
|
+
import { buildHtml } from "./webview-html.js";
|
|
4
|
+
export class ChatViewProvider {
|
|
5
|
+
extensionUri;
|
|
6
|
+
title;
|
|
7
|
+
bridge;
|
|
8
|
+
ui;
|
|
9
|
+
constructor(extensionUri, title, bridge, ui) {
|
|
10
|
+
this.extensionUri = extensionUri;
|
|
11
|
+
this.title = title;
|
|
12
|
+
this.bridge = bridge;
|
|
13
|
+
this.ui = ui;
|
|
14
|
+
}
|
|
15
|
+
resolveWebviewView(view) {
|
|
16
|
+
const root = vscode.Uri.joinPath(this.extensionUri, "dist", "webview");
|
|
17
|
+
const bannerUri = this.ui?.bannerPath
|
|
18
|
+
? vscode.Uri.file(this.ui.bannerPath)
|
|
19
|
+
: undefined;
|
|
20
|
+
view.webview.options = {
|
|
21
|
+
enableScripts: true,
|
|
22
|
+
localResourceRoots: bannerUri
|
|
23
|
+
? [root, vscode.Uri.joinPath(bannerUri, "..")]
|
|
24
|
+
: [root],
|
|
25
|
+
};
|
|
26
|
+
view.webview.html = buildHtml({
|
|
27
|
+
cspSource: view.webview.cspSource,
|
|
28
|
+
nonce: randomBytes(16).toString("base64url"),
|
|
29
|
+
scriptUri: view.webview
|
|
30
|
+
.asWebviewUri(vscode.Uri.joinPath(root, "main.js"))
|
|
31
|
+
.toString(),
|
|
32
|
+
styleUri: view.webview
|
|
33
|
+
.asWebviewUri(vscode.Uri.joinPath(root, "style.css"))
|
|
34
|
+
.toString(),
|
|
35
|
+
title: this.title,
|
|
36
|
+
});
|
|
37
|
+
let uiConfig;
|
|
38
|
+
if (this.ui) {
|
|
39
|
+
const { bannerPath: _bannerPath, ...rest } = this.ui;
|
|
40
|
+
uiConfig = bannerUri
|
|
41
|
+
? {
|
|
42
|
+
...rest,
|
|
43
|
+
bannerUri: view.webview.asWebviewUri(bannerUri).toString(),
|
|
44
|
+
}
|
|
45
|
+
: rest;
|
|
46
|
+
}
|
|
47
|
+
const sub = this.bridge.attach(view.webview, uiConfig);
|
|
48
|
+
view.onDidDispose(() => sub.dispose());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { LoginOptions } from "@chatbridge/core";
|
|
2
|
+
import type { InstallBrowserOptions } from "./install-browser.js";
|
|
3
|
+
import type { SessionController } from "./session-controller.js";
|
|
4
|
+
import type { VscodeUi } from "./vscode-ui.js";
|
|
5
|
+
export interface CommandDeps {
|
|
6
|
+
displayName: string;
|
|
7
|
+
controller: SessionController;
|
|
8
|
+
ui: VscodeUi;
|
|
9
|
+
runLogin: (opts: LoginOptions) => Promise<void>;
|
|
10
|
+
installBrowser: (opts: InstallBrowserOptions) => Promise<void>;
|
|
11
|
+
/** Provider, auth store, etc.; the command adds signal and onProgress. */
|
|
12
|
+
loginOptions: () => Omit<LoginOptions, "signal" | "onProgress">;
|
|
13
|
+
installOptions: () => Omit<InstallBrowserOptions, "onProgress">;
|
|
14
|
+
clearAuth: () => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface CommandHandlers {
|
|
17
|
+
login(): Promise<void>;
|
|
18
|
+
logout(): Promise<void>;
|
|
19
|
+
newChat(): Promise<void>;
|
|
20
|
+
installBrowser(): Promise<void>;
|
|
21
|
+
sendSelection(): Promise<void>;
|
|
22
|
+
sendFile(uri: unknown): Promise<void>;
|
|
23
|
+
focus(): void;
|
|
24
|
+
/** From the webview's input box. */
|
|
25
|
+
send(text: string): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare function createCommands(deps: CommandDeps): CommandHandlers;
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { LoginAbortedError } from "@chatbridge/core";
|
|
2
|
+
function utf8Bytes(text) {
|
|
3
|
+
return new TextEncoder().encode(text).byteLength;
|
|
4
|
+
}
|
|
5
|
+
function message(err) {
|
|
6
|
+
return err instanceof Error ? err.message : String(err);
|
|
7
|
+
}
|
|
8
|
+
export function createCommands(deps) {
|
|
9
|
+
const { controller, ui } = deps;
|
|
10
|
+
function isBusy() {
|
|
11
|
+
const status = controller.getState().status;
|
|
12
|
+
return status === "busy" || status === "opening";
|
|
13
|
+
}
|
|
14
|
+
async function runInstall() {
|
|
15
|
+
try {
|
|
16
|
+
await ui.withProgress("Installing Chromium", false, (progress) => deps.installBrowser({
|
|
17
|
+
...deps.installOptions(),
|
|
18
|
+
onProgress: (l) => progress.report(l),
|
|
19
|
+
}));
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
await ui.showErrorMessage(`Chromium install failed: ${message(err)}`);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function attach(path, text) {
|
|
28
|
+
const result = controller.addAttachment({
|
|
29
|
+
path,
|
|
30
|
+
bytes: utf8Bytes(text),
|
|
31
|
+
content: text,
|
|
32
|
+
});
|
|
33
|
+
if (!result.ok) {
|
|
34
|
+
ui.showWarningMessage(result.reason);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
ui.focusView();
|
|
38
|
+
}
|
|
39
|
+
function attachEditor(editor, useSelection) {
|
|
40
|
+
if (useSelection && editor.selection) {
|
|
41
|
+
const s = editor.selection;
|
|
42
|
+
attach(`${editor.path}:L${s.startLine}-L${s.endLine}`, s.text);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
attach(editor.path, editor.text);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
async login() {
|
|
50
|
+
if (isBusy()) {
|
|
51
|
+
ui.showWarningMessage("Wait for the current reply to finish, then log in.");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
await ui.withProgress(`Log in to ${deps.displayName}`, true, (progress, signal) => deps.runLogin({
|
|
56
|
+
...deps.loginOptions(),
|
|
57
|
+
signal,
|
|
58
|
+
onProgress: (m) => progress.report(m),
|
|
59
|
+
}));
|
|
60
|
+
controller.markLoggedIn();
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err instanceof LoginAbortedError)
|
|
64
|
+
return;
|
|
65
|
+
await ui.showErrorMessage(`Login failed: ${message(err)}`);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async logout() {
|
|
69
|
+
if (!(await controller.discard("Logged out"))) {
|
|
70
|
+
ui.showWarningMessage("Wait for the current reply to finish, then log out.");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await deps.clearAuth();
|
|
74
|
+
},
|
|
75
|
+
newChat: () => controller.newChat(),
|
|
76
|
+
async installBrowser() {
|
|
77
|
+
if (await runInstall())
|
|
78
|
+
ui.showInformationMessage("Chromium installed.");
|
|
79
|
+
},
|
|
80
|
+
async sendSelection() {
|
|
81
|
+
const editor = ui.activeEditor();
|
|
82
|
+
if (!editor) {
|
|
83
|
+
ui.showWarningMessage("No active editor.");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
attachEditor(editor, true);
|
|
87
|
+
},
|
|
88
|
+
async sendFile(uri) {
|
|
89
|
+
if (uri !== undefined && uri !== null) {
|
|
90
|
+
const doc = await ui.openDocument(uri);
|
|
91
|
+
attach(doc.path, doc.text);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const editor = ui.activeEditor();
|
|
95
|
+
if (!editor) {
|
|
96
|
+
ui.showWarningMessage("No active editor.");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
attachEditor(editor, false);
|
|
100
|
+
},
|
|
101
|
+
focus: () => ui.focusView(),
|
|
102
|
+
async send(text) {
|
|
103
|
+
const result = await controller.send(text);
|
|
104
|
+
if (result.ok || result.code !== "BROWSER_UNAVAILABLE")
|
|
105
|
+
return;
|
|
106
|
+
const choice = await ui.showErrorMessage(result.message, "Install");
|
|
107
|
+
if (choice !== "Install")
|
|
108
|
+
return;
|
|
109
|
+
if (await runInstall())
|
|
110
|
+
await controller.retryLast();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Provider } from "@chatbridge/core";
|
|
2
|
+
import * as vscode from "vscode";
|
|
3
|
+
import { SessionController } from "./session-controller.js";
|
|
4
|
+
import { type ExtensionUiOptions } from "./ui-config.js";
|
|
5
|
+
export interface CreateExtensionOptions {
|
|
6
|
+
/** Prefix for every contributed ID, e.g. "company-ai". */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Shown as the view title and in notifications. */
|
|
9
|
+
displayName: string;
|
|
10
|
+
/** Always pinned; no dynamic provider loading. */
|
|
11
|
+
provider: Provider;
|
|
12
|
+
/** Directory name under ~/.config; defaults to `id`. Use the vendor
|
|
13
|
+
* CLI's `configDir` so one `auth login` serves both. */
|
|
14
|
+
configDir?: string;
|
|
15
|
+
/** Defaults; the user's `<id>.timeoutSec` / `<id>.headless` settings win. */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
headless?: boolean;
|
|
18
|
+
/** Test-only: overrides the config/auth-store base directory. */
|
|
19
|
+
baseDir?: string;
|
|
20
|
+
/** Vendor UI customisation: welcome text, banner, footer, Send colours. */
|
|
21
|
+
ui?: ExtensionUiOptions;
|
|
22
|
+
/** Path of `playwright/cli.js`; defaults to
|
|
23
|
+
* `<extension>/node_modules/playwright/cli.js`. */
|
|
24
|
+
playwrightCliPath?: string;
|
|
25
|
+
}
|
|
26
|
+
/** What `activate` returns: the E2E drives the controller directly. */
|
|
27
|
+
export interface ExtensionApi {
|
|
28
|
+
controller: SessionController;
|
|
29
|
+
}
|
|
30
|
+
export declare function createExtension(opts: CreateExtensionOptions): {
|
|
31
|
+
activate: (context: vscode.ExtensionContext) => Promise<ExtensionApi>;
|
|
32
|
+
deactivate: () => Promise<void>;
|
|
33
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ChatSession, createAuthStore, runLogin, } from "@chatbridge/core";
|
|
4
|
+
import * as vscode from "vscode";
|
|
5
|
+
import { ChatViewBridge } from "./chat-view-bridge.js";
|
|
6
|
+
import { ChatViewProvider } from "./chat-view-provider.js";
|
|
7
|
+
import { createCommands } from "./commands.js";
|
|
8
|
+
import { installBrowser } from "./install-browser.js";
|
|
9
|
+
import { COMMAND_NAMES, missingContributions } from "./manifest.js";
|
|
10
|
+
import { SessionController } from "./session-controller.js";
|
|
11
|
+
import { resolveUiConfig } from "./ui-config.js";
|
|
12
|
+
import { createVscodeUi } from "./vscode-ui.js";
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
14
|
+
export function createExtension(opts) {
|
|
15
|
+
let controller;
|
|
16
|
+
async function activate(context) {
|
|
17
|
+
const missing = missingContributions(context.extension.packageJSON, opts.id);
|
|
18
|
+
if (missing.length > 0) {
|
|
19
|
+
throw new Error(`${opts.displayName}: package.json lacks contributes entries for "${opts.id}": ${missing.join(", ")}`);
|
|
20
|
+
}
|
|
21
|
+
const uiConfig = resolveUiConfig(opts.ui, context.extensionPath, existsSync);
|
|
22
|
+
const authStore = createAuthStore({
|
|
23
|
+
configDir: opts.configDir ?? opts.id,
|
|
24
|
+
providerName: opts.provider.name,
|
|
25
|
+
baseDir: opts.baseDir,
|
|
26
|
+
});
|
|
27
|
+
const output = vscode.window.createOutputChannel(opts.displayName);
|
|
28
|
+
const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
|
|
29
|
+
context.subscriptions.push(output, statusBar);
|
|
30
|
+
const bridge = new ChatViewBridge(() => controller.getState(), {
|
|
31
|
+
send: (text) => void handlers.send(text),
|
|
32
|
+
removeAttachment: (i) => controller?.removeAttachment(i),
|
|
33
|
+
command: (name) => void handlers[name](),
|
|
34
|
+
});
|
|
35
|
+
function settings() {
|
|
36
|
+
const cfg = vscode.workspace.getConfiguration(opts.id);
|
|
37
|
+
return {
|
|
38
|
+
headless: cfg.get("headless", opts.headless ?? true),
|
|
39
|
+
timeoutMs: cfg.get("timeoutSec", (opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000) * 1000,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function progress(message) {
|
|
43
|
+
output.appendLine(message);
|
|
44
|
+
statusBar.text = `$(sync~spin) ${message}`;
|
|
45
|
+
bridge.pushProgress(message);
|
|
46
|
+
}
|
|
47
|
+
controller = new SessionController({
|
|
48
|
+
openSession: () => ChatSession.open({
|
|
49
|
+
provider: opts.provider,
|
|
50
|
+
authStore,
|
|
51
|
+
...settings(),
|
|
52
|
+
onProgress: progress,
|
|
53
|
+
}),
|
|
54
|
+
hints: {
|
|
55
|
+
BLOCKED: `Set the "${opts.id}.headless" setting to false and try again.`,
|
|
56
|
+
},
|
|
57
|
+
onChange: (state) => {
|
|
58
|
+
bridge.pushState(state);
|
|
59
|
+
if (state.status === "busy" || state.status === "opening")
|
|
60
|
+
statusBar.show();
|
|
61
|
+
else
|
|
62
|
+
statusBar.hide();
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const cliPath = opts.playwrightCliPath ??
|
|
66
|
+
join(context.extensionPath, "node_modules", "playwright", "cli.js");
|
|
67
|
+
const handlers = createCommands({
|
|
68
|
+
displayName: opts.displayName,
|
|
69
|
+
controller,
|
|
70
|
+
ui: createVscodeUi(vscode, opts.id),
|
|
71
|
+
runLogin,
|
|
72
|
+
installBrowser,
|
|
73
|
+
loginOptions: () => ({ provider: opts.provider, authStore }),
|
|
74
|
+
installOptions: () => {
|
|
75
|
+
if (!existsSync(cliPath)) {
|
|
76
|
+
throw new Error(`playwright is not bundled with this extension (looked for ${cliPath}).`);
|
|
77
|
+
}
|
|
78
|
+
return { cliPath };
|
|
79
|
+
},
|
|
80
|
+
clearAuth: () => authStore.clear(),
|
|
81
|
+
});
|
|
82
|
+
context.subscriptions.push(vscode.window.registerWebviewViewProvider(`${opts.id}.chat`, new ChatViewProvider(context.extensionUri, opts.displayName, bridge, uiConfig)));
|
|
83
|
+
for (const name of COMMAND_NAMES) {
|
|
84
|
+
context.subscriptions.push(vscode.commands.registerCommand(`${opts.id}.${name}`, (arg) => name === "sendFile" ? handlers.sendFile(arg) : handlers[name]()));
|
|
85
|
+
}
|
|
86
|
+
return { controller };
|
|
87
|
+
}
|
|
88
|
+
async function deactivate() {
|
|
89
|
+
await controller?.close();
|
|
90
|
+
}
|
|
91
|
+
return { activate, deactivate };
|
|
92
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./protocol.js";
|
|
2
|
+
export { type AddResult, type ChatSessionLike, type PendingAttachment, type SendResult, SessionController, type SessionControllerOptions, } from "./session-controller.js";
|
|
3
|
+
export { type ChildLike, installBrowser, type InstallBrowserOptions, type SpawnFn, splitProgressLines, } from "./install-browser.js";
|
|
4
|
+
export { COMMAND_NAMES, type CommandName, expectedContributions, missingContributions, } from "./manifest.js";
|
|
5
|
+
export { ChatViewBridge, type ChatViewHandlers, type WebviewLike, } from "./chat-view-bridge.js";
|
|
6
|
+
export { buildHtml, type HtmlInputs } from "./webview-html.js";
|
|
7
|
+
export { type ExtensionUiOptions, type ResolvedUiConfig, resolveUiConfig, } from "./ui-config.js";
|
|
8
|
+
export { createVscodeUi, type EditorSnapshot, type ProgressReporter, type VscodeUi, } from "./vscode-ui.js";
|
|
9
|
+
export { type CommandDeps, type CommandHandlers, createCommands, } from "./commands.js";
|
|
10
|
+
export { createExtension, type CreateExtensionOptions, type ExtensionApi, } from "./create-extension.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./protocol.js";
|
|
2
|
+
export { SessionController, } from "./session-controller.js";
|
|
3
|
+
export { installBrowser, splitProgressLines, } from "./install-browser.js";
|
|
4
|
+
export { COMMAND_NAMES, expectedContributions, missingContributions, } from "./manifest.js";
|
|
5
|
+
export { ChatViewBridge, } from "./chat-view-bridge.js";
|
|
6
|
+
export { buildHtml } from "./webview-html.js";
|
|
7
|
+
export { resolveUiConfig, } from "./ui-config.js";
|
|
8
|
+
export { createVscodeUi, } from "./vscode-ui.js";
|
|
9
|
+
export { createCommands, } from "./commands.js";
|
|
10
|
+
export { createExtension, } from "./create-extension.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ChildLike {
|
|
2
|
+
stdout: {
|
|
3
|
+
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
4
|
+
} | null;
|
|
5
|
+
stderr: {
|
|
6
|
+
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
7
|
+
} | null;
|
|
8
|
+
on(event: "exit", listener: (code: number | null) => void): unknown;
|
|
9
|
+
on(event: "error", listener: (err: Error) => void): unknown;
|
|
10
|
+
}
|
|
11
|
+
export type SpawnFn = (command: string, args: string[], opts: {
|
|
12
|
+
env: Record<string, string | undefined>;
|
|
13
|
+
}) => ChildLike;
|
|
14
|
+
export interface InstallBrowserOptions {
|
|
15
|
+
/** Absolute path of `playwright/cli.js` inside the extension's node_modules. */
|
|
16
|
+
cliPath: string;
|
|
17
|
+
/**
|
|
18
|
+
* The Node binary to run it with; the extension host's process.execPath
|
|
19
|
+
* (Electron) works with ELECTRON_RUN_AS_NODE=1.
|
|
20
|
+
*/
|
|
21
|
+
execPath?: string;
|
|
22
|
+
env?: Record<string, string | undefined>;
|
|
23
|
+
spawn?: SpawnFn;
|
|
24
|
+
/** One call per progress line (download names, progress bars). */
|
|
25
|
+
onProgress?: (line: string) => void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Playwright redraws its progress bar with `\r`; treat every `\r` or `\n`
|
|
29
|
+
* as a line break and drop blanks.
|
|
30
|
+
*/
|
|
31
|
+
export declare function splitProgressLines(chunk: string): string[];
|
|
32
|
+
/** Runs `playwright install chromium` and resolves when it exits 0. */
|
|
33
|
+
export declare function installBrowser(opts: InstallBrowserOptions): Promise<void>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Playwright redraws its progress bar with `\r`; treat every `\r` or `\n`
|
|
4
|
+
* as a line break and drop blanks.
|
|
5
|
+
*/
|
|
6
|
+
export function splitProgressLines(chunk) {
|
|
7
|
+
return chunk
|
|
8
|
+
.split(/\r?\n|\r/)
|
|
9
|
+
.map((l) => l.trim())
|
|
10
|
+
.filter((l) => l !== "");
|
|
11
|
+
}
|
|
12
|
+
/** Runs `playwright install chromium` and resolves when it exits 0. */
|
|
13
|
+
export function installBrowser(opts) {
|
|
14
|
+
const spawn = opts.spawn ?? ((c, a, o) => nodeSpawn(c, a, o));
|
|
15
|
+
const child = spawn(opts.execPath ?? process.execPath, [opts.cliPath, "install", "chromium"], {
|
|
16
|
+
env: { ...(opts.env ?? process.env), ELECTRON_RUN_AS_NODE: "1" },
|
|
17
|
+
});
|
|
18
|
+
let lastStderr = "";
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
child.stdout?.on("data", (chunk) => {
|
|
21
|
+
for (const line of splitProgressLines(String(chunk)))
|
|
22
|
+
opts.onProgress?.(line);
|
|
23
|
+
});
|
|
24
|
+
child.stderr?.on("data", (chunk) => {
|
|
25
|
+
const lines = splitProgressLines(String(chunk));
|
|
26
|
+
if (lines.length > 0)
|
|
27
|
+
lastStderr = lines[lines.length - 1];
|
|
28
|
+
});
|
|
29
|
+
child.on("error", reject);
|
|
30
|
+
child.on("exit", (code) => {
|
|
31
|
+
if (code === 0) {
|
|
32
|
+
resolve();
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
reject(new Error(`playwright install chromium exited with ${code ?? "signal"}${lastStderr ? `: ${lastStderr}` : ""}`));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const COMMAND_NAMES: readonly ["login", "logout", "newChat", "installBrowser", "sendSelection", "sendFile", "focus"];
|
|
2
|
+
export type CommandName = (typeof COMMAND_NAMES)[number];
|
|
3
|
+
/** The `contributes` IDs a vendor extension must declare for `id`. */
|
|
4
|
+
export declare function expectedContributions(id: string): {
|
|
5
|
+
viewContainers: string[];
|
|
6
|
+
views: string[];
|
|
7
|
+
commands: string[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The contributes entries a vendor manifest must declare for `id`, that
|
|
11
|
+
* this package.json lacks. Empty when the manifest is complete.
|
|
12
|
+
*/
|
|
13
|
+
export declare function missingContributions(packageJSON: unknown, id: string): string[];
|