Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/extension/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
dev/pulseplot_harness/main.js
media/vendor/
19 changes: 19 additions & 0 deletions packages/extension/esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ if (existsSync(`${arRoot}/dist/amico-run.js`)) {
console.warn("[esbuild] amico-run/dist not built — run `pnpm --filter @amicode/amico-run build` before packaging");
}

// Stage KaTeX assets for the interview webview's Hamiltonian panel — the
// webview CSP forbids CDNs, so css + fonts ship locally from media/vendor/
// (a build artifact, gitignored; the js is bundled into interview_webview.js).
mkdirSync("media/vendor/katex", { recursive: true });
cpSync("node_modules/katex/dist/katex.min.css", "media/vendor/katex/katex.min.css", { dereference: true });
cpSync("node_modules/katex/dist/fonts", "media/vendor/katex/fonts", { recursive: true, dereference: true });

const targets = [
// extension host entry point
{
Expand All @@ -32,6 +39,18 @@ const targets = [
minify: false,
logLevel: "info",
},
// UX1 live interview webview bundle (#46)
{
entryPoints: ["src/interview_webview.ts"],
bundle: true,
platform: "browser",
target: "es2022",
format: "iife",
outfile: "dist/interview_webview.js",
sourcemap: true,
minify: false,
logLevel: "info",
},
// catalog-card dev preview webview bundle (#47 scaffold)
{
entryPoints: ["src/catalog_card_webview.ts"],
Expand Down
35 changes: 35 additions & 0 deletions packages/extension/media/ui/atoms/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Loader atom — an indeterminate spinner for in-flight agent work. Rides the
// VS Code progress token so it recolors with the theme; identity is carried
// by the aria-label, never visible text (the design call: loader in lieu of
// "Amico is thinking…" copy).

import { defineStyle } from "../style";

defineStyle("loader", `
.loader { display: inline-flex; align-items: center; gap: var(--space-sm); }
.loader .ld-spin { width: 14px; height: 14px; border-radius: 50%; flex: none;
border: 2px solid var(--border-color);
border-top-color: var(--vscode-progressBar-background, var(--color-run));
animation: ld-rotate 0.9s linear infinite; }
@keyframes ld-rotate { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.loader .ld-spin { animation-duration: 2.4s; }
}
`);

export interface Loader {
el: HTMLSpanElement;
/** Show/hide without layout churn — callers keep one instance mounted. */
set(visible: boolean): void;
}

export function loader(label = "working"): Loader {
const el = document.createElement("span");
el.className = "loader";
const spin = document.createElement("span");
spin.className = "ld-spin";
spin.setAttribute("role", "progressbar");
spin.setAttribute("aria-label", label);
el.append(spin);
return { el, set(visible) { el.style.display = visible ? "" : "none"; } };
}
164 changes: 164 additions & 0 deletions packages/extension/media/ui/components/askframe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Askframe component (#46, UX1) — renders one live interview question:
// header chip (stage id), persona lead-in, question text, option rows
// (default pilled "recommended"), and an Other slot. The question SHAPE
// stays AskUserQuestion-compatible (header/question/options/custom) so the
// interview protocol and any future renderer share one contract.
//
// LAYOUT CONTRACT (fork-transcript ready): these components ultimately render
// inside the opencode-fork transcript — an infinite VERTICAL scroll. Options
// therefore stack vertically as full-width rows (never a horizontal card
// grid), and nothing in here assumes a fixed viewport height or column count.

import { defineStyle } from "../style";
import { text } from "../atoms/text";
import { pill } from "../atoms/pill";

defineStyle("askframe", `
.askframe { display: flex; flex-direction: column; gap: var(--space-md);
background: var(--bg-box);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius); padding: var(--space-lg);
max-width: 720px; }
.askframe .af-header { display: flex; align-items: center; gap: var(--space-sm); }
.askframe .af-chip { font-size: var(--text-label); text-transform: uppercase;
letter-spacing: 0.6px; font-weight: 600;
padding: 2px var(--space-sm); border-radius: var(--border-radius);
background: var(--vscode-badge-background, var(--bg-plot));
color: var(--vscode-badge-foreground, var(--vscode-foreground)); }
.askframe .af-persona { color: var(--color-dim); font-style: italic; }
.askframe .af-question { font-size: var(--text-value); font-weight: 600; }
.askframe .af-options { display: flex; flex-direction: column; gap: var(--space-sm); }
.askframe .af-option { display: flex; flex-direction: column; gap: var(--space-xs);
width: 100%; box-sizing: border-box;
padding: var(--space-sm) var(--space-md); cursor: pointer;
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
background: var(--vscode-button-secondaryBackground, var(--bg-plot));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); }
.askframe .af-option:hover { background: color-mix(in srgb, var(--color-accent-fill, var(--color-accent)) 12%,
var(--vscode-button-secondaryBackground, var(--bg-plot))); }
.askframe .af-option:active { background: color-mix(in srgb, var(--color-accent-fill, var(--color-accent)) 24%,
var(--vscode-button-secondaryBackground, var(--bg-plot))); }
.askframe .af-option .af-labelrow { display: flex; align-items: center;
gap: var(--space-sm); }
.askframe .af-option .af-label { font-weight: 600; font-size: var(--text-small); }
.askframe .af-option .af-desc { font-size: var(--text-small); color: var(--color-dim); }
.askframe .af-option.other { border-style: dashed; opacity: 0.85; }
.askframe .af-options.af-multi .af-option:not(.other) .af-label::before { content: "☐ "; font-weight: 400; }
.askframe .af-options.af-multi .af-option.selected .af-label::before { content: "☑ "; color: var(--color-accent); }
.askframe .af-option.selected { border-color: var(--color-accent); }
.askframe .af-confirm { display: flex; }
.askframe .af-confirm button {
font-family: var(--text-font); font-size: var(--text-small);
padding: var(--space-xs) var(--space-md); cursor: pointer;
border: 1px solid var(--vscode-button-border, transparent); border-radius: 2px;
color: var(--vscode-button-foreground, #fff);
background: var(--vscode-button-background, #0e639c); }
.askframe .af-confirm button:disabled { opacity: 0.5; cursor: not-allowed; }
.askframe.proposed { border-style: dashed; }
.askframe.proposed .af-chip::after { content: " · proposed"; opacity: 0.8; }
.askframe .af-annotation { border-top: var(--border-width) dashed var(--border-color);
padding-top: var(--space-sm); font-size: var(--text-small);
color: var(--color-dim); }
.askframe .af-annotation::before { content: "📝 "; }
`);

export interface AskOption {
label: string;
description?: string;
default?: boolean;
/** Branch key reported to onChoose. Defaults to the label. */
value?: string;
}

export interface AskFrameSpec {
/** Header chip, e.g. "system setup · 1/4". */
stage: string;
/** Amico's one-line persona lead-in (spec: lightweight anchor). */
persona?: string;
question: string;
options: AskOption[];
/** Storyboard annotation: provenance, divergences, feedback prompts. */
annotation?: string;
/** Amendment frame — not in the draft tree; rendered dashed/marked. */
proposed?: boolean;
/** Render the free-form Other slot (AskUserQuestion always offers it). */
other?: boolean;
/** Multi-select: options toggle, a confirm button submits the set (the
* physics question — the live Hamiltonian assembles from the selection). */
multiple?: boolean;
}

export interface AskFrame {
el: HTMLDivElement;
}

/** Single-select calls onChoose with the option value on click; multi-select
* calls it with the selected label SET on confirm. onToggle fires on every
* multi-select toggle so a live view (the Hamiltonian panel) can track it. */
export function askframe(spec: AskFrameSpec, onChoose: (value: string | string[]) => void, onToggle?: (selected: string[]) => void): AskFrame {
const el = document.createElement("div");
el.className = spec.proposed ? "askframe proposed" : "askframe";

const header = document.createElement("div");
header.className = "af-header";
const chipEl = document.createElement("span");
chipEl.className = "af-chip";
chipEl.textContent = spec.stage;
header.append(chipEl);
el.append(header);

if (spec.persona) el.append(text("af-persona", spec.persona).el);
el.append(text("af-question", spec.question).el);

const opts = document.createElement("div");
opts.className = spec.multiple ? "af-options af-multi" : "af-options";
const selected = new Set<string>();
for (const o of spec.options) {
const card = document.createElement("div");
card.className = o.default ? "af-option default" : "af-option";
const labelRow = document.createElement("div");
labelRow.className = "af-labelrow";
labelRow.append(text("af-label", o.label).el);
if (o.default) labelRow.append(pill("done", "recommended", { dot: false }).el);
card.append(labelRow);
if (o.description) card.append(text("af-desc", o.description).el);
const value = o.value ?? o.label;
if (spec.multiple) {
// Recommended default starts selected — toggling is refinement, not
// building the set from zero.
if (o.default) { selected.add(value); card.classList.add("selected"); }
card.addEventListener("click", () => {
card.classList.toggle("selected") ? selected.add(value) : selected.delete(value);
confirm.disabled = selected.size === 0;
confirm.textContent = `use these (${selected.size})`;
onToggle?.([...selected]);
});
} else {
card.addEventListener("click", () => onChoose(value));
}
opts.append(card);
}
if (spec.other ?? true) {
const card = document.createElement("div");
card.className = "af-option other";
card.append(text("af-label", "Other…").el, text("af-desc", "free-form answer").el);
card.addEventListener("click", () => onChoose("__other"));
opts.append(card);
}
el.append(opts);

const confirm = document.createElement("button");
if (spec.multiple) {
confirm.textContent = `use these (${selected.size})`;
confirm.disabled = selected.size === 0;
confirm.addEventListener("click", () => onChoose([...selected]));
const row = document.createElement("div");
row.className = "af-confirm";
row.append(confirm);
el.append(row);
}

if (spec.annotation) el.append(text("af-annotation", spec.annotation).el);
return { el };
}
55 changes: 55 additions & 0 deletions packages/extension/media/ui/components/hamiltonian.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Hamiltonian panel (#46, UX1) — the model Hamiltonian assembling itself in
// front of the physicist as they toggle terms on the multi-select physics
// question. Rendered as REAL math via KaTeX (bundled locally — the webview
// CSP forbids CDNs; css + fonts ship from media/vendor/katex). Term → math
// mapping lives in hamiltonian_terms.ts (pure).

import katex from "katex";
import { defineStyle } from "../style";
import { text } from "../atoms/text";
import { hamiltonianLines, LHS_LATEX } from "./hamiltonian_terms";

defineStyle("hamiltonian", `
.hm-panel { display: flex; flex-direction: column; gap: var(--space-xs);
background: var(--bg-plot);
border: var(--border-width) dashed var(--border-color);
border-radius: var(--border-radius);
padding: var(--space-md) var(--space-lg); max-width: 720px; }
.hm-panel .hm-title { font-size: var(--text-label); text-transform: uppercase;
letter-spacing: 0.6px; color: var(--color-dim); }
.hm-panel .hm-line { padding-left: var(--space-lg); }
.hm-panel .hm-line.first { padding-left: 0; }
.hm-panel .hm-line.lindblad { opacity: 0.75; }
.hm-panel .hm-line .katex { font-size: 1.05em; }
.hm-panel .hm-note { font-size: var(--text-small); color: var(--color-dim);
margin-left: var(--space-lg); }
`);

export interface HamiltonianPanel {
el: HTMLDivElement;
/** Re-render for the currently selected term labels. */
set(selected: string[]): void;
}

export function hamiltonianPanel(): HamiltonianPanel {
const el = document.createElement("div");
el.className = "hm-panel";
const body = document.createElement("div");
el.append(text("hm-title", "model Hamiltonian").el, body);

function set(selected: string[]): void {
body.replaceChildren();
hamiltonianLines(selected).forEach((l, i) => {
// First line carries the LHS; its leading "+" folds into the "=".
const latex = i === 0 ? LHS_LATEX + l.latex.replace(/^\+\\,?/, "") : l.latex;
const line = document.createElement("div");
line.className = "hm-line" + (i === 0 ? " first" : "") + (l.lindblad ? " lindblad" : "");
line.innerHTML = katex.renderToString(latex, { throwOnError: false, output: "html" });
body.append(line);
if (l.note) body.append(text("hm-note", l.note).el);
});
}

set([]);
return { el, set };
}
104 changes: 104 additions & 0 deletions packages/extension/media/ui/components/hamiltonian_terms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Hamiltonian term registry (#46, UX1) — pure mapping from the interview's
// physics option labels to rotating-frame Hamiltonian lines, so the view can
// assemble Ĥ(t) live as the user toggles terms. DOM-free by design: unit-
// testable in node, and shared with any future renderer venue.
//
// Conventions (single transmon, qubit-rotating frame, RWA): δ > 0 positive
// convention (the template's), two I/Q drive quadratures always present.

export interface HamiltonianTerm {
/** Matches an option label / physics slot entry. */
match: RegExp;
/** One displayed term (unicode math — the fallback + test surface). */
math: string;
/** The same term as LaTeX (KaTeX-rendered in the panel). */
latex: string;
/** Physicist aside, rendered dim beside the line. */
note?: string;
/** Open-system effects enter the Lindbladian, never Ĥ. */
lindblad?: boolean;
}

export const HAMILTONIAN_TERMS: HamiltonianTerm[] = [
{
match: /anharmonicity/i,
math: "− (δ⁄2)·â†â†ââ",
latex: "-\\tfrac{\\delta}{2}\\,\\hat a^{\\dagger}\\hat a^{\\dagger}\\hat a\\hat a",
note: "anharmonicity (δ > 0 convention)",
},
{
match: /zz|crosstalk/i,
math: "+ ζ·(â†â ⊗ b̂†b̂)",
latex: "+\\,\\zeta\\,\\bigl(\\hat a^{\\dagger}\\hat a\\otimes\\hat b^{\\dagger}\\hat b\\bigr)",
note: "static ZZ with a spectator b̂",
},
{
match: /coupler/i,
math: "+ g(t)·(â b̂† + ↠b̂)",
latex: "+\\,g(t)\\,\\bigl(\\hat a\\hat b^{\\dagger}+\\hat a^{\\dagger}\\hat b\\bigr)",
note: "tunable-coupler exchange",
},
{
match: /t1|t2|decoher|dephas|dissipat|relax|noise/i,
math: "𝓛[ρ̂] ⊃ γ₁·𝒟[â] + γ_φ·𝒟[â†â]",
latex: "\\mathcal{L}[\\hat\\rho]\\supset\\gamma_1\\,\\mathcal{D}[\\hat a]+\\gamma_{\\phi}\\,\\mathcal{D}[\\hat a^{\\dagger}\\hat a]",
note: "open-system — enters the Lindbladian, not Ĥ",
lindblad: true,
},
];

/** The controls — every gate problem drives the qubit, so this line is
* unconditional. */
export const DRIVE_LINE: HamiltonianLine = {
math: "+ u₁(t)·(â + â†) + u₂(t)·i(↠− â)",
latex: "+\\,u_1(t)\\,(\\hat a+\\hat a^{\\dagger})+u_2(t)\\,i\\,(\\hat a^{\\dagger}-\\hat a)",
note: "I/Q drives (always present)",
};

/** LHS prefix for the first rendered line. */
export const LHS_LATEX = "\\hat H(t)/\\hbar \\;=\\; ";

export interface HamiltonianLine {
math: string;
latex: string;
note?: string;
lindblad?: boolean;
}

/** Sanitize a free-text label for embedding in \text{} — strip TeX-active
* characters rather than escape-juggling them. */
function texSafe(label: string): string {
return label.replace(/[\\{}$%&#^_~]/g, " ").trim();
}

/** True when a physics option label maps to a known term — the view mounts
* the live-Hamiltonian panel iff a question offers at least one. */
export function isHamiltonianTerm(label: string): boolean {
return HAMILTONIAN_TERMS.some((t) => t.match.test(label));
}

/** Assemble the display lines for a set of selected term labels: drift terms
* first, the drive line always, then interactions, then Lindblad asides.
* Unrecognized labels still show up (captured, agent-interpreted) — the
* panel must never silently drop physics the user asked for. */
export function hamiltonianLines(selected: string[]): HamiltonianLine[] {
const drift: HamiltonianLine[] = [];
const interactions: HamiltonianLine[] = [];
const lindblad: HamiltonianLine[] = [];
for (const s of selected) {
if (!s.trim() || /^(none|default|just the basics)/i.test(s)) continue;
const t = HAMILTONIAN_TERMS.find((t) => t.match.test(s));
if (!t) {
interactions.push({
math: `+ Ĥ⟨${s}⟩(t)`,
latex: `+\\,\\hat H_{\\langle\\text{${texSafe(s)}}\\rangle}(t)`,
note: "captured — Amico will interpret",
});
} else if (t.lindblad) {
lindblad.push({ math: t.math, latex: t.latex, note: t.note, lindblad: true });
} else {
(t.math.startsWith("−") ? drift : interactions).push({ math: t.math, latex: t.latex, note: t.note });
}
}
return [...drift, DRIVE_LINE, ...interactions, ...lindblad];
}
Loading
Loading