@chatbridge/vscode 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -2
- package/dist/chat-view-bridge.d.ts +10 -2
- package/dist/chat-view-bridge.js +37 -3
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +51 -3
- package/dist/create-extension.d.ts +3 -0
- package/dist/create-extension.js +29 -3
- package/dist/index.d.ts +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +1 -0
- package/dist/protocol.d.ts +43 -5
- package/dist/session-controller.d.ts +59 -8
- package/dist/session-controller.js +201 -26
- package/dist/timeout-setting.d.ts +7 -0
- package/dist/timeout-setting.js +12 -0
- package/dist/vscode-ui.d.ts +5 -0
- package/dist/vscode-ui.js +2 -0
- package/dist/webview/main.js +185 -10
- package/dist/webview/style.css +43 -0
- package/dist/webview-html.js +3 -1
- package/package.json +2 -2
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`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { State, ToHost, ToWebview, UiConfig } from "./protocol.js";
|
|
1
|
+
import type { QueueEntry, State, ToHost, ToWebview, UiConfig, WebviewCommand } from "./protocol.js";
|
|
2
2
|
/** The slice of vscode.Webview the bridge uses; a fake in tests. */
|
|
3
3
|
export interface WebviewLike {
|
|
4
4
|
postMessage(message: ToWebview): Thenable<boolean>;
|
|
@@ -9,8 +9,14 @@ 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
|
}
|
|
18
|
+
/** Every command the webview may post; the bridge rejects the rest. */
|
|
19
|
+
export declare const COMMAND_LIST: ("help" | "login" | "logout" | "newChat" | "installBrowser" | "reopen")[];
|
|
14
20
|
/** Translates webview messages into handler calls and pushes state to
|
|
15
21
|
* whichever webview is currently attached (VSCode recreates it). */
|
|
16
22
|
export declare class ChatViewBridge {
|
|
@@ -22,5 +28,7 @@ export declare class ChatViewBridge {
|
|
|
22
28
|
dispose(): void;
|
|
23
29
|
};
|
|
24
30
|
pushState(state: State): void;
|
|
31
|
+
pushPasteResult(id: number, attached: boolean): void;
|
|
32
|
+
pushTookBack(entries: QueueEntry[]): void;
|
|
25
33
|
pushProgress(text: string): void;
|
|
26
34
|
}
|
package/dist/chat-view-bridge.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/** Every command the webview may post; the bridge rejects the rest. */
|
|
2
|
+
export const COMMAND_LIST = [
|
|
3
|
+
"login",
|
|
4
|
+
"logout",
|
|
5
|
+
"newChat",
|
|
6
|
+
"installBrowser",
|
|
7
|
+
"reopen",
|
|
8
|
+
"help",
|
|
9
|
+
];
|
|
10
|
+
const COMMANDS = new Set(COMMAND_LIST);
|
|
1
11
|
function isToHost(m) {
|
|
2
12
|
if (typeof m !== "object" || m === null)
|
|
3
13
|
return false;
|
|
@@ -9,10 +19,16 @@ function isToHost(m) {
|
|
|
9
19
|
return typeof msg.text === "string";
|
|
10
20
|
case "removeAttachment":
|
|
11
21
|
return typeof msg.index === "number";
|
|
22
|
+
case "takeBack":
|
|
23
|
+
return true;
|
|
24
|
+
case "removeQueued":
|
|
25
|
+
return typeof msg.index === "number";
|
|
12
26
|
case "command":
|
|
13
|
-
return
|
|
14
|
-
|
|
15
|
-
|
|
27
|
+
return typeof msg.name === "string" && COMMANDS.has(msg.name);
|
|
28
|
+
case "attachUris":
|
|
29
|
+
return (Array.isArray(msg.uris) && msg.uris.every((u) => typeof u === "string"));
|
|
30
|
+
case "pasted":
|
|
31
|
+
return typeof msg.id === "number" && typeof msg.text === "string";
|
|
16
32
|
default:
|
|
17
33
|
return false;
|
|
18
34
|
}
|
|
@@ -44,9 +60,21 @@ export class ChatViewBridge {
|
|
|
44
60
|
case "removeAttachment":
|
|
45
61
|
this.handlers.removeAttachment(raw.index);
|
|
46
62
|
break;
|
|
63
|
+
case "takeBack":
|
|
64
|
+
this.handlers.takeBack();
|
|
65
|
+
break;
|
|
66
|
+
case "removeQueued":
|
|
67
|
+
this.handlers.removeQueued(raw.index);
|
|
68
|
+
break;
|
|
47
69
|
case "command":
|
|
48
70
|
this.handlers.command(raw.name);
|
|
49
71
|
break;
|
|
72
|
+
case "attachUris":
|
|
73
|
+
this.handlers.attachUris(raw.uris);
|
|
74
|
+
break;
|
|
75
|
+
case "pasted":
|
|
76
|
+
this.handlers.pasted(raw.id, raw.text);
|
|
77
|
+
break;
|
|
50
78
|
}
|
|
51
79
|
});
|
|
52
80
|
return {
|
|
@@ -60,6 +88,12 @@ export class ChatViewBridge {
|
|
|
60
88
|
pushState(state) {
|
|
61
89
|
void this.webview?.postMessage({ type: "state", ...state });
|
|
62
90
|
}
|
|
91
|
+
pushPasteResult(id, attached) {
|
|
92
|
+
void this.webview?.postMessage({ type: "pasteResult", id, attached });
|
|
93
|
+
}
|
|
94
|
+
pushTookBack(entries) {
|
|
95
|
+
void this.webview?.postMessage({ type: "tookBack", entries });
|
|
96
|
+
}
|
|
63
97
|
pushProgress(text) {
|
|
64
98
|
void this.webview?.postMessage({ type: "progress", text });
|
|
65
99
|
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -17,11 +17,18 @@ 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>;
|
|
22
|
+
/** From the webview's `/help`: the listing joins the history. */
|
|
23
|
+
help(): void;
|
|
21
24
|
sendSelection(): Promise<void>;
|
|
22
25
|
sendFile(uri: unknown): Promise<void>;
|
|
23
26
|
focus(): void;
|
|
24
27
|
/** From the webview's input box. */
|
|
25
28
|
send(text: string): Promise<void>;
|
|
29
|
+
/** Files dropped on the webview; unreadable URIs are reported together. */
|
|
30
|
+
attachUris(uris: string[]): Promise<void>;
|
|
31
|
+
/** A paste into the input box; true when it became a selection chip. */
|
|
32
|
+
pasted(text: string): boolean;
|
|
26
33
|
}
|
|
27
34
|
export declare function createCommands(deps: CommandDeps): CommandHandlers;
|
package/dist/commands.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { LoginAbortedError } from "@chatbridge/core";
|
|
2
|
+
import { helpText } from "@chatbridge/core/slash-commands";
|
|
2
3
|
function utf8Bytes(text) {
|
|
3
4
|
return new TextEncoder().encode(text).byteLength;
|
|
4
5
|
}
|
|
6
|
+
/** Pasted text arrives with the platform's line endings; the editor's
|
|
7
|
+
* selection text does not. Compare them on the same footing. */
|
|
8
|
+
const normalise = (s) => s.replace(/\r\n/g, "\n");
|
|
5
9
|
function message(err) {
|
|
6
10
|
return err instanceof Error ? err.message : String(err);
|
|
7
11
|
}
|
|
@@ -9,7 +13,7 @@ export function createCommands(deps) {
|
|
|
9
13
|
const { controller, ui } = deps;
|
|
10
14
|
function isBusy() {
|
|
11
15
|
const status = controller.getState().status;
|
|
12
|
-
return status === "busy" || status === "opening";
|
|
16
|
+
return status === "busy" || status === "opening" || status === "reopening";
|
|
13
17
|
}
|
|
14
18
|
async function runInstall() {
|
|
15
19
|
try {
|
|
@@ -66,13 +70,28 @@ export function createCommands(deps) {
|
|
|
66
70
|
}
|
|
67
71
|
},
|
|
68
72
|
async logout() {
|
|
69
|
-
if (
|
|
73
|
+
if (isBusy()) {
|
|
70
74
|
ui.showWarningMessage("Wait for the current reply to finish, then log out.");
|
|
71
75
|
return;
|
|
72
76
|
}
|
|
77
|
+
// The auth state goes first: `discard` drains the queue, and a queued
|
|
78
|
+
// entry would otherwise reopen the browser — and send — under the
|
|
79
|
+
// credentials the user just asked to delete.
|
|
73
80
|
await deps.clearAuth();
|
|
81
|
+
if (!(await controller.discard("Logged out"))) {
|
|
82
|
+
// A turn started while the auth state was being deleted. The file
|
|
83
|
+
// is gone either way; only the session is still open, so warn about
|
|
84
|
+
// that alone.
|
|
85
|
+
ui.showWarningMessage("Wait for the current reply to finish, then log out.");
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async newChat() {
|
|
89
|
+
if (!(await controller.newChat())) {
|
|
90
|
+
ui.showWarningMessage("Wait for the current reply to finish, or press Ctrl+R to reopen.");
|
|
91
|
+
}
|
|
74
92
|
},
|
|
75
|
-
|
|
93
|
+
reopen: () => controller.reopen(),
|
|
94
|
+
help: () => controller.pushHelp(helpText()),
|
|
76
95
|
async installBrowser() {
|
|
77
96
|
if (await runInstall())
|
|
78
97
|
ui.showInformationMessage("Chromium installed.");
|
|
@@ -87,6 +106,10 @@ export function createCommands(deps) {
|
|
|
87
106
|
},
|
|
88
107
|
async sendFile(uri) {
|
|
89
108
|
if (uri !== undefined && uri !== null) {
|
|
109
|
+
if (!ui.isUri(uri)) {
|
|
110
|
+
ui.showWarningMessage("Nothing to attach.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
90
113
|
const doc = await ui.openDocument(uri);
|
|
91
114
|
attach(doc.path, doc.text);
|
|
92
115
|
return;
|
|
@@ -99,6 +122,31 @@ export function createCommands(deps) {
|
|
|
99
122
|
attachEditor(editor, false);
|
|
100
123
|
},
|
|
101
124
|
focus: () => ui.focusView(),
|
|
125
|
+
async attachUris(uris) {
|
|
126
|
+
const skipped = [];
|
|
127
|
+
for (const raw of new Set(uris)) {
|
|
128
|
+
try {
|
|
129
|
+
const doc = await ui.openDocument(ui.parseUri(raw));
|
|
130
|
+
attach(doc.path, doc.text);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
skipped.push(raw);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (skipped.length > 0) {
|
|
137
|
+
ui.showWarningMessage(`Skipped: ${skipped.join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
pasted(text) {
|
|
141
|
+
const editor = ui.activeEditor();
|
|
142
|
+
const selection = editor?.selection;
|
|
143
|
+
if (!editor || !selection || selection.text.trim() === "")
|
|
144
|
+
return false;
|
|
145
|
+
if (normalise(text) !== normalise(selection.text))
|
|
146
|
+
return false;
|
|
147
|
+
attachEditor(editor, true);
|
|
148
|
+
return true;
|
|
149
|
+
},
|
|
102
150
|
async send(text) {
|
|
103
151
|
const result = await controller.send(text);
|
|
104
152
|
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
|
@@ -8,6 +8,7 @@ import { createCommands } from "./commands.js";
|
|
|
8
8
|
import { installBrowser } from "./install-browser.js";
|
|
9
9
|
import { COMMAND_NAMES, missingContributions } from "./manifest.js";
|
|
10
10
|
import { SessionController } from "./session-controller.js";
|
|
11
|
+
import { parseTimeoutSec } from "./timeout-setting.js";
|
|
11
12
|
import { resolveUiConfig } from "./ui-config.js";
|
|
12
13
|
import { createVscodeUi } from "./vscode-ui.js";
|
|
13
14
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
@@ -30,13 +31,35 @@ export function createExtension(opts) {
|
|
|
30
31
|
const bridge = new ChatViewBridge(() => controller.getState(), {
|
|
31
32
|
send: (text) => void handlers.send(text),
|
|
32
33
|
removeAttachment: (i) => controller?.removeAttachment(i),
|
|
34
|
+
takeBack: () => {
|
|
35
|
+
const r = controller?.takeBack();
|
|
36
|
+
if (!r)
|
|
37
|
+
return;
|
|
38
|
+
// `takeBack()` has already emitted a `state` with the queue
|
|
39
|
+
// empty; the webview fills the composer from `tookBack` alone,
|
|
40
|
+
// so this arriving second does not matter.
|
|
41
|
+
bridge.pushTookBack(r.entries);
|
|
42
|
+
if (r.droppedAttachments > 0) {
|
|
43
|
+
void vscode.window.showWarningMessage(`${r.droppedAttachments} attachment(s) left out: total size limit.`);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
removeQueued: (i) => controller?.removeQueued(i),
|
|
33
47
|
command: (name) => void handlers[name](),
|
|
48
|
+
attachUris: (uris) => void handlers.attachUris(uris),
|
|
49
|
+
pasted: (id, text) => bridge.pushPasteResult(id, handlers.pasted(text)),
|
|
34
50
|
});
|
|
51
|
+
let warnedTimeout = false;
|
|
35
52
|
function settings() {
|
|
36
53
|
const cfg = vscode.workspace.getConfiguration(opts.id);
|
|
54
|
+
const fallbackMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
55
|
+
const { timeoutMs, invalid } = parseTimeoutSec(cfg.get("timeoutSec"), fallbackMs);
|
|
56
|
+
if (invalid && !warnedTimeout) {
|
|
57
|
+
warnedTimeout = true;
|
|
58
|
+
void vscode.window.showWarningMessage(`${opts.displayName}: "${opts.id}.timeoutSec" must be a positive number; using ${fallbackMs / 1000} s.`);
|
|
59
|
+
}
|
|
37
60
|
return {
|
|
38
61
|
headless: cfg.get("headless", opts.headless ?? true),
|
|
39
|
-
timeoutMs
|
|
62
|
+
timeoutMs,
|
|
40
63
|
};
|
|
41
64
|
}
|
|
42
65
|
function progress(message) {
|
|
@@ -53,10 +76,13 @@ export function createExtension(opts) {
|
|
|
53
76
|
}),
|
|
54
77
|
hints: {
|
|
55
78
|
BLOCKED: `Set the "${opts.id}.headless" setting to false and try again.`,
|
|
79
|
+
BROWSER_UNAVAILABLE: `Run "${opts.displayName}: Install Browser" and send again.`,
|
|
56
80
|
},
|
|
57
81
|
onChange: (state) => {
|
|
58
82
|
bridge.pushState(state);
|
|
59
|
-
if (state.status === "busy" ||
|
|
83
|
+
if (state.status === "busy" ||
|
|
84
|
+
state.status === "opening" ||
|
|
85
|
+
state.status === "reopening")
|
|
60
86
|
statusBar.show();
|
|
61
87
|
else
|
|
62
88
|
statusBar.hide();
|
|
@@ -83,7 +109,7 @@ export function createExtension(opts) {
|
|
|
83
109
|
for (const name of COMMAND_NAMES) {
|
|
84
110
|
context.subscriptions.push(vscode.commands.registerCommand(`${opts.id}.${name}`, (arg) => name === "sendFile" ? handlers.sendFile(arg) : handlers[name]()));
|
|
85
111
|
}
|
|
86
|
-
return { controller };
|
|
112
|
+
return { controller, handlers };
|
|
87
113
|
}
|
|
88
114
|
async function deactivate() {
|
|
89
115
|
await controller?.close();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from "./protocol.js";
|
|
2
|
-
export { type AddResult, type ChatSessionLike, type PendingAttachment, type SendResult, SessionController, type SessionControllerOptions, } from "./session-controller.js";
|
|
2
|
+
export { type AddResult, type ChatSessionLike, type HintCode, type PendingAttachment, type SendResult, SessionController, type SessionControllerOptions, type TakeBackResult, } from "./session-controller.js";
|
|
3
3
|
export { type ChildLike, installBrowser, type InstallBrowserOptions, type SpawnFn, splitProgressLines, } from "./install-browser.js";
|
|
4
4
|
export { COMMAND_NAMES, type CommandName, expectedContributions, missingContributions, } from "./manifest.js";
|
|
5
5
|
export { ChatViewBridge, type ChatViewHandlers, type WebviewLike, } from "./chat-view-bridge.js";
|
package/dist/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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Attachment } from "@chatbridge/core";
|
|
2
|
-
export type Role = "user" | "assistant" | "error" | "separator";
|
|
2
|
+
export type Role = "user" | "assistant" | "error" | "separator" | "help";
|
|
3
3
|
export interface Message {
|
|
4
4
|
role: Role;
|
|
5
5
|
text: string;
|
|
@@ -7,15 +7,26 @@ 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"
|
|
28
|
+
/** Webview only: the host answers with a `help` history entry. */
|
|
29
|
+
| "help";
|
|
19
30
|
/** webview → host */
|
|
20
31
|
export type ToHost = {
|
|
21
32
|
type: "ready";
|
|
@@ -25,9 +36,25 @@ export type ToHost = {
|
|
|
25
36
|
} | {
|
|
26
37
|
type: "removeAttachment";
|
|
27
38
|
index: number;
|
|
39
|
+
} | {
|
|
40
|
+
type: "takeBack";
|
|
41
|
+
} | {
|
|
42
|
+
type: "removeQueued";
|
|
43
|
+
index: number;
|
|
28
44
|
} | {
|
|
29
45
|
type: "command";
|
|
30
|
-
name:
|
|
46
|
+
name: WebviewCommand;
|
|
47
|
+
}
|
|
48
|
+
/** Files dropped on the webview, as URI strings. */
|
|
49
|
+
| {
|
|
50
|
+
type: "attachUris";
|
|
51
|
+
uris: string[];
|
|
52
|
+
}
|
|
53
|
+
/** A paste into the input box; `id` pairs it with its `pasteResult`. */
|
|
54
|
+
| {
|
|
55
|
+
type: "pasted";
|
|
56
|
+
id: number;
|
|
57
|
+
text: string;
|
|
31
58
|
};
|
|
32
59
|
/** Vendor UI customisation, as the webview receives it. */
|
|
33
60
|
export interface UiConfig {
|
|
@@ -51,4 +78,15 @@ export type ToWebview = ({
|
|
|
51
78
|
text: string;
|
|
52
79
|
} | ({
|
|
53
80
|
type: "config";
|
|
54
|
-
} & UiConfig)
|
|
81
|
+
} & UiConfig)
|
|
82
|
+
/** Answer to `pasted`: when attached, the webview drops the pasted text. */
|
|
83
|
+
| {
|
|
84
|
+
type: "pasteResult";
|
|
85
|
+
id: number;
|
|
86
|
+
attached: boolean;
|
|
87
|
+
}
|
|
88
|
+
/** Answer to `takeBack`: the entries removed from the queue. */
|
|
89
|
+
| {
|
|
90
|
+
type: "tookBack";
|
|
91
|
+
entries: QueueEntry[];
|
|
92
|
+
};
|
|
@@ -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,17 +12,30 @@ 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
|
} | {
|
|
23
26
|
ok: false;
|
|
24
27
|
reason: string;
|
|
25
28
|
};
|
|
29
|
+
/** Error codes the extension can attach a remedy to. */
|
|
30
|
+
export type HintCode = "BLOCKED" | "BROWSER_UNAVAILABLE" | "AUTH_REQUIRED" | "AUTH_EXPIRED" | "RESPONSE_TIMEOUT";
|
|
31
|
+
export interface TakeBackResult {
|
|
32
|
+
/** Every entry that was queued. `entries[i].attachments` lists everything
|
|
33
|
+
* that entry carried, including attachments that were *not* restored to
|
|
34
|
+
* pending: only their number is reported, in `droppedAttachments`. */
|
|
35
|
+
entries: QueueEntry[];
|
|
36
|
+
/** Attachments left out because restoring them would exceed MAX_TOTAL_BYTES. */
|
|
37
|
+
droppedAttachments: number;
|
|
38
|
+
}
|
|
26
39
|
export interface SessionControllerOptions {
|
|
27
40
|
/** Opens a ChatSession; called lazily on the first send after `closed`. */
|
|
28
41
|
openSession: () => Promise<ChatSessionLike>;
|
|
@@ -31,9 +44,9 @@ export interface SessionControllerOptions {
|
|
|
31
44
|
/** Called with the full state after every change. */
|
|
32
45
|
onChange?: (state: State) => void;
|
|
33
46
|
/** Extra line appended to the history entry of a failed turn, keyed by
|
|
34
|
-
* `ChatBridgeError.code`. Core's messages
|
|
35
|
-
*
|
|
36
|
-
hints?: Partial<Record<
|
|
47
|
+
* `ChatBridgeError.code`. Core's messages carry no UI-specific remedy;
|
|
48
|
+
* the extension adds the VSCode-side one. */
|
|
49
|
+
hints?: Partial<Record<HintCode, string>>;
|
|
37
50
|
}
|
|
38
51
|
export declare const CLOSE_TIMEOUT_MS = 5000;
|
|
39
52
|
/** Owns the history, the pending attachments and the ChatSession. No
|
|
@@ -47,26 +60,64 @@ export declare class SessionController {
|
|
|
47
60
|
private session;
|
|
48
61
|
/** The full prompt of the last send, for retryLast(). */
|
|
49
62
|
private lastPrompt;
|
|
63
|
+
/** Turns sent while the controller was not ready, oldest first. */
|
|
64
|
+
private queue;
|
|
65
|
+
/** Bumped by every reopen; a send from an older generation is stale. */
|
|
66
|
+
private generation;
|
|
67
|
+
/** The reopen in flight, so a second Ctrl+R joins it instead of racing. */
|
|
68
|
+
private reopening;
|
|
69
|
+
/** The openSession in flight (first send or reopen), so close() can wait
|
|
70
|
+
* for it instead of orphaning the browser it is about to produce. */
|
|
71
|
+
private opening;
|
|
50
72
|
private readonly closeTimeoutMs;
|
|
51
73
|
constructor(opts: SessionControllerOptions);
|
|
52
74
|
getState(): State;
|
|
53
75
|
private setStatus;
|
|
76
|
+
/** A `/help` listing, as a history entry. */
|
|
77
|
+
pushHelp(text: string): void;
|
|
54
78
|
private push;
|
|
55
79
|
private emit;
|
|
56
80
|
addAttachment(a: PendingAttachment): AddResult;
|
|
57
81
|
removeAttachment(index: number): void;
|
|
58
|
-
/**
|
|
59
|
-
*
|
|
82
|
+
/** True when a turn can start right now. `dead` counts: an explicit
|
|
83
|
+
* send is the user retrying, and only automatic draining must stop
|
|
84
|
+
* while the controller is dead. */
|
|
85
|
+
private get canStartTurn();
|
|
86
|
+
/** Sends the text plus the pending attachments as one turn, or queues it
|
|
87
|
+
* when a turn is already running. Never throws: the outcome is the
|
|
88
|
+
* result and the history. */
|
|
60
89
|
send(text: string): Promise<SendResult>;
|
|
90
|
+
/** Pushes the user entry and runs the turn. Shared by send and drain. */
|
|
91
|
+
private startTurn;
|
|
92
|
+
/** Starts the oldest queued entry, if any, when the controller is ready
|
|
93
|
+
* for a turn. Called at every transition back to a ready state, before
|
|
94
|
+
* the caller emits, so the webview never sees an idle frame with a
|
|
95
|
+
* queue still waiting. */
|
|
96
|
+
private drain;
|
|
97
|
+
/** Empties the queue back into the composer: the entries are returned
|
|
98
|
+
* and their attachments become pending again. */
|
|
99
|
+
takeBack(): TakeBackResult;
|
|
100
|
+
removeQueued(index: number): void;
|
|
61
101
|
/** Re-runs the last prompt after a recoverable fatal error (a missing
|
|
62
102
|
* browser that was just installed). Drops the trailing error entry so
|
|
63
103
|
* the history reads user → assistant. */
|
|
64
104
|
retryLast(): Promise<SendResult>;
|
|
65
105
|
private runTurn;
|
|
106
|
+
/** Pushes the error entry (message plus the extension's remedy, if any)
|
|
107
|
+
* and reports the code back to the caller. */
|
|
108
|
+
private pushError;
|
|
66
109
|
private fail;
|
|
110
|
+
/** Tracks an openSession so close()/dropSession can wait for it. */
|
|
111
|
+
private trackOpen;
|
|
67
112
|
private dropSession;
|
|
68
|
-
/** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
|
|
69
|
-
|
|
113
|
+
/** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily.
|
|
114
|
+
* False when a turn is in flight, so the caller can warn. */
|
|
115
|
+
newChat(): Promise<boolean>;
|
|
116
|
+
/** Replaces the browser in every state. The in-flight turn, if any, is
|
|
117
|
+
* abandoned: its result is dropped by the generation check. A second
|
|
118
|
+
* call while one is running joins the first. */
|
|
119
|
+
reopen(): Promise<void>;
|
|
120
|
+
private runReopen;
|
|
70
121
|
/** Closes the session (if any) and pushes `separator`. Refused (returns
|
|
71
122
|
* false) while a turn is in flight. Clears a dead state. */
|
|
72
123
|
discard(separator: string): Promise<boolean>;
|