@larose/pi-web 0.3.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 +235 -0
- package/README.md +50 -0
- package/THIRD_PARTY_LICENSES.md +40 -0
- package/dist/client/home.js +1619 -0
- package/dist/client/session.js +3703 -0
- package/dist/server/api.js +485 -0
- package/dist/server/cli.js +51 -0
- package/dist/server/directory-browser.js +104 -0
- package/dist/server/errors.js +10 -0
- package/dist/server/event-buffer.js +40 -0
- package/dist/server/extension-ui.js +245 -0
- package/dist/server/git-workspaces.js +559 -0
- package/dist/server/runtime-registry.js +703 -0
- package/dist/server/server.js +190 -0
- package/dist/server/session-repository.js +374 -0
- package/package.json +46 -0
- package/public/home.html +139 -0
- package/public/session.html +144 -0
- package/public/styles.css +2463 -0
- package/screenshots/home.png +0 -0
- package/screenshots/session.png +0 -0
- package/src/client/display-title.ts +36 -0
- package/src/client/event-stream.ts +194 -0
- package/src/client/home.ts +1575 -0
- package/src/client/markdown.ts +98 -0
- package/src/client/message-queue.ts +67 -0
- package/src/client/path-combobox.ts +271 -0
- package/src/client/session.ts +2174 -0
- package/src/client/shared.ts +99 -0
- package/src/client/slash-completion.ts +184 -0
- package/src/client/transcript-activity.ts +188 -0
- package/src/client/usage-format.ts +156 -0
- package/src/client/workspace-browser.ts +36 -0
- package/src/server/api.ts +652 -0
- package/src/server/cli.ts +63 -0
- package/src/server/directory-browser.ts +137 -0
- package/src/server/errors.ts +11 -0
- package/src/server/event-buffer.ts +59 -0
- package/src/server/extension-ui.ts +359 -0
- package/src/server/git-workspaces.ts +750 -0
- package/src/server/runtime-registry.ts +943 -0
- package/src/server/server.ts +248 -0
- package/src/server/session-repository.ts +488 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Marked, type Tokens } from "marked";
|
|
2
|
+
|
|
3
|
+
const fallbackOrigin = "http://localhost";
|
|
4
|
+
const safeLinkProtocols = new Set(["http:", "https:", "mailto:"]);
|
|
5
|
+
const safeImageProtocols = new Set(["http:", "https:"]);
|
|
6
|
+
|
|
7
|
+
interface NormalizedUrl {
|
|
8
|
+
href: string;
|
|
9
|
+
protocol: string;
|
|
10
|
+
sameOrigin: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function escapeHtml(value: string): string {
|
|
14
|
+
return value
|
|
15
|
+
.replaceAll("&", "&")
|
|
16
|
+
.replaceAll("<", "<")
|
|
17
|
+
.replaceAll(">", ">")
|
|
18
|
+
.replaceAll('"', """)
|
|
19
|
+
.replaceAll("'", "'");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function currentOrigin(): string {
|
|
23
|
+
return typeof window === "undefined" ? fallbackOrigin : window.location.origin;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeUrl(value: string, origin: string): NormalizedUrl | undefined {
|
|
27
|
+
try {
|
|
28
|
+
const base = new URL(origin);
|
|
29
|
+
const url = new URL(value, base);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
href: url.href,
|
|
33
|
+
protocol: url.protocol.toLowerCase(),
|
|
34
|
+
sameOrigin: url.origin === base.origin,
|
|
35
|
+
};
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function titleAttribute(title: string | null | undefined): string {
|
|
42
|
+
return title === null || title === undefined ? "" : ` title="${escapeHtml(title)}"`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderLink(
|
|
46
|
+
token: Tokens.Link,
|
|
47
|
+
origin: string,
|
|
48
|
+
parseInline: (tokens: Tokens.Link["tokens"]) => string,
|
|
49
|
+
): string {
|
|
50
|
+
const label = parseInline(token.tokens);
|
|
51
|
+
const url = normalizeUrl(token.href, origin);
|
|
52
|
+
|
|
53
|
+
if (!url || !safeLinkProtocols.has(url.protocol)) {
|
|
54
|
+
return `<span class="markdown-unsafe-link">${label}</span>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return `<a href="${escapeHtml(url.href)}"${titleAttribute(token.title)} target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderImage(token: Tokens.Image, origin: string): string {
|
|
61
|
+
const alt = token.text || "image";
|
|
62
|
+
const escapedAlt = escapeHtml(alt);
|
|
63
|
+
const url = normalizeUrl(token.href, origin);
|
|
64
|
+
|
|
65
|
+
if (!url || !safeImageProtocols.has(url.protocol)) {
|
|
66
|
+
return `<span class="markdown-image-unavailable">${escapedAlt}</span>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!url.sameOrigin) {
|
|
70
|
+
return `<a class="markdown-external-image" href="${escapeHtml(url.href)}"${titleAttribute(token.title)} target="_blank" rel="noopener noreferrer">Open image: ${escapedAlt}</a>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return `<img class="markdown-image" src="${escapeHtml(url.href)}" alt="${escapedAlt}"${titleAttribute(token.title)}>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function renderMarkdown(source: string, origin = currentOrigin()): string {
|
|
77
|
+
try {
|
|
78
|
+
const parser = new Marked({
|
|
79
|
+
async: false,
|
|
80
|
+
gfm: true,
|
|
81
|
+
renderer: {
|
|
82
|
+
html(token): string {
|
|
83
|
+
return escapeHtml(token.text);
|
|
84
|
+
},
|
|
85
|
+
link(token): string {
|
|
86
|
+
return renderLink(token, origin, (tokens) => this.parser.parseInline(tokens));
|
|
87
|
+
},
|
|
88
|
+
image(token): string {
|
|
89
|
+
return renderImage(token, origin);
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return parser.parse(source, { async: false });
|
|
95
|
+
} catch {
|
|
96
|
+
return `<pre class="markdown-fallback">${escapeHtml(source)}</pre>`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface MessageQueue {
|
|
2
|
+
steering: string[];
|
|
3
|
+
followUp: string[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function copyMessageQueue(queue: MessageQueue | undefined): MessageQueue {
|
|
7
|
+
return {
|
|
8
|
+
steering: [...(queue?.steering ?? [])],
|
|
9
|
+
followUp: [...(queue?.followUp ?? [])],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function messageQueueFromEvent(event: Record<string, unknown>): MessageQueue {
|
|
14
|
+
return {
|
|
15
|
+
steering: Array.isArray(event.steering)
|
|
16
|
+
? event.steering.filter((message): message is string => typeof message === "string")
|
|
17
|
+
: [],
|
|
18
|
+
followUp: Array.isArray(event.followUp)
|
|
19
|
+
? event.followUp.filter((message): message is string => typeof message === "string")
|
|
20
|
+
: [],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function removedSteeringPrefix(previous: MessageQueue, next: MessageQueue): string[] {
|
|
25
|
+
const removedCount = previous.steering.length - next.steering.length;
|
|
26
|
+
if (removedCount <= 0) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const unchangedRemainder = next.steering.every(
|
|
31
|
+
(message, index) => previous.steering[index + removedCount] === message,
|
|
32
|
+
);
|
|
33
|
+
return unchangedRemainder ? previous.steering.slice(0, removedCount) : [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function steeringQueueGrew(previous: MessageQueue, next: MessageQueue): boolean {
|
|
37
|
+
if (next.steering.length <= previous.steering.length) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return previous.steering.every((message, index) => next.steering[index] === message);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function reconcileMessageQueue(
|
|
45
|
+
previous: MessageQueue,
|
|
46
|
+
next: MessageQueue,
|
|
47
|
+
discardRemoved = false,
|
|
48
|
+
): { queue: MessageQueue; dequeuedSteering: string[] } {
|
|
49
|
+
return {
|
|
50
|
+
queue: copyMessageQueue(next),
|
|
51
|
+
dequeuedSteering: discardRemoved ? [] : removedSteeringPrefix(previous, next),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function withoutSteeringMessage(
|
|
56
|
+
queue: MessageQueue,
|
|
57
|
+
index: number,
|
|
58
|
+
expectedMessage: string,
|
|
59
|
+
): MessageQueue | undefined {
|
|
60
|
+
if (!Number.isSafeInteger(index) || index < 0 || queue.steering[index] !== expectedMessage) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const next = copyMessageQueue(queue);
|
|
65
|
+
next.steering.splice(index, 1);
|
|
66
|
+
return next;
|
|
67
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
export interface PathComboboxSuggestion {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
detail?: string;
|
|
5
|
+
source: "recent" | "server";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PathComboboxOptions {
|
|
9
|
+
input: HTMLInputElement;
|
|
10
|
+
list: HTMLElement;
|
|
11
|
+
status: HTMLElement;
|
|
12
|
+
toggle: HTMLButtonElement;
|
|
13
|
+
shortcuts(): PathComboboxSuggestion[];
|
|
14
|
+
load(value: string, signal: AbortSignal): Promise<PathComboboxSuggestion[]>;
|
|
15
|
+
commit(value: string): unknown | Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let nextComboboxId = 0;
|
|
19
|
+
|
|
20
|
+
function uniqueSuggestions(suggestions: readonly PathComboboxSuggestion[]): PathComboboxSuggestion[] {
|
|
21
|
+
const values = new Set<string>();
|
|
22
|
+
return suggestions.filter((suggestion) => {
|
|
23
|
+
if (values.has(suggestion.value)) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
values.add(suggestion.value);
|
|
27
|
+
return true;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class PathCombobox {
|
|
32
|
+
private readonly options: PathComboboxOptions;
|
|
33
|
+
private readonly optionIdPrefix: string;
|
|
34
|
+
private suggestions: PathComboboxSuggestion[] = [];
|
|
35
|
+
private activeIndex = -1;
|
|
36
|
+
private generation = 0;
|
|
37
|
+
private request: AbortController | null = null;
|
|
38
|
+
private openState = false;
|
|
39
|
+
private loading = false;
|
|
40
|
+
private disabled = false;
|
|
41
|
+
|
|
42
|
+
constructor(options: PathComboboxOptions) {
|
|
43
|
+
this.options = options;
|
|
44
|
+
this.optionIdPrefix = `path-combobox-option-${++nextComboboxId}`;
|
|
45
|
+
|
|
46
|
+
options.input.setAttribute("role", "combobox");
|
|
47
|
+
options.input.setAttribute("aria-autocomplete", "list");
|
|
48
|
+
options.input.setAttribute("aria-controls", options.list.id);
|
|
49
|
+
options.input.setAttribute("aria-expanded", "false");
|
|
50
|
+
options.input.setAttribute("aria-haspopup", "listbox");
|
|
51
|
+
options.toggle.setAttribute("aria-controls", options.list.id);
|
|
52
|
+
options.list.setAttribute("role", "listbox");
|
|
53
|
+
options.list.setAttribute("aria-label", "Directory suggestions");
|
|
54
|
+
|
|
55
|
+
options.input.addEventListener("focus", () => this.open());
|
|
56
|
+
options.input.addEventListener("click", () => {
|
|
57
|
+
const end = options.input.value.length;
|
|
58
|
+
options.input.setSelectionRange(end, end);
|
|
59
|
+
});
|
|
60
|
+
options.input.addEventListener("input", () => {
|
|
61
|
+
this.activeIndex = -1;
|
|
62
|
+
this.openState = true;
|
|
63
|
+
this.render();
|
|
64
|
+
void this.refresh();
|
|
65
|
+
});
|
|
66
|
+
options.input.addEventListener("keydown", (event) => this.handleKeydown(event));
|
|
67
|
+
options.input.addEventListener("blur", () => {
|
|
68
|
+
window.setTimeout(() => this.close(), 0);
|
|
69
|
+
});
|
|
70
|
+
options.toggle.addEventListener("click", () => {
|
|
71
|
+
if (this.disabled) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (this.openState) {
|
|
76
|
+
this.close();
|
|
77
|
+
} else {
|
|
78
|
+
options.input.focus();
|
|
79
|
+
if (!this.openState) {
|
|
80
|
+
this.open();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setValue(value: string): void {
|
|
87
|
+
this.options.input.value = value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
value(): string {
|
|
91
|
+
return this.options.input.value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setDisabled(disabled: boolean): void {
|
|
95
|
+
this.disabled = disabled;
|
|
96
|
+
this.options.input.disabled = disabled;
|
|
97
|
+
this.options.toggle.disabled = disabled;
|
|
98
|
+
if (disabled) {
|
|
99
|
+
this.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
refreshShortcuts(): void {
|
|
104
|
+
if (this.openState) {
|
|
105
|
+
void this.refresh();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
close(): void {
|
|
110
|
+
this.openState = false;
|
|
111
|
+
this.activeIndex = -1;
|
|
112
|
+
this.request?.abort();
|
|
113
|
+
this.request = null;
|
|
114
|
+
this.options.input.removeAttribute("aria-activedescendant");
|
|
115
|
+
this.render();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private open(): void {
|
|
119
|
+
if (this.disabled) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.openState = true;
|
|
124
|
+
this.render();
|
|
125
|
+
void this.refresh();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async refresh(): Promise<void> {
|
|
129
|
+
const generation = ++this.generation;
|
|
130
|
+
this.request?.abort();
|
|
131
|
+
const request = new AbortController();
|
|
132
|
+
this.request = request;
|
|
133
|
+
const shortcuts = this.options.shortcuts();
|
|
134
|
+
const value = this.options.input.value.trim();
|
|
135
|
+
|
|
136
|
+
this.loading = Boolean(value);
|
|
137
|
+
this.suggestions = shortcuts;
|
|
138
|
+
this.options.status.textContent = this.loading ? "Loading folders…" : "";
|
|
139
|
+
this.render();
|
|
140
|
+
if (!value) {
|
|
141
|
+
this.loading = false;
|
|
142
|
+
this.options.status.textContent = shortcuts.length > 0 ? `${shortcuts.length} recent paths` : "Enter a path";
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const loaded = await this.options.load(value, request.signal);
|
|
148
|
+
if (generation !== this.generation || request.signal.aborted) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.suggestions = uniqueSuggestions([...shortcuts, ...loaded]);
|
|
153
|
+
this.options.status.textContent =
|
|
154
|
+
this.suggestions.length > 0 ? `${this.suggestions.length} paths available` : "No matching directories";
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (generation !== this.generation || request.signal.aborted) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.suggestions = shortcuts;
|
|
161
|
+
this.options.status.textContent = error instanceof Error ? error.message : "Could not browse directories";
|
|
162
|
+
} finally {
|
|
163
|
+
if (generation === this.generation) {
|
|
164
|
+
this.loading = false;
|
|
165
|
+
this.request = null;
|
|
166
|
+
this.render();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private render(): void {
|
|
172
|
+
this.options.input.setAttribute("aria-expanded", String(this.openState));
|
|
173
|
+
this.options.input.setAttribute("aria-busy", String(this.loading));
|
|
174
|
+
this.options.toggle.setAttribute("aria-expanded", String(this.openState));
|
|
175
|
+
this.options.list.hidden = !this.openState;
|
|
176
|
+
if (!this.openState) {
|
|
177
|
+
this.options.list.replaceChildren();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const fragment = document.createDocumentFragment();
|
|
182
|
+
let previousSource: PathComboboxSuggestion["source"] | null = null;
|
|
183
|
+
for (const [index, suggestion] of this.suggestions.entries()) {
|
|
184
|
+
if (suggestion.source !== previousSource) {
|
|
185
|
+
const heading = document.createElement("li");
|
|
186
|
+
heading.className = "path-combobox-heading";
|
|
187
|
+
heading.textContent = suggestion.source === "recent" ? "Recent" : "Folders";
|
|
188
|
+
heading.setAttribute("role", "presentation");
|
|
189
|
+
fragment.append(heading);
|
|
190
|
+
previousSource = suggestion.source;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const item = document.createElement("li");
|
|
194
|
+
item.className = "path-combobox-option";
|
|
195
|
+
item.id = `${this.optionIdPrefix}-${index}`;
|
|
196
|
+
item.setAttribute("role", "option");
|
|
197
|
+
item.setAttribute("aria-selected", String(index === this.activeIndex));
|
|
198
|
+
item.append(Object.assign(document.createElement("strong"), { textContent: suggestion.label }));
|
|
199
|
+
if (suggestion.detail) {
|
|
200
|
+
item.append(Object.assign(document.createElement("span"), { textContent: suggestion.detail }));
|
|
201
|
+
}
|
|
202
|
+
item.addEventListener("mousedown", (event) => event.preventDefault());
|
|
203
|
+
item.addEventListener("click", () => this.choose(index));
|
|
204
|
+
fragment.append(item);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (this.suggestions.length === 0 && !this.loading) {
|
|
208
|
+
const empty = document.createElement("li");
|
|
209
|
+
empty.className = "path-combobox-empty";
|
|
210
|
+
empty.textContent = "No directories found";
|
|
211
|
+
empty.setAttribute("role", "presentation");
|
|
212
|
+
fragment.append(empty);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this.options.list.replaceChildren(fragment);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private choose(index: number): void {
|
|
219
|
+
const suggestion = this.suggestions[index];
|
|
220
|
+
if (!suggestion) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.options.input.value = suggestion.value;
|
|
225
|
+
this.close();
|
|
226
|
+
void this.options.commit(suggestion.value);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private moveActive(offset: number): void {
|
|
230
|
+
if (this.suggestions.length === 0) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.activeIndex =
|
|
235
|
+
this.activeIndex < 0
|
|
236
|
+
? offset > 0
|
|
237
|
+
? 0
|
|
238
|
+
: this.suggestions.length - 1
|
|
239
|
+
: (this.activeIndex + offset + this.suggestions.length) % this.suggestions.length;
|
|
240
|
+
this.options.input.setAttribute("aria-activedescendant", `${this.optionIdPrefix}-${this.activeIndex}`);
|
|
241
|
+
this.render();
|
|
242
|
+
document.getElementById(`${this.optionIdPrefix}-${this.activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private handleKeydown(event: KeyboardEvent): void {
|
|
246
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
247
|
+
event.preventDefault();
|
|
248
|
+
if (!this.openState) {
|
|
249
|
+
this.open();
|
|
250
|
+
}
|
|
251
|
+
this.moveActive(event.key === "ArrowDown" ? 1 : -1);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (event.key === "Escape" && this.openState) {
|
|
256
|
+
event.preventDefault();
|
|
257
|
+
this.close();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (event.key === "Enter") {
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
if (this.openState && this.activeIndex >= 0) {
|
|
264
|
+
this.choose(this.activeIndex);
|
|
265
|
+
} else {
|
|
266
|
+
this.close();
|
|
267
|
+
void this.options.commit(this.options.input.value);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|