Monorepo for Aesthetic.Computer
aesthetic.computer
1// VSCode Extension, 23.06.24.18.58
2// A VSCode extension for live coding aesthetic.computer pieces and
3// exploring the system documentation.
4
5/* #region TODO 📓
6#endregion */
7
8// Import necessary modules from vscode
9import * as vscode from "vscode";
10import { Buffer } from "buffer";
11
12// (generated-views import removed — welcome panel now loads dev.html via iframe only)
13
14// 🌈 KidLisp Syntax Highlighting
15// Embedded copy of shared/kidlisp-syntax.mjs for bundling
16// This provides Monaco-parity highlighting for .lisp files in VS Code
17import * as KidLispSyntax from "./kidlisp-syntax";
18
19// Dynamically import path, fs, child_process, and http to ensure web compatibility.
20let path: any, fs: any, cp: any, http: any;
21let _modulesReady: Promise<void>;
22_modulesReady = (async () => {
23 if (typeof window === "undefined") {
24 path = await import("path");
25 fs = await import("fs");
26 cp = await import("child_process");
27 http = await import("http");
28 }
29})();
30
31import { AestheticAuthenticationProvider } from "./aestheticAuthenticationProviderRemote";
32import * as acorn from "acorn";
33const { keys } = Object;
34
35// AST parsing and tracking for JS/MJS files
36interface ASTNode {
37 id: string;
38 type: string;
39 name?: string;
40 start: number;
41 end: number;
42 children: ASTNode[];
43 parent?: string;
44 depth: number;
45 loc?: { start: { line: number; column: number }; end: { line: number; column: number } };
46 // Extra metadata for richer display
47 kind?: string; // const, let, var for VariableDeclaration
48 source?: string; // import source path
49 callee?: string; // function being called
50 property?: string; // member access property
51 value?: string; // literal value
52}
53
54interface TrackedFile {
55 uri: string;
56 fileName: string;
57 filePath: string;
58 ast: ASTNode | null;
59 lastUpdate: number;
60}
61
62const trackedFiles = new Map<string, TrackedFile>();
63let astUpdateCallback: ((files: TrackedFile[]) => void) | null = null;
64
65function parseJSToAST(code: string, fileName: string): ASTNode | null {
66 try {
67 const ast = acorn.parse(code, {
68 ecmaVersion: 'latest',
69 sourceType: 'module',
70 locations: true,
71 });
72
73 let nodeId = 0;
74
75 function convertNode(node: any, depth: number = 0, parentId?: string): ASTNode {
76 const id = `${fileName}-${nodeId++}`;
77 const children: ASTNode[] = [];
78
79 // Get a friendly name for the node
80 let name: string | undefined;
81 let kind: string | undefined;
82 let source: string | undefined;
83 let callee: string | undefined;
84 let property: string | undefined;
85 let value: string | undefined;
86
87 // Extract identifier names
88 if (node.id?.name) name = node.id.name;
89 else if (node.key?.name) name = node.key.name;
90 else if (node.key?.value) name = String(node.key.value);
91 else if (node.name) name = node.name;
92 else if (node.type === 'Identifier') name = node.name;
93
94 // Extract additional metadata based on node type
95 switch (node.type) {
96 case 'VariableDeclaration':
97 kind = node.kind; // const, let, var
98 break;
99 case 'ImportDeclaration':
100 source = node.source?.value;
101 break;
102 case 'ExportNamedDeclaration':
103 case 'ExportDefaultDeclaration':
104 if (node.declaration?.id?.name) name = node.declaration.id.name;
105 if (node.source?.value) source = node.source.value;
106 break;
107 case 'CallExpression':
108 if (node.callee?.name) callee = node.callee.name;
109 else if (node.callee?.property?.name) callee = node.callee.property.name;
110 else if (node.callee?.type === 'MemberExpression') {
111 const obj = node.callee.object?.name || '';
112 const prop = node.callee.property?.name || '';
113 callee = obj ? `${obj}.${prop}` : prop;
114 }
115 break;
116 case 'MemberExpression':
117 property = node.property?.name || node.property?.value;
118 break;
119 case 'Literal':
120 value = String(node.value).slice(0, 30);
121 name = value.length > 15 ? value.slice(0, 12) + '…' : value;
122 break;
123 case 'TemplateLiteral':
124 name = '`template`';
125 break;
126 case 'Property':
127 name = node.key?.name || node.key?.value;
128 break;
129 }
130
131 const astNode: ASTNode = {
132 id,
133 type: node.type,
134 name,
135 start: node.start,
136 end: node.end,
137 children,
138 parent: parentId,
139 depth,
140 loc: node.loc,
141 kind,
142 source,
143 callee,
144 property,
145 value,
146 };
147
148 // Recursively process child nodes
149 for (const key of Object.keys(node)) {
150 if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue;
151 const val = node[key];
152 if (val && typeof val === 'object') {
153 if (Array.isArray(val)) {
154 for (const item of val) {
155 if (item && typeof item === 'object' && item.type) {
156 children.push(convertNode(item, depth + 1, id));
157 }
158 }
159 } else if (val.type) {
160 children.push(convertNode(val, depth + 1, id));
161 }
162 }
163 }
164
165 return astNode;
166 }
167
168 return convertNode(ast);
169 } catch (e) {
170 console.log(`AST parse error for ${fileName}:`, e);
171 return null;
172 }
173}
174
175function updateTrackedFile(document: vscode.TextDocument) {
176 const uri = document.uri.toString();
177 const filePath = document.fileName;
178 const fileName = filePath.split('/').pop() || filePath;
179
180 // Track JS/MJS/TS files (for AST), plus Markdown and Lisp files (as open buffers)
181 const isCodeFile = fileName.endsWith('.js') || fileName.endsWith('.mjs') || fileName.endsWith('.ts');
182 const isMarkdown = fileName.endsWith('.md');
183 const isLisp = fileName.endsWith('.lisp');
184
185 if (!isCodeFile && !isMarkdown && !isLisp) {
186 return;
187 }
188
189 // Only parse AST for code files
190 const ast = isCodeFile ? parseJSToAST(document.getText(), fileName) : null;
191
192 trackedFiles.set(uri, {
193 uri,
194 fileName,
195 filePath,
196 ast,
197 lastUpdate: Date.now(),
198 });
199
200 if (astUpdateCallback) {
201 astUpdateCallback(Array.from(trackedFiles.values()));
202 }
203}
204
205function removeTrackedFile(uri: string) {
206 trackedFiles.delete(uri);
207 if (astUpdateCallback) {
208 astUpdateCallback(Array.from(trackedFiles.values()));
209 }
210}
211
212let local: boolean = false;
213let localServerAvailable: boolean = false;
214let codeChannel: string | undefined;
215
216// Detect if we're in GitHub Codespaces
217const isCodespaces = process?.env.CODESPACES === "true";
218const codespaceName = process?.env.CODESPACE_NAME;
219const codespacesDomain = process?.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN;
220
221let mergedDocs: any = {};
222let docs: any;
223
224let extContext: any;
225let webWindow: any;
226let kidlispWindow: any;
227let newsWindow: any;
228let atWindow: any;
229let welcomePanel: vscode.WebviewPanel | null = null;
230let localServerCheckInterval: NodeJS.Timeout | undefined;
231let provider: AestheticViewProvider;
232let lastWebviewRefreshAt = 0; // Debounce webview refreshes
233const WEBVIEW_REFRESH_DEBOUNCE_MS = 10000; // Minimum 10s between auto-refreshes
234
235// Check if the local server is available
236async function checkLocalServer(): Promise<boolean> {
237 try {
238 // Use https module directly to allow self-signed certificates
239 const https = await import("https");
240 return new Promise((resolve) => {
241 const req = https.request(
242 {
243 hostname: "localhost",
244 port: 8888,
245 // HEAD can return 404/redirect even when the server is healthy.
246 // We only care that something is listening and responding.
247 path: `/?vscode=1&ts=${Date.now()}`,
248 method: "GET",
249 rejectUnauthorized: false, // Allow self-signed certs
250 timeout: 2000,
251 },
252 (res) => {
253 // Any response means the server is reachable.
254 resolve(true);
255 // Ensure we don't hang waiting for body data.
256 res.resume();
257 }
258 );
259 req.on("error", () => resolve(false));
260 req.on("timeout", () => {
261 req.destroy();
262 resolve(false);
263 });
264 req.end();
265 });
266 } catch (e) {
267 return false;
268 }
269}
270
271// Start polling for local server availability
272function startLocalServerCheck() {
273 if (localServerCheckInterval) {
274 clearInterval(localServerCheckInterval);
275 }
276
277 // Helper to refresh all webviews with debouncing
278 function refreshAllWebviews() {
279 const now = Date.now();
280 if (now - lastWebviewRefreshAt < WEBVIEW_REFRESH_DEBOUNCE_MS) {
281 console.log("⏳ Skipping webview refresh (debounced)");
282 return;
283 }
284 lastWebviewRefreshAt = now;
285 console.log("✅ Local server is now available");
286 // Refresh webviews when server becomes available
287 if (provider) provider.refreshWebview();
288 refreshWebWindow();
289 refreshKidLispWindow();
290 refreshNewsWindow();
291 refreshAtWindow();
292 }
293
294 // Check immediately
295 checkLocalServer().then((available) => {
296 const wasAvailable = localServerAvailable;
297 localServerAvailable = available;
298 if (available && !wasAvailable) {
299 refreshAllWebviews();
300 }
301 });
302
303 // Then check every 3 seconds
304 localServerCheckInterval = setInterval(async () => {
305 const wasAvailable = localServerAvailable;
306 localServerAvailable = await checkLocalServer();
307
308 if (localServerAvailable && !wasAvailable) {
309 refreshAllWebviews();
310 } else if (!localServerAvailable && wasAvailable) {
311 console.log("⏳ Local server disconnected - waiting for reconnect...");
312 // Don't immediately show waiting screen - server may come back quickly during hot reload
313 // The webview will show the waiting screen on next manual refresh or when we first load
314 }
315 }, 3000);
316}
317
318// Stop polling for local server
319function stopLocalServerCheck() {
320 if (localServerCheckInterval) {
321 clearInterval(localServerCheckInterval);
322 localServerCheckInterval = undefined;
323 }
324}
325
326// 🌈 KidLisp Syntax Highlighting Setup
327// Uses atomic decoration updates to avoid flickering (like Monaco's deltaDecorations)
328const kidlispDecorationTypes = new Map<string, vscode.TextEditorDecorationType>();
329let kidlispHighlightInterval: NodeJS.Timeout | undefined;
330// Track which decoration type keys were used in the last render per editor
331const lastUsedDecorationKeys = new Map<string, Set<string>>();
332
333function getOrCreateDecorationType(color: string, options: { bold?: boolean } = {}): vscode.TextEditorDecorationType {
334 const key = `${color}-${options.bold ? 'bold' : 'normal'}`;
335 if (!kidlispDecorationTypes.has(key)) {
336 const hexColor = KidLispSyntax.colorToHex(color);
337 kidlispDecorationTypes.set(key, vscode.window.createTextEditorDecorationType({
338 color: hexColor,
339 fontWeight: options.bold ? 'bold' : 'normal',
340 }));
341 }
342 return kidlispDecorationTypes.get(key)!;
343}
344
345function applyKidLispDecorations(editor: vscode.TextEditor, isEditMode: boolean = true) {
346 if (!editor || editor.document.languageId !== 'kidlisp') {
347 return;
348 }
349
350 const document = editor.document;
351 const text = document.getText();
352 const tokens = KidLispSyntax.tokenizeWithPositions(text);
353 const tokenValues = tokens.map(t => t.value);
354
355 // Group decorations by color
356 const decorationsByColor = new Map<string, vscode.DecorationOptions[]>();
357
358 // Check if we're in light mode
359 const isLightTheme = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light ||
360 vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight;
361
362 for (let i = 0; i < tokens.length; i++) {
363 const token = tokens[i];
364 let color = KidLispSyntax.getTokenColor(token.value, tokenValues, i, { isEditMode });
365
366 // Handle special color markers
367 if (color === 'RAINBOW') {
368 // Rainbow: each character gets a different color
369 for (let j = 0; j < token.value.length; j++) {
370 const charColor = KidLispSyntax.RAINBOW_COLORS[j % KidLispSyntax.RAINBOW_COLORS.length];
371 const startPos = document.positionAt(token.pos + j);
372 const endPos = document.positionAt(token.pos + j + 1);
373 const range = new vscode.Range(startPos, endPos);
374
375 if (!decorationsByColor.has(charColor)) {
376 decorationsByColor.set(charColor, []);
377 }
378 decorationsByColor.get(charColor)!.push({ range });
379 }
380 continue;
381 }
382
383 if (color === 'ZEBRA') {
384 // Zebra: alternating black and white
385 for (let j = 0; j < token.value.length; j++) {
386 const charColor = KidLispSyntax.ZEBRA_COLORS[j % KidLispSyntax.ZEBRA_COLORS.length];
387 const startPos = document.positionAt(token.pos + j);
388 const endPos = document.positionAt(token.pos + j + 1);
389 const range = new vscode.Range(startPos, endPos);
390
391 if (!decorationsByColor.has(charColor)) {
392 decorationsByColor.set(charColor, []);
393 }
394 decorationsByColor.get(charColor)!.push({ range });
395 }
396 continue;
397 }
398
399 if (color.startsWith('COMPOUND:')) {
400 // Compound: prefix char in one color, rest in another
401 const [, prefixColor, identifierColor] = color.split(':');
402
403 // Prefix character ($ or # or !)
404 const prefixStart = document.positionAt(token.pos);
405 const prefixEnd = document.positionAt(token.pos + 1);
406 const prefixRange = new vscode.Range(prefixStart, prefixEnd);
407
408 const finalPrefixColor = isLightTheme ? KidLispSyntax.getLightModeColor(prefixColor) : prefixColor;
409 if (!decorationsByColor.has(finalPrefixColor)) {
410 decorationsByColor.set(finalPrefixColor, []);
411 }
412 decorationsByColor.get(finalPrefixColor)!.push({ range: prefixRange });
413
414 // Identifier part
415 const idStart = document.positionAt(token.pos + 1);
416 const idEnd = document.positionAt(token.pos + token.value.length);
417 const idRange = new vscode.Range(idStart, idEnd);
418
419 const finalIdColor = isLightTheme ? KidLispSyntax.getLightModeColor(identifierColor) : identifierColor;
420 if (!decorationsByColor.has(finalIdColor)) {
421 decorationsByColor.set(finalIdColor, []);
422 }
423 decorationsByColor.get(finalIdColor)!.push({ range: idRange });
424
425 continue;
426 }
427
428 // Regular color
429 const finalColor = isLightTheme ? KidLispSyntax.getLightModeColor(color) : color;
430 const startPos = document.positionAt(token.pos);
431 const endPos = document.positionAt(token.pos + token.value.length);
432 const range = new vscode.Range(startPos, endPos);
433
434 if (!decorationsByColor.has(finalColor)) {
435 decorationsByColor.set(finalColor, []);
436 }
437 decorationsByColor.get(finalColor)!.push({ range });
438 }
439
440 // Atomic update: apply new decorations and clear stale ones in one pass
441 // setDecorations(type, ranges) replaces all decorations for that type,
442 // so we don't need to clear first — just set the new ranges.
443 const editorId = editor.document.uri.toString();
444 const currentKeys = new Set<string>();
445
446 for (const [color, ranges] of decorationsByColor) {
447 const decorationType = getOrCreateDecorationType(color, { bold: true });
448 const key = `${color}-bold`;
449 currentKeys.add(key);
450 editor.setDecorations(decorationType, ranges);
451 }
452
453 // Only clear decoration types that were used last time but NOT this time
454 const previousKeys = lastUsedDecorationKeys.get(editorId);
455 if (previousKeys) {
456 for (const key of previousKeys) {
457 if (!currentKeys.has(key)) {
458 const decorationType = kidlispDecorationTypes.get(key);
459 if (decorationType) {
460 editor.setDecorations(decorationType, []);
461 }
462 }
463 }
464 }
465
466 lastUsedDecorationKeys.set(editorId, currentKeys);
467}
468
469// Check if any visible KidLisp editor has timing tokens that need blinking
470const timingPattern = /\d*\.?\d+[sf](?:\.\.\.?|!?)/;
471function hasTimingTokens(): boolean {
472 for (const editor of vscode.window.visibleTextEditors) {
473 if (editor.document.languageId === 'kidlisp' && timingPattern.test(editor.document.getText())) {
474 return true;
475 }
476 }
477 return false;
478}
479
480function setupKidLispHighlighting(context: vscode.ExtensionContext) {
481 // Apply highlighting to currently visible KidLisp editors
482 function refreshAllKidLispEditors() {
483 for (const editor of vscode.window.visibleTextEditors) {
484 if (editor.document.languageId === 'kidlisp') {
485 applyKidLispDecorations(editor, true);
486 }
487 }
488 }
489
490 // Initial application
491 refreshAllKidLispEditors();
492
493 // Refresh when editors change
494 context.subscriptions.push(
495 vscode.window.onDidChangeVisibleTextEditors(() => {
496 refreshAllKidLispEditors();
497 })
498 );
499
500 // Refresh when document content changes
501 context.subscriptions.push(
502 vscode.workspace.onDidChangeTextDocument(event => {
503 const editor = vscode.window.visibleTextEditors.find(
504 e => e.document === event.document
505 );
506 if (editor && editor.document.languageId === 'kidlisp') {
507 applyKidLispDecorations(editor, true);
508 }
509 })
510 );
511
512 // Refresh when theme changes (for light/dark mode support)
513 context.subscriptions.push(
514 vscode.window.onDidChangeActiveColorTheme(() => {
515 // Clear cached decoration types since colors may need updating
516 for (const [, decorationType] of kidlispDecorationTypes) {
517 decorationType.dispose();
518 }
519 kidlispDecorationTypes.clear();
520 lastUsedDecorationKeys.clear();
521 refreshAllKidLispEditors();
522 })
523 );
524
525 // Timing blink animation interval — only refreshes when timing tokens exist
526 if (kidlispHighlightInterval) {
527 clearInterval(kidlispHighlightInterval);
528 }
529 kidlispHighlightInterval = setInterval(() => {
530 if (hasTimingTokens()) {
531 refreshAllKidLispEditors();
532 }
533 }, 250); // ~4fps is enough for blink animation
534
535 // Clean up on deactivation
536 context.subscriptions.push({
537 dispose: () => {
538 if (kidlispHighlightInterval) {
539 clearInterval(kidlispHighlightInterval);
540 kidlispHighlightInterval = undefined;
541 }
542 for (const [, decorationType] of kidlispDecorationTypes) {
543 decorationType.dispose();
544 }
545 kidlispDecorationTypes.clear();
546 lastUsedDecorationKeys.clear();
547 }
548 });
549}
550
551async function activate(context: vscode.ExtensionContext): Promise<void> {
552 // local = context.globalState.get("aesthetic:local", false); // Retrieve env.
553
554 // Show all environment variables...
555 // console.log("🌎 Environment:", process.env);
556 // Always retrieve the stored local state first
557 local = context.globalState.get("aesthetic:local", false);
558
559 const isInDevContainer = process?.env.REMOTE_CONTAINERS === "true";
560 if (isInDevContainer) {
561 // console.log("✅ 🥡 Running inside a container.");
562 // Keep the retrieved local state
563 } else {
564 // console.log("❌ 🥡 Not in container.");
565 // Keep the user's preference even when not in container
566 }
567
568 // Start checking for local server if local mode is enabled
569 if (local) {
570 startLocalServerCheck();
571 }
572
573 // 🌳 AST Tracking: Watch open JS/MJS documents for live AST visualization
574 // Track currently open editors
575 vscode.window.visibleTextEditors.forEach(editor => {
576 updateTrackedFile(editor.document);
577 });
578
579 // Track when editors change
580 context.subscriptions.push(
581 vscode.window.onDidChangeVisibleTextEditors(editors => {
582 // Add new files
583 editors.forEach(editor => updateTrackedFile(editor.document));
584 })
585 );
586
587 // Track document changes for live updates
588 context.subscriptions.push(
589 vscode.workspace.onDidChangeTextDocument(event => {
590 if (trackedFiles.has(event.document.uri.toString())) {
591 updateTrackedFile(event.document);
592 }
593 })
594 );
595
596 // Track document closes
597 context.subscriptions.push(
598 vscode.workspace.onDidCloseTextDocument(document => {
599 removeTrackedFile(document.uri.toString());
600 })
601 );
602
603 // 🛡️ Periodic Cleanup: Ensure tracked files match actual open documents
604 // (Fixes issues where files might get "stuck" if events are missed)
605 setInterval(() => {
606 const openUris = new Set(vscode.workspace.textDocuments.map(d => d.uri.toString()));
607 let changed = false;
608 for (const [uri, file] of trackedFiles) {
609 if (!openUris.has(uri)) {
610 console.log('🧹 Pruning stuck file:', file.fileName);
611 trackedFiles.delete(uri);
612 changed = true;
613 }
614 }
615 if (changed && astUpdateCallback) {
616 astUpdateCallback(Array.from(trackedFiles.values()));
617 }
618 }, 2000);
619
620 // Set up AST update callback to send to welcome panel
621 astUpdateCallback = (files) => {
622 if (welcomePanel) {
623 welcomePanel.webview.postMessage({
624 command: 'astUpdate',
625 files: files.map(f => ({
626 id: f.uri, // Use URI as unique ID
627 fileName: f.fileName,
628 filePath: f.filePath, // Send full path
629 ast: f.ast,
630 lastUpdate: f.lastUpdate,
631 })),
632 });
633 }
634 };
635
636 // 🌈 KidLisp Syntax Highlighting with Decorations
637 // This provides Monaco-parity highlighting for .lisp files including:
638 // - Rainbow/zebra color words, timing blinks, fade expressions
639 // - Color codes, CSS colors, RGB channels, nested paren colors
640 setupKidLispHighlighting(context);
641
642 // console.log("🟢 Aesthetic Computer Extension: Activated");
643 extContext = context;
644
645 // Load the docs from the web.
646 if (!docs) {
647 try {
648 // const url = `https://${ // local ? "localhost:8888" : "aesthetic.computer" }/api/docs`;
649 const url = `https://aesthetic.computer/docs.json`;
650 const response = await fetch(url);
651 if (!response.ok) {
652 throw new Error(`HTTP error! status: ${response.status}`);
653 }
654 const data: any = await response.json();
655 // console.log("📚 Docs loaded:", data);
656
657 keys(data.api).forEach((key) => {
658 // Add the category to each doc before smushing them.
659 // Such as `structure` so doc pages can be found like `structure:boot`.
660 keys(data.api[key]).forEach((k) => {
661 data.api[key][k].category = key;
662 });
663 // 😛 Smush them.
664 mergedDocs = {
665 ...mergedDocs,
666 ...data.api[key],
667 };
668 });
669
670 docs = data;
671 } catch (error) {
672 console.error("Failed to fetch documentation:", error);
673 }
674 }
675
676 // Set up all the autocompletion and doc hints.
677 const codeLensProvider = new AestheticCodeLensProvider();
678 vscode.languages.registerCodeLensProvider(
679 { language: "javascript", pattern: "**/*.mjs" },
680 codeLensProvider,
681 );
682
683 const completionProvider = vscode.languages.registerCompletionItemProvider(
684 { language: "javascript", pattern: "**/*.mjs" },
685 {
686 provideCompletionItems(
687 document: vscode.TextDocument,
688 position: vscode.Position,
689 ): vscode.ProviderResult<
690 vscode.CompletionItem[] | vscode.CompletionList
691 > {
692 if (document.lineCount > 500 || !docs) return []; // Skip for long files.
693 return keys(mergedDocs).map(
694 (word: string) => new vscode.CompletionItem(word),
695 );
696 },
697 },
698 );
699
700 context.subscriptions.push(completionProvider);
701
702 const hoverProvider = vscode.languages.registerHoverProvider(
703 { language: "javascript", pattern: "**/*.mjs" },
704 {
705 provideHover(document, position) {
706 if (document.lineCount > 500 || !docs) return; // Skip for long files.
707
708 const range = document.getWordRangeAtPosition(position);
709 const word = document.getText(range);
710
711 if (keys(mergedDocs).indexOf(word) > -1) {
712 const contents = new vscode.MarkdownString();
713 contents.isTrusted = true; // Enable for custom markdown.
714 contents.appendCodeblock(`${mergedDocs[word].sig}`, "javascript");
715 contents.appendText("\n\n");
716 contents.appendMarkdown(`${mergedDocs[word].desc}`);
717 return new vscode.Hover(contents, range);
718 }
719 },
720 },
721 );
722
723 context.subscriptions.push(hoverProvider);
724
725 // Code Action Provider for manual documentation opening
726 const codeActionProvider = vscode.languages.registerCodeActionsProvider(
727 { language: "javascript", pattern: "**/*.mjs" },
728 {
729 provideCodeActions(
730 document: vscode.TextDocument,
731 range: vscode.Range | vscode.Selection,
732 context: vscode.CodeActionContext,
733 token: vscode.CancellationToken
734 ): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> {
735 if (document.lineCount > 500 || !docs) return []; // Skip for long files.
736
737 const wordRange = document.getWordRangeAtPosition(range.start);
738 if (!wordRange) return [];
739
740 const word = document.getText(wordRange);
741
742 if (keys(mergedDocs).indexOf(word) > -1) {
743 const action = new vscode.CodeAction(
744 `📚 Open documentation for "${word}"`,
745 vscode.CodeActionKind.QuickFix
746 );
747 action.command = {
748 title: `Open docs for ${word}`,
749 command: "aestheticComputer.openDoc",
750 arguments: [word]
751 };
752 action.isPreferred = true;
753 return [action];
754 }
755
756 return [];
757 }
758 }
759 );
760
761 context.subscriptions.push(codeActionProvider);
762 const definitionProvider = vscode.languages.registerDefinitionProvider(
763 { language: "javascript", pattern: "**/*.mjs" },
764 {
765 provideDefinition(
766 document,
767 position,
768 token,
769 ): vscode.ProviderResult<vscode.Definition | vscode.DefinitionLink[]> {
770 if (document.lineCount > 500) return; // Skip for large documents.
771
772 const range = document.getWordRangeAtPosition(position);
773 const word = document.getText(range);
774
775 if (mergedDocs[word]) {
776 // Instead of automatically opening docs, create a virtual definition location
777 // that shows in the peek definition view without triggering a popup
778 const uri = vscode.Uri.parse(`aesthetic-doc:${word}`);
779 return new vscode.Location(uri, new vscode.Position(0, 0));
780 }
781
782 return null;
783 },
784 },
785 );
786
787 let docsPanel: any = null; // Only keep one docs panel open.
788
789 context.subscriptions.push(
790 vscode.commands.registerCommand(
791 "aestheticComputer.openDoc",
792 (functionName) => {
793 // const uri = vscode.Uri.parse(`${docScheme}:${functionName}`);
794
795 let path = "";
796 if (functionName)
797 path = "/" + mergedDocs[functionName].category + ":" + functionName;
798
799 const title = path || "docs";
800
801 // Check if the panel already exists. If so, reveal it.
802 if (docsPanel) {
803 // Update the title
804 docsPanel.title =
805 "📚 " + title.replace("/", "") + " · Aesthetic Computer";
806 docsPanel.reveal(vscode.ViewColumn.Beside);
807 } else {
808 // Create and show a new webview
809 docsPanel = vscode.window.createWebviewPanel(
810 "aestheticDoc", // Identifies the type of the webview. Used internally
811 "📚 " + title.replace("/", "") + " · Aesthetic Computer", // Title of the panel displayed to the user
812 vscode.ViewColumn.Beside, // Editor column to show the new webview panel in.
813 { enableScripts: true, enableForms: true }, // Webview options.
814 );
815
816 // Reset when the current panel is closed
817 docsPanel.onDidDispose(() => {
818 docsPanel = null;
819 }, null);
820 }
821
822 const nonce = getNonce();
823
824 // Build CSP frame-src based on environment
825 let cspFrameSrc = "frame-src https://aesthetic.computer https://aesthetic.local:8888 https://localhost:8888";
826 let cspChildSrc = "child-src https://aesthetic.computer https://aesthetic.local:8888 https://localhost:8888";
827
828 if (isCodespaces && codespacesDomain) {
829 const codespaceWildcard = `https://*.${codespacesDomain}`;
830 cspFrameSrc += ` ${codespaceWildcard}`;
831 cspChildSrc += ` ${codespaceWildcard}`;
832 }
833
834 // And set its HTML content
835 docsPanel.webview.html = `
836 <!DOCTYPE html>
837 <html lang="en">
838 <head>
839 <meta charset="UTF-8">
840 <meta http-equiv="Permissions-Policy" content="midi=*">
841 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${cspFrameSrc}; ${cspChildSrc}; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; img-src https: data:;">
842 <style nonce="${nonce}">
843 body {
844 margin: 0;
845 padding: 0;
846 overflow: hidden;"
847 }
848 iframe {
849 border: none;
850 width: 100vw;
851 height: 100vh;
852 }
853 </style>
854 </head>
855 <body>
856 <iframe allow="clipboard-write; clipboard-read" credentialless sandbox="allow-scripts allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation" src="https://aesthetic.computer/docs${path}">
857 </body>
858 </html>
859 `.trim();
860 },
861 ),
862 );
863
864 // ✦ Dashboard — Git Status + AT Protocol Firehose + Tangled Knot Activity
865 // Multi-section live dashboard for all Aesthetic Computer platform activity.
866
867 interface GitRepoStatus {
868 name: string;
869 root: string;
870 branch: string;
871 files: { status: string; file: string }[];
872 error?: string;
873 }
874
875 interface FirehoseEvent {
876 id: string;
877 type: string; // tape, mood, painting, news, handle, piece, kidlisp
878 action: string; // create, update, delete
879 handle?: string;
880 did?: string;
881 summary: string;
882 timestamp: string;
883 url?: string;
884 }
885
886 interface TangledEvent {
887 id: string;
888 type: string; // push, issue, star, fork
889 repo: string;
890 author?: string;
891 summary: string;
892 timestamp: string;
893 commitHash?: string;
894 url?: string;
895 }
896
897 // Detect current VS Code theme kind
898 function getVSCodeThemeKind(): 'dark' | 'light' {
899 const themeName = vscode.window.activeColorTheme.name || '';
900 if (themeName.includes('Aesthetic Computer')) {
901 if (themeName.includes('Light')) return 'light';
902 if (themeName.includes('Dark')) return 'dark';
903 }
904 const kind = vscode.window.activeColorTheme.kind;
905 return (kind === 1 || kind === 4) ? 'light' : 'dark';
906 }
907
908 // Run a git command and return stdout (fast, ~5-15ms)
909 async function gitExec(args: string, cwd: string): Promise<string> {
910 await _modulesReady;
911 return new Promise((resolve, reject) => {
912 if (!cp) { reject(new Error('child_process not available')); return; }
913 cp.exec(`git ${args}`, { cwd, timeout: 5000, maxBuffer: 1024 * 512 }, (err: any, stdout: string) => {
914 if (err) reject(err);
915 else resolve(stdout);
916 });
917 });
918 }
919
920 // Get git status for a repo directory (with graceful rev-parse fallback)
921 async function getGitStatus(repoPath: string, name: string): Promise<GitRepoStatus> {
922 try {
923 const statusOut = await gitExec('status --porcelain', repoPath);
924 let branch = '(init)';
925 try {
926 branch = (await gitExec('rev-parse --abbrev-ref HEAD', repoPath)).trim();
927 } catch {
928 // No commits yet — rev-parse fails on fresh repos, use symbolic-ref
929 try {
930 const ref = (await gitExec('symbolic-ref --short HEAD', repoPath)).trim();
931 branch = ref || '(init)';
932 } catch {
933 branch = '(no commits)';
934 }
935 }
936 const files = statusOut.split('\n').filter(Boolean).map(line => ({
937 status: line.substring(0, 2).trim(),
938 file: line.substring(3),
939 }));
940 return { name, root: repoPath, branch, files };
941 } catch (e: any) {
942 return { name, root: repoPath, branch: '?', files: [], error: e.message };
943 }
944 }
945
946 // Get recent git log for a repo
947 async function getGitLog(repoPath: string, count: number = 8): Promise<{ hash: string; subject: string; author: string; date: string }[]> {
948 try {
949 const logOut = await gitExec(`log --oneline --format="%h|%s|%an|%cr" -${count}`, repoPath);
950 return logOut.split('\n').filter(Boolean).map(line => {
951 const [hash, subject, author, date] = line.split('|');
952 return { hash, subject, author, date };
953 });
954 } catch {
955 return [];
956 }
957 }
958
959 // Gather status for all repos
960 async function getAllGitStatus(): Promise<{ repos: GitRepoStatus[]; logs: { name: string; commits: { hash: string; subject: string; author: string; date: string }[] }[] }> {
961 await _modulesReady;
962 const repos: { name: string; root: string }[] = [];
963
964 const wsFolder = vscode.workspace.workspaceFolders?.[0];
965 if (wsFolder && fs && path) {
966 const acRoot = wsFolder.uri.fsPath;
967 try {
968 if (fs.existsSync(path.join(acRoot, '.git'))) {
969 repos.push({ name: 'aesthetic-computer', root: acRoot });
970 }
971 } catch {}
972 try {
973 const vaultPath = path.join(acRoot, 'aesthetic-computer-vault');
974 if (fs.statSync(vaultPath).isDirectory() && fs.existsSync(path.join(vaultPath, '.git'))) {
975 repos.push({ name: 'vault', root: vaultPath });
976 }
977 } catch {}
978 }
979
980 const [statuses, logs] = await Promise.all([
981 Promise.all(repos.map(r => getGitStatus(r.root, r.name))),
982 Promise.all(repos.map(async r => ({ name: r.name, commits: await getGitLog(r.root) }))),
983 ]);
984 return { repos: statuses, logs };
985 }
986
987 // AT Protocol firehose — poll PDS for recent records across all collections
988 const AT_COLLECTIONS = [
989 { collection: 'computer.aesthetic.tape', label: 'Tape', icon: '\u{1F3AC}' },
990 { collection: 'computer.aesthetic.mood', label: 'Mood', icon: '\u{1F30A}' },
991 { collection: 'computer.aesthetic.painting', label: 'Painting', icon: '\u{1F3A8}' },
992 { collection: 'computer.aesthetic.news', label: 'News', icon: '\u{1F4F0}' },
993 { collection: 'computer.aesthetic.piece', label: 'Piece', icon: '\u{1F9E9}' },
994 { collection: 'computer.aesthetic.kidlisp', label: 'KidLisp', icon: '\u{1F308}' },
995 ];
996 const PDS_URL = 'https://at.aesthetic.computer';
997 const TANGLED_KNOT_URL = 'https://knot.aesthetic.computer';
998
999 let firehoseEvents: FirehoseEvent[] = [];
1000 let tangledEvents: TangledEvent[] = [];
1001 let firehoseSeenIds = new Set<string>();
1002 let tangledSeenIds = new Set<string>();
1003
1004 async function fetchFirehoseEvents(): Promise<FirehoseEvent[]> {
1005 await _modulesReady;
1006 if (!http) return [];
1007 const events: FirehoseEvent[] = [];
1008
1009 // Fetch recent records from each collection on the PDS
1010 // We list records from the guest/system DID to show platform-wide activity
1011 for (const col of AT_COLLECTIONS) {
1012 try {
1013 const url = `${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=5`;
1014 // Instead, list records for known DIDs — use repo.listRecords with a broader approach
1015 // For now, use listRepos to discover DIDs, then sample records
1016 const reposJson = await httpGetJson(`${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=20`);
1017 if (!reposJson?.repos) continue;
1018
1019 for (const repo of reposJson.repos.slice(0, 5)) {
1020 try {
1021 const recordsJson = await httpGetJson(
1022 `${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(repo.did)}&collection=${encodeURIComponent(col.collection)}&limit=3&reverse=true`
1023 );
1024 if (!recordsJson?.records) continue;
1025 for (const rec of recordsJson.records) {
1026 const id = rec.uri || `${repo.did}:${col.collection}:${rec.cid}`;
1027 if (firehoseSeenIds.has(id)) continue;
1028 firehoseSeenIds.add(id);
1029
1030 const val = rec.value || {};
1031 let summary = val.title || val.text || val.name || val.code || val.headline || col.label;
1032 if (summary.length > 80) summary = summary.substring(0, 77) + '...';
1033
1034 events.push({
1035 id,
1036 type: col.label.toLowerCase(),
1037 action: 'create',
1038 did: repo.did,
1039 handle: val.handle || repo.did.substring(0, 20),
1040 summary: `${col.icon} ${summary}`,
1041 timestamp: val.createdAt || val.when || new Date().toISOString(),
1042 url: val.uri || undefined,
1043 });
1044 }
1045 } catch {}
1046 }
1047 } catch {}
1048 }
1049
1050 // Sort by timestamp descending
1051 events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1052 return events.slice(0, 50);
1053 }
1054
1055 // Simple HTTPS GET → JSON helper using Node http/https
1056 function httpGetJson(url: string): Promise<any> {
1057 return new Promise((resolve) => {
1058 try {
1059 const https = require('https');
1060 const req = https.get(url, { timeout: 8000 }, (res: any) => {
1061 let data = '';
1062 res.on('data', (chunk: string) => { data += chunk; });
1063 res.on('end', () => {
1064 try { resolve(JSON.parse(data)); } catch { resolve(null); }
1065 });
1066 });
1067 req.on('error', () => resolve(null));
1068 req.on('timeout', () => { req.destroy(); resolve(null); });
1069 } catch { resolve(null); }
1070 });
1071 }
1072
1073 async function fetchTangledEvents(): Promise<TangledEvent[]> {
1074 await _modulesReady;
1075 const events: TangledEvent[] = [];
1076
1077 // Query the Tangled knot server for recent repo activity
1078 try {
1079 // Try the knot's repo endpoint for recent activity
1080 const repoInfo = await httpGetJson(`${TANGLED_KNOT_URL}/aesthetic.computer/core/info/refs?service=git-upload-pack`);
1081 // The knot exposes a REST-ish API — try listing recent commits via the appview
1082 const tangledCommits = await httpGetJson(`https://tangled.org/api/repos/aesthetic.computer/core/commits?limit=10`);
1083 if (tangledCommits && Array.isArray(tangledCommits)) {
1084 for (const commit of tangledCommits) {
1085 const id = commit.sha || commit.hash || commit.id;
1086 if (!id || tangledSeenIds.has(id)) continue;
1087 tangledSeenIds.add(id);
1088 events.push({
1089 id,
1090 type: 'push',
1091 repo: 'aesthetic.computer/core',
1092 author: commit.author?.name || commit.author || 'unknown',
1093 summary: commit.message || commit.subject || '(no message)',
1094 timestamp: commit.date || commit.timestamp || new Date().toISOString(),
1095 commitHash: id.substring(0, 7),
1096 url: `https://tangled.org/aesthetic.computer/core/commit/${id}`,
1097 });
1098 }
1099 }
1100 } catch {}
1101
1102 // Also try the git log locally if the tangled remote is configured
1103 try {
1104 const wsFolder = vscode.workspace.workspaceFolders?.[0];
1105 if (wsFolder && path) {
1106 const acRoot = wsFolder.uri.fsPath;
1107 // Check if tangled remote exists
1108 const remotes = await gitExec('remote -v', acRoot);
1109 if (remotes.includes('tangled') || remotes.includes('knot')) {
1110 const remoteName = remotes.includes('tangled') ? 'tangled' : 'knot';
1111 try {
1112 await gitExec(`fetch ${remoteName} --no-tags`, acRoot);
1113 } catch {}
1114 const logOut = await gitExec(`log ${remoteName}/main --oneline --format="%h|%s|%an|%cr" -10`, acRoot);
1115 for (const line of logOut.split('\n').filter(Boolean)) {
1116 const [hash, subject, author, date] = line.split('|');
1117 const id = `tangled:${hash}`;
1118 if (tangledSeenIds.has(id)) continue;
1119 tangledSeenIds.add(id);
1120 events.push({
1121 id,
1122 type: 'push',
1123 repo: 'aesthetic.computer/core',
1124 author,
1125 summary: subject,
1126 timestamp: date,
1127 commitHash: hash,
1128 url: `https://tangled.org/aesthetic.computer/core`,
1129 });
1130 }
1131 }
1132 }
1133 } catch {}
1134
1135 return events;
1136 }
1137
1138 // Send all dashboard data to the welcome panel
1139 let dashboardPending = false;
1140 async function sendDashboardData() {
1141 if (!welcomePanel || dashboardPending) return;
1142 dashboardPending = true;
1143 try {
1144 const gitData = await getAllGitStatus();
1145 if (welcomePanel) {
1146 welcomePanel.webview.postMessage({ command: 'gitStatus', repos: gitData.repos, logs: gitData.logs });
1147 }
1148 } finally {
1149 dashboardPending = false;
1150 }
1151 }
1152
1153 // Fetch and send firehose data (separate from git for independent refresh)
1154 let firehosePending = false;
1155 async function sendFirehoseData() {
1156 if (!welcomePanel || firehosePending) return;
1157 firehosePending = true;
1158 try {
1159 const events = await fetchFirehoseEvents();
1160 if (events.length > 0) {
1161 firehoseEvents = events;
1162 }
1163 if (welcomePanel) {
1164 welcomePanel.webview.postMessage({ command: 'firehose', events: firehoseEvents });
1165 }
1166 } finally {
1167 firehosePending = false;
1168 }
1169 }
1170
1171 // Fetch and send Tangled data
1172 let tangledPending = false;
1173 async function sendTangledData() {
1174 if (!welcomePanel || tangledPending) return;
1175 tangledPending = true;
1176 try {
1177 const events = await fetchTangledEvents();
1178 if (events.length > 0) {
1179 tangledEvents = events;
1180 }
1181 if (welcomePanel) {
1182 welcomePanel.webview.postMessage({ command: 'tangled', events: tangledEvents });
1183 }
1184 } finally {
1185 tangledPending = false;
1186 }
1187 }
1188
1189 function getDashboardHtml(): string {
1190 const theme = getVSCodeThemeKind();
1191 const c = theme === 'light'
1192 ? { bg: '#fcf7c5', fg: '#281e5a', fgMuted: '#907070', fgDim: '#b0a080', accent: '#387adf',
1193 added: '#1a7f37', modified: '#9a6700', deleted: '#cf222e', untracked: '#6e7781',
1194 fileBg: '#f6f0d0', fileBorder: '#e8e0b0', hoverBg: '#efe8c0', repoBg: '#f0e8b8', headerBg: '#e8dfa0',
1195 sectionBg: '#f2ecc0', badgeBg: '#e0d8a0', tapeBg: '#e8f0d8', moodBg: '#d8e8f0', paintBg: '#f0d8e8',
1196 newsBg: '#e8e0d8', commitBg: '#e0e8d8', liveDot: '#1a7f37', onlineBg: '#d8f0d8' }
1197 : { bg: '#181318', fg: '#e0d0e0', fgMuted: '#807080', fgDim: '#504050', accent: '#a87090',
1198 added: '#3fb950', modified: '#d29922', deleted: '#f85149', untracked: '#606870',
1199 fileBg: '#1e1820', fileBorder: '#2a2030', hoverBg: '#28202e', repoBg: '#201828', headerBg: '#1a1220',
1200 sectionBg: '#1a1520', badgeBg: '#2a2030', tapeBg: '#1a2818', moodBg: '#182028', paintBg: '#281828',
1201 newsBg: '#282018', commitBg: '#182818', liveDot: '#3fb950', onlineBg: '#1a2818' };
1202
1203 return `<!DOCTYPE html>
1204<html lang="en">
1205<head>
1206 <meta charset="UTF-8">
1207 <meta name="viewport" content="width=device-width, initial-scale=1.0">
1208 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src https://at.aesthetic.computer https://knot.aesthetic.computer https://tangled.org;">
1209 <style>
1210 * { margin: 0; padding: 0; box-sizing: border-box; }
1211 body { background: ${c.bg}; color: ${c.fg}; font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-size: 11px; height: 100vh; overflow: auto; padding: 0; }
1212
1213 /* Header */
1214 .dash-header { padding: 12px 16px 10px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid ${c.fileBorder}; background: ${c.headerBg}; position: sticky; top: 0; z-index: 10; }
1215 .dash-header .star { font-size: 16px; color: ${c.accent}; }
1216 .dash-header .title { font-size: 12px; font-weight: 600; letter-spacing: 0.5px; }
1217 .dash-header .live { margin-left: auto; display: flex; align-items: center; gap: 5px; font-size: 10px; color: ${c.fgMuted}; }
1218 .dash-header .live-dot { width: 6px; height: 6px; border-radius: 50%; background: ${c.liveDot}; animation: livePulse 2s ease-in-out infinite; }
1219 @keyframes livePulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
1220
1221 /* Section nav tabs */
1222 .section-tabs { display: flex; gap: 0; border-bottom: 1px solid ${c.fileBorder}; background: ${c.sectionBg}; position: sticky; top: 39px; z-index: 9; overflow-x: auto; }
1223 .section-tab { padding: 6px 12px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: ${c.fgMuted}; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s; white-space: nowrap; display: flex; align-items: center; gap: 5px; }
1224 .section-tab:hover { color: ${c.fg}; background: ${c.hoverBg}; }
1225 .section-tab.active { color: ${c.accent}; border-bottom-color: ${c.accent}; }
1226 .section-tab .badge { background: ${c.badgeBg}; color: ${c.fgMuted}; padding: 1px 5px; border-radius: 8px; font-size: 9px; font-weight: 400; min-width: 16px; text-align: center; }
1227
1228 /* Sections */
1229 .section { display: none; }
1230 .section.visible { display: block; }
1231
1232 /* Source Changes section */
1233 .repo { margin: 0; }
1234 .repo-header { padding: 8px 16px 5px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.5px; color: ${c.fgMuted}; background: ${c.repoBg}; border-bottom: 1px solid ${c.fileBorder}; display: flex; align-items: center; gap: 6px; }
1235 .repo-header .branch { font-weight: 400; text-transform: none; letter-spacing: 0; color: ${c.accent}; }
1236 .repo-header .count { font-weight: 400; text-transform: none; letter-spacing: 0; color: ${c.fgDim}; margin-left: auto; }
1237 .file-list { list-style: none; }
1238 .file-item { display: flex; align-items: center; gap: 6px; padding: 3px 16px; cursor: pointer; border-bottom: 1px solid ${c.fileBorder}; transition: background 0.1s; }
1239 .file-item:hover { background: ${c.hoverBg}; }
1240 .status { font-weight: 700; width: 16px; text-align: center; flex-shrink: 0; font-size: 10px; }
1241 .status.M { color: ${c.modified}; }
1242 .status.A { color: ${c.added}; }
1243 .status.D { color: ${c.deleted}; }
1244 .status.R { color: ${c.modified}; }
1245 .status.U { color: ${c.untracked}; }
1246 .status.q { color: ${c.untracked}; }
1247 .file-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1248 .file-dir { color: ${c.fgMuted}; }
1249 .file-name { color: ${c.fg}; }
1250 .empty { padding: 16px; text-align: center; color: ${c.fgMuted}; font-style: italic; font-size: 11px; }
1251 .error { padding: 10px 16px; color: ${c.deleted}; font-size: 10px; }
1252
1253 /* Git log */
1254 .git-log { border-top: 1px solid ${c.fileBorder}; }
1255 .log-item { display: flex; align-items: center; gap: 6px; padding: 3px 16px; border-bottom: 1px solid ${c.fileBorder}; font-size: 10px; }
1256 .log-hash { color: ${c.accent}; font-weight: 600; flex-shrink: 0; }
1257 .log-subject { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: ${c.fg}; }
1258 .log-date { color: ${c.fgDim}; flex-shrink: 0; font-size: 9px; }
1259
1260 /* Firehose section */
1261 .firehose-feed { max-height: none; }
1262 .feed-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 16px; border-bottom: 1px solid ${c.fileBorder}; transition: background 0.1s; animation: fadeIn 0.3s ease; }
1263 .feed-item:hover { background: ${c.hoverBg}; }
1264 .feed-item.new { background: ${c.onlineBg}; }
1265 @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
1266 .feed-icon { font-size: 14px; flex-shrink: 0; line-height: 1.4; }
1267 .feed-body { flex: 1; min-width: 0; }
1268 .feed-handle { color: ${c.accent}; font-weight: 600; font-size: 10px; }
1269 .feed-summary { color: ${c.fg}; margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1270 .feed-time { color: ${c.fgDim}; font-size: 9px; flex-shrink: 0; align-self: center; }
1271 .feed-type-badge { display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-right: 4px; }
1272 .feed-type-badge.tape { background: ${c.tapeBg}; color: ${c.added}; }
1273 .feed-type-badge.mood { background: ${c.moodBg}; color: ${c.accent}; }
1274 .feed-type-badge.painting { background: ${c.paintBg}; color: ${c.modified}; }
1275 .feed-type-badge.news { background: ${c.newsBg}; color: ${c.fg}; }
1276 .feed-type-badge.piece { background: ${c.sectionBg}; color: ${c.fgMuted}; }
1277 .feed-type-badge.kidlisp { background: ${c.tapeBg}; color: ${c.modified}; }
1278
1279 /* Tangled section */
1280 .tangled-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 16px; border-bottom: 1px solid ${c.fileBorder}; transition: background 0.1s; }
1281 .tangled-item:hover { background: ${c.hoverBg}; }
1282 .tangled-hash { color: ${c.accent}; font-weight: 600; font-size: 10px; flex-shrink: 0; font-family: inherit; }
1283 .tangled-body { flex: 1; min-width: 0; }
1284 .tangled-msg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: ${c.fg}; }
1285 .tangled-meta { color: ${c.fgDim}; font-size: 9px; margin-top: 1px; }
1286
1287 /* Filter bar */
1288 .filter-bar { display: flex; gap: 4px; padding: 6px 16px; border-bottom: 1px solid ${c.fileBorder}; background: ${c.sectionBg}; flex-wrap: wrap; }
1289 .filter-pill { padding: 2px 8px; border-radius: 10px; font-size: 9px; cursor: pointer; border: 1px solid ${c.fileBorder}; color: ${c.fgMuted}; transition: all 0.15s; }
1290 .filter-pill:hover { border-color: ${c.accent}; color: ${c.fg}; }
1291 .filter-pill.active { background: ${c.accent}; color: ${c.bg}; border-color: ${c.accent}; }
1292
1293 /* Loading & status */
1294 .loading { padding: 16px; text-align: center; color: ${c.fgMuted}; }
1295 .loading .dot { display: inline-block; animation: livePulse 1.5s ease-in-out infinite; color: ${c.accent}; }
1296 .status-line { padding: 4px 16px; font-size: 9px; color: ${c.fgDim}; text-align: right; border-bottom: 1px solid ${c.fileBorder}; }
1297 </style>
1298</head>
1299<body>
1300 <div class="dash-header">
1301 <span class="star">✦</span>
1302 <span class="title">Dashboard</span>
1303 <span class="live"><span class="live-dot"></span>live</span>
1304 </div>
1305
1306 <div class="section-tabs">
1307 <div class="section-tab active" data-section="source">Source <span class="badge" id="source-badge">0</span></div>
1308 <div class="section-tab" data-section="firehose">AT Firehose <span class="badge" id="firehose-badge">0</span></div>
1309 <div class="section-tab" data-section="tangled">Tangled <span class="badge" id="tangled-badge">0</span></div>
1310 </div>
1311
1312 <div id="section-source" class="section visible">
1313 <div id="source-content">
1314 <div class="loading"><span class="dot">✦</span> scanning repos...</div>
1315 </div>
1316 </div>
1317
1318 <div id="section-firehose" class="section">
1319 <div class="filter-bar" id="firehose-filters">
1320 <span class="filter-pill active" data-filter="all">All</span>
1321 <span class="filter-pill" data-filter="tape">Tape</span>
1322 <span class="filter-pill" data-filter="mood">Mood</span>
1323 <span class="filter-pill" data-filter="painting">Painting</span>
1324 <span class="filter-pill" data-filter="news">News</span>
1325 <span class="filter-pill" data-filter="piece">Piece</span>
1326 <span class="filter-pill" data-filter="kidlisp">KidLisp</span>
1327 </div>
1328 <div id="firehose-content" class="firehose-feed">
1329 <div class="loading"><span class="dot">✦</span> connecting to PDS...</div>
1330 </div>
1331 </div>
1332
1333 <div id="section-tangled" class="section">
1334 <div id="tangled-content">
1335 <div class="loading"><span class="dot">✦</span> fetching knot activity...</div>
1336 </div>
1337 </div>
1338
1339 <script>
1340 (function() {
1341 const vscode = acquireVsCodeApi();
1342
1343 // State
1344 let activeSection = 'source';
1345 let activeFilter = 'all';
1346 let firehoseEvents = [];
1347 let tangledEvents = [];
1348 let autoscroll = true;
1349
1350 // Tab switching
1351 document.querySelectorAll('.section-tab').forEach(tab => {
1352 tab.addEventListener('click', () => {
1353 document.querySelectorAll('.section-tab').forEach(t => t.classList.remove('active'));
1354 document.querySelectorAll('.section').forEach(s => s.classList.remove('visible'));
1355 tab.classList.add('active');
1356 const section = tab.getAttribute('data-section');
1357 activeSection = section;
1358 document.getElementById('section-' + section).classList.add('visible');
1359 });
1360 });
1361
1362 // Filter pills
1363 document.getElementById('firehose-filters').addEventListener('click', (e) => {
1364 const pill = e.target.closest('.filter-pill');
1365 if (!pill) return;
1366 document.querySelectorAll('#firehose-filters .filter-pill').forEach(p => p.classList.remove('active'));
1367 pill.classList.add('active');
1368 activeFilter = pill.getAttribute('data-filter');
1369 renderFirehose();
1370 });
1371
1372 // Auto-scroll detection
1373 document.addEventListener('scroll', () => {
1374 const el = document.scrollingElement || document.documentElement;
1375 autoscroll = (el.scrollHeight - el.scrollTop - el.clientHeight) < 50;
1376 });
1377
1378 function esc(s) {
1379 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
1380 }
1381
1382 function timeAgo(ts) {
1383 if (!ts) return '';
1384 // If it's already relative (like "2 hours ago"), return as-is
1385 if (typeof ts === 'string' && ts.includes('ago')) return ts;
1386 const diff = Date.now() - new Date(ts).getTime();
1387 if (isNaN(diff)) return ts;
1388 const s = Math.floor(diff / 1000);
1389 if (s < 60) return s + 's ago';
1390 const m = Math.floor(s / 60);
1391 if (m < 60) return m + 'm ago';
1392 const h = Math.floor(m / 60);
1393 if (h < 24) return h + 'h ago';
1394 const d = Math.floor(h / 24);
1395 return d + 'd ago';
1396 }
1397
1398 function statusLabel(s) {
1399 if (s === '??' || s === '?') return { letter: '?', cls: 'q', title: 'Untracked' };
1400 const ch = s.charAt(0) === ' ' ? s.charAt(1) : s.charAt(0);
1401 const map = { M: 'Modified', A: 'Added', D: 'Deleted', R: 'Renamed', C: 'Copied', U: 'Unmerged' };
1402 return { letter: ch, cls: ch, title: map[ch] || ch };
1403 }
1404
1405 // Render source changes + git log
1406 function renderSource(repos, logs) {
1407 const el = document.getElementById('source-content');
1408 if (!repos || repos.length === 0) {
1409 el.innerHTML = '<div class="empty">No git repositories detected.</div>';
1410 document.getElementById('source-badge').textContent = '0';
1411 return;
1412 }
1413
1414 let totalChanges = 0;
1415 let html = '';
1416 for (const repo of repos) {
1417 totalChanges += repo.files.length;
1418 html += '<div class="repo">';
1419 html += '<div class="repo-header">';
1420 html += '<span>' + esc(repo.name) + '</span>';
1421 html += '<span class="branch">' + esc(repo.branch) + '</span>';
1422 html += '<span class="count">' + repo.files.length + '</span>';
1423 html += '</div>';
1424
1425 if (repo.error) {
1426 html += '<div class="error">' + esc(repo.error) + '</div>';
1427 } else if (repo.files.length === 0) {
1428 html += '<div class="empty">Clean</div>';
1429 } else {
1430 html += '<ul class="file-list">';
1431 for (const f of repo.files) {
1432 const s = statusLabel(f.status);
1433 const lastSlash = f.file.lastIndexOf('/');
1434 const dir = lastSlash >= 0 ? f.file.substring(0, lastSlash + 1) : '';
1435 const name = lastSlash >= 0 ? f.file.substring(lastSlash + 1) : f.file;
1436 html += '<li class="file-item" data-root="' + esc(repo.root) + '" data-file="' + esc(f.file) + '" title="' + esc(s.title + ': ' + f.file) + '">';
1437 html += '<span class="status ' + s.cls + '">' + s.letter + '</span>';
1438 html += '<span class="file-path"><span class="file-dir">' + esc(dir) + '</span><span class="file-name">' + esc(name) + '</span></span>';
1439 html += '</li>';
1440 }
1441 html += '</ul>';
1442 }
1443
1444 // Recent commits
1445 const repoLog = logs && logs.find(l => l.name === repo.name);
1446 if (repoLog && repoLog.commits.length > 0) {
1447 html += '<div class="git-log">';
1448 for (const c of repoLog.commits) {
1449 html += '<div class="log-item">';
1450 html += '<span class="log-hash">' + esc(c.hash) + '</span>';
1451 html += '<span class="log-subject">' + esc(c.subject) + '</span>';
1452 html += '<span class="log-date">' + esc(c.date) + '</span>';
1453 html += '</div>';
1454 }
1455 html += '</div>';
1456 }
1457
1458 html += '</div>';
1459 }
1460 el.innerHTML = html;
1461 document.getElementById('source-badge').textContent = String(totalChanges);
1462 }
1463
1464 // Render firehose events
1465 function renderFirehose() {
1466 const el = document.getElementById('firehose-content');
1467 const filtered = activeFilter === 'all' ? firehoseEvents : firehoseEvents.filter(e => e.type === activeFilter);
1468
1469 if (filtered.length === 0) {
1470 el.innerHTML = '<div class="empty">No events yet. Waiting for AT Protocol activity...</div>';
1471 return;
1472 }
1473
1474 let html = '';
1475 for (const evt of filtered) {
1476 html += '<div class="feed-item" data-url="' + esc(evt.url || '') + '">';
1477 html += '<div class="feed-body">';
1478 html += '<span class="feed-type-badge ' + esc(evt.type) + '">' + esc(evt.type) + '</span>';
1479 if (evt.handle) html += '<span class="feed-handle">@' + esc(evt.handle) + '</span>';
1480 html += '<div class="feed-summary">' + esc(evt.summary) + '</div>';
1481 html += '</div>';
1482 html += '<span class="feed-time">' + timeAgo(evt.timestamp) + '</span>';
1483 html += '</div>';
1484 }
1485 el.innerHTML = html;
1486 document.getElementById('firehose-badge').textContent = String(firehoseEvents.length);
1487 }
1488
1489 // Render Tangled events
1490 function renderTangled() {
1491 const el = document.getElementById('tangled-content');
1492 if (tangledEvents.length === 0) {
1493 el.innerHTML = '<div class="empty">No Tangled knot activity detected.</div>';
1494 document.getElementById('tangled-badge').textContent = '0';
1495 return;
1496 }
1497
1498 let html = '';
1499 for (const evt of tangledEvents) {
1500 html += '<div class="tangled-item" data-url="' + esc(evt.url || '') + '">';
1501 if (evt.commitHash) html += '<span class="tangled-hash">' + esc(evt.commitHash) + '</span>';
1502 html += '<div class="tangled-body">';
1503 html += '<div class="tangled-msg">' + esc(evt.summary) + '</div>';
1504 html += '<div class="tangled-meta">';
1505 if (evt.author) html += esc(evt.author) + ' ';
1506 html += timeAgo(evt.timestamp);
1507 html += '</div>';
1508 html += '</div>';
1509 html += '</div>';
1510 }
1511 el.innerHTML = html;
1512 document.getElementById('tangled-badge').textContent = String(tangledEvents.length);
1513 }
1514
1515 // Click handlers
1516 document.getElementById('source-content').addEventListener('click', (e) => {
1517 const item = e.target.closest('.file-item');
1518 if (!item) return;
1519 const root = item.getAttribute('data-root');
1520 const file = item.getAttribute('data-file');
1521 if (root && file) vscode.postMessage({ command: 'openFile', root, file });
1522 });
1523
1524 document.getElementById('firehose-content').addEventListener('click', (e) => {
1525 const item = e.target.closest('.feed-item');
1526 if (!item) return;
1527 const url = item.getAttribute('data-url');
1528 if (url) vscode.postMessage({ command: 'openExternal', url });
1529 });
1530
1531 document.getElementById('tangled-content').addEventListener('click', (e) => {
1532 const item = e.target.closest('.tangled-item');
1533 if (!item) return;
1534 const url = item.getAttribute('data-url');
1535 if (url) vscode.postMessage({ command: 'openExternal', url });
1536 });
1537
1538 // Keyboard shortcuts
1539 document.addEventListener('keydown', (e) => {
1540 if (e.key === '1') { document.querySelector('[data-section="source"]').click(); }
1541 if (e.key === '2') { document.querySelector('[data-section="firehose"]').click(); }
1542 if (e.key === '3') { document.querySelector('[data-section="tangled"]').click(); }
1543 });
1544
1545 // Message handling from extension
1546 window.addEventListener('message', (event) => {
1547 const msg = event.data;
1548 if (msg.command === 'gitStatus') {
1549 renderSource(msg.repos, msg.logs);
1550 } else if (msg.command === 'firehose') {
1551 firehoseEvents = msg.events || [];
1552 renderFirehose();
1553 } else if (msg.command === 'tangled') {
1554 tangledEvents = msg.events || [];
1555 renderTangled();
1556 }
1557 });
1558
1559 // Request initial data
1560 vscode.postMessage({ command: 'requestDashboard' });
1561 })();
1562 </script>
1563</body>
1564</html>`;
1565 }
1566
1567 // ✦ Dashboard Panel — git status + AT firehose + Tangled knot
1568 function showWelcomePanel() {
1569 if (welcomePanel) {
1570 welcomePanel.reveal(vscode.ViewColumn.One);
1571 return;
1572 }
1573
1574 welcomePanel = vscode.window.createWebviewPanel(
1575 "aestheticWelcome",
1576 "✦ Dashboard",
1577 vscode.ViewColumn.One,
1578 { enableScripts: true }
1579 );
1580
1581 welcomePanel.webview.onDidReceiveMessage(
1582 message => {
1583 switch (message.command) {
1584 case 'requestDashboard':
1585 sendDashboardData();
1586 sendFirehoseData();
1587 sendTangledData();
1588 return;
1589 case 'requestGitStatus':
1590 sendDashboardData();
1591 return;
1592 case 'openFile':
1593 if (message.root && message.file) {
1594 const filePath = path?.join(message.root, message.file);
1595 if (filePath) {
1596 const openPath = vscode.Uri.file(filePath);
1597 vscode.workspace.openTextDocument(openPath).then(
1598 doc => vscode.window.showTextDocument(doc),
1599 () => {}
1600 );
1601 }
1602 }
1603 return;
1604 case 'openExternal':
1605 if (message.url) {
1606 vscode.env.openExternal(vscode.Uri.parse(message.url));
1607 }
1608 return;
1609 }
1610 },
1611 undefined,
1612 context.subscriptions
1613 );
1614
1615 welcomePanel.onDidDispose(() => {
1616 welcomePanel = null;
1617 if (firehoseInterval) { clearInterval(firehoseInterval); firehoseInterval = undefined; }
1618 if (tangledInterval) { clearInterval(tangledInterval); tangledInterval = undefined; }
1619 });
1620
1621 welcomePanel.webview.html = getDashboardHtml();
1622
1623 // Send initial data
1624 setTimeout(() => {
1625 sendDashboardData();
1626 sendFirehoseData();
1627 sendTangledData();
1628 }, 100);
1629
1630 // Poll firehose every 30 seconds, Tangled every 60 seconds
1631 firehoseInterval = setInterval(() => sendFirehoseData(), 30000);
1632 tangledInterval = setInterval(() => sendTangledData(), 60000);
1633 }
1634
1635 let firehoseInterval: NodeJS.Timeout | undefined;
1636 let tangledInterval: NodeJS.Timeout | undefined;
1637
1638 // Refresh welcome panel on theme change
1639 function refreshWelcomePanel() {
1640 if (welcomePanel) {
1641 welcomePanel.webview.html = getDashboardHtml();
1642 setTimeout(() => {
1643 sendDashboardData();
1644 sendFirehoseData();
1645 sendTangledData();
1646 }, 100);
1647 }
1648 }
1649
1650 // Watch for file changes in both repos to auto-refresh git status
1651 function setupGitWatchers() {
1652 const wsFolder = vscode.workspace.workspaceFolders?.[0];
1653 if (!wsFolder) return;
1654
1655 let refreshTimer: NodeJS.Timeout | undefined;
1656 function debouncedRefresh() {
1657 if (refreshTimer) clearTimeout(refreshTimer);
1658 refreshTimer = setTimeout(() => sendDashboardData(), 300);
1659 }
1660
1661 const watcher = vscode.workspace.createFileSystemWatcher(
1662 new vscode.RelativePattern(wsFolder, '**/*')
1663 );
1664 watcher.onDidChange(debouncedRefresh);
1665 watcher.onDidCreate(debouncedRefresh);
1666 watcher.onDidDelete(debouncedRefresh);
1667 context.subscriptions.push(watcher);
1668
1669 context.subscriptions.push(
1670 vscode.workspace.onDidSaveTextDocument(() => debouncedRefresh())
1671 );
1672 }
1673
1674 setupGitWatchers();
1675
1676 // Listen for VS Code theme changes and refresh welcome panel
1677 context.subscriptions.push(
1678 vscode.window.onDidChangeActiveColorTheme(() => {
1679 refreshWelcomePanel();
1680 })
1681 );
1682
1683 context.subscriptions.push(
1684 vscode.commands.registerCommand("aestheticComputer.welcome", showWelcomePanel)
1685 );
1686
1687 // Show welcome on startup if no editors are open
1688 if (vscode.window.visibleTextEditors.length === 0) {
1689 setTimeout(() => {
1690 if (vscode.window.visibleTextEditors.length === 0 && !welcomePanel) {
1691 showWelcomePanel();
1692 }
1693 }, 500);
1694 }
1695
1696 // � User Handle Status Bar
1697 let statusBarUser: vscode.StatusBarItem;
1698
1699 function updateUserStatusBar() {
1700 if (!statusBarUser) {
1701 statusBarUser = vscode.window.createStatusBarItem(
1702 vscode.StatusBarAlignment.Right,
1703 50,
1704 );
1705 statusBarUser.command = "aestheticComputer.login";
1706 context.subscriptions.push(statusBarUser);
1707 }
1708
1709 // Check for aesthetic session
1710 const aestheticSession = context.globalState.get<any>("aesthetic:session");
1711 const sotceSession = context.globalState.get<any>("sotce:session");
1712
1713 if (aestheticSession?.account?.label) {
1714 const label = aestheticSession.account.label;
1715 const displayLabel = label.startsWith("@") ? label : `@${label}`;
1716 statusBarUser.text = `$(account) ${displayLabel}`;
1717 statusBarUser.tooltip = `Logged in to Aesthetic Computer as ${displayLabel}\nClick to manage account`;
1718 statusBarUser.backgroundColor = undefined;
1719 statusBarUser.show();
1720 } else if (sotceSession?.account?.label) {
1721 const label = sotceSession.account.label;
1722 const displayLabel = label.startsWith("@") ? label : `@${label}`;
1723 statusBarUser.text = `$(account) ${displayLabel}`;
1724 statusBarUser.tooltip = `Logged in to Sotce Net as ${displayLabel}\nClick to manage account`;
1725 statusBarUser.backgroundColor = undefined;
1726 statusBarUser.show();
1727 } else {
1728 statusBarUser.text = `$(sign-in) Sign In`;
1729 statusBarUser.tooltip = `Sign in to Aesthetic Computer`;
1730 statusBarUser.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
1731 statusBarUser.show();
1732 }
1733 }
1734
1735 // Initialize user status bar
1736 updateUserStatusBar();
1737
1738 // 🔥 AC-OS OTA Build Status Bar
1739 // Polls oven.aesthetic.computer/native-build to show live OTA build status.
1740 let statusBarOTA: vscode.StatusBarItem;
1741 let otaPollInterval: NodeJS.Timeout | undefined;
1742
1743 interface OTABuildState {
1744 activeJobId: string | null;
1745 active: {
1746 id: string;
1747 ref: string;
1748 status: string;
1749 stage: string;
1750 percent: number;
1751 elapsedMs: number;
1752 error?: string;
1753 buildName?: string;
1754 commitMsg?: string;
1755 } | null;
1756 recent: Array<{
1757 id: string;
1758 ref: string;
1759 status: string;
1760 stage: string;
1761 percent: number;
1762 elapsedMs: number;
1763 finishedAt?: string;
1764 error?: string;
1765 buildName?: string;
1766 commitMsg?: string;
1767 flags?: string[];
1768 }>;
1769 }
1770
1771 const OTA_STAGES: Record<string, { icon: string; label: string; step: number }> = {
1772 'preflight-sync': { icon: '$(repo-sync~spin)', label: 'sync', step: 1 },
1773 'prune': { icon: '$(trash~spin)', label: 'prune', step: 1 },
1774 'docker-build': { icon: '$(container~spin)', label: 'docker', step: 2 },
1775 'binary': { icon: '$(gear~spin)', label: 'binary', step: 3 },
1776 'initramfs': { icon: '$(package~spin)', label: 'initramfs', step: 4 },
1777 'kernel': { icon: '$(cpu~spin)', label: 'kernel', step: 5 },
1778 'smoke-test': { icon: '$(beaker~spin)', label: 'test', step: 6 },
1779 'upload': { icon: '$(cloud-upload~spin)', label: 'upload', step: 7 },
1780 'done': { icon: '$(check)', label: 'done', step: 8 },
1781 };
1782 const OTA_TOTAL_STEPS = 7;
1783
1784 function getOTAIcon(stage: string, status: string): string {
1785 if (status === 'running') {
1786 return OTA_STAGES[stage]?.icon || '$(sync~spin)';
1787 }
1788 switch (status) {
1789 case 'success': return '$(check)';
1790 case 'failed': return '$(error)';
1791 case 'cancelled': return '$(close)';
1792 default: return '$(flame)';
1793 }
1794 }
1795
1796 function getOTAColor(status: string): vscode.ThemeColor | undefined {
1797 switch (status) {
1798 case 'running': return new vscode.ThemeColor('statusBarItem.warningBackground');
1799 case 'failed': return new vscode.ThemeColor('statusBarItem.errorBackground');
1800 default: return undefined;
1801 }
1802 }
1803
1804 async function fetchOTAStatus(): Promise<OTABuildState | null> {
1805 try {
1806 const https = await import("https");
1807 return new Promise((resolve) => {
1808 const req = https.request({
1809 hostname: 'oven.aesthetic.computer',
1810 path: '/native-build',
1811 method: 'GET',
1812 headers: { 'User-Agent': 'aesthetic-computer-vscode' },
1813 timeout: 5000,
1814 }, (res: any) => {
1815 let data = '';
1816 res.on('data', (chunk: string) => data += chunk);
1817 res.on('end', () => {
1818 try { resolve(JSON.parse(data)); }
1819 catch { resolve(null); }
1820 });
1821 });
1822 req.on('error', () => resolve(null));
1823 req.on('timeout', () => { req.destroy(); resolve(null); });
1824 req.end();
1825 });
1826 } catch {
1827 return null;
1828 }
1829 }
1830
1831 async function updateOTAStatusBar() {
1832 if (!statusBarOTA) {
1833 statusBarOTA = vscode.window.createStatusBarItem(
1834 vscode.StatusBarAlignment.Left,
1835 99,
1836 );
1837 statusBarOTA.command = "aestheticComputer.openOSPage";
1838 context.subscriptions.push(statusBarOTA);
1839 }
1840
1841 const state = await fetchOTAStatus();
1842 if (!state) {
1843 statusBarOTA.text = '$(flame) OTA';
1844 statusBarOTA.tooltip = 'Could not reach oven.aesthetic.computer';
1845 statusBarOTA.backgroundColor = undefined;
1846 statusBarOTA.show();
1847 return;
1848 }
1849
1850 if (state.active) {
1851 const a = state.active;
1852 const elapsed = Math.round(a.elapsedMs / 1000);
1853 const mins = Math.floor(elapsed / 60);
1854 const secs = elapsed % 60;
1855 const timeStr = mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
1856 const name = a.buildName || a.ref?.substring(0, 7) || '';
1857 const variant = a.stage?.startsWith('cl-') ? ' CL' : ' C';
1858 const rawStage = a.stage?.replace('cl-', '') || '';
1859 const stageInfo = OTA_STAGES[rawStage];
1860 const stageLabel = stageInfo?.label || rawStage;
1861 const stepNum = stageInfo?.step || 0;
1862 const progress = a.percent > 0 ? ` ${a.percent}%` : '';
1863 const stepStr = stepNum > 0 ? ` [${stepNum}/${OTA_TOTAL_STEPS}]` : '';
1864 statusBarOTA.text = `${getOTAIcon(a.stage, 'running')} ${name}${variant} | ${stageLabel}${progress}${stepStr} | ${timeStr}`;
1865 statusBarOTA.backgroundColor = getOTAColor('running');
1866 let tip = `AC-OS Building: ${name}\nVariant:${variant}\nStage: ${stageLabel}${progress}${stepStr}\nElapsed: ${timeStr}`;
1867 if (a.commitMsg) tip += `\n${a.commitMsg}`;
1868 if (a.ref) tip += `\nCommit: ${a.ref.substring(0, 11)}`;
1869 statusBarOTA.tooltip = tip;
1870 } else if (state.recent?.length > 0) {
1871 const r = state.recent[0];
1872 const name = r.buildName || r.ref?.substring(0, 7) || '';
1873 const elapsed = Math.round(r.elapsedMs / 1000);
1874 const mins = Math.floor(elapsed / 60);
1875 const timeStr = mins > 0 ? `${mins}m${elapsed % 60}s` : `${elapsed}s`;
1876 const time = r.finishedAt ? new Date(r.finishedAt).toLocaleTimeString() : '';
1877 const icon = getOTAIcon(r.stage, r.status);
1878 statusBarOTA.text = r.status === 'success'
1879 ? `${icon} ${name} | ${timeStr}`
1880 : `${icon} ${name} ${r.status}`;
1881 statusBarOTA.backgroundColor = getOTAColor(r.status);
1882 let tip = `AC-OS: ${r.status} — ${name}`;
1883 if (r.commitMsg) tip += `\n${r.commitMsg}`;
1884 tip += `\nBuild time: ${timeStr}`;
1885 if (time) tip += ` | Finished: ${time}`;
1886 if (r.error) tip += `\nError: ${r.error}`;
1887 statusBarOTA.tooltip = tip;
1888 }
1889
1890 statusBarOTA.show();
1891 adjustOTAPollRate(state);
1892 }
1893
1894 let otaPollRate = 30000;
1895 function adjustOTAPollRate(state: OTABuildState | null) {
1896 const isActive = !!state?.active;
1897 const newRate = isActive ? 3000 : 30000; // 3s when building, 30s idle
1898 if (newRate !== otaPollRate) {
1899 otaPollRate = newRate;
1900 startOTAPolling();
1901 }
1902 }
1903
1904 function startOTAPolling() {
1905 if (otaPollInterval) clearInterval(otaPollInterval);
1906 otaPollInterval = setInterval(() => updateOTAStatusBar(), otaPollRate);
1907 }
1908
1909 updateOTAStatusBar();
1910 startOTAPolling();
1911
1912 context.subscriptions.push(
1913 vscode.commands.registerCommand("aestheticComputer.showOTADetails", async () => {
1914 const state = await fetchOTAStatus();
1915 if (!state) {
1916 vscode.window.showWarningMessage('Could not reach oven.aesthetic.computer');
1917 return;
1918 }
1919
1920 const items: vscode.QuickPickItem[] = [];
1921
1922 // Active build at top
1923 if (state.active) {
1924 const a = state.active;
1925 const name = a.buildName || a.ref?.substring(0, 7) || 'building';
1926 items.push({
1927 label: `$(sync~spin) ${name}`,
1928 description: `${a.stage} ${a.percent}%`,
1929 detail: a.commitMsg || `ref: ${a.ref?.substring(0, 11) || '?'}`,
1930 });
1931 }
1932
1933 // Recent builds
1934 for (const r of state.recent || []) {
1935 const name = r.buildName || r.ref?.substring(0, 7) || '?';
1936 const icon = r.status === 'success' ? '$(check)' : r.status === 'failed' ? '$(error)' : '$(close)';
1937 const elapsed = Math.round(r.elapsedMs / 1000);
1938 const time = r.finishedAt ? new Date(r.finishedAt).toLocaleTimeString() : '';
1939 items.push({
1940 label: `${icon} ${name}`,
1941 description: `${r.status} — ${elapsed}s${time ? ' — ' + time : ''}`,
1942 detail: r.commitMsg || (r.error ? `Error: ${r.error}` : `ref: ${r.ref?.substring(0, 11) || '?'}`),
1943 });
1944 }
1945
1946 // Action items at bottom
1947 items.push(
1948 { label: '', kind: vscode.QuickPickItemKind.Separator },
1949 { label: '$(globe) Open Oven Dashboard', description: 'oven.aesthetic.computer' },
1950 { label: '$(cloud-download) Pull & Flash USB', description: 'ac-os pull' },
1951 { label: '$(refresh) Refresh', description: '' },
1952 );
1953
1954 const pick = await vscode.window.showQuickPick(items, {
1955 title: 'AC-OS OTA Builds',
1956 placeHolder: 'Select a build or action...',
1957 });
1958
1959 if (pick?.label.includes('Open Oven')) {
1960 vscode.env.openExternal(vscode.Uri.parse('https://oven.aesthetic.computer/native-build'));
1961 } else if (pick?.label.includes('Pull & Flash')) {
1962 const terminal = vscode.window.createTerminal('AC-OS Flash');
1963 terminal.show();
1964 terminal.sendText('fedac/native/ac-os pull');
1965 } else if (pick?.label.includes('Refresh')) {
1966 updateOTAStatusBar();
1967 }
1968 })
1969 );
1970
1971
1972 context.subscriptions.push(
1973 vscode.commands.registerCommand("aestheticComputer.openOSPage", () => {
1974 vscode.env.openExternal(vscode.Uri.parse('https://prompt.ac/os'));
1975 })
1976 );
1977
1978 // 🔒 Vault Unlock Status Bar
1979 // Only shown when an aesthetic-computer-vault directory exists in the workspace.
1980 // Click → passphrase input → imports GPG key if missing, warms gpg-agent,
1981 // runs vault-tool.fish unlock + devault.fish. Passphrase persists via SecretStorage.
1982 let statusBarVault: vscode.StatusBarItem | undefined;
1983
1984 function findVaultDir(): string | undefined {
1985 const folders = vscode.workspace.workspaceFolders;
1986 if (!folders || !path || !fs) return undefined;
1987 for (const f of folders) {
1988 const candidate = path.join(f.uri.fsPath, "aesthetic-computer-vault");
1989 if (fs.existsSync(path.join(candidate, "vault-tool.fish"))) return candidate;
1990 }
1991 return undefined;
1992 }
1993
1994 function findPrivateKeyFile(vaultDir: string): string | undefined {
1995 if (!path || !fs) return undefined;
1996 const root = path.dirname(vaultDir);
1997 const candidates = [
1998 path.join(root, "jeffrey-private.asc"),
1999 path.join(vaultDir, "gpg", "jeffrey-private.asc"),
2000 ];
2001 for (const c of candidates) if (fs.existsSync(c)) return c;
2002 return undefined;
2003 }
2004
2005 function isSecretKeyImported(): boolean {
2006 if (!cp) return false;
2007 try {
2008 const r = cp.spawnSync("gpg", ["--list-secret-keys", "mail@aesthetic.computer"], { encoding: "utf8" });
2009 return r.status === 0 && typeof r.stdout === "string" && r.stdout.includes("mail@aesthetic.computer");
2010 } catch { return false; }
2011 }
2012
2013 function isVaultUnlocked(vaultDir: string): boolean {
2014 if (!fs || !path) return false;
2015 // Consider unlocked if the home/.ssh/id_rsa plaintext exists (devault has run)
2016 try { return fs.existsSync(path.join(vaultDir, "home/.ssh/id_rsa")); } catch { return false; }
2017 }
2018
2019 async function runVaultUnlock(passphrase: string, vaultDir: string): Promise<string> {
2020 const os = await import("os");
2021 const gnupgDir = path.join(os.homedir(), ".gnupg");
2022 fs.mkdirSync(gnupgDir, { recursive: true, mode: 0o700 });
2023
2024 const agentConf = path.join(gnupgDir, "gpg-agent.conf");
2025 const existingAgent = fs.existsSync(agentConf) ? fs.readFileSync(agentConf, "utf8") : "";
2026 if (!existingAgent.includes("allow-loopback-pinentry")) {
2027 fs.appendFileSync(agentConf, (existingAgent.endsWith("\n") || !existingAgent ? "" : "\n") + "allow-loopback-pinentry\n");
2028 try { cp.execSync("gpgconf --kill gpg-agent", { stdio: "ignore" }); } catch {}
2029 }
2030 const gpgConf = path.join(gnupgDir, "gpg.conf");
2031 const existingGpg = fs.existsSync(gpgConf) ? fs.readFileSync(gpgConf, "utf8") : "";
2032 if (!existingGpg.includes("pinentry-mode loopback")) {
2033 fs.appendFileSync(gpgConf, (existingGpg.endsWith("\n") || !existingGpg ? "" : "\n") + "pinentry-mode loopback\n");
2034 }
2035
2036 if (!isSecretKeyImported()) {
2037 const keyFile = findPrivateKeyFile(vaultDir);
2038 if (!keyFile) throw new Error("No GPG private key to import. Place jeffrey-private.asc in the repo root.");
2039 const imp = cp.spawnSync("gpg", [
2040 "--batch", "--yes", "--passphrase-fd", "0", "--pinentry-mode", "loopback", "--import", keyFile,
2041 ], { input: passphrase, encoding: "utf8" });
2042 if (imp.status !== 0) throw new Error(`gpg import failed: ${imp.stderr || imp.stdout}`);
2043 }
2044
2045 // Verify passphrase by decrypting a known vault file to a temp location
2046 const testGpg = path.join(vaultDir, "home/.ssh/id_rsa.gpg");
2047 if (fs.existsSync(testGpg)) {
2048 const test = cp.spawnSync("gpg", [
2049 "--batch", "--yes", "--passphrase-fd", "0", "--pinentry-mode", "loopback", "--decrypt", testGpg,
2050 ], { input: passphrase, encoding: "utf8" });
2051 if (test.status !== 0) throw new Error("Passphrase incorrect (test decrypt failed).");
2052 }
2053
2054 // Warm gpg-agent cache so vault-tool.fish's batch decrypts don't prompt.
2055 try {
2056 const kg = cp.execSync(
2057 "gpg --with-keygrip --list-secret-keys mail@aesthetic.computer | awk '/Keygrip/ {print $3; exit}'",
2058 { encoding: "utf8" }
2059 ).trim();
2060 if (kg) {
2061 const hexPass = Buffer.from(passphrase, "utf8").toString("hex").toUpperCase();
2062 cp.execSync(`gpg-connect-agent "PRESET_PASSPHRASE ${kg} -1 ${hexPass}" /bye`, { stdio: "ignore" });
2063 }
2064 } catch {}
2065
2066 // Run vault-tool.fish unlock then devault.fish (which distributes secrets to parent repo).
2067 const unlock = cp.spawnSync("fish", [path.join(vaultDir, "vault-tool.fish"), "unlock"], {
2068 encoding: "utf8", cwd: vaultDir,
2069 env: { ...process.env, GPG_TTY: "" },
2070 });
2071 if (unlock.status !== 0) throw new Error(`vault unlock failed: ${(unlock.stderr || "") + (unlock.stdout || "")}`.slice(0, 500));
2072
2073 const devault = cp.spawnSync("fish", [path.join(vaultDir, "devault.fish")], {
2074 encoding: "utf8", cwd: vaultDir,
2075 env: { ...process.env, GPG_TTY: "" },
2076 });
2077 if (devault.status !== 0) throw new Error(`devault failed: ${(devault.stderr || "") + (devault.stdout || "")}`.slice(0, 500));
2078
2079 return "Vault unlocked and distributed.";
2080 }
2081
2082 async function updateVaultStatusBar() {
2083 await _modulesReady;
2084 const vaultDir = findVaultDir();
2085 if (!vaultDir) { statusBarVault?.hide(); return; }
2086 if (!statusBarVault) {
2087 statusBarVault = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 48);
2088 context.subscriptions.push(statusBarVault);
2089 }
2090 if (isVaultUnlocked(vaultDir)) {
2091 statusBarVault.text = "$(unlock) Vault";
2092 statusBarVault.tooltip = "Aesthetic Computer vault is unlocked. Click to lock.";
2093 statusBarVault.command = "aestheticComputer.vaultLock";
2094 statusBarVault.backgroundColor = undefined;
2095 } else {
2096 statusBarVault.text = "$(lock) Vault";
2097 statusBarVault.tooltip = "Aesthetic Computer vault is locked. Click to unlock.";
2098 statusBarVault.command = "aestheticComputer.vaultUnlock";
2099 statusBarVault.backgroundColor = new vscode.ThemeColor("statusBarItem.warningBackground");
2100 }
2101 statusBarVault.show();
2102 }
2103
2104 context.subscriptions.push(
2105 vscode.commands.registerCommand("aestheticComputer.vaultUnlock", async () => {
2106 await _modulesReady;
2107 const vaultDir = findVaultDir();
2108 if (!vaultDir) { vscode.window.showErrorMessage("No aesthetic-computer-vault found in workspace."); return; }
2109
2110 let pass = await context.secrets.get("aestheticComputer.vaultPassphrase");
2111 if (!pass) {
2112 pass = await vscode.window.showInputBox({
2113 prompt: "Vault passphrase",
2114 password: true,
2115 ignoreFocusOut: true,
2116 placeHolder: "Enter GPG passphrase for mail@aesthetic.computer",
2117 });
2118 if (!pass) return;
2119 }
2120
2121 try {
2122 const msg = await vscode.window.withProgress({
2123 location: vscode.ProgressLocation.Notification,
2124 title: "Unlocking vault…",
2125 cancellable: false,
2126 }, async () => runVaultUnlock(pass!, vaultDir));
2127 await context.secrets.store("aestheticComputer.vaultPassphrase", pass);
2128 vscode.window.showInformationMessage("🔓 " + msg);
2129 } catch (err: any) {
2130 // Clear stored passphrase if it was wrong so the next click re-prompts.
2131 if (String(err?.message || "").includes("Passphrase incorrect")) {
2132 await context.secrets.delete("aestheticComputer.vaultPassphrase");
2133 }
2134 vscode.window.showErrorMessage("Vault unlock failed: " + (err?.message || err));
2135 } finally {
2136 updateVaultStatusBar();
2137 }
2138 })
2139 );
2140
2141 context.subscriptions.push(
2142 vscode.commands.registerCommand("aestheticComputer.vaultLock", async () => {
2143 await _modulesReady;
2144 const vaultDir = findVaultDir();
2145 if (!vaultDir) return;
2146 const choice = await vscode.window.showWarningMessage(
2147 "Lock the vault? This will re-encrypt and shred plaintext secrets.",
2148 { modal: true }, "Lock"
2149 );
2150 if (choice !== "Lock") return;
2151 try {
2152 await vscode.window.withProgress({
2153 location: vscode.ProgressLocation.Notification,
2154 title: "Locking vault…",
2155 cancellable: false,
2156 }, async () => {
2157 const r = cp.spawnSync("fish", [path.join(vaultDir, "vault-tool.fish"), "lock"], {
2158 encoding: "utf8", cwd: vaultDir,
2159 });
2160 if (r.status !== 0) throw new Error((r.stderr || r.stdout || "").slice(0, 500));
2161 });
2162 vscode.window.showInformationMessage("🔒 Vault locked.");
2163 } catch (err: any) {
2164 vscode.window.showErrorMessage("Vault lock failed: " + (err?.message || err));
2165 } finally {
2166 updateVaultStatusBar();
2167 }
2168 })
2169 );
2170
2171 context.subscriptions.push(
2172 vscode.commands.registerCommand("aestheticComputer.vaultForgetPassphrase", async () => {
2173 await context.secrets.delete("aestheticComputer.vaultPassphrase");
2174 vscode.window.showInformationMessage("Vault passphrase cleared from SecretStorage.");
2175 })
2176 );
2177
2178 updateVaultStatusBar();
2179
2180 context.subscriptions.push(
2181 vscode.commands.registerCommand("aestheticComputer.openWindow", () => {
2182 const panel = vscode.window.createWebviewPanel(
2183 "webView", // Identifies the type of the webview. Used internally
2184 "Aesthetic", // Title of the panel displayed to the user
2185 vscode.ViewColumn.One, // Editor column to show the new webview panel in.
2186 {
2187 enableScripts: true,
2188 enableForms: true,
2189 localResourceRoots: [extContext.extensionUri],
2190 }, // Webview options.
2191 );
2192
2193 panel.title = "Aesthetic" + (local ? ": 🧑🤝🧑" : ""); // Update the title if local.
2194 panel.webview.html = getWebViewContent(panel.webview, "");
2195 webWindow = panel;
2196
2197 panel.onDidDispose(
2198 () => {
2199 webWindow = null;
2200 },
2201 null,
2202 context.subscriptions,
2203 );
2204 }),
2205 );
2206
2207 // 🌈 KidLisp.com Window
2208 context.subscriptions.push(
2209 vscode.commands.registerCommand("aestheticComputer.openKidLispWindow", () => {
2210 // If window already exists, reveal it instead of creating a new one
2211 if (kidlispWindow) {
2212 kidlispWindow.reveal(vscode.ViewColumn.One);
2213 return;
2214 }
2215
2216 const panel = vscode.window.createWebviewPanel(
2217 "kidlispWebView", // Identifies the type of the webview
2218 "KidLisp.com", // Title of the panel displayed to the user
2219 vscode.ViewColumn.One, // Editor column to show the new webview panel in
2220 {
2221 enableScripts: true,
2222 enableForms: true,
2223 localResourceRoots: [extContext.extensionUri],
2224 },
2225 );
2226
2227 panel.title = "KidLisp.com" + (local ? " 🧑🤝🧑" : "");
2228 panel.webview.html = getKidLispWebViewContent(panel.webview);
2229 kidlispWindow = panel;
2230
2231 // Push current session into the KidLisp iframe
2232 sendKidLispSession(panel.webview);
2233
2234 // Handle messages from the KidLisp webview
2235 panel.webview.onDidReceiveMessage(
2236 async (message) => {
2237 switch (message.type) {
2238 case "vscode-extension:closeAllEditors":
2239 await vscode.commands.executeCommand("workbench.action.closeAllEditors");
2240 break;
2241 case "vscode-extension:reload":
2242 vscode.commands.executeCommand("workbench.action.reloadWindow");
2243 break;
2244 case "kidlisp:ready": {
2245 sendKidLispSession(panel.webview);
2246 break;
2247 }
2248 case "kidlisp:login": {
2249 const session = await vscode.authentication.getSession(
2250 "aesthetic",
2251 ["profile"],
2252 { createIfNone: true },
2253 );
2254 if (session) {
2255 extContext.globalState.update("aesthetic:session", session);
2256 updateUserStatusBar();
2257 await sendKidLispSession(panel.webview);
2258 }
2259 break;
2260 }
2261 case "kidlisp:logout": {
2262 const session = await vscode.authentication.getSession(
2263 "aesthetic",
2264 ["profile"],
2265 { silent: true },
2266 );
2267 if (session) {
2268 await ap.removeSession(session.id);
2269 extContext.globalState.update("aesthetic:session", undefined);
2270 }
2271 updateUserStatusBar();
2272 await sendKidLispSession(panel.webview);
2273 break;
2274 }
2275 // Fallback for plain login/logout messages forwarded from the iframe
2276 case "login": {
2277 vscode.commands.executeCommand("aestheticComputer.logIn");
2278 break;
2279 }
2280 case "logout": {
2281 vscode.commands.executeCommand("aestheticComputer.logOut");
2282 break;
2283 }
2284 }
2285 },
2286 undefined,
2287 context.subscriptions,
2288 );
2289
2290 panel.onDidDispose(
2291 () => {
2292 kidlispWindow = null;
2293 },
2294 null,
2295 context.subscriptions,
2296 );
2297 }),
2298 );
2299
2300 // 🧭 AT Window
2301 context.subscriptions.push(
2302 vscode.commands.registerCommand("aestheticComputer.openAtWindow", () => {
2303 if (atWindow) {
2304 atWindow.reveal(vscode.ViewColumn.One);
2305 return;
2306 }
2307
2308 const panel = vscode.window.createWebviewPanel(
2309 "atWebView",
2310 "AT",
2311 vscode.ViewColumn.One,
2312 {
2313 enableScripts: true,
2314 enableForms: true,
2315 localResourceRoots: [extContext.extensionUri],
2316 },
2317 );
2318
2319 panel.title = "AT" + (local ? " 🧑🤝🧑" : "");
2320 panel.webview.html = getAtWebViewContent(panel.webview);
2321 atWindow = panel;
2322
2323 panel.onDidDispose(
2324 () => {
2325 atWindow = null;
2326 },
2327 null,
2328 context.subscriptions,
2329 );
2330 }),
2331 );
2332
2333 // 📰 News Window
2334 context.subscriptions.push(
2335 vscode.commands.registerCommand("aestheticComputer.openNewsWindow", () => {
2336 if (newsWindow) {
2337 newsWindow.reveal(vscode.ViewColumn.One);
2338 return;
2339 }
2340
2341 const panel = vscode.window.createWebviewPanel(
2342 "newsWebView",
2343 "News",
2344 vscode.ViewColumn.One,
2345 {
2346 enableScripts: true,
2347 enableForms: true,
2348 localResourceRoots: [extContext.extensionUri],
2349 },
2350 );
2351
2352 panel.title = "News" + (local ? " 🧑🤝🧑" : "");
2353 panel.webview.html = getNewsWebViewContent(panel.webview);
2354 newsWindow = panel;
2355
2356 sendNewsSession(panel.webview);
2357
2358 panel.webview.onDidReceiveMessage(
2359 async (message) => {
2360 switch (message.type) {
2361 case "news:ready": {
2362 sendNewsSession(panel.webview);
2363 break;
2364 }
2365 case "news:login": {
2366 const session = await vscode.authentication.getSession(
2367 "aesthetic",
2368 ["profile"],
2369 { createIfNone: true },
2370 );
2371 if (session) {
2372 extContext.globalState.update("aesthetic:session", session);
2373 updateUserStatusBar();
2374 await sendNewsSession(panel.webview);
2375 }
2376 break;
2377 }
2378 case "news:logout": {
2379 const session = await vscode.authentication.getSession(
2380 "aesthetic",
2381 ["profile"],
2382 { silent: true },
2383 );
2384 if (session) {
2385 await ap.removeSession(session.id);
2386 extContext.globalState.update("aesthetic:session", undefined);
2387 }
2388 updateUserStatusBar();
2389 await sendNewsSession(panel.webview);
2390 break;
2391 }
2392 case "news:signup": {
2393 // Signup is just login with a hint - Auth0 handles the rest
2394 const session = await vscode.authentication.getSession(
2395 "aesthetic",
2396 ["profile"],
2397 { createIfNone: true },
2398 );
2399 if (session) {
2400 extContext.globalState.update("aesthetic:session", session);
2401 updateUserStatusBar();
2402 await sendNewsSession(panel.webview);
2403 }
2404 break;
2405 }
2406 case "login": {
2407 const session = await vscode.authentication.getSession(
2408 "aesthetic",
2409 ["profile"],
2410 { createIfNone: true },
2411 );
2412 if (session) {
2413 extContext.globalState.update("aesthetic:session", session);
2414 updateUserStatusBar();
2415 await sendNewsSession(panel.webview);
2416 }
2417 break;
2418 }
2419 case "signup": {
2420 const session = await vscode.authentication.getSession(
2421 "aesthetic",
2422 ["profile"],
2423 { createIfNone: true },
2424 );
2425 if (session) {
2426 extContext.globalState.update("aesthetic:session", session);
2427 updateUserStatusBar();
2428 await sendNewsSession(panel.webview);
2429 }
2430 break;
2431 }
2432 case "logout": {
2433 const session = await vscode.authentication.getSession(
2434 "aesthetic",
2435 ["profile"],
2436 { silent: true },
2437 );
2438 if (session) {
2439 await ap.removeSession(session.id);
2440 extContext.globalState.update("aesthetic:session", undefined);
2441 }
2442 updateUserStatusBar();
2443 await sendNewsSession(panel.webview);
2444 break;
2445 }
2446 }
2447 },
2448 undefined,
2449 context.subscriptions,
2450 );
2451
2452 panel.onDidDispose(
2453 () => {
2454 newsWindow = null;
2455 },
2456 null,
2457 context.subscriptions,
2458 );
2459 }),
2460 );
2461
2462 // Add definitionProvider to context.subscriptions if necessary
2463 context.subscriptions.push(definitionProvider);
2464
2465 // 🗝️ Authorization
2466 const ap = new AestheticAuthenticationProvider(context, local, "aesthetic");
2467 const sp = new AestheticAuthenticationProvider(context, local, "sotce");
2468
2469 context.subscriptions.push(ap);
2470 context.subscriptions.push(sp);
2471
2472 context.subscriptions.push(
2473 vscode.commands.registerCommand("aestheticComputer.logIn", async () => {
2474 const session = await vscode.authentication.getSession(
2475 "aesthetic",
2476 ["profile"],
2477 { createIfNone: true },
2478 );
2479 }),
2480 );
2481
2482 // Alias to match older calls expecting lowercase "login"
2483 context.subscriptions.push(
2484 vscode.commands.registerCommand("aestheticComputer.login", async () => {
2485 await vscode.commands.executeCommand("aestheticComputer.logIn");
2486 }),
2487 );
2488
2489 context.subscriptions.push(
2490 vscode.commands.registerCommand("aestheticComputer.logOut", async () => {
2491 const session = await vscode.authentication.getSession(
2492 "aesthetic",
2493 ["profile"],
2494 { silent: true },
2495 );
2496 if (session) {
2497 await ap.removeSession(session.id);
2498 vscode.window.showInformationMessage("🟪 You have been logged out.");
2499 } else {
2500 vscode.window.showInformationMessage("No active session found.");
2501 }
2502 }),
2503 );
2504
2505 // Alias to match older calls expecting lowercase "logout"
2506 context.subscriptions.push(
2507 vscode.commands.registerCommand("aestheticComputer.logout", async () => {
2508 await vscode.commands.executeCommand("aestheticComputer.logOut");
2509 }),
2510 );
2511
2512 context.subscriptions.push(
2513 vscode.commands.registerCommand(
2514 "aestheticComputer.sotceLogIn",
2515 async () => {
2516 const session = await vscode.authentication.getSession(
2517 "sotce",
2518 ["profile"],
2519 { createIfNone: true },
2520 );
2521 },
2522 ),
2523 );
2524
2525 context.subscriptions.push(
2526 vscode.commands.registerCommand(
2527 "aestheticComputer.sotceLogOut",
2528 async () => {
2529 const session = await vscode.authentication.getSession(
2530 "sotce",
2531 ["profile"],
2532 { silent: true },
2533 );
2534 if (session) {
2535 await sp.removeSession(session.id);
2536 vscode.window.showInformationMessage("🪷 You have been logged out.");
2537 } else {
2538 vscode.window.showInformationMessage("No active session found.");
2539 }
2540 },
2541 ),
2542 );
2543
2544 const getSession = async (tenant: string) => {
2545 const session = await vscode.authentication.getSession(
2546 tenant,
2547 ["profile"],
2548 { silent: true },
2549 );
2550
2551 if (session) {
2552 vscode.window.showInformationMessage(
2553 `👋 Welcome back to ${
2554 tenant === "aesthetic" ? "Aesthetic Computer" : "Sotce Net"
2555 }! (${session.account.label})`,
2556 );
2557 context.globalState.update(`${tenant}:session`, session);
2558 } else {
2559 context.globalState.update(`${tenant}:session`, undefined);
2560 // console.log("😀 Erased session!");
2561 }
2562
2563 // Update user status bar after session change
2564 updateUserStatusBar();
2565
2566 return session;
2567 };
2568
2569 context.subscriptions.push(
2570 vscode.authentication.onDidChangeSessions(async (e) => {
2571 // console.log("🏃 Sessions changed:", e);
2572 if (e.provider.id === "aesthetic" || e.provider.id === "sotce") {
2573 await getSession(e.provider.id);
2574 provider.refreshWebview();
2575 refreshWebWindow();
2576 refreshKidLispWindow();
2577 refreshNewsWindow();
2578 sendKidLispSession();
2579 sendNewsSession();
2580 updateUserStatusBar();
2581 }
2582 }),
2583 );
2584
2585 // GUI
2586
2587 provider = new AestheticViewProvider();
2588
2589 // Connect to session server for jump commands
2590 provider.connectToSessionServer();
2591
2592 context.subscriptions.push(
2593 vscode.window.registerWebviewViewProvider(
2594 AestheticViewProvider.viewType,
2595 provider,
2596 ),
2597 );
2598
2599 // 🧩 Piece Running
2600
2601 // Send piece code through the code channel.
2602 function upload() {
2603 let editor = vscode.window.activeTextEditor;
2604 if (!editor) {
2605 return;
2606 }
2607
2608 if (local) {
2609 // console.log("😊 Skipping `/run` api endpoint. (In local mode.)");
2610 return;
2611 }
2612
2613 let source = editor.document.getText();
2614 const piece = editor.document.fileName
2615 .split(/\/|\\/) // Split on both forward slash and backslash
2616 .slice(-1)[0]
2617 .replace(".mjs", "");
2618
2619 // 📓 The `local` won't work due to VSCode's Proxy, but the option
2620 // is here just in case it's ever possible again.
2621 const host = local === false ? "aesthetic.computer" : "localhost:8888";
2622
2623 let url = `https://${host}/run`;
2624
2625 vscode.window.showInformationMessage(`🧩 ${piece}`);
2626
2627 fetch(url, {
2628 method: "POST",
2629 body: JSON.stringify({ piece, source, codeChannel }),
2630 headers: { "Content-Type": "application/json" },
2631 })
2632 .then((res) => res.text()) // Convert the response to text
2633 .then((text) => {
2634 // Now 'text' is a string that can be used in showInformationMessage
2635 // vscode.window.showInformationMessage(`🧩 \`${piece}\``);
2636 })
2637 .catch((error) => {
2638 // If you catch an error, make sure to convert it to a string if it isn't already
2639 console.error(error);
2640 vscode.window.showInformationMessage("🔴" + "Piece error.");
2641 });
2642 }
2643
2644 context.subscriptions.push(
2645 vscode.commands.registerCommand("aestheticComputer.runPiece", () => {
2646 upload();
2647 }),
2648 vscode.commands.registerCommand("aestheticComputer.localServer", () => {
2649 local = !local;
2650 context.globalState.update("aesthetic:local", local);
2651
2652 // Start or stop local server checking
2653 if (local) {
2654 localServerAvailable = false; // Reset until we confirm
2655 startLocalServerCheck();
2656 } else {
2657 stopLocalServerCheck();
2658 localServerAvailable = false;
2659 }
2660
2661 // Refresh the webview with the new local state
2662 provider.refreshWebview();
2663 refreshWebWindow();
2664 refreshKidLispWindow();
2665 refreshNewsWindow();
2666 refreshWelcomePanel(); // Also refresh welcome panel for dev mode
2667 vscode.window.showInformationMessage(
2668 `💻 Local Development: ${local ? "Enabled" : "Disabled"}`,
2669 );
2670 }),
2671 vscode.commands.registerCommand("aestheticComputer.clearSlug", () => {
2672 // Clear the stored slug data
2673 context.globalState.update("panel:slug", "");
2674 // Refresh the webview to reflect the cleared state
2675 provider.refreshWebview();
2676 refreshWebWindow();
2677 vscode.window.showInformationMessage(
2678 "🧹 Slug data cleared successfully!",
2679 );
2680 }),
2681 vscode.commands.registerCommand("aestheticComputer.closeAllEditors", async () => {
2682 await vscode.commands.executeCommand("workbench.action.closeAllEditors");
2683 }),
2684 );
2685
2686 // Automatically re-run the piece when saving any .mjs file.
2687 vscode.workspace.onDidSaveTextDocument((document) => {
2688 function mjsOrLisp(path: string) {
2689 return path.endsWith(".mjs") || path.endsWith(".lisp");
2690 }
2691
2692 if (vscode.window.activeTextEditor?.document === document) {
2693 // console.log("🔩 File path:", document.uri.fsPath);
2694 const inMonoRepo =
2695 document.uri.fsPath.indexOf("aesthetic-computer/system") > -1;
2696 const inDisks =
2697 document.uri.fsPath.indexOf(
2698 "aesthetic-computer/system/public/aesthetic.computer/disks",
2699 ) > -1;
2700
2701 if (inMonoRepo) {
2702 if (inDisks && mjsOrLisp(document.uri.fsPath)) {
2703 // console.log("🟡 Loading piece...", document.uri.fsPath);
2704 vscode.commands.executeCommand("aestheticComputer.runPiece");
2705 }
2706 } else if (mjsOrLisp(document.uri.fsPath)) {
2707 // console.log("🟡 Loading piece...", document.uri.fsPath);
2708 vscode.commands.executeCommand("aestheticComputer.runPiece");
2709 }
2710 }
2711 });
2712
2713 // 🌐 CDP Command Server — local HTTP endpoint for test harnesses and automation
2714 if (http) {
2715 const CDP_PORT = 19998;
2716 const cdpServer = http.createServer(async (req: any, res: any) => {
2717 res.setHeader("Access-Control-Allow-Origin", "*");
2718 res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
2719 res.setHeader("Access-Control-Allow-Headers", "Content-Type");
2720 if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
2721
2722 if (req.url === "/status") {
2723 res.writeHead(200, { "Content-Type": "application/json" });
2724 res.end(JSON.stringify({ ok: true, extension: "aesthetic-computer" }));
2725 return;
2726 }
2727
2728 if (req.url === "/command" && req.method === "POST") {
2729 const body: Buffer[] = [];
2730 req.on("data", (chunk: Buffer) => body.push(chunk));
2731 req.on("end", async () => {
2732 try {
2733 const { command, args } = JSON.parse(Buffer.concat(body).toString());
2734 const result = await vscode.commands.executeCommand(command, ...(args || []));
2735 res.writeHead(200, { "Content-Type": "application/json" });
2736 res.end(JSON.stringify({ ok: true, result: result ?? null }));
2737 } catch (e: any) {
2738 res.writeHead(500, { "Content-Type": "application/json" });
2739 res.end(JSON.stringify({ ok: false, error: e.message }));
2740 }
2741 });
2742 return;
2743 }
2744
2745 res.writeHead(404);
2746 res.end("Not found");
2747 });
2748
2749 cdpServer.listen(CDP_PORT, "127.0.0.1", () => {
2750 console.log(`🌐 CDP command server listening on http://127.0.0.1:${CDP_PORT}`);
2751 });
2752 cdpServer.on("error", (e: any) => {
2753 if (e.code === "EADDRINUSE") {
2754 console.log(`🌐 CDP command server port ${CDP_PORT} already in use, skipping`);
2755 }
2756 });
2757 context.subscriptions.push({ dispose: () => cdpServer.close() });
2758 }
2759}
2760
2761// 📓 Documentation
2762
2763// This is just for top-level functions and maybe something at the very top?
2764class AestheticCodeLensProvider implements vscode.CodeLensProvider {
2765 provideCodeLenses(
2766 document: vscode.TextDocument,
2767 // token: vscode.CancellationToken,
2768 ): vscode.CodeLens[] {
2769 let codeLenses: vscode.CodeLens[] = [];
2770
2771 function escapeRegExp(word: string) {
2772 return word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2773 }
2774
2775 if (document.lineCount > 500 || !docs) return codeLenses; // Don't compute for large documents.
2776
2777 const escapedWords = keys(docs.api.structure)
2778 .map((word) => "function " + word)
2779 .map(escapeRegExp);
2780 const regex = new RegExp(`\\b(${escapedWords.join("|")})\\b`, "gi");
2781
2782 for (let i = 0; i < document.lineCount; i++) {
2783 const line = document.lineAt(i);
2784 let matches;
2785 while ((matches = regex.exec(line.text)) !== null) {
2786 const word = matches[0];
2787 const range = new vscode.Range(
2788 i,
2789 matches.index,
2790 i,
2791 matches.index + word.length,
2792 );
2793 const docKey = word.toLowerCase().replace("function ", "");
2794
2795 const command = {
2796 title: docs.api.structure[docKey].label,
2797 command: "aestheticComputer.openDoc",
2798 arguments: [docKey],
2799 };
2800 codeLenses.push(new vscode.CodeLens(range, command));
2801 }
2802 }
2803
2804 return codeLenses;
2805 }
2806}
2807
2808// 🪟 Panel Rendering
2809class AestheticViewProvider implements vscode.WebviewViewProvider {
2810 public static readonly viewType = "aestheticComputer.sidebarView";
2811 private _view?: vscode.WebviewView;
2812 private ws?: any;
2813
2814 constructor() {}
2815
2816 // Method to send message to the webview
2817 public sendMessageToWebview(message: any) {
2818 if (this._view && this._view.webview) {
2819 this._view.webview.postMessage(message);
2820 }
2821 }
2822
2823 // Connect to session server as WebSocket client
2824 public connectToSessionServer() {
2825 const WebSocket = require('ws');
2826 const url = local ? "wss://localhost:8889" : "wss://aesthetic.computer";
2827
2828 this.ws = new WebSocket(url, {
2829 rejectUnauthorized: false
2830 });
2831
2832 this.ws.on("open", () => {
2833 console.log("✅ Connected to session server");
2834 // Identify as VSCode extension
2835 this.ws.send(JSON.stringify({
2836 type: "identify",
2837 content: { type: "vscode" }
2838 }));
2839 });
2840
2841 this.ws.on("message", (data: any) => {
2842 try {
2843 const msg = JSON.parse(data.toString());
2844
2845 if (msg.type === "vscode:jump" && msg.content?.piece) {
2846 console.log("🎯 Jump command received:", msg.content.piece);
2847 this.handleJump(msg.content.piece);
2848 }
2849 } catch (err) {
2850 console.error("Failed to parse message:", err);
2851 }
2852 });
2853
2854 this.ws.on("close", () => {
2855 console.log("❌ Disconnected from session server");
2856 // Reconnect after 5 seconds
2857 setTimeout(() => this.connectToSessionServer(), 5000);
2858 });
2859
2860 this.ws.on("error", (err: any) => {
2861 console.error("WebSocket error:", err.message);
2862 });
2863 }
2864
2865 // Handle jump command by updating slug and showing panel
2866 private async handleJump(piece: string) {
2867 // Update the stored slug BEFORE showing the panel
2868 // This ensures the panel loads with the correct URL immediately
2869 await extContext.globalState.update("panel:slug", piece);
2870
2871 // If panel is hidden, show it (it will load with the new slug)
2872 if (!this._view?.visible) {
2873 await vscode.commands.executeCommand(
2874 "aestheticComputer.sidebarView.focus"
2875 );
2876 // The panel will initialize with the updated slug from globalState
2877 } else {
2878 // Panel is visible, send message to navigate
2879 this.sendMessageToWebview({ type: "jump", piece });
2880 }
2881 }
2882
2883 public refreshWebview(): void {
2884 if (this._view) {
2885 const slug = extContext.globalState.get("panel:slug", "");
2886 // if (slug) console.log("🪱 Loading slug:", slug);
2887 this._view.title = slug + (local ? " 🧑🤝🧑" : "");
2888 this._view.webview.html = getWebViewContent(this._view.webview, slug);
2889 }
2890 }
2891
2892 public resolveWebviewView(
2893 webviewView: vscode.WebviewView,
2894 context: vscode.WebviewViewResolveContext<unknown>,
2895 _token: vscode.CancellationToken,
2896 ): void {
2897 this._view = webviewView;
2898
2899 const slug = extContext.globalState.get("panel:slug", "");
2900 // if (slug) console.log("🪱 Loading slug:", slug);
2901
2902 this._view.title = slug + (local ? " 🧑🤝🧑" : "");
2903
2904 // Set retainContextWhenHidden to true
2905 this._view.webview.options = {
2906 enableScripts: true,
2907 enableForms: true,
2908 localResourceRoots: [extContext.extensionUri],
2909 };
2910
2911 webviewView.webview.html = getWebViewContent(this._view.webview, slug);
2912
2913 webviewView.webview.onDidReceiveMessage(async (data) => {
2914 switch (data.type) {
2915 case "url:updated": {
2916 // console.log("😫 Slug updated...", data.slug);
2917 extContext.globalState.update("panel:slug", data.slug);
2918 webviewView.title = data.slug + (local ? " 🧑🤝🧑" : "");
2919 break;
2920 }
2921 case "clipboard:copy": {
2922 vscode.env.clipboard.writeText(data.value).then(() => {
2923 // console.log("📋 Copied text to clipboard!");
2924 webviewView.webview.postMessage({
2925 type: "clipboard:copy:confirmation",
2926 });
2927 });
2928 break;
2929 }
2930 case "publish":
2931 if (data.url) vscode.env.openExternal(vscode.Uri.parse(data.url));
2932 break;
2933 case "setCode":
2934 codeChannel = data.value;
2935 // const currentTitle = webviewView.title;
2936 // webviewView.title = currentTitle?.split(" · ")[0] + " · " + codeChannel;
2937 // ^ Disabled because it's always rendered uppercase. 24.01.27.17.26
2938 break;
2939 case "vscode-extension:reload": {
2940 vscode.commands.executeCommand("workbench.action.reloadWindow");
2941 break;
2942 }
2943 case "vscode-extension:closeAllEditors": {
2944 await vscode.commands.executeCommand("workbench.action.closeAllEditors");
2945 break;
2946 }
2947 case "vscode-extension:defocus": {
2948 const editor = vscode.window.activeTextEditor;
2949 if (editor) {
2950 await vscode.window.showTextDocument(
2951 editor.document,
2952 editor.viewColumn,
2953 );
2954 }
2955 break;
2956 break;
2957 }
2958 case "openDocs": {
2959 // console.log("🏃 Opening docs...");
2960 vscode.commands.executeCommand("aestheticComputer.openDoc");
2961 break;
2962 }
2963 case "openSource": {
2964 // console.log("📃 Opening a new source file...", data);
2965 // const tempUri = document.uri.with({ path: document.uri.path + '.mjs' });
2966 vscode.workspace
2967 .openTextDocument({
2968 content: data.source,
2969 // language: "javascript",
2970 })
2971 .then((document) => {
2972 vscode.window
2973 .showTextDocument(document, { preview: false })
2974 .then(() => {
2975 return vscode.window.showInformationMessage(
2976 "💾 Save this code with an `.mjs` extension to run it on Aesthetic Computer",
2977 { modal: true },
2978 );
2979 })
2980 .then(() => {
2981 if (fs && path) {
2982 const defaultUri = vscode.Uri.file(
2983 path.join(vscode.workspace.rootPath || "", data.title),
2984 );
2985 return vscode.window.showSaveDialog({
2986 filters: {
2987 "JavaScript Module": ["mjs"],
2988 },
2989 defaultUri: defaultUri,
2990 saveLabel: "Save As",
2991 });
2992 } else {
2993 return;
2994 }
2995 })
2996 .then((fileUri) => {
2997 if (fileUri) {
2998 // Read the content of the current document
2999 const content = document.getText();
3000
3001 // Write the content to the new file
3002 fs.writeFile(fileUri.fsPath, content, (err: any) => {
3003 if (err) {
3004 vscode.window.showErrorMessage(
3005 "Failed to save file: " + err.message,
3006 );
3007 // return;
3008 return Promise.resolve(null);
3009 }
3010
3011 // Close the current editor
3012 vscode.commands
3013 .executeCommand(
3014 "workbench.action.revertAndCloseActiveEditor",
3015 )
3016 .then(() => {
3017 // Open the saved file in the editor
3018 vscode.workspace
3019 .openTextDocument(fileUri)
3020 .then((doc) => {
3021 vscode.window.showTextDocument(doc, {
3022 preview: false,
3023 });
3024 // vscode.window.showInformationMessage(
3025 // "File saved at: " + fileUri.fsPath,
3026 // );
3027 });
3028 });
3029 });
3030 }
3031 });
3032 });
3033 break;
3034 }
3035 case "runPiece": {
3036 console.log("🏃 Running piece...");
3037 vscode.commands.executeCommand("aestheticComputer.runPiece");
3038 break;
3039 }
3040 case "login": {
3041 console.log("📂 Logging in...");
3042 const command = data.tenant === "sotce" ? "sotceLogIn" : "logIn";
3043 vscode.commands.executeCommand(`aestheticComputer.${command}`);
3044 break;
3045 }
3046 case "logout": {
3047 console.log("🚪 Logging out...");
3048 const command = data.tenant === "sotce" ? "sotceLogOut" : "logOut";
3049 vscode.commands.executeCommand(`aestheticComputer.${command}`);
3050 break;
3051 }
3052 case "openExternal": {
3053 console.log("🌐 Opening external URL:", data.url);
3054 if (data.url) {
3055 vscode.env.openExternal(vscode.Uri.parse(data.url));
3056 }
3057 break;
3058 }
3059 }
3060 });
3061
3062 webviewView.onDidChangeVisibility(() => {
3063 if (!webviewView.visible) {
3064 // console.log("🔴 Panel hidden.");
3065 // Perform any cleanup or state update here when the view is hidden
3066 const slug = extContext.globalState.get("panel:slug", "");
3067 // if (slug) console.log("🪱 Slug:", slug);
3068 webviewView.title = slug + (local ? " 🧑🤝🧑" : "");
3069 webviewView.webview.html = getWebViewContent(webviewView.webview, slug);
3070 } else {
3071 // console.log("🟢 Panel open.");
3072 // Send focus event to webview so prompt can be activated
3073 webviewView.webview.postMessage({ type: "aesthetic-parent:focused" });
3074 }
3075 });
3076 }
3077}
3078
3079// 📚 Library
3080
3081function getNonce(): string {
3082 let text = "";
3083 const possible =
3084 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
3085 for (let i = 0; i < 32; i++) {
3086 text += possible.charAt(Math.floor(Math.random() * possible.length));
3087 }
3088 return text;
3089}
3090
3091function refreshWebWindow() {
3092 if (webWindow) {
3093 const slug = extContext.globalState.get("panel:slug", "");
3094 // if (slug) console.log("🪱 Loading slug:", slug);
3095
3096 webWindow.title = "Aesthetic: " + slug + (local ? " 🧑🤝🧑" : ""); // Update the title if local.
3097
3098 webWindow.webview.html = getWebViewContent(webWindow.webview, slug);
3099 }
3100}
3101
3102function refreshKidLispWindow() {
3103 if (kidlispWindow) {
3104 kidlispWindow.title = "KidLisp.com" + (local ? " 🧑🤝🧑" : "");
3105 kidlispWindow.webview.html = getKidLispWebViewContent(kidlispWindow.webview);
3106 }
3107}
3108
3109function refreshAtWindow() {
3110 if (atWindow) {
3111 atWindow.title = "AT" + (local ? " 🧑🤝🧑" : "");
3112 atWindow.webview.html = getAtWebViewContent(atWindow.webview);
3113 }
3114}
3115
3116function refreshNewsWindow() {
3117 if (newsWindow) {
3118 newsWindow.title = "News" + (local ? " 🧑🤝🧑" : "");
3119 newsWindow.webview.html = getNewsWebViewContent(newsWindow.webview);
3120 }
3121}
3122
3123async function sendKidLispSession(target?: vscode.Webview) {
3124 const webview = target || kidlispWindow?.webview;
3125 if (!webview) return;
3126
3127 let session: vscode.AuthenticationSession | null = null;
3128 try {
3129 session = await vscode.authentication.getSession(
3130 "aesthetic",
3131 ["profile"],
3132 { silent: true },
3133 );
3134 } catch (e) {
3135 console.log("🔴 Unable to fetch KidLisp session:", e);
3136 }
3137
3138 webview.postMessage({ type: "setSession", tenant: "aesthetic", session });
3139}
3140
3141async function sendNewsSession(target?: vscode.Webview) {
3142 const webview = target || newsWindow?.webview;
3143 if (!webview) return;
3144
3145 let session: vscode.AuthenticationSession | null = null;
3146 try {
3147 session = await vscode.authentication.getSession(
3148 "aesthetic",
3149 ["profile"],
3150 { silent: true },
3151 );
3152 } catch (e) {
3153 console.log("🔴 Unable to fetch News session:", e);
3154 }
3155
3156 webview.postMessage({ type: "setSession", tenant: "aesthetic", session });
3157}
3158
3159function getWebViewContent(webview: any, slug: string) {
3160 const scriptUri = webview.asWebviewUri(
3161 vscode.Uri.joinPath(extContext.extensionUri, "embedded.js"),
3162 );
3163
3164 const nonce = getNonce();
3165 const styleUri = webview.asWebviewUri(
3166 vscode.Uri.joinPath(extContext.extensionUri, "main.css"),
3167 );
3168
3169 const resetStyleUri = webview.asWebviewUri(
3170 vscode.Uri.joinPath(extContext.extensionUri, "reset.css"),
3171 );
3172
3173 const vscodeStyleUri = webview.asWebviewUri(
3174 vscode.Uri.joinPath(extContext.extensionUri, "vscode.css"),
3175 );
3176
3177 // Get proper URI for background image
3178 const purplePalsUri = webview.asWebviewUri(
3179 vscode.Uri.joinPath(extContext.extensionUri, "resources", "purple-pals.svg"),
3180 );
3181
3182 const sessionAesthetic = extContext.globalState.get(
3183 "aesthetic:session",
3184 undefined,
3185 );
3186
3187 const sessionSotce = extContext.globalState.get("sotce:session", undefined);
3188 // console.log("🟪 Aesthetic:", sessionAesthetic, "🪷 Sotce:", sessionSotce);
3189
3190 // console.log("🪱 Slug:", slug);
3191 let hashFragment = "";
3192 let pathPart = slug || "";
3193
3194 // Encode # as %23 in kidlisp code to prevent URL fragment interpretation
3195 // This handles cases like "(stamp #wNb 0 0)" in the path
3196 if (pathPart && !pathPart.startsWith("#")) {
3197 // Only encode # if it's not already a hash fragment (painting code)
3198 pathPart = pathPart.replace(/#/g, "%23");
3199 }
3200
3201 if (pathPart.startsWith("#")) {
3202 hashFragment = pathPart;
3203 pathPart = "";
3204 }
3205
3206 const hasQuery = pathPart.includes("?");
3207 let param = pathPart;
3208
3209 if (param) {
3210 param += hasQuery ? "&vscode=true" : "?vscode=true";
3211 } else {
3212 param = "?vscode=true";
3213 }
3214
3215 [sessionAesthetic, sessionSotce].forEach((session, index) => {
3216 const paramBase = `&session-${index === 0 ? "aesthetic" : "sotce"}=`;
3217
3218 if (typeof session === "object") {
3219 // Logged in.
3220 if (keys(session)?.length > 0) {
3221 const base64EncodedSession = btoa(JSON.stringify(session));
3222 param += paramBase + encodeURIComponent(base64EncodedSession);
3223 }
3224 } else {
3225 // Logged out.
3226 param += paramBase + "null";
3227 }
3228 });
3229
3230 // param = "?clearSession=true"; Probably never needed.
3231
3232 // Determine the iframe URL based on environment
3233 let iframeUrl;
3234 let iframeProtocol = "https://";
3235 if (isCodespaces && codespaceName && codespacesDomain) {
3236 // In Codespaces, always use the forwarded URL (without protocol, added later)
3237 iframeUrl = `${codespaceName}-8888.${codespacesDomain}`;
3238 } else {
3239 // On local laptop, use localhost if local mode is enabled
3240 iframeUrl = local ? "localhost:8888" : "aesthetic.computer";
3241 }
3242
3243 // Build CSP frame-src and child-src based on environment
3244 let cspFrameSrc = "frame-src https://aesthetic.computer https://hi.aesthetic.computer https://aesthetic.local:8888 https://localhost:8888 https://sotce.net https://hi.sotce.net https://sotce.local:8888";
3245 let cspChildSrc = "child-src https://aesthetic.computer https://aesthetic.local:8888 https://sotce.net https://sotce.local:8888 https://localhost:8888";
3246
3247 if (isCodespaces && codespacesDomain) {
3248 // Use wildcard for any codespace in this domain
3249 const codespaceWildcard = `https://*.${codespacesDomain}`;
3250 cspFrameSrc += ` ${codespaceWildcard}`;
3251 cspChildSrc += ` ${codespaceWildcard}`;
3252 }
3253
3254 // Show waiting UI if local mode is enabled but server isn't available yet
3255 if (local && !localServerAvailable && !isCodespaces) {
3256 // Theme-aware colors for waiting screen
3257 const themeKind = vscode.window.activeColorTheme.kind;
3258 const isDark = themeKind === 2 || themeKind === 3; // Dark or HighContrast
3259 const bg = isDark ? '#181318' : '#fcf7c5';
3260 const textColor = isDark ? '#ffffffcc' : '#3b2a1a';
3261 const titleColor = isDark ? '#a87090' : '#387adf';
3262 const subtitleBg = isDark ? '#101010' : '#fff9f0';
3263 const subtitleBorder = isDark ? '#483848' : '#e2d4c3';
3264 const subtitleColor = isDark ? '#b0a0a8' : '#6b4a2e';
3265 const codeColor = isDark ? '#70c070' : '#006400';
3266
3267 return `<!DOCTYPE html>
3268 <html lang="en">
3269 <head>
3270 <meta charset="UTF-8">
3271 <meta name="viewport" content="width=device-width, initial-scale=1.0">
3272 <link href="${styleUri}" rel="stylesheet">
3273 <link href="${resetStyleUri}" rel="stylesheet">
3274 <link href="${vscodeStyleUri}" rel="stylesheet">
3275 <title>aesthetic.computer</title>
3276 <style>
3277 body {
3278 display: flex;
3279 flex-direction: column;
3280 align-items: center;
3281 justify-content: center;
3282 height: 100vh;
3283 margin: 0;
3284 background: ${bg};
3285 color: ${textColor};
3286 font-family: system-ui, -apple-system, sans-serif;
3287 }
3288 .waiting {
3289 text-align: center;
3290 }
3291 .plug {
3292 font-size: 64px;
3293 margin-bottom: 24px;
3294 animation: wiggle 2s ease-in-out infinite;
3295 }
3296 .title {
3297 font-size: 20px;
3298 font-weight: 500;
3299 color: ${titleColor};
3300 margin-bottom: 12px;
3301 letter-spacing: 0.5px;
3302 }
3303 .subtitle {
3304 font-size: 14px;
3305 color: ${subtitleColor};
3306 font-family: monospace;
3307 background: ${subtitleBg};
3308 padding: 8px 16px;
3309 border-radius: 4px;
3310 border: 1px solid ${subtitleBorder};
3311 }
3312 .subtitle code {
3313 color: ${codeColor};
3314 font-weight: 600;
3315 }
3316 .dots::after {
3317 content: '';
3318 animation: dots 1.5s steps(4, end) infinite;
3319 }
3320 @keyframes dots {
3321 0%, 20% { content: ''; }
3322 40% { content: '.'; }
3323 60% { content: '..'; }
3324 80%, 100% { content: '...'; }
3325 }
3326 @keyframes wiggle {
3327 0%, 100% { transform: rotate(-5deg); }
3328 50% { transform: rotate(5deg); }
3329 }
3330 </style>
3331 </head>
3332 <body>
3333 <div class="waiting">
3334 <div class="plug">🔌</div>
3335 <div class="title">Waiting for local server<span class="dots"></span></div>
3336 <div class="subtitle">Run <code>ac-site</code> to start localhost:8888</div>
3337 </div>
3338 </body>
3339 </html>`;
3340 }
3341
3342 return `<!DOCTYPE html>
3343 <html lang="en">
3344 <head>
3345 <meta charset="UTF-8">
3346 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${cspFrameSrc}; ${cspChildSrc}; style-src ${
3347 webview.cspSource
3348 } 'unsafe-inline'; script-src 'nonce-${nonce}'; media-src *; img-src ${webview.cspSource} data:;">
3349 <meta name="viewport" content="width=device-width, initial-scale=1.0">
3350 <link href="${styleUri}" rel="stylesheet">
3351 <link href="${resetStyleUri}" rel="stylesheet">
3352 <link href="${vscodeStyleUri}" rel="stylesheet">
3353 <title>aesthetic.computer</title>
3354 </head>
3355 <body>
3356 <iframe id="aesthetic" sandbox="allow-scripts allow-same-origin allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation allow-pointer-lock" allow="clipboard-write; clipboard-read" src="${iframeProtocol}${iframeUrl}/${param}${hashFragment}" border="none"></iframe>
3357 <script nonce="${nonce}" src="${scriptUri}"></script>
3358 </body>
3359 </html>`;
3360}
3361
3362 // 📰 News WebView Content
3363 function getNewsWebViewContent(webview: any) {
3364 const nonce = getNonce();
3365
3366 // Detect VS Code theme for News waiting screen
3367 const themeKind = vscode.window.activeColorTheme.kind;
3368 const isDark = themeKind === 2 || themeKind === 3; // Dark or HighContrast
3369
3370 const styleUri = webview.asWebviewUri(
3371 vscode.Uri.joinPath(extContext.extensionUri, "main.css"),
3372 );
3373
3374 const resetStyleUri = webview.asWebviewUri(
3375 vscode.Uri.joinPath(extContext.extensionUri, "reset.css"),
3376 );
3377
3378 const vscodeStyleUri = webview.asWebviewUri(
3379 vscode.Uri.joinPath(extContext.extensionUri, "vscode.css"),
3380 );
3381
3382 if (local && !localServerAvailable && !isCodespaces) {
3383 // Theme-aware colors for waiting screen
3384 const bg = isDark ? '#1a1a1a' : '#f7f1e1';
3385 const textColor = isDark ? '#d4d4d4' : '#3b2a1a';
3386 const titleColor = isDark ? '#ff69b4' : '#a85a2a';
3387 const subtitleBg = isDark ? '#252525' : '#fff9f0';
3388 const subtitleBorder = isDark ? '#404040' : '#e2d4c3';
3389 const subtitleColor = isDark ? '#a0a0a0' : '#6b4a2e';
3390 const codeColor = isDark ? '#ff69b4' : '#a85a2a';
3391
3392 return `<!DOCTYPE html>
3393 <html lang="en">
3394 <head>
3395 <meta charset="UTF-8">
3396 <meta name="viewport" content="width=device-width, initial-scale=1.0">
3397 <link href="${styleUri}" rel="stylesheet">
3398 <link href="${resetStyleUri}" rel="stylesheet">
3399 <link href="${vscodeStyleUri}" rel="stylesheet">
3400 <title>News</title>
3401 <style>
3402 body {
3403 display: flex;
3404 flex-direction: column;
3405 align-items: center;
3406 justify-content: center;
3407 height: 100vh;
3408 margin: 0;
3409 background: ${bg};
3410 color: ${textColor};
3411 font-family: system-ui, -apple-system, sans-serif;
3412 }
3413 .waiting { text-align: center; }
3414 .plug { font-size: 64px; margin-bottom: 24px; animation: wiggle 2s ease-in-out infinite; }
3415 .title { font-size: 20px; font-weight: 600; color: ${titleColor}; margin-bottom: 12px; letter-spacing: 0.5px; }
3416 .subtitle {
3417 font-size: 14px;
3418 color: ${subtitleColor};
3419 font-family: monospace;
3420 background: ${subtitleBg};
3421 padding: 8px 16px;
3422 border-radius: 4px;
3423 border: 1px solid ${subtitleBorder};
3424 }
3425 .subtitle code { color: ${codeColor}; font-weight: 600; }
3426 .dots::after { content: ''; animation: dots 1.5s steps(4, end) infinite; }
3427 @keyframes dots { 0%, 20% { content: ''; } 40% { content: '.'; } 60% { content: '..'; } 80%, 100% { content: '...'; } }
3428 @keyframes wiggle { 0%, 100% { transform: rotate(-5deg); } 50% { transform: rotate(5deg); } }
3429 </style>
3430 </head>
3431 <body>
3432 <div class="waiting">
3433 <div class="plug">📰</div>
3434 <div class="title">Waiting for local server<span class="dots"></span></div>
3435 <div class="subtitle">Run <code>ac-site</code> to start localhost:8888</div>
3436 </div>
3437 </body>
3438 </html>`;
3439 }
3440
3441 let iframeUrl;
3442 let iframeProtocol = "https://";
3443 let path = "";
3444 if (isCodespaces && codespaceName && codespacesDomain) {
3445 iframeUrl = `${codespaceName}-8888.${codespacesDomain}`;
3446 path = "/news.aesthetic.computer";
3447 } else if (local) {
3448 iframeUrl = "localhost:8888";
3449 path = "/news.aesthetic.computer";
3450 } else {
3451 iframeUrl = "news.aesthetic.computer";
3452 path = "";
3453 }
3454
3455 let cspFrameSrc = "frame-src https://news.aesthetic.computer https://localhost:8888 https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com";
3456 let cspChildSrc = "child-src https://news.aesthetic.computer https://localhost:8888 https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com";
3457 if (isCodespaces && codespacesDomain) {
3458 const codespaceWildcard = `https://*.${codespacesDomain}`;
3459 cspFrameSrc += ` ${codespaceWildcard}`;
3460 cspChildSrc += ` ${codespaceWildcard}`;
3461 }
3462
3463 const sessionAesthetic = extContext.globalState.get(
3464 "aesthetic:session",
3465 undefined,
3466 );
3467
3468 let param = "?vscode=true";
3469 if (sessionAesthetic && typeof sessionAesthetic === "object") {
3470 try {
3471 const encoded = Buffer.from(JSON.stringify(sessionAesthetic)).toString(
3472 "base64",
3473 );
3474 param += `&session-aesthetic=${encodeURIComponent(encoded)}`;
3475 } catch (e) {
3476 console.log("🔴 Failed to encode session for News webview:", e);
3477 param += "&session-aesthetic=null";
3478 }
3479 } else {
3480 param += "&session-aesthetic=null";
3481 }
3482
3483 return `<!DOCTYPE html>
3484 <html lang="en">
3485 <head>
3486 <meta charset="UTF-8">
3487 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${cspFrameSrc}; ${cspChildSrc}; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; media-src *; img-src ${webview.cspSource} data:;">
3488 <meta name="viewport" content="width=device-width, initial-scale=1.0">
3489 <link href="${styleUri}" rel="stylesheet">
3490 <link href="${resetStyleUri}" rel="stylesheet">
3491 <link href="${vscodeStyleUri}" rel="stylesheet">
3492 <title>News</title>
3493 <style>
3494 body { margin: 0; padding: 0; overflow: hidden; }
3495 iframe#news { width: 100vw; height: 100vh; border: none; }
3496 </style>
3497 </head>
3498 <body>
3499 <iframe id="news" class="visible" sandbox="allow-scripts allow-same-origin allow-modals allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation" allow="clipboard-write; clipboard-read; autoplay; encrypted-media; fullscreen; accelerometer; gyroscope; picture-in-picture" src="${iframeProtocol}${iframeUrl}${path}${param}"></iframe>
3500 <script nonce="${nonce}">
3501 const vscode = acquireVsCodeApi();
3502 const newsIframe = document.getElementById('news');
3503
3504 window.addEventListener('message', (event) => {
3505 if (event.data?.type === 'setSession') {
3506 newsIframe?.contentWindow?.postMessage(event.data, '*');
3507 }
3508 // Forward news: prefixed messages and login/logout/signup to extension
3509 if (event.data?.type && (
3510 event.data.type.startsWith('vscode-extension:') ||
3511 event.data.type.startsWith('news:') ||
3512 event.data.type === 'login' ||
3513 event.data.type === 'logout' ||
3514 event.data.type === 'signup' ||
3515 event.data.type === 'openExternal'
3516 )) {
3517 vscode.postMessage(event.data);
3518 }
3519 });
3520
3521 newsIframe?.addEventListener('load', () => {
3522 vscode.postMessage({ type: 'news:ready' });
3523 });
3524 </script>
3525 </body>
3526 </html>`;
3527 }
3528
3529// 🧭 AT WebView Content
3530function getAtWebViewContent(webview: any) {
3531 const nonce = getNonce();
3532
3533 const styleUri = webview.asWebviewUri(
3534 vscode.Uri.joinPath(extContext.extensionUri, "main.css"),
3535 );
3536
3537 const resetStyleUri = webview.asWebviewUri(
3538 vscode.Uri.joinPath(extContext.extensionUri, "reset.css"),
3539 );
3540
3541 const vscodeStyleUri = webview.asWebviewUri(
3542 vscode.Uri.joinPath(extContext.extensionUri, "vscode.css"),
3543 );
3544
3545 let iframeUrl;
3546 let iframeProtocol = "https://";
3547 if (isCodespaces && codespaceName && codespacesDomain) {
3548 iframeUrl = `${codespaceName}-4177.${codespacesDomain}`;
3549 } else if (local) {
3550 iframeUrl = "localhost:4177";
3551 iframeProtocol = "http://";
3552 } else {
3553 iframeUrl = "at.aesthetic.computer";
3554 }
3555
3556 let cspFrameSrc = "frame-src https://at.aesthetic.computer http://localhost:4177";
3557 let cspChildSrc = "child-src https://at.aesthetic.computer http://localhost:4177";
3558
3559 if (isCodespaces && codespacesDomain) {
3560 const codespaceWildcard = `https://*.${codespacesDomain}`;
3561 cspFrameSrc += ` ${codespaceWildcard}`;
3562 cspChildSrc += ` ${codespaceWildcard}`;
3563 }
3564
3565 const path = local ? "/user.html" : "/";
3566 const param = local ? "?handle=art.at.aesthetic.computer&vscode=true" : "?vscode=true";
3567
3568 return `<!DOCTYPE html>
3569 <html lang="en">
3570 <head>
3571 <meta charset="UTF-8">
3572 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${cspFrameSrc}; ${cspChildSrc}; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; media-src *; img-src ${webview.cspSource} data:;">
3573 <meta name="viewport" content="width=device-width, initial-scale=1.0">
3574 <link href="${styleUri}" rel="stylesheet">
3575 <link href="${resetStyleUri}" rel="stylesheet">
3576 <link href="${vscodeStyleUri}" rel="stylesheet">
3577 <title>AT</title>
3578 <style>
3579 body {
3580 margin: 0;
3581 padding: 0;
3582 overflow: hidden;
3583 }
3584 iframe#at {
3585 width: 100vw;
3586 height: 100vh;
3587 border: none;
3588 }
3589 </style>
3590 </head>
3591 <body>
3592 <iframe id="at" class="visible" sandbox="allow-scripts allow-same-origin allow-modals allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation" allow="clipboard-write; clipboard-read" src="${iframeProtocol}${iframeUrl}${path}${param}" border="none"></iframe>
3593 </body>
3594 </html>`;
3595}
3596
3597// 🌈 KidLisp.com WebView Content
3598function getKidLispWebViewContent(webview: any) {
3599 const nonce = getNonce();
3600
3601 // Detect VS Code theme for KidLisp waiting screen
3602 const themeKind = vscode.window.activeColorTheme.kind;
3603 const isDark = themeKind === 2 || themeKind === 3; // Dark or HighContrast
3604
3605 const styleUri = webview.asWebviewUri(
3606 vscode.Uri.joinPath(extContext.extensionUri, "main.css"),
3607 );
3608
3609 const resetStyleUri = webview.asWebviewUri(
3610 vscode.Uri.joinPath(extContext.extensionUri, "reset.css"),
3611 );
3612
3613 const vscodeStyleUri = webview.asWebviewUri(
3614 vscode.Uri.joinPath(extContext.extensionUri, "vscode.css"),
3615 );
3616
3617 // Show waiting UI if local mode is enabled but server isn't available yet
3618 if (local && !localServerAvailable && !isCodespaces) {
3619 // Theme-aware colors
3620 const bg = isDark ? 'linear-gradient(135deg, #181318 0%, #141214 100%)' : 'linear-gradient(135deg, #fffacd 0%, #fff9c0 100%)';
3621 const textColor = isDark ? '#ffffffcc' : '#333';
3622 const accentColor = isDark ? '#ff69b4' : '#9370DB';
3623 const subtitleBg = isDark ? '#1a1a1a' : '#fff';
3624 const subtitleBorder = isDark ? '#483848' : '#e0d8a8';
3625 const subtitleColor = isDark ? '#b0a0a8' : '#666';
3626
3627 return `<!DOCTYPE html>
3628 <html lang="en">
3629 <head>
3630 <meta charset="UTF-8">
3631 <meta name="viewport" content="width=device-width, initial-scale=1.0">
3632 <link href="${styleUri}" rel="stylesheet">
3633 <link href="${resetStyleUri}" rel="stylesheet">
3634 <link href="${vscodeStyleUri}" rel="stylesheet">
3635 <title>KidLisp.com</title>
3636 <style>
3637 body {
3638 display: flex;
3639 flex-direction: column;
3640 align-items: center;
3641 justify-content: center;
3642 height: 100vh;
3643 margin: 0;
3644 background: ${bg};
3645 color: ${textColor};
3646 font-family: system-ui, -apple-system, sans-serif;
3647 }
3648 .waiting {
3649 text-align: center;
3650 }
3651 .plug {
3652 font-size: 64px;
3653 margin-bottom: 24px;
3654 animation: wiggle 2s ease-in-out infinite;
3655 }
3656 .title {
3657 font-size: 20px;
3658 font-weight: 500;
3659 color: ${accentColor};
3660 margin-bottom: 12px;
3661 letter-spacing: 0.5px;
3662 }
3663 .subtitle {
3664 font-size: 14px;
3665 color: ${subtitleColor};
3666 font-family: monospace;
3667 background: ${subtitleBg};
3668 padding: 8px 16px;
3669 border-radius: 4px;
3670 border: 1px solid ${subtitleBorder};
3671 }
3672 .subtitle code {
3673 color: ${accentColor};
3674 font-weight: 600;
3675 }
3676 .dots::after {
3677 content: '';
3678 animation: dots 1.5s steps(4, end) infinite;
3679 }
3680 @keyframes dots {
3681 0%, 20% { content: ''; }
3682 40% { content: '.'; }
3683 60% { content: '..'; }
3684 80%, 100% { content: '...'; }
3685 }
3686 @keyframes wiggle {
3687 0%, 100% { transform: rotate(-5deg); }
3688 50% { transform: rotate(5deg); }
3689 }
3690 </style>
3691 </head>
3692 <body>
3693 <div class="waiting">
3694 <div class="plug">🌈</div>
3695 <div class="title">Waiting for local server<span class="dots"></span></div>
3696 <div class="subtitle">Run <code>ac-site</code> to start localhost:8888</div>
3697 </div>
3698 </body>
3699 </html>`;
3700 }
3701
3702 // Determine the iframe URL based on environment
3703 let iframeUrl;
3704 let iframeProtocol = "https://";
3705 if (isCodespaces && codespaceName && codespacesDomain) {
3706 iframeUrl = `${codespaceName}-8888.${codespacesDomain}`;
3707 } else {
3708 iframeUrl = local ? "localhost:8888" : "aesthetic.computer";
3709 }
3710
3711 // Build CSP for kidlisp.com
3712 let cspFrameSrc = "frame-src https://aesthetic.computer https://localhost:8888";
3713 let cspChildSrc = "child-src https://aesthetic.computer https://localhost:8888";
3714
3715 if (isCodespaces && codespacesDomain) {
3716 const codespaceWildcard = `https://*.${codespacesDomain}`;
3717 cspFrameSrc += ` ${codespaceWildcard}`;
3718 cspChildSrc += ` ${codespaceWildcard}`;
3719 }
3720
3721 // Encode current session for kidlisp.com so it can hydrate without another login
3722 const sessionAesthetic = extContext.globalState.get(
3723 "aesthetic:session",
3724 undefined,
3725 );
3726
3727 let param = "?vscode=true";
3728 if (sessionAesthetic && typeof sessionAesthetic === "object") {
3729 try {
3730 const encoded = Buffer.from(JSON.stringify(sessionAesthetic)).toString(
3731 "base64",
3732 );
3733 param += `&session-aesthetic=${encodeURIComponent(encoded)}`;
3734 } catch (e) {
3735 console.log("🔴 Failed to encode session for KidLisp webview:", e);
3736 param += "&session-aesthetic=null";
3737 }
3738 } else {
3739 param += "&session-aesthetic=null";
3740 }
3741
3742 return `<!DOCTYPE html>
3743 <html lang="en">
3744 <head>
3745 <meta charset="UTF-8">
3746 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${cspFrameSrc}; ${cspChildSrc}; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; media-src *; img-src ${webview.cspSource} data:;">
3747 <meta name="viewport" content="width=device-width, initial-scale=1.0">
3748 <link href="${styleUri}" rel="stylesheet">
3749 <link href="${resetStyleUri}" rel="stylesheet">
3750 <link href="${vscodeStyleUri}" rel="stylesheet">
3751 <title>KidLisp.com</title>
3752 <style>
3753 body {
3754 margin: 0;
3755 padding: 0;
3756 overflow: hidden;
3757 }
3758 iframe#kidlisp {
3759 width: 100vw;
3760 height: 100vh;
3761 border: none;
3762 background: linear-gradient(135deg, #fffacd 0%, #fff9c0 100%);
3763 }
3764 </style>
3765 </head>
3766 <body>
3767 <iframe id="kidlisp" class="visible" sandbox="allow-scripts allow-same-origin allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation" allow="clipboard-write; clipboard-read" src="${iframeProtocol}${iframeUrl}/kidlisp.com${param}" border="none"></iframe>
3768 <script nonce="${nonce}">
3769 const vscode = acquireVsCodeApi();
3770 const kidlispIframe = document.getElementById('kidlisp');
3771
3772 // Forward messages from extension to the iframe (sessions, etc.)
3773 window.addEventListener('message', (event) => {
3774 if (event.data?.type === 'setSession') {
3775 kidlispIframe?.contentWindow?.postMessage(event.data, '*');
3776 }
3777 if (event.data && event.data.type && (event.data.type.startsWith('vscode-extension:') || event.data.type.startsWith('kidlisp:'))) {
3778 vscode.postMessage(event.data);
3779 }
3780 });
3781
3782 kidlispIframe?.addEventListener('load', () => {
3783 vscode.postMessage({ type: 'kidlisp:ready' });
3784 });
3785 </script>
3786 </body>
3787 </html>`;
3788}
3789
3790export { activate, AestheticViewProvider };