@chatbridge/vscode 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -7
- package/dist/chat-view-bridge.d.ts +7 -2
- package/dist/chat-view-bridge.js +31 -3
- package/dist/commands.d.ts +5 -0
- package/dist/commands.js +43 -3
- package/dist/create-extension.d.ts +3 -0
- package/dist/create-extension.js +8 -2
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +1 -0
- package/dist/protocol.d.ts +35 -4
- package/dist/session-controller.d.ts +36 -5
- package/dist/session-controller.js +157 -25
- package/dist/vscode-ui.d.ts +2 -0
- package/dist/vscode-ui.js +1 -0
- package/dist/webview/main.js +154 -10
- package/dist/webview/style.css +35 -0
- package/dist/webview-html.js +3 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -61,6 +61,23 @@ export const { activate, deactivate } = createExtension({
|
|
|
61
61
|
- `baseDir` — test-only override for the base directory of the config /
|
|
62
62
|
auth-state store.
|
|
63
63
|
|
|
64
|
+
## Composer
|
|
65
|
+
|
|
66
|
+
- **Enter** sends. While a turn is in flight the message is queued instead
|
|
67
|
+
and drains in order once the turn finishes; Shift+Enter inserts a newline.
|
|
68
|
+
- **Up** on an empty composer takes the last queued message back for
|
|
69
|
+
editing; queued entries can also be removed individually.
|
|
70
|
+
- **`/` commands** — `/login`, `/logout`, `/new`, `/reopen`, `/help`, the
|
|
71
|
+
same table the TUI uses. Only the bare form on its own counts, so
|
|
72
|
+
anything else (including `/usr/bin` style paths) is sent verbatim.
|
|
73
|
+
- **Drop files** onto the composer to add them as attachment chips. Hold
|
|
74
|
+
**Shift** while dropping: without it VSCode keeps the drag for itself and
|
|
75
|
+
opens the file in an editor instead (the same rule as dropping into a text
|
|
76
|
+
editor).
|
|
77
|
+
- **Paste** text copied from an editor selection and it becomes a selection
|
|
78
|
+
chip (`path:L2-L3`) instead of inline text; unrelated clipboard text is
|
|
79
|
+
pasted as usual.
|
|
80
|
+
|
|
64
81
|
## Manifest
|
|
65
82
|
|
|
66
83
|
The vendor's `package.json` must contribute, with `<id>` replaced by the
|
|
@@ -68,7 +85,7 @@ The vendor's `package.json` must contribute, with `<id>` replaced by the
|
|
|
68
85
|
|
|
69
86
|
- View `<id>.chat` (a webview, typically under its own `viewsContainers`
|
|
70
87
|
entry)
|
|
71
|
-
- Commands `<id>.login`, `<id>.logout`, `<id>.newChat`,
|
|
88
|
+
- Commands `<id>.login`, `<id>.logout`, `<id>.newChat`, `<id>.reopen`,
|
|
72
89
|
`<id>.installBrowser`, `<id>.sendSelection`, `<id>.sendFile`, `<id>.focus`
|
|
73
90
|
- Settings `<id>.headless` (boolean) and `<id>.timeoutSec` (number)
|
|
74
91
|
|
|
@@ -78,7 +95,22 @@ missing ID:
|
|
|
78
95
|
- `contributes.viewsContainers.activitybar[]` contains an entry with
|
|
79
96
|
`id === <id>`
|
|
80
97
|
- `contributes.views.<id>[]` contains an entry with `id === <id>.chat`
|
|
81
|
-
- `contributes.commands[]` contains all
|
|
98
|
+
- `contributes.commands[]` contains all eight `<id>.*` commands above
|
|
99
|
+
|
|
100
|
+
A `keybindings` entry is recommended so Ctrl+R (Cmd+R on macOS) reopens the
|
|
101
|
+
browser while the chat view is focused; it is not validated. The webview
|
|
102
|
+
also handles the shortcut itself when the composer has focus.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
"keybindings": [
|
|
106
|
+
{
|
|
107
|
+
"command": "<id>.reopen",
|
|
108
|
+
"key": "ctrl+r",
|
|
109
|
+
"mac": "cmd+r",
|
|
110
|
+
"when": "focusedView == <id>.chat"
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
```
|
|
82
114
|
|
|
83
115
|
`contributes.configuration` is not validated: a missing `<id>.headless` or
|
|
84
116
|
`<id>.timeoutSec` setting simply falls back to the `createExtension`
|
|
@@ -90,11 +122,32 @@ Bundle with esbuild as CJS, with `vscode` and `playwright` marked external.
|
|
|
90
122
|
`examples/vscode-dummy-chat` keeps `"type": "module"` in its manifest, so
|
|
91
123
|
its bundle is `dist/extension.cjs`, not `.js` — match that if you copy the
|
|
92
124
|
example's `esbuild.mjs`. Its `package` script runs
|
|
93
|
-
`vsce package --no-dependencies`, which is only a CI packaging smoke test
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this extension"
|
|
125
|
+
`vsce package --no-dependencies`, which is only a CI packaging smoke test
|
|
126
|
+
(its `workspace:*` dependencies cannot be npm-installed). A distributable
|
|
127
|
+
`.vsix` must ship `node_modules/playwright` so the Install Browser button
|
|
128
|
+
works for end users; without it the button reports "playwright is not
|
|
129
|
+
bundled with this extension".
|
|
130
|
+
|
|
131
|
+
### Building a distributable `.vsix` (vendor repo)
|
|
132
|
+
|
|
133
|
+
1. In the extension's `package.json`, keep only `playwright` under
|
|
134
|
+
`dependencies`. `@chatbridge/vscode` (and everything it pulls in) is
|
|
135
|
+
inlined by esbuild, so it belongs in `devDependencies` together with
|
|
136
|
+
`esbuild` and `@vscode/vsce`.
|
|
137
|
+
2. Build the bundle: `bun run build` (esbuild → `dist/extension.cjs` and
|
|
138
|
+
`dist/webview/`).
|
|
139
|
+
3. Create a plain production `node_modules` with npm, not bun:
|
|
140
|
+
`rm -rf node_modules && npm install --omit=dev`. vsce discovers
|
|
141
|
+
dependencies by walking npm's layout; bun's symlinked workspace tree
|
|
142
|
+
makes that walk escape the folder. The result is just `playwright` and
|
|
143
|
+
`playwright-core`.
|
|
144
|
+
4. `npx @vscode/vsce package` (no `--no-dependencies`). Expect roughly 4 MB
|
|
145
|
+
and about 185 files.
|
|
146
|
+
5. Verify: `unzip -l *.vsix | grep node_modules/playwright/cli.js`. Then
|
|
147
|
+
restore the dev tree with `bun install`.
|
|
148
|
+
6. Install locally with `code --install-extension <file>.vsix`. Chromium is
|
|
149
|
+
never inside the `.vsix`: end users get it from the Install Browser
|
|
150
|
+
button (or `npx playwright install chromium`).
|
|
98
151
|
|
|
99
152
|
`examples/vscode-dummy-chat` in the framework repo is the reference
|
|
100
153
|
implementation to copy into a vendor repo.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { State, ToHost, ToWebview, UiConfig } from "./protocol.js";
|
|
1
|
+
import type { 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>;
|
|
@@ -9,7 +9,11 @@ export interface WebviewLike {
|
|
|
9
9
|
export interface ChatViewHandlers {
|
|
10
10
|
send(text: string): void;
|
|
11
11
|
removeAttachment(index: number): void;
|
|
12
|
-
|
|
12
|
+
takeBack(): void;
|
|
13
|
+
removeQueued(index: number): void;
|
|
14
|
+
command(name: WebviewCommand): void;
|
|
15
|
+
attachUris(uris: string[]): void;
|
|
16
|
+
pasted(id: number, text: string): void;
|
|
13
17
|
}
|
|
14
18
|
/** Translates webview messages into handler calls and pushes state to
|
|
15
19
|
* whichever webview is currently attached (VSCode recreates it). */
|
|
@@ -22,5 +26,6 @@ export declare class ChatViewBridge {
|
|
|
22
26
|
dispose(): void;
|
|
23
27
|
};
|
|
24
28
|
pushState(state: State): void;
|
|
29
|
+
pushPasteResult(id: number, attached: boolean): void;
|
|
25
30
|
pushProgress(text: string): void;
|
|
26
31
|
}
|
package/dist/chat-view-bridge.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
const COMMANDS = new Set([
|
|
2
|
+
"login",
|
|
3
|
+
"logout",
|
|
4
|
+
"newChat",
|
|
5
|
+
"installBrowser",
|
|
6
|
+
"reopen",
|
|
7
|
+
]);
|
|
1
8
|
function isToHost(m) {
|
|
2
9
|
if (typeof m !== "object" || m === null)
|
|
3
10
|
return false;
|
|
@@ -9,10 +16,16 @@ function isToHost(m) {
|
|
|
9
16
|
return typeof msg.text === "string";
|
|
10
17
|
case "removeAttachment":
|
|
11
18
|
return typeof msg.index === "number";
|
|
19
|
+
case "takeBack":
|
|
20
|
+
return true;
|
|
21
|
+
case "removeQueued":
|
|
22
|
+
return typeof msg.index === "number";
|
|
12
23
|
case "command":
|
|
13
|
-
return
|
|
14
|
-
|
|
15
|
-
|
|
24
|
+
return typeof msg.name === "string" && COMMANDS.has(msg.name);
|
|
25
|
+
case "attachUris":
|
|
26
|
+
return (Array.isArray(msg.uris) && msg.uris.every((u) => typeof u === "string"));
|
|
27
|
+
case "pasted":
|
|
28
|
+
return typeof msg.id === "number" && typeof msg.text === "string";
|
|
16
29
|
default:
|
|
17
30
|
return false;
|
|
18
31
|
}
|
|
@@ -44,9 +57,21 @@ export class ChatViewBridge {
|
|
|
44
57
|
case "removeAttachment":
|
|
45
58
|
this.handlers.removeAttachment(raw.index);
|
|
46
59
|
break;
|
|
60
|
+
case "takeBack":
|
|
61
|
+
this.handlers.takeBack();
|
|
62
|
+
break;
|
|
63
|
+
case "removeQueued":
|
|
64
|
+
this.handlers.removeQueued(raw.index);
|
|
65
|
+
break;
|
|
47
66
|
case "command":
|
|
48
67
|
this.handlers.command(raw.name);
|
|
49
68
|
break;
|
|
69
|
+
case "attachUris":
|
|
70
|
+
this.handlers.attachUris(raw.uris);
|
|
71
|
+
break;
|
|
72
|
+
case "pasted":
|
|
73
|
+
this.handlers.pasted(raw.id, raw.text);
|
|
74
|
+
break;
|
|
50
75
|
}
|
|
51
76
|
});
|
|
52
77
|
return {
|
|
@@ -60,6 +85,9 @@ export class ChatViewBridge {
|
|
|
60
85
|
pushState(state) {
|
|
61
86
|
void this.webview?.postMessage({ type: "state", ...state });
|
|
62
87
|
}
|
|
88
|
+
pushPasteResult(id, attached) {
|
|
89
|
+
void this.webview?.postMessage({ type: "pasteResult", id, attached });
|
|
90
|
+
}
|
|
63
91
|
pushProgress(text) {
|
|
64
92
|
void this.webview?.postMessage({ type: "progress", text });
|
|
65
93
|
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -17,11 +17,16 @@ export interface CommandHandlers {
|
|
|
17
17
|
login(): Promise<void>;
|
|
18
18
|
logout(): Promise<void>;
|
|
19
19
|
newChat(): Promise<void>;
|
|
20
|
+
reopen(): Promise<void>;
|
|
20
21
|
installBrowser(): Promise<void>;
|
|
21
22
|
sendSelection(): Promise<void>;
|
|
22
23
|
sendFile(uri: unknown): Promise<void>;
|
|
23
24
|
focus(): void;
|
|
24
25
|
/** From the webview's input box. */
|
|
25
26
|
send(text: string): Promise<void>;
|
|
27
|
+
/** Files dropped on the webview; unreadable URIs are reported together. */
|
|
28
|
+
attachUris(uris: string[]): Promise<void>;
|
|
29
|
+
/** A paste into the input box; true when it became a selection chip. */
|
|
30
|
+
pasted(text: string): boolean;
|
|
26
31
|
}
|
|
27
32
|
export declare function createCommands(deps: CommandDeps): CommandHandlers;
|
package/dist/commands.js
CHANGED
|
@@ -9,7 +9,7 @@ export function createCommands(deps) {
|
|
|
9
9
|
const { controller, ui } = deps;
|
|
10
10
|
function isBusy() {
|
|
11
11
|
const status = controller.getState().status;
|
|
12
|
-
return status === "busy" || status === "opening";
|
|
12
|
+
return status === "busy" || status === "opening" || status === "reopening";
|
|
13
13
|
}
|
|
14
14
|
async function runInstall() {
|
|
15
15
|
try {
|
|
@@ -66,13 +66,27 @@ export function createCommands(deps) {
|
|
|
66
66
|
}
|
|
67
67
|
},
|
|
68
68
|
async logout() {
|
|
69
|
-
if (
|
|
69
|
+
if (isBusy()) {
|
|
70
70
|
ui.showWarningMessage("Wait for the current reply to finish, then log out.");
|
|
71
71
|
return;
|
|
72
72
|
}
|
|
73
|
+
// The auth state goes first: `discard` drains the queue, and a queued
|
|
74
|
+
// entry would otherwise reopen the browser — and send — under the
|
|
75
|
+
// credentials the user just asked to delete.
|
|
73
76
|
await deps.clearAuth();
|
|
77
|
+
if (!(await controller.discard("Logged out"))) {
|
|
78
|
+
// A turn started while the auth state was being deleted. The file
|
|
79
|
+
// is gone either way; only the session is still open, so warn about
|
|
80
|
+
// that alone.
|
|
81
|
+
ui.showWarningMessage("Wait for the current reply to finish, then log out.");
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
async newChat() {
|
|
85
|
+
if (!(await controller.newChat())) {
|
|
86
|
+
ui.showWarningMessage("Wait for the current reply to finish, or press Ctrl+R to reopen.");
|
|
87
|
+
}
|
|
74
88
|
},
|
|
75
|
-
|
|
89
|
+
reopen: () => controller.reopen(),
|
|
76
90
|
async installBrowser() {
|
|
77
91
|
if (await runInstall())
|
|
78
92
|
ui.showInformationMessage("Chromium installed.");
|
|
@@ -99,6 +113,32 @@ export function createCommands(deps) {
|
|
|
99
113
|
attachEditor(editor, false);
|
|
100
114
|
},
|
|
101
115
|
focus: () => ui.focusView(),
|
|
116
|
+
async attachUris(uris) {
|
|
117
|
+
const skipped = [];
|
|
118
|
+
for (const raw of uris) {
|
|
119
|
+
try {
|
|
120
|
+
const doc = await ui.openDocument(ui.parseUri(raw));
|
|
121
|
+
attach(doc.path, doc.text);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
skipped.push(raw);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (skipped.length > 0) {
|
|
128
|
+
ui.showWarningMessage(`Skipped: ${skipped.join(", ")}`);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
pasted(text) {
|
|
132
|
+
const editor = ui.activeEditor();
|
|
133
|
+
const selection = editor?.selection;
|
|
134
|
+
if (!editor || !selection)
|
|
135
|
+
return false;
|
|
136
|
+
const normalise = (s) => s.replace(/\r\n/g, "\n");
|
|
137
|
+
if (normalise(text) !== normalise(selection.text))
|
|
138
|
+
return false;
|
|
139
|
+
attachEditor(editor, true);
|
|
140
|
+
return true;
|
|
141
|
+
},
|
|
102
142
|
async send(text) {
|
|
103
143
|
const result = await controller.send(text);
|
|
104
144
|
if (result.ok || result.code !== "BROWSER_UNAVAILABLE")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Provider } from "@chatbridge/core";
|
|
2
2
|
import * as vscode from "vscode";
|
|
3
|
+
import { type CommandHandlers } from "./commands.js";
|
|
3
4
|
import { SessionController } from "./session-controller.js";
|
|
4
5
|
import { type ExtensionUiOptions } from "./ui-config.js";
|
|
5
6
|
export interface CreateExtensionOptions {
|
|
@@ -26,6 +27,8 @@ export interface CreateExtensionOptions {
|
|
|
26
27
|
/** What `activate` returns: the E2E drives the controller directly. */
|
|
27
28
|
export interface ExtensionApi {
|
|
28
29
|
controller: SessionController;
|
|
30
|
+
/** The E2E drives the command handlers directly. */
|
|
31
|
+
handlers: CommandHandlers;
|
|
29
32
|
}
|
|
30
33
|
export declare function createExtension(opts: CreateExtensionOptions): {
|
|
31
34
|
activate: (context: vscode.ExtensionContext) => Promise<ExtensionApi>;
|
package/dist/create-extension.js
CHANGED
|
@@ -30,7 +30,11 @@ export function createExtension(opts) {
|
|
|
30
30
|
const bridge = new ChatViewBridge(() => controller.getState(), {
|
|
31
31
|
send: (text) => void handlers.send(text),
|
|
32
32
|
removeAttachment: (i) => controller?.removeAttachment(i),
|
|
33
|
+
takeBack: () => controller?.takeBack(),
|
|
34
|
+
removeQueued: (i) => controller?.removeQueued(i),
|
|
33
35
|
command: (name) => void handlers[name](),
|
|
36
|
+
attachUris: (uris) => void handlers.attachUris(uris),
|
|
37
|
+
pasted: (id, text) => bridge.pushPasteResult(id, handlers.pasted(text)),
|
|
34
38
|
});
|
|
35
39
|
function settings() {
|
|
36
40
|
const cfg = vscode.workspace.getConfiguration(opts.id);
|
|
@@ -56,7 +60,9 @@ export function createExtension(opts) {
|
|
|
56
60
|
},
|
|
57
61
|
onChange: (state) => {
|
|
58
62
|
bridge.pushState(state);
|
|
59
|
-
if (state.status === "busy" ||
|
|
63
|
+
if (state.status === "busy" ||
|
|
64
|
+
state.status === "opening" ||
|
|
65
|
+
state.status === "reopening")
|
|
60
66
|
statusBar.show();
|
|
61
67
|
else
|
|
62
68
|
statusBar.hide();
|
|
@@ -83,7 +89,7 @@ export function createExtension(opts) {
|
|
|
83
89
|
for (const name of COMMAND_NAMES) {
|
|
84
90
|
context.subscriptions.push(vscode.commands.registerCommand(`${opts.id}.${name}`, (arg) => name === "sendFile" ? handlers.sendFile(arg) : handlers[name]()));
|
|
85
91
|
}
|
|
86
|
-
return { controller };
|
|
92
|
+
return { controller, handlers };
|
|
87
93
|
}
|
|
88
94
|
async function deactivate() {
|
|
89
95
|
await controller?.close();
|
package/dist/manifest.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const COMMAND_NAMES: readonly ["login", "logout", "newChat", "installBrowser", "sendSelection", "sendFile", "focus"];
|
|
1
|
+
export declare const COMMAND_NAMES: readonly ["login", "logout", "newChat", "reopen", "installBrowser", "sendSelection", "sendFile", "focus"];
|
|
2
2
|
export type CommandName = (typeof COMMAND_NAMES)[number];
|
|
3
3
|
/** The `contributes` IDs a vendor extension must declare for `id`. */
|
|
4
4
|
export declare function expectedContributions(id: string): {
|
package/dist/manifest.js
CHANGED
package/dist/protocol.d.ts
CHANGED
|
@@ -7,15 +7,24 @@ export interface Message {
|
|
|
7
7
|
attachments?: Attachment[];
|
|
8
8
|
}
|
|
9
9
|
/** closed: no browser. opening: ChatSession.open in flight. idle: ready.
|
|
10
|
-
* busy: a turn is in flight.
|
|
11
|
-
|
|
10
|
+
* busy: a turn is in flight. reopening: Ctrl+R is replacing the browser.
|
|
11
|
+
* dead: fatal error; New chat, Log in or Reopen recover. */
|
|
12
|
+
export type Status = "closed" | "opening" | "idle" | "busy" | "reopening" | "dead";
|
|
13
|
+
/** A message waiting for its turn: sent while the controller was not idle. */
|
|
14
|
+
export interface QueueEntry {
|
|
15
|
+
text: string;
|
|
16
|
+
attachments: Attachment[];
|
|
17
|
+
}
|
|
12
18
|
export interface State {
|
|
13
19
|
status: Status;
|
|
14
20
|
messages: Message[];
|
|
15
21
|
pendingAttachments: Attachment[];
|
|
22
|
+
/** Oldest first; drained one entry per turn end. */
|
|
23
|
+
queue: QueueEntry[];
|
|
16
24
|
/** `ChatBridgeError.code` of the error that made the status `dead`. */
|
|
17
25
|
lastError?: string;
|
|
18
26
|
}
|
|
27
|
+
export type WebviewCommand = "login" | "logout" | "newChat" | "installBrowser" | "reopen";
|
|
19
28
|
/** webview → host */
|
|
20
29
|
export type ToHost = {
|
|
21
30
|
type: "ready";
|
|
@@ -25,9 +34,25 @@ export type ToHost = {
|
|
|
25
34
|
} | {
|
|
26
35
|
type: "removeAttachment";
|
|
27
36
|
index: number;
|
|
37
|
+
} | {
|
|
38
|
+
type: "takeBack";
|
|
39
|
+
} | {
|
|
40
|
+
type: "removeQueued";
|
|
41
|
+
index: number;
|
|
28
42
|
} | {
|
|
29
43
|
type: "command";
|
|
30
|
-
name:
|
|
44
|
+
name: WebviewCommand;
|
|
45
|
+
}
|
|
46
|
+
/** Files dropped on the webview, as URI strings. */
|
|
47
|
+
| {
|
|
48
|
+
type: "attachUris";
|
|
49
|
+
uris: string[];
|
|
50
|
+
}
|
|
51
|
+
/** A paste into the input box; `id` pairs it with its `pasteResult`. */
|
|
52
|
+
| {
|
|
53
|
+
type: "pasted";
|
|
54
|
+
id: number;
|
|
55
|
+
text: string;
|
|
31
56
|
};
|
|
32
57
|
/** Vendor UI customisation, as the webview receives it. */
|
|
33
58
|
export interface UiConfig {
|
|
@@ -51,4 +76,10 @@ export type ToWebview = ({
|
|
|
51
76
|
text: string;
|
|
52
77
|
} | ({
|
|
53
78
|
type: "config";
|
|
54
|
-
} & UiConfig)
|
|
79
|
+
} & UiConfig)
|
|
80
|
+
/** Answer to `pasted`: when attached, the webview drops the pasted text. */
|
|
81
|
+
| {
|
|
82
|
+
type: "pasteResult";
|
|
83
|
+
id: number;
|
|
84
|
+
attached: boolean;
|
|
85
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Attachment } from "@chatbridge/core";
|
|
2
|
-
import type { State } from "./protocol.js";
|
|
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>;
|
|
@@ -12,11 +12,14 @@ export interface PendingAttachment extends Attachment {
|
|
|
12
12
|
}
|
|
13
13
|
export type SendResult = {
|
|
14
14
|
ok: true;
|
|
15
|
+
queued?: true;
|
|
15
16
|
} | {
|
|
16
17
|
ok: false;
|
|
17
18
|
code: string;
|
|
18
19
|
message: string;
|
|
19
20
|
};
|
|
21
|
+
/** The separator pushed into the history by `reopen()`. */
|
|
22
|
+
export declare const REOPENED_SEPARATOR = "reopened";
|
|
20
23
|
export type AddResult = {
|
|
21
24
|
ok: true;
|
|
22
25
|
} | {
|
|
@@ -47,6 +50,12 @@ export declare class SessionController {
|
|
|
47
50
|
private session;
|
|
48
51
|
/** The full prompt of the last send, for retryLast(). */
|
|
49
52
|
private lastPrompt;
|
|
53
|
+
/** Turns sent while the controller was not ready, oldest first. */
|
|
54
|
+
private queue;
|
|
55
|
+
/** Bumped by every reopen; a send from an older generation is stale. */
|
|
56
|
+
private generation;
|
|
57
|
+
/** The reopen in flight, so a second Ctrl+R joins it instead of racing. */
|
|
58
|
+
private reopening;
|
|
50
59
|
private readonly closeTimeoutMs;
|
|
51
60
|
constructor(opts: SessionControllerOptions);
|
|
52
61
|
getState(): State;
|
|
@@ -55,9 +64,25 @@ export declare class SessionController {
|
|
|
55
64
|
private emit;
|
|
56
65
|
addAttachment(a: PendingAttachment): AddResult;
|
|
57
66
|
removeAttachment(index: number): void;
|
|
58
|
-
/**
|
|
59
|
-
*
|
|
67
|
+
/** True when a turn can start right now. `dead` counts: an explicit
|
|
68
|
+
* send is the user retrying, and only automatic draining must stop
|
|
69
|
+
* while the controller is dead. */
|
|
70
|
+
private get canStartTurn();
|
|
71
|
+
/** Sends the text plus the pending attachments as one turn, or queues it
|
|
72
|
+
* when a turn is already running. Never throws: the outcome is the
|
|
73
|
+
* result and the history. */
|
|
60
74
|
send(text: string): Promise<SendResult>;
|
|
75
|
+
/** Pushes the user entry and runs the turn. Shared by send and drain. */
|
|
76
|
+
private startTurn;
|
|
77
|
+
/** Starts the oldest queued entry, if any, when the controller is ready
|
|
78
|
+
* for a turn. Called at every transition back to a ready state, before
|
|
79
|
+
* the caller emits, so the webview never sees an idle frame with a
|
|
80
|
+
* queue still waiting. */
|
|
81
|
+
private drain;
|
|
82
|
+
/** Empties the queue back into the composer: the entries are returned
|
|
83
|
+
* and their attachments become pending again. */
|
|
84
|
+
takeBack(): QueueEntry[];
|
|
85
|
+
removeQueued(index: number): void;
|
|
61
86
|
/** Re-runs the last prompt after a recoverable fatal error (a missing
|
|
62
87
|
* browser that was just installed). Drops the trailing error entry so
|
|
63
88
|
* the history reads user → assistant. */
|
|
@@ -65,8 +90,14 @@ export declare class SessionController {
|
|
|
65
90
|
private runTurn;
|
|
66
91
|
private fail;
|
|
67
92
|
private dropSession;
|
|
68
|
-
/** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
|
|
69
|
-
|
|
93
|
+
/** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
|
|
94
|
+
* False when a turn is in flight, so the caller can warn. */
|
|
95
|
+
newChat(): Promise<boolean>;
|
|
96
|
+
/** Replaces the browser in every state. The in-flight turn, if any, is
|
|
97
|
+
* abandoned: its result is dropped by the generation check. A second
|
|
98
|
+
* call while one is running joins the first. */
|
|
99
|
+
reopen(): Promise<void>;
|
|
100
|
+
private runReopen;
|
|
70
101
|
/** Closes the session (if any) and pushes `separator`. Refused (returns
|
|
71
102
|
* false) while a turn is in flight. Clears a dead state. */
|
|
72
103
|
discard(separator: string): Promise<boolean>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { ChatBridgeError, MAX_FILE_BYTES, MAX_TOTAL_BYTES, closeOrKill, formatAttachment, formatSize, } from "@chatbridge/core";
|
|
2
|
+
/** The separator pushed into the history by `reopen()`. */
|
|
3
|
+
export const REOPENED_SEPARATOR = "reopened";
|
|
2
4
|
export const CLOSE_TIMEOUT_MS = 5_000;
|
|
3
5
|
const EMPTY = {
|
|
4
6
|
ok: false,
|
|
@@ -16,6 +18,12 @@ export class SessionController {
|
|
|
16
18
|
session;
|
|
17
19
|
/** The full prompt of the last send, for retryLast(). */
|
|
18
20
|
lastPrompt;
|
|
21
|
+
/** Turns sent while the controller was not ready, oldest first. */
|
|
22
|
+
queue = [];
|
|
23
|
+
/** Bumped by every reopen; a send from an older generation is stale. */
|
|
24
|
+
generation = 0;
|
|
25
|
+
/** The reopen in flight, so a second Ctrl+R joins it instead of racing. */
|
|
26
|
+
reopening;
|
|
19
27
|
closeTimeoutMs;
|
|
20
28
|
constructor(opts) {
|
|
21
29
|
this.opts = opts;
|
|
@@ -32,6 +40,10 @@ export class SessionController {
|
|
|
32
40
|
path,
|
|
33
41
|
bytes,
|
|
34
42
|
})),
|
|
43
|
+
queue: this.queue.map((q) => ({
|
|
44
|
+
text: q.text,
|
|
45
|
+
attachments: q.attachments.map(({ path, bytes }) => ({ path, bytes })),
|
|
46
|
+
})),
|
|
35
47
|
};
|
|
36
48
|
if (this.lastError !== undefined)
|
|
37
49
|
state.lastError = this.lastError;
|
|
@@ -72,37 +84,89 @@ export class SessionController {
|
|
|
72
84
|
this.pending.splice(index, 1);
|
|
73
85
|
this.emit();
|
|
74
86
|
}
|
|
75
|
-
/**
|
|
76
|
-
*
|
|
87
|
+
/** True when a turn can start right now. `dead` counts: an explicit
|
|
88
|
+
* send is the user retrying, and only automatic draining must stop
|
|
89
|
+
* while the controller is dead. */
|
|
90
|
+
get canStartTurn() {
|
|
91
|
+
return (this.status === "idle" ||
|
|
92
|
+
this.status === "closed" ||
|
|
93
|
+
this.status === "dead");
|
|
94
|
+
}
|
|
95
|
+
/** Sends the text plus the pending attachments as one turn, or queues it
|
|
96
|
+
* when a turn is already running. Never throws: the outcome is the
|
|
97
|
+
* result and the history. */
|
|
77
98
|
async send(text) {
|
|
78
|
-
if (this.status === "busy" || this.status === "opening") {
|
|
79
|
-
return {
|
|
80
|
-
ok: false,
|
|
81
|
-
code: "INVALID_STATE",
|
|
82
|
-
message: "A send is already in progress.",
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
99
|
const body = text.trim() === "" ? "" : text;
|
|
86
100
|
if (body === "" && this.pending.length === 0)
|
|
87
101
|
return EMPTY;
|
|
88
|
-
const
|
|
89
|
-
const prompt = [body, ...sections].filter((s) => s !== "").join("\n\n");
|
|
90
|
-
const attachments = this.pending.map(({ path, bytes }) => ({
|
|
91
|
-
path,
|
|
92
|
-
bytes,
|
|
93
|
-
}));
|
|
102
|
+
const attachments = this.pending;
|
|
94
103
|
this.pending = [];
|
|
95
|
-
|
|
104
|
+
// A non-empty queue means earlier entries are waiting (the controller
|
|
105
|
+
// is `dead`, where draining stops): keep FIFO by queueing behind them
|
|
106
|
+
// and letting drain start the oldest, which reopens the browser.
|
|
107
|
+
if (!this.canStartTurn || this.queue.length > 0) {
|
|
108
|
+
this.queue.push({ text: body, attachments });
|
|
109
|
+
this.drain();
|
|
110
|
+
this.emit();
|
|
111
|
+
return { ok: true, queued: true };
|
|
112
|
+
}
|
|
113
|
+
return this.startTurn({ text: body, attachments });
|
|
114
|
+
}
|
|
115
|
+
/** Pushes the user entry and runs the turn. Shared by send and drain. */
|
|
116
|
+
startTurn(turn) {
|
|
117
|
+
const sections = turn.attachments.map((a) => formatAttachment(a.path, a.content));
|
|
118
|
+
const prompt = [turn.text, ...sections]
|
|
119
|
+
.filter((s) => s !== "")
|
|
120
|
+
.join("\n\n");
|
|
121
|
+
this.push({
|
|
122
|
+
role: "user",
|
|
123
|
+
text: turn.text,
|
|
124
|
+
attachments: turn.attachments.map(({ path, bytes }) => ({ path, bytes })),
|
|
125
|
+
});
|
|
96
126
|
this.lastPrompt = prompt;
|
|
97
127
|
return this.runTurn(prompt);
|
|
98
128
|
}
|
|
129
|
+
/** Starts the oldest queued entry, if any, when the controller is ready
|
|
130
|
+
* for a turn. Called at every transition back to a ready state, before
|
|
131
|
+
* the caller emits, so the webview never sees an idle frame with a
|
|
132
|
+
* queue still waiting. */
|
|
133
|
+
drain() {
|
|
134
|
+
// markLoggedIn() can arrive in any status; never start a second turn
|
|
135
|
+
// on top of a running one. The other call sites are already ready.
|
|
136
|
+
if (!this.canStartTurn)
|
|
137
|
+
return;
|
|
138
|
+
const next = this.queue.shift();
|
|
139
|
+
if (next === undefined)
|
|
140
|
+
return;
|
|
141
|
+
void this.startTurn(next);
|
|
142
|
+
}
|
|
143
|
+
/** Empties the queue back into the composer: the entries are returned
|
|
144
|
+
* and their attachments become pending again. */
|
|
145
|
+
takeBack() {
|
|
146
|
+
if (this.queue.length === 0)
|
|
147
|
+
return [];
|
|
148
|
+
const entries = this.queue.splice(0);
|
|
149
|
+
for (const e of entries)
|
|
150
|
+
this.pending.push(...e.attachments);
|
|
151
|
+
this.emit();
|
|
152
|
+
return entries.map((e) => ({
|
|
153
|
+
text: e.text,
|
|
154
|
+
attachments: e.attachments.map(({ path, bytes }) => ({ path, bytes })),
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
removeQueued(index) {
|
|
158
|
+
if (index < 0 || index >= this.queue.length)
|
|
159
|
+
return;
|
|
160
|
+
this.queue.splice(index, 1);
|
|
161
|
+
this.emit();
|
|
162
|
+
}
|
|
99
163
|
/** Re-runs the last prompt after a recoverable fatal error (a missing
|
|
100
164
|
* browser that was just installed). Drops the trailing error entry so
|
|
101
165
|
* the history reads user → assistant. */
|
|
102
166
|
async retryLast() {
|
|
103
167
|
if (this.lastPrompt === undefined)
|
|
104
168
|
return EMPTY;
|
|
105
|
-
if (this.
|
|
169
|
+
if (!this.canStartTurn) {
|
|
106
170
|
return {
|
|
107
171
|
ok: false,
|
|
108
172
|
code: "INVALID_STATE",
|
|
@@ -122,18 +186,32 @@ export class SessionController {
|
|
|
122
186
|
// `lastError` would keep the webview's error banner up after a
|
|
123
187
|
// successful send from `dead`.
|
|
124
188
|
this.lastError = undefined;
|
|
189
|
+
const generation = this.generation;
|
|
125
190
|
try {
|
|
126
191
|
if (this.session === undefined) {
|
|
127
192
|
this.setStatus("opening");
|
|
128
|
-
|
|
193
|
+
const session = await this.opts.openSession();
|
|
194
|
+
// A reopen ran while we were opening: this browser is an orphan.
|
|
195
|
+
if (generation !== this.generation) {
|
|
196
|
+
await closeOrKill(session, this.closeTimeoutMs);
|
|
197
|
+
return { ok: true };
|
|
198
|
+
}
|
|
199
|
+
this.session = session;
|
|
129
200
|
}
|
|
130
201
|
this.setStatus("busy");
|
|
131
202
|
const reply = await this.session.send(prompt);
|
|
203
|
+
if (generation !== this.generation)
|
|
204
|
+
return { ok: true }; // stale
|
|
132
205
|
this.messages.push({ role: "assistant", text: reply });
|
|
133
|
-
|
|
206
|
+
// Claim the next turn before emitting, so no idle frame is shown.
|
|
207
|
+
this.status = "idle";
|
|
208
|
+
this.drain();
|
|
209
|
+
this.emit();
|
|
134
210
|
return { ok: true };
|
|
135
211
|
}
|
|
136
212
|
catch (err) {
|
|
213
|
+
if (generation !== this.generation)
|
|
214
|
+
return { ok: true }; // stale
|
|
137
215
|
return this.fail(err);
|
|
138
216
|
}
|
|
139
217
|
}
|
|
@@ -148,7 +226,9 @@ export class SessionController {
|
|
|
148
226
|
// Show the error before `dropSession` (up to `closeTimeoutMs`) runs.
|
|
149
227
|
this.emit();
|
|
150
228
|
if (code === "RESPONSE_TIMEOUT") {
|
|
151
|
-
this.
|
|
229
|
+
this.status = "idle";
|
|
230
|
+
this.drain();
|
|
231
|
+
this.emit();
|
|
152
232
|
}
|
|
153
233
|
else {
|
|
154
234
|
this.lastError = code;
|
|
@@ -163,21 +243,68 @@ export class SessionController {
|
|
|
163
243
|
if (old !== undefined)
|
|
164
244
|
await closeOrKill(old, this.closeTimeoutMs);
|
|
165
245
|
}
|
|
166
|
-
/** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
|
|
246
|
+
/** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
|
|
247
|
+
* False when a turn is in flight, so the caller can warn. */
|
|
167
248
|
async newChat() {
|
|
168
|
-
|
|
249
|
+
return this.discard("New chat");
|
|
250
|
+
}
|
|
251
|
+
/** Replaces the browser in every state. The in-flight turn, if any, is
|
|
252
|
+
* abandoned: its result is dropped by the generation check. A second
|
|
253
|
+
* call while one is running joins the first. */
|
|
254
|
+
reopen() {
|
|
255
|
+
if (this.reopening)
|
|
256
|
+
return this.reopening;
|
|
257
|
+
const run = this.runReopen().finally(() => {
|
|
258
|
+
this.reopening = undefined;
|
|
259
|
+
});
|
|
260
|
+
this.reopening = run;
|
|
261
|
+
return run;
|
|
262
|
+
}
|
|
263
|
+
async runReopen() {
|
|
264
|
+
const generation = ++this.generation;
|
|
265
|
+
this.setStatus("reopening");
|
|
266
|
+
await this.dropSession();
|
|
267
|
+
try {
|
|
268
|
+
const session = await this.opts.openSession();
|
|
269
|
+
// `close()` (deactivate) ran while the browser was opening: this one
|
|
270
|
+
// is an orphan nobody would ever close, and the controller is closed.
|
|
271
|
+
if (generation !== this.generation) {
|
|
272
|
+
await closeOrKill(session, this.closeTimeoutMs);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.session = session;
|
|
276
|
+
this.lastError = undefined;
|
|
277
|
+
this.messages.push({ role: "separator", text: REOPENED_SEPARATOR });
|
|
278
|
+
this.status = "idle";
|
|
279
|
+
this.drain();
|
|
280
|
+
this.emit();
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
const code = err instanceof ChatBridgeError ? err.code : "UNKNOWN";
|
|
284
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
285
|
+
const hint = this.opts.hints?.[code];
|
|
286
|
+
this.messages.push({
|
|
287
|
+
role: "error",
|
|
288
|
+
text: hint === undefined ? message : `${message}\n${hint}`,
|
|
289
|
+
});
|
|
290
|
+
this.lastError = code;
|
|
291
|
+
this.setStatus("dead");
|
|
292
|
+
}
|
|
169
293
|
}
|
|
170
294
|
/** Closes the session (if any) and pushes `separator`. Refused (returns
|
|
171
295
|
* false) while a turn is in flight. Clears a dead state. */
|
|
172
296
|
async discard(separator) {
|
|
173
|
-
if (this.
|
|
297
|
+
if (!this.canStartTurn)
|
|
174
298
|
return false;
|
|
175
299
|
await this.dropSession();
|
|
176
300
|
this.lastError = undefined;
|
|
177
301
|
// A fresh chat must not re-send a prompt from before the break.
|
|
178
302
|
this.lastPrompt = undefined;
|
|
179
303
|
this.status = "closed";
|
|
180
|
-
this.push({ role: "separator", text: separator });
|
|
304
|
+
this.messages.push({ role: "separator", text: separator });
|
|
305
|
+
// The queue outlives the break: drain it into the new chat.
|
|
306
|
+
this.drain();
|
|
307
|
+
this.emit();
|
|
181
308
|
return true;
|
|
182
309
|
}
|
|
183
310
|
/** After a successful login command: a dead controller may try again. */
|
|
@@ -186,10 +313,15 @@ export class SessionController {
|
|
|
186
313
|
this.lastError = undefined;
|
|
187
314
|
this.status = "closed";
|
|
188
315
|
}
|
|
189
|
-
this.push({ role: "separator", text: "Logged in" });
|
|
316
|
+
this.messages.push({ role: "separator", text: "Logged in" });
|
|
317
|
+
this.drain();
|
|
318
|
+
this.emit();
|
|
190
319
|
}
|
|
191
320
|
/** deactivate: close the browser, keep the history. Idempotent. */
|
|
192
321
|
async close() {
|
|
322
|
+
// A reopen in flight is stale from here on: its new browser must be
|
|
323
|
+
// closed rather than adopted by a controller the user has shut down.
|
|
324
|
+
this.generation++;
|
|
193
325
|
await this.dropSession();
|
|
194
326
|
if (this.status !== "dead")
|
|
195
327
|
this.status = "closed";
|
package/dist/vscode-ui.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ 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
|
+
/** Parses a URI string; throws when it is not a valid URI. */
|
|
24
|
+
parseUri(uri: string): unknown;
|
|
23
25
|
/** Opens the document behind an explorer Uri (or any Uri) read-only. */
|
|
24
26
|
openDocument(uri: unknown): Promise<{
|
|
25
27
|
path: string;
|
package/dist/vscode-ui.js
CHANGED
|
@@ -29,6 +29,7 @@ export function createVscodeUi(api, id) {
|
|
|
29
29
|
}
|
|
30
30
|
return snap;
|
|
31
31
|
},
|
|
32
|
+
parseUri: (uri) => api.Uri.parse(uri, true),
|
|
32
33
|
openDocument: async (uri) => {
|
|
33
34
|
const doc = await api.workspace.openTextDocument(uri);
|
|
34
35
|
return { path: relPath(doc.uri), text: doc.getText() };
|
package/dist/webview/main.js
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
(() => {
|
|
3
|
+
// ../core/dist/slash-commands.js
|
|
4
|
+
var SLASH_COMMANDS = [
|
|
5
|
+
{ name: "login", description: "Log in in a browser window" },
|
|
6
|
+
{ name: "logout", description: "Delete the saved login and close the chat" },
|
|
7
|
+
{ name: "new", description: "Start a new chat" },
|
|
8
|
+
{ name: "reopen", description: "Reopen the browser (also Ctrl+R)" },
|
|
9
|
+
{ name: "help", description: "List these commands" }
|
|
10
|
+
];
|
|
11
|
+
var NAMES = new Set(SLASH_COMMANDS.map((c) => c.name));
|
|
12
|
+
var PATTERN = /^\/([a-z]+)$/;
|
|
13
|
+
function parseSlashCommand(text) {
|
|
14
|
+
const m = PATTERN.exec(text.trim());
|
|
15
|
+
if (!m || m[1] === void 0)
|
|
16
|
+
return void 0;
|
|
17
|
+
const word = m[1];
|
|
18
|
+
return NAMES.has(word) ? { command: word } : { unknown: word };
|
|
19
|
+
}
|
|
20
|
+
function unknownCommandMessage(word) {
|
|
21
|
+
return `Unknown command: /${word}. Type /help.`;
|
|
22
|
+
}
|
|
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
|
+
|
|
3
28
|
// src/webview/main.ts
|
|
4
29
|
var vscode = acquireVsCodeApi();
|
|
5
30
|
var history = document.getElementById("history");
|
|
@@ -12,7 +37,10 @@
|
|
|
12
37
|
var welcomeText = document.getElementById("welcome-text");
|
|
13
38
|
var banner = document.getElementById("banner");
|
|
14
39
|
var footer = document.getElementById("footer");
|
|
40
|
+
var queue = document.getElementById("queue");
|
|
41
|
+
var inlineError = document.getElementById("inline-error");
|
|
15
42
|
var config = {};
|
|
43
|
+
var lastState;
|
|
16
44
|
function applyConfig(c) {
|
|
17
45
|
config = c;
|
|
18
46
|
if (c.sendButton?.background) {
|
|
@@ -68,17 +96,19 @@
|
|
|
68
96
|
}
|
|
69
97
|
return box;
|
|
70
98
|
}
|
|
99
|
+
function waitingText(status2) {
|
|
100
|
+
if (status2 === "reopening") return "Reopening browser...";
|
|
101
|
+
if (status2 === "opening") return "Opening browser...";
|
|
102
|
+
return "Waiting...";
|
|
103
|
+
}
|
|
71
104
|
function renderStatus(s) {
|
|
72
105
|
status.replaceChildren();
|
|
73
106
|
status.hidden = false;
|
|
74
|
-
if (s.status === "busy" || s.status === "opening") {
|
|
107
|
+
if (s.status === "busy" || s.status === "opening" || s.status === "reopening") {
|
|
75
108
|
status.appendChild(el("span", "spinner"));
|
|
109
|
+
const queued = s.queue.length > 0 ? ` \xB7 ${s.queue.length} queued` : "";
|
|
76
110
|
status.appendChild(
|
|
77
|
-
el(
|
|
78
|
-
"span",
|
|
79
|
-
"progress-text",
|
|
80
|
-
s.status === "opening" ? "Opening browser..." : "Waiting..."
|
|
81
|
-
)
|
|
111
|
+
el("span", "progress-text", `${waitingText(s.status)}${queued}`)
|
|
82
112
|
);
|
|
83
113
|
return;
|
|
84
114
|
}
|
|
@@ -99,6 +129,12 @@
|
|
|
99
129
|
)
|
|
100
130
|
);
|
|
101
131
|
}
|
|
132
|
+
status.appendChild(
|
|
133
|
+
button(
|
|
134
|
+
"Reopen",
|
|
135
|
+
() => vscode.postMessage({ type: "command", name: "reopen" })
|
|
136
|
+
)
|
|
137
|
+
);
|
|
102
138
|
status.appendChild(
|
|
103
139
|
button(
|
|
104
140
|
"New chat",
|
|
@@ -109,6 +145,23 @@
|
|
|
109
145
|
}
|
|
110
146
|
status.hidden = true;
|
|
111
147
|
}
|
|
148
|
+
function renderQueue(s) {
|
|
149
|
+
queue.replaceChildren();
|
|
150
|
+
queue.hidden = s.queue.length === 0;
|
|
151
|
+
s.queue.forEach((entry, index) => {
|
|
152
|
+
const li = document.createElement("li");
|
|
153
|
+
const firstLine = entry.text.split("\n")[0] ?? "";
|
|
154
|
+
const label = entry.attachments.length > 0 ? `${firstLine} \u{1F4CE} ${entry.attachments.length}` : firstLine;
|
|
155
|
+
li.appendChild(el("span", "queue-text", `\u25B9 ${label}`));
|
|
156
|
+
const x = button(
|
|
157
|
+
"\xD7",
|
|
158
|
+
() => vscode.postMessage({ type: "removeQueued", index })
|
|
159
|
+
);
|
|
160
|
+
x.className = "chip-remove";
|
|
161
|
+
li.appendChild(x);
|
|
162
|
+
queue.appendChild(li);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
112
165
|
function renderAttachments(s) {
|
|
113
166
|
attachments.replaceChildren();
|
|
114
167
|
s.pendingAttachments.forEach((a, index) => {
|
|
@@ -126,17 +179,45 @@
|
|
|
126
179
|
history.replaceChildren(...s.messages.map(renderMessage));
|
|
127
180
|
history.scrollTop = history.scrollHeight;
|
|
128
181
|
renderStatus(s);
|
|
182
|
+
renderQueue(s);
|
|
129
183
|
renderAttachments(s);
|
|
130
184
|
welcome.hidden = s.messages.length > 0 || !config.welcome && !config.bannerUri;
|
|
131
185
|
history.hidden = !welcome.hidden;
|
|
132
|
-
const
|
|
133
|
-
input.disabled =
|
|
134
|
-
sendButton.disabled =
|
|
135
|
-
|
|
186
|
+
const active = s.status === "busy" || s.status === "opening" || s.status === "reopening";
|
|
187
|
+
input.disabled = false;
|
|
188
|
+
sendButton.disabled = false;
|
|
189
|
+
sendButton.textContent = active ? "Queue" : "Send";
|
|
190
|
+
if (document.activeElement === null || document.activeElement === document.body) {
|
|
191
|
+
input.focus();
|
|
192
|
+
}
|
|
193
|
+
lastState = s;
|
|
194
|
+
}
|
|
195
|
+
function showInlineError(text) {
|
|
196
|
+
inlineError.textContent = text ?? "";
|
|
197
|
+
inlineError.hidden = text === void 0;
|
|
136
198
|
}
|
|
137
199
|
function submit() {
|
|
138
200
|
const text = input.value;
|
|
139
201
|
if (text.trim() === "" && attachments.childElementCount === 0) return;
|
|
202
|
+
const slash = parseSlashCommand(text);
|
|
203
|
+
if (slash && "unknown" in slash) {
|
|
204
|
+
showInlineError(unknownCommandMessage(slash.unknown));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
showInlineError(void 0);
|
|
208
|
+
if (slash) {
|
|
209
|
+
input.value = "";
|
|
210
|
+
if (slash.command === "help") {
|
|
211
|
+
welcome.hidden = true;
|
|
212
|
+
history.hidden = false;
|
|
213
|
+
history.appendChild(el("div", "message help", helpText()));
|
|
214
|
+
history.scrollTop = history.scrollHeight;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const name = slash.command === "new" ? "newChat" : slash.command;
|
|
218
|
+
vscode.postMessage({ type: "command", name });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
140
221
|
vscode.postMessage({ type: "send", text });
|
|
141
222
|
input.value = "";
|
|
142
223
|
}
|
|
@@ -148,6 +229,63 @@
|
|
|
148
229
|
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
149
230
|
e.preventDefault();
|
|
150
231
|
submit();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (e.key === "ArrowUp" && input.value === "" && (lastState?.queue.length ?? 0) > 0) {
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
const entries = lastState?.queue ?? [];
|
|
237
|
+
input.value = entries.map((q) => q.text).join("\n\n");
|
|
238
|
+
showInlineError(void 0);
|
|
239
|
+
vscode.postMessage({ type: "takeBack" });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
input.addEventListener("input", () => showInlineError(void 0));
|
|
243
|
+
function urisFromDrop(dt) {
|
|
244
|
+
const list = dt?.getData("text/uri-list") ?? "";
|
|
245
|
+
return list.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== "" && !l.startsWith("#"));
|
|
246
|
+
}
|
|
247
|
+
document.addEventListener("dragover", (e) => {
|
|
248
|
+
e.preventDefault();
|
|
249
|
+
document.body.classList.add("drop-target");
|
|
250
|
+
});
|
|
251
|
+
document.addEventListener(
|
|
252
|
+
"dragleave",
|
|
253
|
+
() => document.body.classList.remove("drop-target")
|
|
254
|
+
);
|
|
255
|
+
document.addEventListener("drop", (e) => {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
document.body.classList.remove("drop-target");
|
|
258
|
+
const uris = urisFromDrop(e.dataTransfer);
|
|
259
|
+
if (uris.length > 0) vscode.postMessage({ type: "attachUris", uris });
|
|
260
|
+
});
|
|
261
|
+
var PASTE_TIMEOUT_MS = 500;
|
|
262
|
+
var pasteSeq = 0;
|
|
263
|
+
var pendingPastes = /* @__PURE__ */ new Map();
|
|
264
|
+
function insertAtCaret(text) {
|
|
265
|
+
input.focus();
|
|
266
|
+
if (!document.execCommand("insertText", false, text)) {
|
|
267
|
+
const { selectionStart, selectionEnd, value } = input;
|
|
268
|
+
input.value = value.slice(0, selectionStart) + text + value.slice(selectionEnd);
|
|
269
|
+
const pos = selectionStart + text.length;
|
|
270
|
+
input.setSelectionRange(pos, pos);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
input.addEventListener("paste", (e) => {
|
|
274
|
+
const text = e.clipboardData?.getData("text/plain") ?? "";
|
|
275
|
+
if (!text.includes("\n")) return;
|
|
276
|
+
e.preventDefault();
|
|
277
|
+
const id = ++pasteSeq;
|
|
278
|
+
const timer = setTimeout(() => {
|
|
279
|
+
pendingPastes.delete(id);
|
|
280
|
+
insertAtCaret(text);
|
|
281
|
+
}, PASTE_TIMEOUT_MS);
|
|
282
|
+
pendingPastes.set(id, { text, timer });
|
|
283
|
+
vscode.postMessage({ type: "pasted", id, text });
|
|
284
|
+
});
|
|
285
|
+
document.addEventListener("keydown", (e) => {
|
|
286
|
+
if (e.key.toLowerCase() === "r" && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
vscode.postMessage({ type: "command", name: "reopen" });
|
|
151
289
|
}
|
|
152
290
|
});
|
|
153
291
|
window.addEventListener("message", (event) => {
|
|
@@ -161,6 +299,12 @@
|
|
|
161
299
|
} else if (m.type === "progress") {
|
|
162
300
|
const t = status.querySelector(".progress-text");
|
|
163
301
|
if (t) t.textContent = m.text;
|
|
302
|
+
} else if (m.type === "pasteResult") {
|
|
303
|
+
const p = pendingPastes.get(m.id);
|
|
304
|
+
if (!p) return;
|
|
305
|
+
clearTimeout(p.timer);
|
|
306
|
+
pendingPastes.delete(m.id);
|
|
307
|
+
if (!m.attached) insertAtCaret(p.text);
|
|
164
308
|
}
|
|
165
309
|
});
|
|
166
310
|
vscode.postMessage({ type: "ready" });
|
package/dist/webview/style.css
CHANGED
|
@@ -137,3 +137,38 @@ body {
|
|
|
137
137
|
#input:disabled {
|
|
138
138
|
opacity: 0.5;
|
|
139
139
|
}
|
|
140
|
+
#queue {
|
|
141
|
+
list-style: none;
|
|
142
|
+
margin: 0;
|
|
143
|
+
padding: 0 8px;
|
|
144
|
+
font-size: 90%;
|
|
145
|
+
opacity: 0.8;
|
|
146
|
+
}
|
|
147
|
+
#queue li {
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: 6px;
|
|
151
|
+
padding: 2px 0;
|
|
152
|
+
white-space: nowrap;
|
|
153
|
+
overflow: hidden;
|
|
154
|
+
text-overflow: ellipsis;
|
|
155
|
+
}
|
|
156
|
+
#queue li .queue-text {
|
|
157
|
+
flex: 1;
|
|
158
|
+
overflow: hidden;
|
|
159
|
+
text-overflow: ellipsis;
|
|
160
|
+
}
|
|
161
|
+
#inline-error {
|
|
162
|
+
padding: 2px 8px;
|
|
163
|
+
color: var(--vscode-errorForeground);
|
|
164
|
+
font-size: 90%;
|
|
165
|
+
}
|
|
166
|
+
.message.help {
|
|
167
|
+
font-family: var(--vscode-editor-font-family);
|
|
168
|
+
white-space: pre;
|
|
169
|
+
opacity: 0.8;
|
|
170
|
+
}
|
|
171
|
+
body.drop-target {
|
|
172
|
+
outline: 2px dashed var(--vscode-focusBorder);
|
|
173
|
+
outline-offset: -2px;
|
|
174
|
+
}
|
package/dist/webview-html.js
CHANGED
|
@@ -20,9 +20,11 @@ export function buildHtml(i) {
|
|
|
20
20
|
<div id="welcome" hidden><img id="banner" alt="" hidden><p id="welcome-text"></p></div>
|
|
21
21
|
<main id="history" aria-live="polite"></main>
|
|
22
22
|
<div id="status" hidden></div>
|
|
23
|
+
<ul id="queue" hidden></ul>
|
|
23
24
|
<div id="attachments"></div>
|
|
25
|
+
<div id="inline-error" hidden></div>
|
|
24
26
|
<form id="composer">
|
|
25
|
-
<textarea id="input" rows="3" placeholder="Message (Enter to send, Shift+Enter for a newline)"></textarea>
|
|
27
|
+
<textarea id="input" rows="3" placeholder="Message (Enter to send, Shift+Enter for a newline, / for commands)"></textarea>
|
|
26
28
|
<button id="send" type="submit">Send</button>
|
|
27
29
|
</form>
|
|
28
30
|
<footer id="footer" hidden></footer>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatbridge/vscode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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.8.1"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/vscode": "1.138.0",
|