Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/extension/media/inspector.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
:root {
--amico-accent: #FFF676; /* amico yellow */
--amico-run: #FFF676; /* running — brand yellow */
--amico-ok: #3fb950; /* converged green */
--amico-fail: #f85149; /* failed red */
}
* { box-sizing: border-box; }
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground);
padding: 14px; font-size: 12px; display: flex; flex-direction: column; gap: 12px;
height: 100vh; overflow-y: auto; }
/* ---- top bar ---- */
.topbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.brand { display: flex; align-items: center; gap: 9px; font-size: 13px; font-weight: 600; }
.mark { font-family: var(--vscode-editor-font-family, monospace); color: var(--amico-accent);
letter-spacing: 1px; font-weight: 700;
border: 1px solid color-mix(in srgb, var(--amico-accent) 55%, transparent);
border-radius: 6px; padding: 1px 7px; font-size: 12px; }
.runlabel { font-family: var(--vscode-editor-font-family, monospace); font-size: 11px; opacity: 0.6; }
.badge { margin-left: auto; font-size: 10.5px; font-weight: 600; letter-spacing: 0.5px;
text-transform: uppercase; padding: 3px 10px; border-radius: 999px;
border: 1px solid currentColor; display: inline-flex; align-items: center; gap: 6px; }
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge.idle { color: var(--vscode-descriptionForeground); opacity: 0.7; }
.badge.running { color: var(--amico-run); }
.badge.running::before { animation: pulse 1.1s ease-in-out infinite; }
.badge.done { color: var(--amico-ok); }
.badge.failed { color: var(--amico-fail); }
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } }
/* ---- plot hero ---- */
/* min-height keeps the pulse plot a real plot, not a thin bar, when the
bottom panel is short; body scrolls if the panel can't fit it all. */
.image-host { flex: 1 1 240px; min-height: 240px; min-width: 0; position: relative;
background: var(--vscode-editor-background);
border: 1px solid var(--vscode-panel-border); border-radius: 8px; padding: 6px;
display: grid; place-items: stretch; overflow: hidden; }
img.preview { grid-column: 1; grid-row: 1; width: 100%; height: 100%;
object-fit: contain; display: block; transition: opacity 120ms ease; }
.placeholder { place-self: center; text-align: center; opacity: 0.55; display: flex;
flex-direction: column; align-items: center; gap: 10px; }
.placeholder .mark { font-size: 20px; padding: 4px 12px; opacity: 0.8; }
.placeholder .hint { font-style: italic; max-width: 240px; line-height: 1.5; }
/* ---- metric cards ---- */
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); gap: 8px; }
.card { background: color-mix(in srgb, var(--vscode-panel-border) 25%, transparent);
border: 1px solid var(--vscode-panel-border); border-radius: 7px; padding: 8px 10px;
display: flex; flex-direction: column; gap: 3px; }
.card .k { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.6px;
opacity: 0.55; font-weight: 600; }
.card .v { font-family: var(--vscode-editor-font-family, monospace); font-size: 14px; }
.card.hero { border-color: color-mix(in srgb, var(--amico-accent) 45%, var(--vscode-panel-border)); }
.card.hero .k { color: var(--amico-accent); opacity: 0.85; }
.card.hero .v { font-size: 17px; font-weight: 600; }
19 changes: 19 additions & 0 deletions packages/extension/media/inspector.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<div class="topbar">
<div class="brand"><span class="mark">&lt;0||0&gt;</span> Run Inspector</div>
<span id="runlabel" class="runlabel"></span>
<span id="badge" class="badge idle">idle</span>
</div>
<div class="image-host">
<img id="preview-a" class="preview" alt="frame preview A" style="opacity:0" />
<img id="preview-b" class="preview" alt="frame preview B" style="opacity:0" />
<div id="placeholder" class="placeholder">
<span class="mark">&lt;0||0&gt;</span>
<span class="hint" id="m-hint">No solve in progress — fire one from the Amicode chat, or run “Replay demo run”.</span>
</div>
</div>
<div class="metrics">
<div class="card hero"><div class="k" id="m-obj-k">objective</div><div class="v" id="m-obj">–</div></div>
<div class="card"><div class="k">iteration</div><div class="v" id="m-iter">–</div></div>
<div class="card"><div class="k">feasibility</div><div class="v" id="m-pr">–</div></div>
<div class="card"><div class="k">optimality</div><div class="v" id="m-du">–</div></div>
</div>
122 changes: 49 additions & 73 deletions packages/extension/src/run_inspector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as vscode from "vscode";
import * as path from "node:path";
import { readFileSync } from "node:fs";
import { inspectorResourceRootDirs } from "./opencode_paths";

// ============================================================================
Expand Down Expand Up @@ -184,7 +185,33 @@ class InspectorView implements vscode.WebviewViewProvider {
const scriptUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.ctx.extensionUri, "dist", "inspector_webview.js"),
);
const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.ctx.extensionUri, "media", "inspector.css"),
);
const nonce = newNonce();
// The "look" (body markup) lives in media/inspector.html and the "feel"
// (styling) in media/inspector.css — both owned by the design lane. This
// method owns only the security/wiring shell: the CSP, the nonce, and the
// resource URIs. The DOM-id + message contract between that markup and
// inspector_webview.ts is pinned by inspector_view_contract.test.ts.
//
// style-src keeps 'unsafe-inline' deliberately — it is load-bearing for the
// static style="opacity:0" attrs on preview-a/b in inspector.html (runtime
// .style mutations aren't CSP-governed, so those attrs are the only thing
// that needs it). Don't strip it as "dead" now that the stylesheet moved
// external; the contract test guards this grant.
let body: string;
try {
body = readFileSync(
vscode.Uri.joinPath(this.ctx.extensionUri, "media", "inspector.html").fsPath,
"utf8",
);
} catch (err) {
// A corrupt/partial install (media/inspector.html missing) would otherwise
// throw out of resolveWebviewView before webview.html is ever set → an
// opaque blank panel. Degrade to a readable message instead.
return renderFallbackHtml(err);
}
return /* html */ `<!DOCTYPE html>
<html lang="en">
<head>
Expand All @@ -194,81 +221,10 @@ class InspectorView implements vscode.WebviewViewProvider {
img-src ${webview.cspSource} data: blob: https:;
script-src 'nonce-${nonce}';
style-src ${webview.cspSource} 'unsafe-inline';">
<style>
:root {
--amico-accent: #FFF676; /* amico yellow */
--amico-run: #FFF676; /* running — brand yellow */
--amico-ok: #3fb950; /* converged green */
--amico-fail: #f85149; /* failed red */
}
* { box-sizing: border-box; }
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground);
padding: 14px; font-size: 12px; display: flex; flex-direction: column; gap: 12px;
height: 100vh; overflow-y: auto; }
/* ---- top bar ---- */
.topbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.brand { display: flex; align-items: center; gap: 9px; font-size: 13px; font-weight: 600; }
.mark { font-family: var(--vscode-editor-font-family, monospace); color: var(--amico-accent);
letter-spacing: 1px; font-weight: 700;
border: 1px solid color-mix(in srgb, var(--amico-accent) 55%, transparent);
border-radius: 6px; padding: 1px 7px; font-size: 12px; }
.runlabel { font-family: var(--vscode-editor-font-family, monospace); font-size: 11px; opacity: 0.6; }
.badge { margin-left: auto; font-size: 10.5px; font-weight: 600; letter-spacing: 0.5px;
text-transform: uppercase; padding: 3px 10px; border-radius: 999px;
border: 1px solid currentColor; display: inline-flex; align-items: center; gap: 6px; }
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge.idle { color: var(--vscode-descriptionForeground); opacity: 0.7; }
.badge.running { color: var(--amico-run); }
.badge.running::before { animation: pulse 1.1s ease-in-out infinite; }
.badge.done { color: var(--amico-ok); }
.badge.failed { color: var(--amico-fail); }
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } }
/* ---- plot hero ---- */
/* min-height keeps the pulse plot a real plot, not a thin bar, when the
bottom panel is short; body scrolls if the panel can't fit it all. */
.image-host { flex: 1 1 240px; min-height: 240px; min-width: 0; position: relative;
background: var(--vscode-editor-background);
border: 1px solid var(--vscode-panel-border); border-radius: 8px; padding: 6px;
display: grid; place-items: stretch; overflow: hidden; }
img.preview { grid-column: 1; grid-row: 1; width: 100%; height: 100%;
object-fit: contain; display: block; transition: opacity 120ms ease; }
.placeholder { place-self: center; text-align: center; opacity: 0.55; display: flex;
flex-direction: column; align-items: center; gap: 10px; }
.placeholder .mark { font-size: 20px; padding: 4px 12px; opacity: 0.8; }
.placeholder .hint { font-style: italic; max-width: 240px; line-height: 1.5; }
/* ---- metric cards ---- */
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); gap: 8px; }
.card { background: color-mix(in srgb, var(--vscode-panel-border) 25%, transparent);
border: 1px solid var(--vscode-panel-border); border-radius: 7px; padding: 8px 10px;
display: flex; flex-direction: column; gap: 3px; }
.card .k { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.6px;
opacity: 0.55; font-weight: 600; }
.card .v { font-family: var(--vscode-editor-font-family, monospace); font-size: 14px; }
.card.hero { border-color: color-mix(in srgb, var(--amico-accent) 45%, var(--vscode-panel-border)); }
.card.hero .k { color: var(--amico-accent); opacity: 0.85; }
.card.hero .v { font-size: 17px; font-weight: 600; }
</style>
<link rel="stylesheet" href="${styleUri}" />
</head>
<body>
<div class="topbar">
<div class="brand"><span class="mark">&lt;0||0&gt;</span> Run Inspector</div>
<span id="runlabel" class="runlabel"></span>
<span id="badge" class="badge idle">idle</span>
</div>
<div class="image-host">
<img id="preview-a" class="preview" alt="frame preview A" style="opacity:0" />
<img id="preview-b" class="preview" alt="frame preview B" style="opacity:0" />
<div id="placeholder" class="placeholder">
<span class="mark">&lt;0||0&gt;</span>
<span class="hint" id="m-hint">No solve in progress — fire one from the Amicode chat, or run “Replay demo run”.</span>
</div>
</div>
<div class="metrics">
<div class="card hero"><div class="k" id="m-obj-k">objective</div><div class="v" id="m-obj">–</div></div>
<div class="card"><div class="k">iteration</div><div class="v" id="m-iter">–</div></div>
<div class="card"><div class="k">feasibility</div><div class="v" id="m-pr">–</div></div>
<div class="card"><div class="k">optimality</div><div class="v" id="m-du">–</div></div>
</div>
${body.trimEnd()}
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
Expand All @@ -282,6 +238,26 @@ function newNonce(): string {
return s;
}

/** Self-contained fallback shown when the view markup can't be read (corrupt or
* partial install). No external resources/scripts so it can't itself fail to
* render; keeps webview.html set so the panel shows a message, not a blank. */
function renderFallbackHtml(err: unknown): string {
const detail = (err instanceof Error ? err.message : String(err))
.replace(/&/g, "&amp;").replace(/</g, "&lt;");
return /* html */ `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';" />
</head>
<body style="font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 14px; font-size: 12px;">
<p>Run Inspector failed to load its view (<code>media/inspector.html</code>).</p>
<p style="opacity: 0.7;">This usually means a corrupt or partial install — try reinstalling the extension.</p>
<p style="opacity: 0.5; font-family: monospace; font-size: 11px;">${detail}</p>
</body>
</html>`;
}

export function registerRunInspector(ctx: vscode.ExtensionContext, runsRoot: string): InspectorView {
INSPECTOR = new InspectorView(ctx, runsRoot);
ctx.subscriptions.push(
Expand Down
1 change: 1 addition & 0 deletions packages/extension/test/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const window = {
showErrorMessage: () => Promise.resolve(undefined),
showWarningMessage: () => Promise.resolve(undefined),
createOutputChannel: () => ({ appendLine() {}, append() {}, dispose() {} }),
registerWebviewViewProvider: () => ({ dispose() {} }),
};
export const commands = { executeCommand: () => Promise.resolve(undefined) };
export const workspace = {
Expand Down
94 changes: 94 additions & 0 deletions packages/extension/test/inspector_view_contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { registerRunInspector } from "../src/run_inspector";

// Pins the plumbing⇄view contract that the run_inspector.ts split now straddles:
// the markup (media/inspector.html) and styling (media/inspector.css) are owned
// by the design lane, while run_inspector.ts + inspector_webview.ts are the
// plumbing. This test reds if a look-and-feel change drops a DOM id or a CSP
// grant the webview script depends on — i.e. it lets design iterate freely while
// guarding the exact seam the two lanes share. Renders through the public
// WebviewViewProvider surface (resolveWebviewView), not internals.

const PKG_ROOT = join(__dirname, "..");

function renderInspectorHtml(): string {
const ctx = { extensionUri: { fsPath: PKG_ROOT }, subscriptions: [] as unknown[] };
const inspector = registerRunInspector(ctx as never, "/tmp/runs-test");
let captured = "";
const view = {
webview: {
options: {},
cspSource: "vscode-webview://unit",
asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }),
postMessage: () => undefined,
set html(v: string) { captured = v; },
get html() { return captured; },
},
onDidDispose: () => ({ dispose() {} }),
};
inspector.resolveWebviewView(view as never);
return captured;
}

// Every id the webview script reads/writes MUST exist in the design-owned
// markup, else the live inspector silently breaks with no test failure. The
// regex below recovers the literal $("id")/getElementById("id") lookups; the
// computed hot-path lookups it can't see are pinned explicitly just below.
const SCRIPT = readFileSync(join(PKG_ROOT, "src", "inspector_webview.ts"), "utf8");
function idsReferencedByScript(): string[] {
const ids = new Set<string>();
for (const m of SCRIPT.matchAll(/\$\(\s*"([^"]+)"\s*\)/g)) ids.add(m[1]);
for (const m of SCRIPT.matchAll(/getElementById\(\s*"([^"]+)"\s*\)/g)) ids.add(m[1]);
return [...ids];
}

// Ids addressed only computationally — the double-buffer swap ($("preview-" +
// buffer)) and the metric fan-out (for (const id of [...]) $(id)). The literal
// regex is blind to these; they pass its check today only because they also
// happen to appear as literals elsewhere, so a design edit that drops that
// incidental alias would go unguarded. Listed explicitly rather than parsed out
// of the source on purpose: this single-run seam is temporary (Phase 1.3
// reshapes the inspector into per-run views and this test goes with it), so a
// fully-derived id contract would be throwaway.
const COMPUTED_FORM_IDS = ["preview-a", "preview-b", "m-obj", "m-iter", "m-pr", "m-du"];

describe("Run Inspector view contract (plumbing ⇄ media/inspector.{html,css})", () => {
const html = renderInspectorHtml();

it("renders every DOM id the webview script depends on (literal + computed-form)", () => {
const ids = idsReferencedByScript();
expect(ids.length).toBeGreaterThan(0); // guard the regex itself
for (const id of [...new Set([...ids, ...COMPUTED_FORM_IDS])]) {
expect(html, `markup is missing id="${id}" (inspector_webview.ts drives it)`).toContain(`id="${id}"`);
}
});

it("links the external stylesheet and keeps the CSP authorizing every grant the view depends on", () => {
expect(html).toMatch(/<link[^>]+rel="stylesheet"[^>]+href="vscode-webview:\/\/unit\/[^"]*inspector\.css"/);

// Pin grants to their directive, not just "appears somewhere in the CSP".
const styleSrc = html.match(/style-src([^;]*)/)?.[1] ?? "";
expect(styleSrc, "style-src must grant the webview source for the linked stylesheet").toContain("vscode-webview://unit");
// 'unsafe-inline' is the load-bearing grant: the static style="opacity:0"
// attrs on preview-a/b need it (runtime .style mutations aren't CSP-governed).
// Drop it and the previews start visible instead of fading in.
expect(styleSrc, "style-src must keep 'unsafe-inline' for the static style attrs").toContain("'unsafe-inline'");

// iter-frame PNGs load as asWebviewUri → vscode-webview:// URIs; img-src must grant the source.
const imgSrc = html.match(/img-src([^;]*)/)?.[1] ?? "";
expect(imgSrc, "img-src must grant the webview source for iter-frame PNGs").toContain("vscode-webview://unit");

expect(html).toMatch(/script-src 'nonce-/);
expect(html).toMatch(/<script nonce="[^"]+" src="vscode-webview:\/\/unit\/[^"]*inspector_webview\.js"/);
});

it("sources markup + styling from the design-owned media files", () => {
const bodyTpl = readFileSync(join(PKG_ROOT, "media", "inspector.html"), "utf8");
const css = readFileSync(join(PKG_ROOT, "media", "inspector.css"), "utf8");
expect(bodyTpl.trim().length).toBeGreaterThan(0);
expect(css).toMatch(/--amico-accent/); // the brand token survived the move
expect(html).toContain(bodyTpl.trim().split("\n")[0]); // the rendered doc embeds the body template
});
});
2 changes: 2 additions & 0 deletions packages/extension/test/packaging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const REQUIRED = [
'extension/demo/run/run.toml',
'extension/demo/run/FINISHED',
'extension/demo/run/run.log', // inspector reads run.log for the demo's stats row; *.log-gitignored so easy to drop
'extension/media/inspector.html', // Run Inspector body markup (design-owned) — must ship, else a blank inspector
'extension/media/inspector.css', // Run Inspector styling (design-owned) — must ship, else an unstyled inspector
]

// Guards against a silently-dropped runtime asset (the β.2 .gitignore-fallback
Expand Down
Loading