@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/dist/manifest.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const COMMAND_NAMES = [
|
|
2
|
+
"login",
|
|
3
|
+
"logout",
|
|
4
|
+
"newChat",
|
|
5
|
+
"installBrowser",
|
|
6
|
+
"sendSelection",
|
|
7
|
+
"sendFile",
|
|
8
|
+
"focus",
|
|
9
|
+
];
|
|
10
|
+
/** The `contributes` IDs a vendor extension must declare for `id`. */
|
|
11
|
+
export function expectedContributions(id) {
|
|
12
|
+
return {
|
|
13
|
+
viewContainers: [id],
|
|
14
|
+
views: [`${id}.chat`],
|
|
15
|
+
commands: COMMAND_NAMES.map((n) => `${id}.${n}`),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function ids(list, key) {
|
|
19
|
+
const out = new Set();
|
|
20
|
+
if (Array.isArray(list)) {
|
|
21
|
+
for (const item of list) {
|
|
22
|
+
const v = item?.[key];
|
|
23
|
+
if (typeof v === "string")
|
|
24
|
+
out.add(v);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The contributes entries a vendor manifest must declare for `id`, that
|
|
31
|
+
* this package.json lacks. Empty when the manifest is complete.
|
|
32
|
+
*/
|
|
33
|
+
export function missingContributions(packageJSON, id) {
|
|
34
|
+
const c = (packageJSON
|
|
35
|
+
?.contributes ?? {});
|
|
36
|
+
const expected = expectedContributions(id);
|
|
37
|
+
const containers = ids(c.viewsContainers?.activitybar, "id");
|
|
38
|
+
const views = ids(c.views?.[id], "id");
|
|
39
|
+
const commands = ids(c.commands, "command");
|
|
40
|
+
const missing = [];
|
|
41
|
+
for (const v of expected.viewContainers) {
|
|
42
|
+
if (!containers.has(v))
|
|
43
|
+
missing.push(`viewsContainers.activitybar: ${v}`);
|
|
44
|
+
}
|
|
45
|
+
for (const v of expected.views) {
|
|
46
|
+
if (!views.has(v))
|
|
47
|
+
missing.push(`views.${id}: ${v}`);
|
|
48
|
+
}
|
|
49
|
+
for (const v of expected.commands) {
|
|
50
|
+
if (!commands.has(v))
|
|
51
|
+
missing.push(`commands: ${v}`);
|
|
52
|
+
}
|
|
53
|
+
return missing;
|
|
54
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Attachment } from "@chatbridge/core";
|
|
2
|
+
export type Role = "user" | "assistant" | "error" | "separator";
|
|
3
|
+
export interface Message {
|
|
4
|
+
role: Role;
|
|
5
|
+
text: string;
|
|
6
|
+
/** `user` entries: files appended to the prompt, one line each. */
|
|
7
|
+
attachments?: Attachment[];
|
|
8
|
+
}
|
|
9
|
+
/** closed: no browser. opening: ChatSession.open in flight. idle: ready.
|
|
10
|
+
* busy: a turn is in flight. dead: fatal error; New chat or Log in recover. */
|
|
11
|
+
export type Status = "closed" | "opening" | "idle" | "busy" | "dead";
|
|
12
|
+
export interface State {
|
|
13
|
+
status: Status;
|
|
14
|
+
messages: Message[];
|
|
15
|
+
pendingAttachments: Attachment[];
|
|
16
|
+
/** `ChatBridgeError.code` of the error that made the status `dead`. */
|
|
17
|
+
lastError?: string;
|
|
18
|
+
}
|
|
19
|
+
/** webview → host */
|
|
20
|
+
export type ToHost = {
|
|
21
|
+
type: "ready";
|
|
22
|
+
} | {
|
|
23
|
+
type: "send";
|
|
24
|
+
text: string;
|
|
25
|
+
} | {
|
|
26
|
+
type: "removeAttachment";
|
|
27
|
+
index: number;
|
|
28
|
+
} | {
|
|
29
|
+
type: "command";
|
|
30
|
+
name: "login" | "newChat" | "installBrowser";
|
|
31
|
+
};
|
|
32
|
+
/** Vendor UI customisation, as the webview receives it. */
|
|
33
|
+
export interface UiConfig {
|
|
34
|
+
welcome?: string;
|
|
35
|
+
/** Webview URI (already converted with `asWebviewUri`). */
|
|
36
|
+
bannerUri?: string;
|
|
37
|
+
footer?: string;
|
|
38
|
+
sendButton?: {
|
|
39
|
+
background?: string;
|
|
40
|
+
foreground?: string;
|
|
41
|
+
};
|
|
42
|
+
userMessage?: {
|
|
43
|
+
borderColor?: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** host → webview */
|
|
47
|
+
export type ToWebview = ({
|
|
48
|
+
type: "state";
|
|
49
|
+
} & State) | {
|
|
50
|
+
type: "progress";
|
|
51
|
+
text: string;
|
|
52
|
+
} | ({
|
|
53
|
+
type: "config";
|
|
54
|
+
} & UiConfig);
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type Attachment } from "@chatbridge/core";
|
|
2
|
+
import type { State } from "./protocol.js";
|
|
3
|
+
/** What the controller needs from a ChatSession; lets tests inject a fake. */
|
|
4
|
+
export interface ChatSessionLike {
|
|
5
|
+
send(prompt: string): Promise<string>;
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
kill(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
/** A queued attachment: what the history shows plus the content to send. */
|
|
10
|
+
export interface PendingAttachment extends Attachment {
|
|
11
|
+
content: string;
|
|
12
|
+
}
|
|
13
|
+
export type SendResult = {
|
|
14
|
+
ok: true;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
code: string;
|
|
18
|
+
message: string;
|
|
19
|
+
};
|
|
20
|
+
export type AddResult = {
|
|
21
|
+
ok: true;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
reason: string;
|
|
25
|
+
};
|
|
26
|
+
export interface SessionControllerOptions {
|
|
27
|
+
/** Opens a ChatSession; called lazily on the first send after `closed`. */
|
|
28
|
+
openSession: () => Promise<ChatSessionLike>;
|
|
29
|
+
/** How long newChat / discard / close wait before killing. Default 5 s. */
|
|
30
|
+
closeTimeoutMs?: number;
|
|
31
|
+
/** Called with the full state after every change. */
|
|
32
|
+
onChange?: (state: State) => void;
|
|
33
|
+
/** Extra line appended to the history entry of a failed turn, keyed by
|
|
34
|
+
* `ChatBridgeError.code`. Core's messages are CLI-flavoured (`Try
|
|
35
|
+
* --headful.`); the extension adds the VSCode-side remedy. */
|
|
36
|
+
hints?: Partial<Record<string, string>>;
|
|
37
|
+
}
|
|
38
|
+
export declare const CLOSE_TIMEOUT_MS = 5000;
|
|
39
|
+
/** Owns the history, the pending attachments and the ChatSession. No
|
|
40
|
+
* vscode import: the extension wires it to the webview and the commands. */
|
|
41
|
+
export declare class SessionController {
|
|
42
|
+
private readonly opts;
|
|
43
|
+
private status;
|
|
44
|
+
private messages;
|
|
45
|
+
private pending;
|
|
46
|
+
private lastError;
|
|
47
|
+
private session;
|
|
48
|
+
/** The full prompt of the last send, for retryLast(). */
|
|
49
|
+
private lastPrompt;
|
|
50
|
+
private readonly closeTimeoutMs;
|
|
51
|
+
constructor(opts: SessionControllerOptions);
|
|
52
|
+
getState(): State;
|
|
53
|
+
private setStatus;
|
|
54
|
+
private push;
|
|
55
|
+
private emit;
|
|
56
|
+
addAttachment(a: PendingAttachment): AddResult;
|
|
57
|
+
removeAttachment(index: number): void;
|
|
58
|
+
/** Sends the text plus the pending attachments as one turn. Never
|
|
59
|
+
* throws: the outcome is the result and the history. */
|
|
60
|
+
send(text: string): Promise<SendResult>;
|
|
61
|
+
/** Re-runs the last prompt after a recoverable fatal error (a missing
|
|
62
|
+
* browser that was just installed). Drops the trailing error entry so
|
|
63
|
+
* the history reads user → assistant. */
|
|
64
|
+
retryLast(): Promise<SendResult>;
|
|
65
|
+
private runTurn;
|
|
66
|
+
private fail;
|
|
67
|
+
private dropSession;
|
|
68
|
+
/** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily. */
|
|
69
|
+
newChat(): Promise<void>;
|
|
70
|
+
/** Closes the session (if any) and pushes `separator`. Refused (returns
|
|
71
|
+
* false) while a turn is in flight. Clears a dead state. */
|
|
72
|
+
discard(separator: string): Promise<boolean>;
|
|
73
|
+
/** After a successful login command: a dead controller may try again. */
|
|
74
|
+
markLoggedIn(): void;
|
|
75
|
+
/** deactivate: close the browser, keep the history. Idempotent. */
|
|
76
|
+
close(): Promise<void>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { ChatBridgeError, MAX_FILE_BYTES, MAX_TOTAL_BYTES, closeOrKill, formatAttachment, formatSize, } from "@chatbridge/core";
|
|
2
|
+
export const CLOSE_TIMEOUT_MS = 5_000;
|
|
3
|
+
const EMPTY = {
|
|
4
|
+
ok: false,
|
|
5
|
+
code: "EMPTY",
|
|
6
|
+
message: "Nothing to send.",
|
|
7
|
+
};
|
|
8
|
+
/** Owns the history, the pending attachments and the ChatSession. No
|
|
9
|
+
* vscode import: the extension wires it to the webview and the commands. */
|
|
10
|
+
export class SessionController {
|
|
11
|
+
opts;
|
|
12
|
+
status = "closed";
|
|
13
|
+
messages = [];
|
|
14
|
+
pending = [];
|
|
15
|
+
lastError;
|
|
16
|
+
session;
|
|
17
|
+
/** The full prompt of the last send, for retryLast(). */
|
|
18
|
+
lastPrompt;
|
|
19
|
+
closeTimeoutMs;
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
this.opts = opts;
|
|
22
|
+
this.closeTimeoutMs = opts.closeTimeoutMs ?? CLOSE_TIMEOUT_MS;
|
|
23
|
+
}
|
|
24
|
+
getState() {
|
|
25
|
+
const state = {
|
|
26
|
+
status: this.status,
|
|
27
|
+
messages: this.messages.map((m) => ({
|
|
28
|
+
...m,
|
|
29
|
+
...(m.attachments ? { attachments: [...m.attachments] } : {}),
|
|
30
|
+
})),
|
|
31
|
+
pendingAttachments: this.pending.map(({ path, bytes }) => ({
|
|
32
|
+
path,
|
|
33
|
+
bytes,
|
|
34
|
+
})),
|
|
35
|
+
};
|
|
36
|
+
if (this.lastError !== undefined)
|
|
37
|
+
state.lastError = this.lastError;
|
|
38
|
+
return state;
|
|
39
|
+
}
|
|
40
|
+
setStatus(status) {
|
|
41
|
+
this.status = status;
|
|
42
|
+
this.emit();
|
|
43
|
+
}
|
|
44
|
+
push(message) {
|
|
45
|
+
this.messages.push(message);
|
|
46
|
+
this.emit();
|
|
47
|
+
}
|
|
48
|
+
emit() {
|
|
49
|
+
this.opts.onChange?.(this.getState());
|
|
50
|
+
}
|
|
51
|
+
addAttachment(a) {
|
|
52
|
+
if (a.bytes > MAX_FILE_BYTES) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
reason: `${a.path}: ${Math.ceil(a.bytes / 1024)} KB exceeds ${MAX_FILE_BYTES / 1024} KB`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const total = this.pending.reduce((n, p) => n + p.bytes, 0) + a.bytes;
|
|
59
|
+
if (total > MAX_TOTAL_BYTES) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: `attachments total ${formatSize(total)} exceeds ${formatSize(MAX_TOTAL_BYTES).replace(".0", "")}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
this.pending.push(a);
|
|
66
|
+
this.emit();
|
|
67
|
+
return { ok: true };
|
|
68
|
+
}
|
|
69
|
+
removeAttachment(index) {
|
|
70
|
+
if (index < 0 || index >= this.pending.length)
|
|
71
|
+
return;
|
|
72
|
+
this.pending.splice(index, 1);
|
|
73
|
+
this.emit();
|
|
74
|
+
}
|
|
75
|
+
/** Sends the text plus the pending attachments as one turn. Never
|
|
76
|
+
* throws: the outcome is the result and the history. */
|
|
77
|
+
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
|
+
const body = text.trim() === "" ? "" : text;
|
|
86
|
+
if (body === "" && this.pending.length === 0)
|
|
87
|
+
return EMPTY;
|
|
88
|
+
const sections = this.pending.map((a) => formatAttachment(a.path, a.content));
|
|
89
|
+
const prompt = [body, ...sections].filter((s) => s !== "").join("\n\n");
|
|
90
|
+
const attachments = this.pending.map(({ path, bytes }) => ({
|
|
91
|
+
path,
|
|
92
|
+
bytes,
|
|
93
|
+
}));
|
|
94
|
+
this.pending = [];
|
|
95
|
+
this.push({ role: "user", text: body, attachments });
|
|
96
|
+
this.lastPrompt = prompt;
|
|
97
|
+
return this.runTurn(prompt);
|
|
98
|
+
}
|
|
99
|
+
/** Re-runs the last prompt after a recoverable fatal error (a missing
|
|
100
|
+
* browser that was just installed). Drops the trailing error entry so
|
|
101
|
+
* the history reads user → assistant. */
|
|
102
|
+
async retryLast() {
|
|
103
|
+
if (this.lastPrompt === undefined)
|
|
104
|
+
return EMPTY;
|
|
105
|
+
if (this.status === "busy" || this.status === "opening") {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
code: "INVALID_STATE",
|
|
109
|
+
message: "A send is already in progress.",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (this.messages.at(-1)?.role === "error")
|
|
113
|
+
this.messages.pop();
|
|
114
|
+
this.lastError = undefined;
|
|
115
|
+
if (this.status === "dead")
|
|
116
|
+
this.status = "closed";
|
|
117
|
+
this.emit();
|
|
118
|
+
return this.runTurn(this.lastPrompt);
|
|
119
|
+
}
|
|
120
|
+
async runTurn(prompt) {
|
|
121
|
+
// A new turn supersedes whatever killed the previous one: a stale
|
|
122
|
+
// `lastError` would keep the webview's error banner up after a
|
|
123
|
+
// successful send from `dead`.
|
|
124
|
+
this.lastError = undefined;
|
|
125
|
+
try {
|
|
126
|
+
if (this.session === undefined) {
|
|
127
|
+
this.setStatus("opening");
|
|
128
|
+
this.session = await this.opts.openSession();
|
|
129
|
+
}
|
|
130
|
+
this.setStatus("busy");
|
|
131
|
+
const reply = await this.session.send(prompt);
|
|
132
|
+
this.messages.push({ role: "assistant", text: reply });
|
|
133
|
+
this.setStatus("idle");
|
|
134
|
+
return { ok: true };
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
return this.fail(err);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async fail(err) {
|
|
141
|
+
const code = err instanceof ChatBridgeError ? err.code : "UNKNOWN";
|
|
142
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
143
|
+
const hint = this.opts.hints?.[code];
|
|
144
|
+
this.messages.push({
|
|
145
|
+
role: "error",
|
|
146
|
+
text: hint === undefined ? message : `${message}\n${hint}`,
|
|
147
|
+
});
|
|
148
|
+
// Show the error before `dropSession` (up to `closeTimeoutMs`) runs.
|
|
149
|
+
this.emit();
|
|
150
|
+
if (code === "RESPONSE_TIMEOUT") {
|
|
151
|
+
this.setStatus("idle");
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
this.lastError = code;
|
|
155
|
+
await this.dropSession();
|
|
156
|
+
this.setStatus("dead");
|
|
157
|
+
}
|
|
158
|
+
return { ok: false, code, message };
|
|
159
|
+
}
|
|
160
|
+
async dropSession() {
|
|
161
|
+
const old = this.session;
|
|
162
|
+
this.session = undefined;
|
|
163
|
+
if (old !== undefined)
|
|
164
|
+
await closeOrKill(old, this.closeTimeoutMs);
|
|
165
|
+
}
|
|
166
|
+
/** Ctrl+R of the TUI: drop the browser, mark the break, reopen lazily. */
|
|
167
|
+
async newChat() {
|
|
168
|
+
await this.discard("New chat");
|
|
169
|
+
}
|
|
170
|
+
/** Closes the session (if any) and pushes `separator`. Refused (returns
|
|
171
|
+
* false) while a turn is in flight. Clears a dead state. */
|
|
172
|
+
async discard(separator) {
|
|
173
|
+
if (this.status === "busy" || this.status === "opening")
|
|
174
|
+
return false;
|
|
175
|
+
await this.dropSession();
|
|
176
|
+
this.lastError = undefined;
|
|
177
|
+
// A fresh chat must not re-send a prompt from before the break.
|
|
178
|
+
this.lastPrompt = undefined;
|
|
179
|
+
this.status = "closed";
|
|
180
|
+
this.push({ role: "separator", text: separator });
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
/** After a successful login command: a dead controller may try again. */
|
|
184
|
+
markLoggedIn() {
|
|
185
|
+
if (this.status === "dead") {
|
|
186
|
+
this.lastError = undefined;
|
|
187
|
+
this.status = "closed";
|
|
188
|
+
}
|
|
189
|
+
this.push({ role: "separator", text: "Logged in" });
|
|
190
|
+
}
|
|
191
|
+
/** deactivate: close the browser, keep the history. Idempotent. */
|
|
192
|
+
async close() {
|
|
193
|
+
await this.dropSession();
|
|
194
|
+
if (this.status !== "dead")
|
|
195
|
+
this.status = "closed";
|
|
196
|
+
this.emit();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Vendor-facing UI customisation, passed as `createExtension({ ui })`. */
|
|
2
|
+
export interface ExtensionUiOptions {
|
|
3
|
+
/** Shown centred in the empty history until the first message; plain
|
|
4
|
+
* text, `\n` allowed. */
|
|
5
|
+
welcome?: string;
|
|
6
|
+
/** Image path relative to the extension root (png/svg); shown above
|
|
7
|
+
* `welcome`, same lifetime. Must ship inside the .vsix. */
|
|
8
|
+
banner?: string;
|
|
9
|
+
/** One line under the composer, always visible. Plain text. */
|
|
10
|
+
footer?: string;
|
|
11
|
+
/** CSS colour strings for the Send button. */
|
|
12
|
+
sendButton?: {
|
|
13
|
+
background?: string;
|
|
14
|
+
foreground?: string;
|
|
15
|
+
};
|
|
16
|
+
/** CSS colour string for the border around the user's own messages. */
|
|
17
|
+
userMessage?: {
|
|
18
|
+
borderColor?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Host-side resolved form: `bannerPath` is absolute and already validated.
|
|
22
|
+
* `ChatViewProvider` turns it into a `bannerUri` per webview. */
|
|
23
|
+
export interface ResolvedUiConfig {
|
|
24
|
+
welcome?: string;
|
|
25
|
+
bannerPath?: string;
|
|
26
|
+
footer?: string;
|
|
27
|
+
sendButton?: {
|
|
28
|
+
background?: string;
|
|
29
|
+
foreground?: string;
|
|
30
|
+
};
|
|
31
|
+
userMessage?: {
|
|
32
|
+
borderColor?: string;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Validates the vendor's UI options at activation time so a missing banner
|
|
36
|
+
* fails loudly instead of silently rendering nothing. Pure: the caller
|
|
37
|
+
* supplies the file-existence check. */
|
|
38
|
+
export declare function resolveUiConfig(ui: ExtensionUiOptions | undefined, extensionRoot: string, exists: (path: string) => boolean): ResolvedUiConfig | undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isAbsolute, join, normalize, sep } from "node:path";
|
|
2
|
+
/** Validates the vendor's UI options at activation time so a missing banner
|
|
3
|
+
* fails loudly instead of silently rendering nothing. Pure: the caller
|
|
4
|
+
* supplies the file-existence check. */
|
|
5
|
+
export function resolveUiConfig(ui, extensionRoot, exists) {
|
|
6
|
+
if (!ui)
|
|
7
|
+
return undefined;
|
|
8
|
+
const config = {};
|
|
9
|
+
if (ui.welcome !== undefined)
|
|
10
|
+
config.welcome = ui.welcome;
|
|
11
|
+
if (ui.footer !== undefined)
|
|
12
|
+
config.footer = ui.footer;
|
|
13
|
+
if (ui.sendButton !== undefined)
|
|
14
|
+
config.sendButton = ui.sendButton;
|
|
15
|
+
if (ui.userMessage !== undefined)
|
|
16
|
+
config.userMessage = ui.userMessage;
|
|
17
|
+
if (ui.banner !== undefined) {
|
|
18
|
+
const relative = normalize(ui.banner);
|
|
19
|
+
if (ui.banner.trim() === "" ||
|
|
20
|
+
isAbsolute(ui.banner) ||
|
|
21
|
+
relative === "." ||
|
|
22
|
+
relative === ".." ||
|
|
23
|
+
relative.startsWith(`..${sep}`)) {
|
|
24
|
+
throw new Error(`${ui.banner}: banner path must be relative to the extension root`);
|
|
25
|
+
}
|
|
26
|
+
const absolute = join(extensionRoot, relative);
|
|
27
|
+
if (!exists(absolute)) {
|
|
28
|
+
throw new Error(`${ui.banner}: banner image not found (looked for ${absolute})`);
|
|
29
|
+
}
|
|
30
|
+
config.bannerPath = absolute;
|
|
31
|
+
}
|
|
32
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
33
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type * as vscode from "vscode";
|
|
2
|
+
export interface EditorSnapshot {
|
|
3
|
+
/** Workspace-relative, `/`-separated; absolute when outside a workspace. */
|
|
4
|
+
path: string;
|
|
5
|
+
text: string;
|
|
6
|
+
/** Present when the selection is non-empty; lines are 1-based inclusive. */
|
|
7
|
+
selection?: {
|
|
8
|
+
text: string;
|
|
9
|
+
startLine: number;
|
|
10
|
+
endLine: number;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface ProgressReporter {
|
|
14
|
+
report(text: string): void;
|
|
15
|
+
}
|
|
16
|
+
/** The slice of the vscode API the commands use; a fake in unit tests. */
|
|
17
|
+
export interface VscodeUi {
|
|
18
|
+
showErrorMessage(message: string, ...items: string[]): Promise<string | undefined>;
|
|
19
|
+
showWarningMessage(message: string): void;
|
|
20
|
+
showInformationMessage(message: string): void;
|
|
21
|
+
withProgress<T>(title: string, cancellable: boolean, task: (progress: ProgressReporter, signal: AbortSignal) => Promise<T>): Promise<T>;
|
|
22
|
+
activeEditor(): EditorSnapshot | undefined;
|
|
23
|
+
/** Opens the document behind an explorer Uri (or any Uri) read-only. */
|
|
24
|
+
openDocument(uri: unknown): Promise<{
|
|
25
|
+
path: string;
|
|
26
|
+
text: string;
|
|
27
|
+
}>;
|
|
28
|
+
focusView(): void;
|
|
29
|
+
}
|
|
30
|
+
export declare function createVscodeUi(api: typeof vscode, id: string): VscodeUi;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function createVscodeUi(api, id) {
|
|
2
|
+
function relPath(uri) {
|
|
3
|
+
return api.workspace.asRelativePath(uri, false).split("\\").join("/");
|
|
4
|
+
}
|
|
5
|
+
return {
|
|
6
|
+
showErrorMessage: (message, ...items) => Promise.resolve(api.window.showErrorMessage(message, { modal: items.length > 0 }, ...items)),
|
|
7
|
+
showWarningMessage: (message) => void api.window.showWarningMessage(message),
|
|
8
|
+
showInformationMessage: (message) => void api.window.showInformationMessage(message),
|
|
9
|
+
withProgress: (title, cancellable, task) => Promise.resolve(api.window.withProgress({ location: api.ProgressLocation.Notification, title, cancellable }, (progress, token) => {
|
|
10
|
+
const ac = new AbortController();
|
|
11
|
+
token.onCancellationRequested(() => ac.abort());
|
|
12
|
+
return task({ report: (message) => progress.report({ message }) }, ac.signal);
|
|
13
|
+
})),
|
|
14
|
+
activeEditor: () => {
|
|
15
|
+
const editor = api.window.activeTextEditor;
|
|
16
|
+
if (!editor)
|
|
17
|
+
return undefined;
|
|
18
|
+
const doc = editor.document;
|
|
19
|
+
const snap = {
|
|
20
|
+
path: relPath(doc.uri),
|
|
21
|
+
text: doc.getText(),
|
|
22
|
+
};
|
|
23
|
+
if (!editor.selection.isEmpty) {
|
|
24
|
+
snap.selection = {
|
|
25
|
+
text: doc.getText(editor.selection),
|
|
26
|
+
startLine: editor.selection.start.line + 1,
|
|
27
|
+
endLine: editor.selection.end.line + 1,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return snap;
|
|
31
|
+
},
|
|
32
|
+
openDocument: async (uri) => {
|
|
33
|
+
const doc = await api.workspace.openTextDocument(uri);
|
|
34
|
+
return { path: relPath(doc.uri), text: doc.getText() };
|
|
35
|
+
},
|
|
36
|
+
focusView: () => void api.commands.executeCommand(`${id}.chat.focus`),
|
|
37
|
+
};
|
|
38
|
+
}
|