diff --git a/AMICODE-PATCHES.md b/AMICODE-PATCHES.md
index e89ce2c289..0a839a745e 100644
--- a/AMICODE-PATCHES.md
+++ b/AMICODE-PATCHES.md
@@ -225,3 +225,36 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build.
- index.css: @font-face for both (JuliaMono full glyph set — Julia Unicode; Racing Sans One latin subset, font-display swap). logo.tsx + wordmark-v2.tsx: font-family 'Racing Sans One' first, weight 700→400. settings.tsx: monoDefault/monoFallback lead with JuliaMono. theme.css: --font-family-mono leads with JuliaMono. Terminal font DELIBERATELY unchanged (JetBrainsMono Nerd Font Mono via separate terminalFallback).
- New assets (git-added — build breaks without them): public/assets/RacingSansOne-Regular.woff2 (21 KB) + JuliaMono-Regular.woff2 (946 KB).
- Font build sha256: `2a15da111be516516fb1fbd1a4fb5ae02ad9bddd6373ede08cdf7b28c19d163a` (dist/opencode-local + vendored path, write-temp + mv -f swap; SUPERSEDES #13's a73d8583… — same code, fonts now committed). Verify (scratch port 14099): `GET /assets/RacingSansOne-Regular.woff2` → 200 font/woff2 21804 B; `GET /assets/JuliaMono-Regular.woff2` → 200 font/woff2 946516 B; "Racing Sans One" in built css + index/new-session chunks, "JuliaMono" in `index-Dwtxigfs.css`; `GET /amicode/problems` → 200; `GET /` → 200 `
Amicode`; ui `bun test src` → 70 pass; typecheck green ui+app (no snapshots assert fonts, per Aaron — confirmed nothing went red). Bonus confirmation: KaTeX\_\* woff2 assets now in dist — the entity view's katex import (#13) pulls its font set into the embed.
+9. (home CTA fallback) — amicode(home): "Open chat" works on a fresh profile.
+ - BUG: `startWithPrompt` (fork wiring for the Meet-Amico card, patch 5ef6b7e0e) dead-ended
+ silently when the persisted client-side project list was empty (fresh browser profile
+ against a bare `opencode serve`): the `!project` branch called `openNewSession()`, which
+ needs the SAME empty `newSessionProject()` and hits `if (!conn || !project) return`.
+ Primary home CTA did nothing, no error. Hit live 2026-07-08 (web UI on a scratch dir).
+ - FIX (packages/app/src/pages/home.tsx, `startWithPrompt` only): when no project is
+ tracked, fall back to the focused server's own working directory —
+ `focusedSync().data.path.directory` (synced from GET /path; "" until loaded, so the
+ falsy guard holds) — open+touch it as a project (self-heals the home page), then
+ `tabs.newDraft` with the prompt preserved. Deliberately NOT `sync.data.project`:
+ the server's "global" project record has worktree "/".
+ - Regression spec: packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts —
+ fresh profile (NO localStorage seed), mocked server, click the CTA (`exact: true` —
+ the whole card is also a button whose accessible name contains "Open chat"), expect
+ navigation to `/new-session?draftId=` + the cwd persisted as a tracked project.
+ Verified failing on the unfixed code, passing with the fix. Playwright note: config
+ reuses any server on port 3000 (`reuseExistingServer`) — run with `PLAYWRIGHT_PORT=`
+ if something else (e.g. the harmoniqs website dev server) holds 3000.
+ - Checks: `tsgo -b` clean; `bun run test:unit` 376 pass / 0 fail.
+
+15. (mark drift fix, synced to amicode PR #99 final) — amicode: consolidated the fork's brand mark to ONE geometry, matching amicode's redesigned mark. The "kept in sync manually" cross-repo promise from patch #8 had already silently failed.
+
+- Trigger: amicode's mark was redesigned (PR #99) without a corresponding update here — the fork still rendered the OLD "digi" pixel-accented H-robot everywhere. PR #99 went through several iterations before landing on its final geometry; this entry tracks that FINAL state (square viewBox `0 0 3600 3600`), not the intermediate "hackathon mark" (viewBox `116 287 3377 3035`) an earlier draft of this fork PR had copied — that intermediate geometry is now itself stale and was replaced here.
+- Two copies of near-identical geometry lived in THIS repo (logo.tsx's `Robot` used by `Mark`/`Splash`, and spinner.tsx's `AmicoSpinner`), plus a third in favicon/amico.svg. Consolidated to a single `MARK_PATH` exported from logo.tsx.
+- Geometry now mirrors amicode PR #99's two authored SVGs (amicode:`packages/extension/media/amico{,_reduced}.svg`), both square `0 0 3600 3600`:
+ - `MARK_PATH` = amico_reduced.svg's outer-bracket path (fill-rule evenodd screen knockout). Used by `Mark`, `Splash`, `AmicoSpinner`, and mirrored as a literal in favicon/amico.svg — every SMALL context, matching amicode's own "small → reduced" rule.
+ - `MarkDetailed` = amico.svg's full detailed mark (bracket path + internal circuit-pattern rects/polygons). Used ONLY by the Meet Amico home card (`w-12`/48px), large enough for the detail to resolve.
+ - `Mark`/`Splash`/`MarkDetailed` viewBox `0 0 64 56`/`116 287 3377 3035` → `0 0 3600 3600`; logo.css aspect-ratio `8/7` → `1/1` (the mark is square now).
+- Still theme-adaptive via currentColor + var(--icon-strong-base) — this is a live webview DOM, so currentColor resolves (unlike amicode's native VS Code tab icon, which needs committed {light,dark} files; see amicode PR #99).
+- NOT a re-established cross-repo sync promise — `MARK_PATH` is the single source of truth WITHIN this repo; it happens to match amicode's current geometry, kept aligned by hand when the mark changes.
+- Tests: ui `bun test src/amicode` green; typecheck green ui + app.
+- NOT done this round: full native `bun run script/build.ts` compile + vendored-binary swap — this patch only touches the embedded web UI. Deferred to the next amicode.N release tag, same split as patches #8/#11.
diff --git a/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts b/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts
new file mode 100644
index 0000000000..39ee2ca9cc
--- /dev/null
+++ b/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts
@@ -0,0 +1,33 @@
+import { expect, test } from "@playwright/test"
+import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
+import { mockOpenCodeServer } from "../utils/mock-server"
+
+// Regression: on a fresh profile (no tracked projects in localStorage) the
+// home "Open chat" CTA dead-ended silently — startWithPrompt fell through to
+// openNewSession(), which needs the same newSessionProject() that just came
+// back empty. It must instead fall back to the server's own working
+// directory (GET /path → .directory) and start a draft there, tracking the
+// directory as a project so the rest of the home page works from then on.
+test("home 'Open chat' falls back to the server cwd on a fresh profile", async ({ page }) => {
+ await mockOpenCodeServer(page, {
+ sessions: [],
+ provider: fixture.provider,
+ directory: fixture.directory,
+ project: fixture.project,
+ pageMessages,
+ })
+
+ // Deliberately NO localStorage seed — an empty tracked-project list is the
+ // regression condition (contrast: session-list-path-loading.spec.ts seeds it).
+ await page.goto("/")
+ // exact: true — the whole Meet-Amico card is also a button whose accessible
+ // name contains "Open chat"; we want the CTA inside it.
+ await page.getByRole("button", { name: "Open chat", exact: true }).click()
+
+ // Navigates to a new-session draft instead of doing nothing.
+ await expect(page).toHaveURL(/\/new-session\?draftId=/)
+
+ // And the server cwd is now a tracked project (the self-healing part).
+ const persisted = await page.evaluate(() => localStorage.getItem("opencode.global.dat:server") ?? "")
+ expect(persisted).toContain(fixture.directory)
+})
diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx
index 5cb67750e6..aa9bbb621c 100644
--- a/packages/app/src/components/session/session-new-design-view.tsx
+++ b/packages/app/src/components/session/session-new-design-view.tsx
@@ -1,17 +1,18 @@
import { Show, type JSX } from "solid-js"
-import { Logo, Mark } from "@opencode-ai/ui/logo"
+import { Logo, MarkDetailed } from "@opencode-ai/ui/logo"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
// amicode: new-session start screen, top→bottom — H-bot mark (hero), the AMICODE
// wordmark, tagline + how-it-works + starter chips, then the composer. Sizing
// mirrors the classic NewSessionView (Mark w-36 / Logo w-72); keeps the session
// tabs and centered composer. The chips are the one-tap path to the next task.
+// The hero uses the DETAILED mark (144px, well above where the accents resolve).
export function NewSessionDesignView(props: { children: JSX.Element; gettingStarted?: JSX.Element }) {
return (
-
+
{props.gettingStarted}
diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx
index b70f8fb9ab..f3c7d40812 100644
--- a/packages/app/src/components/session/session-new-view.tsx
+++ b/packages/app/src/components/session/session-new-view.tsx
@@ -10,7 +10,7 @@ import { amicodeGet } from "@/utils/amicode-fetch"
import { parseProblemsResponse } from "@opencode-ai/ui/amicode-problem-switcher"
import { Icon } from "@opencode-ai/ui/icon"
import { AmicodeGettingStarted } from "@opencode-ai/ui/amicode-getting-started"
-import { Mark } from "@opencode-ai/ui/logo"
+import { Logo, MarkDetailed } from "@opencode-ai/ui/logo"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
const MAIN_WORKTREE = "main"
@@ -78,19 +78,10 @@ export function NewSessionView(props: NewSessionViewProps) {
-
- {/* amicode: straight wordmark (the Logo's Racing Sans One face reads as
- italic); byline keeps the brand attribution. */}
-
- AMICODE
-
+
+ {/* amicode: brand wordmark in the brand typeface (Racing Sans One — restored per review);
+ MarkDetailed is the redesign's detailed mark (kept from trunk) */}
+
{/* amicode: getting-started block (tagline + how-it-works + starter chips) */}
) {
+ const q = new URLSearchParams()
+ for (const [k, v] of Object.entries(fields)) if (v !== undefined) q.set(k, v)
+ await amicodePost(focusedServer(), `/amicode/profile?${q.toString()}`)
+ await refetchProfile()
+ }
+
+ // Onboarding wizard (session zero): decided ONCE when the profile first
+ // resolves — the mid-wizard profile refetch must not unmount the preview
+ // step, and a dismiss is remembered per install (localStorage).
+ // Library (papers that make Amico smarter): count + latest for the card.
+ const [libraryRaw, { refetch: refetchLibrary }] = createResource(
+ () => state.selection.server,
+ () => amicodeGet(focusedServer(), "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/amicode/library").catch(() => undefined),
+ )
+ const libraryView = createMemo(() => {
+ const raw = libraryRaw() as { ok?: boolean; papers?: { name?: string; path?: string }[] } | undefined
+ if (!raw || raw.ok !== true || !Array.isArray(raw.papers)) return undefined
+ return {
+ count: raw.papers.length,
+ latestName: typeof raw.papers[0]?.name === "string" ? raw.papers[0].name : undefined,
+ latestPath: typeof raw.papers[0]?.path === "string" ? raw.papers[0].path : undefined,
+ }
+ })
+ async function uploadPaper(filename: string, dataB64: string) {
+ const res = await amicodePost(focusedServer(), "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/amicode/library", { filename, data_b64: dataB64 })
+ if ((res as { ok?: boolean } | undefined)?.ok !== true) throw new Error("library save rejected")
+ await refetchLibrary()
+ }
+
+ // Solver mode (rchari/solver-wire): server-truth via /amicode/solver-mode;
+ // selecting a mode POSTs, then the switch wizard polls THROUGH the server
+ // restart (failures expected mid-switch) until the extension reports ready.
+ const [solverRaw, { refetch: refetchSolverMode }] = createResource(
+ () => state.selection.server,
+ () => amicodeGet(focusedServer(), "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/amicode/solver-mode").catch(() => undefined),
+ )
+ const solverState = createMemo(() => {
+ const raw = solverRaw() as { ok?: boolean; mode?: string; status?: string } | undefined
+ if (!raw || raw.ok !== true) return undefined
+ return { mode: (raw.mode === "hp" ? "hp" : "piccolo") as SolverMode, switching: raw.status === "switching" }
+ })
+ const [switchTarget, setSwitchTarget] = createSignal(undefined)
+ const selectSolver = (mode: SolverMode) => {
+ setSwitchTarget(mode)
+ void amicodePost(focusedServer(), `/amicode/solver-mode?mode=${mode}`).catch(() => setSwitchTarget(undefined))
+ }
+ const pollSolver = async () => {
+ const raw = (await amicodeGet(focusedServer(), "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/amicode/solver-mode")) as { mode?: string; status?: string }
+ return { mode: String(raw?.mode ?? ""), status: String(raw?.status ?? "") }
+ }
+
+ const WIZARD_DISMISS_KEY = "amicode-onboarding-dismissed"
+ const [wizardOpen, setWizardOpen] = createSignal(false)
+ let wizardDecided = false
+ createEffect(() => {
+ const view = profileView()
+ if (wizardDecided || view === undefined || !view.ok) return
+ wizardDecided = true
+ let dismissed = false
+ try {
+ dismissed = localStorage.getItem(WIZARD_DISMISS_KEY) === "1"
+ } catch {
+ /* storage unavailable → treat as not dismissed */
+ }
+ setWizardOpen(shouldShowWizard(view.you, dismissed))
+ })
+ const dismissWizard = () => {
+ try {
+ localStorage.setItem(WIZARD_DISMISS_KEY, "1")
+ } catch {
+ /* best-effort */
+ }
+ setWizardOpen(false)
+ }
+
function startWithPrompt(prompt: string) {
const project = newSessionProject()
- if (!project) {
- openNewSession()
+ if (project) {
+ tabs.newDraft({ server: server.key, directory: project.worktree }, prompt)
return
}
- tabs.newDraft({ server: server.key, directory: project.worktree }, prompt)
+ // No tracked projects (fresh profile against a bare `opencode serve`):
+ // openNewSession() would dead-end silently here — it needs the same
+ // newSessionProject() that just came back empty. Fall back to the server's
+ // own working directory (path.directory, synced from GET /path; "" until
+ // loaded) and start tracking it, so the home CTAs work on first visit.
+ // Deliberately NOT sync.data.project: its "global" record has worktree "/".
+ const conn = focusedServer()
+ const directory = focusedSync().data.path.directory
+ if (!conn || !directory) return
+ const ctx = global.createServerCtx(conn)
+ ctx.projects.open(directory)
+ ctx.projects.touch(directory)
+ tabs.newDraft({ server: ServerConnection.key(conn), directory }, prompt)
}
function setSelection(next: HomeProjectSelection) {
@@ -628,19 +720,21 @@ function HomeDesign() {
{/* Cards: full width across, sized to content so they never scroll */}
startWithPrompt("update my profile — my name, affiliation, and what I work on")}
- onSaveProfile={async (fields) => {
- // In-place save (About-You card): identity fields ride query
- // params on the raw POST route; refetch renders the saved state.
- const q = new URLSearchParams()
- for (const [k, v] of Object.entries(fields)) if (v !== undefined) q.set(k, v)
- await amicodePost(focusedServer(), `/amicode/profile?${q.toString()}`)
- await refetchProfile()
- }}
+ onSaveProfile={saveProfileFields}
resumeName={resumeProblem()?.name}
resumeMeta={resumeMeta()}
onResume={() => {
@@ -657,6 +751,33 @@ function HomeDesign() {
/>
+
+ {
+ const v = profileView()
+ return v?.ok ? v.you.name : ""
+ })()}
+ onComplete={saveProfileFields}
+ onUploadPaper={uploadPaper}
+ onDismiss={dismissWizard}
+ onOpenChat={() => {
+ dismissWizard()
+ startWithPrompt("")
+ }}
+ />
+
+
+ {(target) => (
+ {
+ setSwitchTarget(undefined)
+ void refetchSolverMode()
+ }}
+ />
+ )}
+ true,
+ working: () => {
+ const id = params.id
+ return id ? sync.data.session_working(id) : false
+ },
overflowAnchor: "dynamic",
})
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index c6329b39c5..e99ab50167 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -73,6 +73,7 @@ import { useServer } from "@/context/server"
import { usePrompt } from "@/context/prompt"
import { startPrompt, draftPrompt } from "@/utils/start-prompt"
import { amicodeGet } from "@/utils/amicode-fetch"
+import { inAmicode, postAmicode } from "@/pages/session/use-amicode-commands"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { messageAgentColor } from "@/utils/agent"
@@ -568,12 +569,21 @@ export function MessageTimeline(props: {
createEffect(
on(
() => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const,
- () => {
+ (curr, prev) => {
if (!virtualizer) return
if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return
- const keys = timelineRowKeys()
+ const keys = curr[0]
if (keys.length === 0) return
- virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
+ // Only realign via virtua (an estimate-based scroll that can visibly
+ // pre-jump before item heights are measured) when the *set of rows*
+ // changes or the session status flips. Pure intra-row growth while a
+ // token streams is left to the measured-bottom rAF lock below, which
+ // pins against the real DOM height — so the two mechanisms stop landing
+ // on slightly different scroll positions frame-to-frame. `timelineRowKeys`
+ // is memoized with `equals: sameKeys`, so its reference only changes when
+ // the row set actually changes, making this comparison cheap and exact.
+ const rowsChanged = !prev || prev[0] !== keys || prev[2] !== curr[2]
+ if (rowsChanged) virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
scheduleMeasuredBottomAnchor()
},
{ defer: true },
@@ -1697,6 +1707,7 @@ export function MessageTimeline(props: {
}
onOpenEntity={openEntityView}
onOpenSwitcher={openSwitcher}
+ onInspectRun={inAmicode() ? () => postAmicode("amicode.openInspector") : undefined}
retryLabel={language.t("amicode.retry")}
unavailableLabel={language.t("amicode.unavailable")}
onAsk={(text) => {
diff --git a/packages/app/src/pages/session/use-amicode-commands.test.ts b/packages/app/src/pages/session/use-amicode-commands.test.ts
new file mode 100644
index 0000000000..602ba8d10c
--- /dev/null
+++ b/packages/app/src/pages/session/use-amicode-commands.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, spyOn, test } from "bun:test"
+import { inAmicode, postAmicode } from "./use-amicode-commands"
+
+// The "Inspect Run" button (entity rail) and the Amico command palette both
+// reach the VS Code extension through postAmicode(). chat_panel.ts relays the
+// envelope to the host and executes ONLY commands on its BRIDGE_ALLOWED_COMMANDS
+// allowlist — so the exact envelope shape and command string are a contract.
+describe("postAmicode bridge envelope", () => {
+ test('posts {source:"amicode", kind:"command", command} to window.parent with "*"', () => {
+ const spy = spyOn(window.parent, "postMessage").mockImplementation(() => {})
+ try {
+ postAmicode("amicode.openInspector")
+ expect(spy).toHaveBeenCalledTimes(1)
+ // Cast past the DOM postMessage overloads (which type arg 2 as
+ // WindowPostMessageOptions) — postAmicode passes a legacy string origin.
+ const [message, targetOrigin] = spy.mock.calls[0] as unknown as [unknown, unknown]
+ expect(message).toEqual({ source: "amicode", kind: "command", command: "amicode.openInspector" })
+ // Post to any origin — chat_panel.ts pins the origin on the receiving side.
+ expect(targetOrigin).toBe("*")
+ } finally {
+ spy.mockRestore()
+ }
+ })
+
+ test("never throws even if the parent frame rejects the post", () => {
+ const spy = spyOn(window.parent, "postMessage").mockImplementation(() => {
+ throw new Error("no parent")
+ })
+ try {
+ expect(() => postAmicode("amicode.openInspector")).not.toThrow()
+ } finally {
+ spy.mockRestore()
+ }
+ })
+})
+
+// inAmicode() gates the button (and the palette ops) so they never render in the
+// public web / share build, where there is no extension host to relay to. In a
+// non-framed context self === top, so it must report false.
+describe("inAmicode gate", () => {
+ test("false when not framed (self === top)", () => {
+ expect(inAmicode()).toBe(false)
+ })
+})
diff --git a/packages/app/src/pages/session/use-amicode-commands.tsx b/packages/app/src/pages/session/use-amicode-commands.tsx
index ebeee0f065..b9a072be9d 100644
--- a/packages/app/src/pages/session/use-amicode-commands.tsx
+++ b/packages/app/src/pages/session/use-amicode-commands.tsx
@@ -10,9 +10,12 @@ import { useCommand, type CommandOption } from "@/context/command"
// Each command posts {source:"amicode",kind:"command",command} to window.parent;
// chat_panel.ts relays it to an ALLOWLISTED vscode command.
-const inAmicode = () => typeof window !== "undefined" && window.self !== window.top
+// Exported so non-palette surfaces (e.g. the "Inspect Run" button on the entity
+// rail) can fire the same host-bridged commands. inAmicode() gates them out of
+// the public web/share build, where there is no extension host to relay to.
+export const inAmicode = () => typeof window !== "undefined" && window.self !== window.top
-const postAmicode = (command: string) => {
+export const postAmicode = (command: string) => {
try {
window.parent?.postMessage({ source: "amicode", kind: "command", command }, "*")
} catch {}
diff --git a/packages/app/src/utils/amicode-fetch.ts b/packages/app/src/utils/amicode-fetch.ts
index 9a0873753b..64006e9811 100644
--- a/packages/app/src/utils/amicode-fetch.ts
+++ b/packages/app/src/utils/amicode-fetch.ts
@@ -21,7 +21,11 @@ export async function amicodeGet(conn: ServerConnection.Any | undefined, route:
/** POST sibling of amicodeGet — the amicode raw routes keep params in the URL
* (no body), so this is the same call shape with method POST. Used by the
* About-You card's in-place profile save. */
-export async function amicodePost(conn: ServerConnection.Any | undefined, route: string): Promise {
+export async function amicodePost(
+ conn: ServerConnection.Any | undefined,
+ route: string,
+ jsonBody?: unknown,
+): Promise {
if (!conn) throw new Error("no active server")
const headers: Record = {}
if (conn.http.password)
@@ -29,7 +33,12 @@ export async function amicodePost(conn: ServerConnection.Any | undefined, route:
username: conn.http.username,
password: conn.http.password,
})}`
- const res = await fetch(new URL(route, conn.http.url), { method: "POST", headers })
+ if (jsonBody !== undefined) headers["content-type"] = "application/json"
+ const res = await fetch(new URL(route, conn.http.url), {
+ method: "POST",
+ headers,
+ ...(jsonBody !== undefined ? { body: JSON.stringify(jsonBody) } : {}),
+ })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as unknown
}
diff --git a/packages/opencode/src/server/amicode/library.ts b/packages/opencode/src/server/amicode/library.ts
new file mode 100644
index 0000000000..96967f7c08
--- /dev/null
+++ b/packages/opencode/src/server/amicode/library.ts
@@ -0,0 +1,83 @@
+import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs"
+import os from "os"
+import path from "path"
+
+// AMICODE: the user's paper library — PDFs uploaded from the home page that
+// make Amico smarter about THIS user's work. Files land in ~/.amico/library;
+// the amicode extension grants the agent's file tools read access to that dir,
+// so "read the paper I just added" works with zero further plumbing. Same
+// never-reject discipline as problems.ts: every body is a JSON string.
+
+export function libraryRoot(): string {
+ const env = process.env.AMICODE_LIBRARY_DIR
+ if (env && env.trim() !== "") return env
+ return path.join(os.homedir(), ".amico", "library")
+}
+
+export function synthesizeLibrary(code: string, detail: string): string {
+ return JSON.stringify({ ok: false, papers: [], error: `${code}: ${detail}` })
+}
+
+const MAX_BYTES = 30 * 1024 * 1024 // a 30MB PDF is a book; bigger is a mistake
+
+/** Basename-only, conservative charset, single .pdf suffix. */
+export function sanitizeFilename(raw: string): string | null {
+ const base = path.basename(raw).trim()
+ if (!/\.pdf$/i.test(base)) return null
+ const clean = base
+ .slice(0, -4)
+ .replace(/[^\w.\- ]+/g, "-")
+ .replace(/\s+/g, " ")
+ .trim()
+ .slice(0, 120)
+ return clean === "" ? null : `${clean}.pdf`
+}
+
+export function libraryBody(root: string = libraryRoot()): string {
+ try {
+ if (!existsSync(root)) return JSON.stringify({ ok: true, papers: [], error: null })
+ const papers = readdirSync(root)
+ .filter((f) => f.toLowerCase().endsWith(".pdf"))
+ .map((f) => {
+ const st = statSync(path.join(root, f))
+ return { name: f, size: st.size, added_ms: Math.round(st.mtimeMs), path: path.join(root, f) }
+ })
+ .sort((a, b) => b.added_ms - a.added_ms)
+ return JSON.stringify({ ok: true, papers, error: null })
+ } catch (err) {
+ return synthesizeLibrary("bad_output", String(err))
+ }
+}
+
+/** Save one uploaded paper (JSON body: {filename, data_b64}). Returns the
+ * refreshed listing on success so the client renders in one round-trip. */
+export function saveLibraryFile(rawBody: string, root: string = libraryRoot()): string {
+ let parsed: { filename?: unknown; data_b64?: unknown }
+ try {
+ parsed = JSON.parse(rawBody)
+ } catch {
+ return synthesizeLibrary("bad_request", "body must be JSON {filename, data_b64}")
+ }
+ if (typeof parsed.filename !== "string" || typeof parsed.data_b64 !== "string")
+ return synthesizeLibrary("bad_request", "filename and data_b64 are required strings")
+ const name = sanitizeFilename(parsed.filename)
+ if (!name) return synthesizeLibrary("bad_filename", "PDFs only; name must survive sanitization")
+ let bytes: Buffer
+ try {
+ bytes = Buffer.from(parsed.data_b64, "base64")
+ } catch {
+ return synthesizeLibrary("bad_request", "data_b64 is not valid base64")
+ }
+ if (bytes.length === 0) return synthesizeLibrary("bad_request", "empty file")
+ if (bytes.length > MAX_BYTES) return synthesizeLibrary("too_large", `max ${MAX_BYTES} bytes`)
+ // magic check: every real PDF opens with %PDF-
+ if (!bytes.subarray(0, 5).equals(Buffer.from("%PDF-")))
+ return synthesizeLibrary("bad_filetype", "not a PDF (missing %PDF- header)")
+ try {
+ mkdirSync(root, { recursive: true })
+ writeFileSync(path.join(root, name), bytes)
+ } catch (err) {
+ return synthesizeLibrary("write_failed", String(err))
+ }
+ return libraryBody(root)
+}
diff --git a/packages/opencode/src/server/amicode/solver-mode.ts b/packages/opencode/src/server/amicode/solver-mode.ts
new file mode 100644
index 0000000000..c9ad9f4652
--- /dev/null
+++ b/packages/opencode/src/server/amicode/solver-mode.ts
@@ -0,0 +1,56 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
+import os from "os"
+import path from "path"
+
+// AMICODE: solver mode — the state behind the home page's
+// [Piccolo | Piccolissimo + Altissimo] toggle, file-backed so the amicode
+// EXTENSION can react (it watches this file, grants/revokes the `issimo`
+// entitlement, re-preps the session config, restarts the server, then flips
+// status back to "ready" — see amicode packages/extension/src/solver_mode.ts,
+// the other half of this contract; change both in one change-set).
+//
+// {"mode":"piccolo"|"hp", "status":"ready"|"switching", "requested_at": ISO}
+//
+// POST here only ever writes status:"switching" — "ready" is the extension's
+// word, given after the switch actually happened. Same never-reject JSON-body
+// discipline as the sibling modules.
+
+export type SolverMode = "piccolo" | "hp"
+
+export function solverModeFile(): string {
+ const ops = process.env.AMICODE_OPS_DIR ?? path.join(os.homedir(), ".amico", "amicode")
+ return path.join(ops, "solver-mode.json")
+}
+
+export function readSolverMode(file: string = solverModeFile()): { mode: SolverMode; status: "ready" | "switching" } {
+ try {
+ if (!existsSync(file)) return { mode: "piccolo", status: "ready" }
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as { mode?: unknown; status?: unknown }
+ return {
+ mode: parsed.mode === "hp" ? "hp" : "piccolo",
+ status: parsed.status === "switching" ? "switching" : "ready",
+ }
+ } catch {
+ return { mode: "piccolo", status: "ready" }
+ }
+}
+
+export function solverModeBody(file: string = solverModeFile()): string {
+ const state = readSolverMode(file)
+ return JSON.stringify({ ok: true, ...state, error: null })
+}
+
+export function setSolverModeBody(modeRaw: string | undefined, file: string = solverModeFile()): string {
+ if (modeRaw !== "piccolo" && modeRaw !== "hp")
+ return JSON.stringify({ ok: false, mode: null, status: null, error: "bad_request: mode must be piccolo|hp" })
+ const current = readSolverMode(file)
+ // Idempotent: re-selecting the active, settled mode is a no-op (no restart).
+ if (current.mode === modeRaw && current.status === "ready") return solverModeBody(file)
+ try {
+ mkdirSync(path.dirname(file), { recursive: true })
+ writeFileSync(file, JSON.stringify({ mode: modeRaw, status: "switching", requested_at: new Date().toISOString() }))
+ } catch (err) {
+ return JSON.stringify({ ok: false, mode: null, status: null, error: `write_failed: ${String(err)}` })
+ }
+ return solverModeBody(file)
+}
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index 9b6142bf68..87f8cfe449 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -60,6 +60,8 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors
import { serveUIEffect } from "@/server/shared/ui"
import * as AmicodeVaults from "@/server/amicode/vaults"
import * as AmicodeProblems from "@/server/amicode/problems"
+import * as AmicodeLibrary from "@/server/amicode/library"
+import * as AmicodeSolverMode from "@/server/amicode/solver-mode"
import * as AmicodeProfile from "@/server/amicode/profile"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
@@ -233,6 +235,26 @@ const amicodeProblemsRoute = HttpRouter.use((router) =>
// editable identity fields ride query params (small strings; keeps the
// handler body-free like every other amicode route). Returns the fresh
// profile JSON so the card can render the saved state without a second GET.
+ yield* router.add("GET", "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/amicode/solver-mode", () =>
+ Effect.sync(() =>
+ HttpServerResponse.text(AmicodeSolverMode.solverModeBody(), { contentType: "application/json" }),
+ ),
+ )
+ yield* router.add("POST", "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/amicode/solver-mode", (request) =>
+ Effect.sync(() => {
+ const mode = new URL(request.url, "http://localhost").searchParams.get("mode") ?? undefined
+ return HttpServerResponse.text(AmicodeSolverMode.setSolverModeBody(mode), { contentType: "application/json" })
+ }),
+ )
+ yield* router.add("GET", "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/amicode/library", () =>
+ Effect.sync(() => HttpServerResponse.text(AmicodeLibrary.libraryBody(), { contentType: "application/json" })),
+ )
+ yield* router.add("POST", "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/amicode/library", (request) =>
+ Effect.gen(function* () {
+ const body = yield* Effect.orDie(request.text)
+ return HttpServerResponse.text(AmicodeLibrary.saveLibraryFile(body), { contentType: "application/json" })
+ }),
+ )
yield* router.add("POST", "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/amicode/profile", (request) =>
Effect.sync(() => {
const params = new URL(request.url, "http://localhost").searchParams
diff --git a/packages/opencode/test/server/amicode-library.test.ts b/packages/opencode/test/server/amicode-library.test.ts
new file mode 100644
index 0000000000..3da24cd42d
--- /dev/null
+++ b/packages/opencode/test/server/amicode-library.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, test } from "bun:test"
+import { mkdtempSync } from "node:fs"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import { libraryBody, saveLibraryFile, sanitizeFilename } from "@/server/amicode/library"
+
+const PDF = Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.from("x".repeat(64))])
+const body = (filename: string, data: Buffer = PDF) => JSON.stringify({ filename, data_b64: data.toString("base64") })
+
+describe("library", () => {
+ test("save → list roundtrip; newest first; path included for the agent prompt", () => {
+ const root = mkdtempSync(path.join(tmpdir(), "amicode-lib-"))
+ const saved = JSON.parse(saveLibraryFile(body("Krotov Methods (2024).pdf"), root))
+ expect(saved.ok).toBe(true)
+ expect(saved.papers).toHaveLength(1)
+ expect(saved.papers[0].name).toBe("Krotov Methods -2024-.pdf")
+ const listed = JSON.parse(libraryBody(root))
+ expect(listed.papers[0].path).toContain(root)
+ expect(listed.papers[0].size).toBe(PDF.length)
+ })
+ test("rejects non-PDF content, wrong extension, oversize, garbage body", () => {
+ const root = mkdtempSync(path.join(tmpdir(), "amicode-lib-"))
+ expect(JSON.parse(saveLibraryFile(body("notes.txt"), root)).ok).toBe(false)
+ expect(JSON.parse(saveLibraryFile(body("fake.pdf", Buffer.from("hello")))).ok).toBe(false)
+ expect(JSON.parse(saveLibraryFile("not json", root)).ok).toBe(false)
+ expect(JSON.parse(saveLibraryFile(JSON.stringify({ filename: "a.pdf" }), root)).ok).toBe(false)
+ })
+ test("sanitizeFilename: basename-only, pdf-only, traversal-proof", () => {
+ expect(sanitizeFilename("../../etc/passwd.pdf")).toBe("passwd.pdf")
+ expect(sanitizeFilename("paper.PDF")).toMatch(/\.pdf$/i)
+ expect(sanitizeFilename("nope.txt")).toBeNull()
+ expect(sanitizeFilename(".pdf")).toBeNull()
+ })
+})
diff --git a/packages/opencode/test/server/amicode-solver-mode.test.ts b/packages/opencode/test/server/amicode-solver-mode.test.ts
new file mode 100644
index 0000000000..6b2ff99521
--- /dev/null
+++ b/packages/opencode/test/server/amicode-solver-mode.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test"
+import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import { readSolverMode, solverModeBody, setSolverModeBody } from "@/server/amicode/solver-mode"
+
+const tmp = () => path.join(mkdtempSync(path.join(tmpdir(), "solver-")), "solver-mode.json")
+
+describe("solver mode", () => {
+ test("defaults piccolo/ready; malformed file → default", () => {
+ const f = tmp()
+ expect(readSolverMode(f)).toEqual({ mode: "piccolo", status: "ready" })
+ writeFileSync(f, "garbage")
+ expect(readSolverMode(f)).toEqual({ mode: "piccolo", status: "ready" })
+ })
+ test("POST hp → switching (ready is the extension's word, not ours)", () => {
+ const f = tmp()
+ const res = JSON.parse(setSolverModeBody("hp", f))
+ expect(res).toMatchObject({ ok: true, mode: "hp", status: "switching" })
+ expect(JSON.parse(readFileSync(f, "utf8")).requested_at).toBeTruthy()
+ })
+ test("idempotent: re-selecting the settled mode does not re-trigger switching", () => {
+ const f = tmp()
+ writeFileSync(f, JSON.stringify({ mode: "hp", status: "ready" }))
+ expect(JSON.parse(setSolverModeBody("hp", f))).toMatchObject({ mode: "hp", status: "ready" })
+ })
+ test("bad mode rejected", () => {
+ expect(JSON.parse(setSolverModeBody("turbo", tmp())).ok).toBe(false)
+ expect(JSON.parse(setSolverModeBody(undefined, tmp())).ok).toBe(false)
+ })
+ test("GET body reflects the file", () => {
+ const f = tmp()
+ writeFileSync(f, JSON.stringify({ mode: "hp", status: "switching" }))
+ expect(JSON.parse(solverModeBody(f))).toMatchObject({ ok: true, mode: "hp", status: "switching" })
+ })
+})
diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css
new file mode 100644
index 0000000000..e4f005056b
--- /dev/null
+++ b/packages/ui/src/amicode/amicode.css
@@ -0,0 +1,577 @@
+/* AMICODE surfaces — the in-chat receipt card (card.tsx), the entity-view
+ * dialog body (entity-view.tsx), the problem-header rail (entity-rail.tsx), the
+ * question card (ask-card.tsx) and the run window (run-window.tsx). One shared
+ * visual language: a 1px hairline with a 3px accent rail, the H-mark as the
+ * signature (and, while working, the spinner), state carried in form as well as
+ * words. All colors come from the v2 theme tokens; only failure earns the one
+ * red rail. Namespaced by [data-component="amicode-*"] so nothing leaks.
+ *
+ * Imported from styles/index.css (components layer). Interaction/animation/
+ * pseudo-element rules live here because inline styles can't express them. */
+
+@keyframes amc-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.32; }
+}
+@keyframes amc-blip {
+ 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent); }
+ 50% { opacity: 0.45; box-shadow: 0 0 0 4px color-mix(in srgb, var(--v2-icon-icon-accent) 12%, transparent); }
+}
+@keyframes amc-shimmer {
+ 100% { transform: translateX(100%); }
+}
+
+/* ---- shared signature (mark + wordmark) ---------------------------------- */
+.amc-mark {
+ width: 13px;
+ height: 13px;
+ flex-shrink: 0;
+ color: var(--v2-icon-icon-accent);
+ fill: currentColor;
+}
+@media (prefers-reduced-motion: no-preference) {
+ .amc-mark.is-running { animation: amc-pulse 1.2s ease-in-out infinite; }
+}
+.amc-sig {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+.amc-wordmark {
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.11em;
+ color: var(--v2-text-text-accent);
+}
+.amc-rule {
+ width: 1px;
+ align-self: stretch;
+ margin: 2px 10px;
+ background: var(--v2-border-border-base);
+ flex-shrink: 0;
+}
+
+/* ============================================================
+ THE CHIP — [data-component="amicode-card"]
+ ============================================================ */
+[data-component="amicode-card"] {
+ -webkit-appearance: none;
+ appearance: none;
+ font: inherit;
+ display: inline-flex;
+ align-items: center;
+ max-width: 100%;
+ min-width: 0;
+ text-align: left;
+ /* No side rail — identity comes from the accent-tinted wash + hairline and
+ the bright H-mark. A faint gold border reads as "AMICO" without a bar. */
+ border: 1px solid color-mix(in srgb, var(--v2-icon-icon-accent) 22%, var(--v2-border-border-base));
+ border-radius: 9px;
+ background: color-mix(in srgb, var(--v2-icon-icon-accent) 6%, var(--v2-background-bg-layer-01));
+ padding: 5px 9px 5px 10px;
+ font-size: 12.5px;
+ line-height: 17px;
+ color: var(--v2-text-text-base);
+ box-shadow: var(--v2-elevation-elements, 0 0.5px 0.5px rgba(0, 0, 0, 0.2));
+ transition:
+ background 0.16s ease,
+ border-color 0.16s ease,
+ box-shadow 0.16s ease,
+ transform 0.12s ease;
+}
+[data-component="amicode-card"][data-clickable="true"] {
+ cursor: pointer;
+}
+[data-component="amicode-card"][data-clickable="true"]:hover {
+ background: color-mix(in srgb, var(--v2-icon-icon-accent) 11%, var(--v2-background-bg-layer-01));
+ border-color: color-mix(in srgb, var(--v2-icon-icon-accent) 42%, var(--v2-border-border-base));
+ box-shadow:
+ var(--v2-elevation-raised, 0 1px 2px rgba(0, 0, 0, 0.25)),
+ 0 0 0 3px color-mix(in srgb, var(--v2-icon-icon-accent) 8%, transparent);
+}
+[data-component="amicode-card"][data-clickable="true"]:active {
+ transform: translateY(0.5px);
+}
+[data-component="amicode-card"]:focus-visible {
+ outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent));
+ outline-offset: 2px;
+}
+[data-component="amicode-card"][data-state="error"] {
+ border-color: color-mix(in srgb, var(--v2-state-fg-danger) 38%, var(--v2-border-border-base));
+ background: color-mix(in srgb, var(--v2-state-fg-danger) 6%, var(--v2-background-bg-layer-01));
+}
+[data-component="amicode-card"][data-state="error"] .amc-mark,
+[data-component="amicode-card"][data-state="error"] .amc-wordmark {
+ color: var(--v2-state-fg-danger);
+}
+[data-component="amicode-card"] .amc-mark {
+ width: 13px;
+ height: 13px;
+}
+
+[data-component="amicode-card"] .amc-body {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 7px;
+ min-width: 0;
+ overflow: hidden;
+}
+[data-component="amicode-card"] .amc-label {
+ font-weight: 640;
+ color: var(--v2-text-text-base);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+[data-component="amicode-card"] .amc-detail {
+ color: var(--v2-text-text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+[data-component="amicode-card"][data-state="running"] .amc-detail {
+ color: var(--v2-text-text-accent);
+}
+[data-component="amicode-card"][data-state="error"] .amc-detail {
+ color: var(--v2-state-fg-danger);
+}
+
+/* structured diff pieces */
+[data-component="amicode-card"] .amc-diff {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 4px;
+ white-space: nowrap;
+}
+[data-component="amicode-card"] .amc-diff + .amc-diff {
+ padding-left: 7px;
+ margin-left: 2px;
+ border-left: 1px solid var(--v2-border-border-muted);
+}
+[data-component="amicode-card"] .amc-diff .k { color: var(--v2-text-text-muted); }
+[data-component="amicode-card"] .amc-diff .v {
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: 11.5px;
+ color: var(--v2-text-text-base);
+}
+[data-component="amicode-card"] .amc-diff .v.from { color: var(--v2-text-text-faint); }
+[data-component="amicode-card"] .amc-diff .arw {
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ color: var(--v2-text-text-faint);
+ padding: 0 1px;
+}
+
+[data-component="amicode-card"] .amc-trail {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ margin-left: 12px;
+ flex-shrink: 0;
+}
+[data-component="amicode-card"] .amc-tick {
+ width: 12px;
+ height: 12px;
+ color: var(--v2-state-fg-success);
+ flex-shrink: 0;
+}
+[data-component="amicode-card"] .amc-livedot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--v2-icon-icon-accent);
+}
+@media (prefers-reduced-motion: no-preference) {
+ [data-component="amicode-card"][data-state="running"] .amc-livedot { animation: amc-blip 1.2s ease-in-out infinite; }
+}
+[data-component="amicode-card"] .amc-chev {
+ color: var(--v2-text-text-faint);
+ font-size: 15px;
+ line-height: 1;
+ opacity: 0;
+ transform: translateX(-3px);
+ transition: opacity 0.16s ease, transform 0.16s ease, color 0.16s ease;
+}
+[data-component="amicode-card"][data-clickable="true"]:hover .amc-chev,
+[data-component="amicode-card"][data-clickable="true"]:focus-visible .amc-chev {
+ opacity: 1;
+ transform: translateX(0);
+ color: var(--v2-text-text-muted);
+}
+/* keyboard/touch: no hover — keep the affordance visible so it never hides */
+@media (hover: none) {
+ [data-component="amicode-card"][data-clickable="true"] .amc-chev { opacity: 0.6; transform: none; }
+}
+
+/* ============================================================
+ ENTITY VIEW — [data-component="amicode-entity-view"] (dialog body)
+ ============================================================ */
+[data-component="amicode-entity-view"] {
+ display: flex;
+ flex-direction: column;
+ padding: 4px 4px 0;
+ color: var(--v2-text-text-base);
+}
+
+[data-component="amicode-entity-view"] .amc-ev-meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ padding: 2px 4px 8px;
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: 11px;
+ color: var(--v2-text-text-muted);
+}
+[data-component="amicode-entity-view"] .amc-tier {
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: 9px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ font-weight: 600;
+ padding: 3px 7px;
+ border-radius: 6px;
+ background: var(--v2-background-bg-layer-02);
+ color: var(--v2-text-text-muted);
+}
+[data-component="amicode-entity-view"] .amc-tier[data-tier="free"] {
+ background: var(--v2-state-bg-warning);
+ color: var(--v2-state-fg-warning);
+}
+
+[data-component="amicode-entity-view"] .amc-ev-formula {
+ margin: 8px 0;
+ padding: 14px 16px;
+ border-radius: 10px;
+ background: var(--v2-background-bg-layer-01);
+ border: 1px solid var(--v2-border-border-muted);
+ overflow-x: auto;
+}
+[data-component="amicode-entity-view"] .amc-ev-formula-label {
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: 9px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--v2-text-text-faint);
+ margin-bottom: 10px;
+}
+
+[data-component="amicode-entity-view"] .amc-ev-sec {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 20px 4px 8px;
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: 9px;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--v2-text-text-faint);
+}
+[data-component="amicode-entity-view"] .amc-ev-sec::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: var(--v2-border-border-muted);
+}
+
+[data-component="amicode-entity-view"] .amc-ev-group {
+ margin: 8px 4px 2px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--v2-text-text-muted);
+}
+
+[data-component="amicode-entity-view"] .amc-field {
+ display: grid;
+ grid-template-columns: 160px 1fr auto;
+ align-items: baseline;
+ gap: 14px;
+ padding: 7px 8px 7px 4px;
+ border-radius: 8px;
+ transition: background 0.12s ease;
+}
+[data-component="amicode-entity-view"] .amc-field:hover { background: var(--v2-background-bg-layer-01); }
+[data-component="amicode-entity-view"] .amc-field .amc-fk { min-width: 0; }
+[data-component="amicode-entity-view"] .amc-field .amc-fk .name {
+ font-size: 13px;
+ font-weight: 560;
+ color: var(--v2-text-text-base);
+}
+[data-component="amicode-entity-view"] .amc-field .amc-fk .raw {
+ display: block;
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: 10px;
+ color: var(--v2-text-text-faint);
+ margin-top: 1px;
+}
+[data-component="amicode-entity-view"] .amc-field .amc-fv {
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: 13px;
+ color: var(--v2-text-text-base);
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ word-break: break-word;
+ min-width: 0;
+}
+[data-component="amicode-entity-view"] .amc-field .amc-fv.changed { color: var(--v2-text-text-accent); }
+[data-component="amicode-entity-view"] .amc-field .amc-fv.changed::before {
+ content: "•";
+ color: var(--v2-icon-icon-accent);
+ padding-right: 6px;
+ font-size: 10px;
+ vertical-align: middle;
+}
+
+[data-component="amicode-entity-view"] .amc-edit {
+ -webkit-appearance: none;
+ appearance: none;
+ justify-self: end;
+ font: inherit;
+ font-size: 11px;
+ color: var(--v2-text-text-muted);
+ background: transparent;
+ border: 1px solid var(--v2-border-border-muted);
+ border-radius: 6px;
+ padding: 2px 8px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ white-space: nowrap;
+ opacity: 0.55;
+ transition: opacity 0.14s ease, color 0.14s ease, border-color 0.14s ease, background 0.14s ease;
+}
+[data-component="amicode-entity-view"] .amc-field:hover .amc-edit { opacity: 1; }
+[data-component="amicode-entity-view"] .amc-edit:hover {
+ color: var(--v2-text-text-accent);
+ border-color: color-mix(in srgb, var(--v2-icon-icon-accent) 45%, transparent);
+ background: color-mix(in srgb, var(--v2-icon-icon-accent) 9%, transparent);
+}
+[data-component="amicode-entity-view"] .amc-edit:focus-visible {
+ outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent));
+ outline-offset: 1px;
+ opacity: 1;
+}
+[data-component="amicode-entity-view"] .amc-edit .amc-edit-label {
+ max-width: 0;
+ overflow: hidden;
+ opacity: 0;
+ transition: max-width 0.18s ease, opacity 0.14s ease;
+}
+[data-component="amicode-entity-view"] .amc-field:hover .amc-edit .amc-edit-label,
+[data-component="amicode-entity-view"] .amc-edit:focus-visible .amc-edit-label {
+ max-width: 90px;
+ opacity: 1;
+}
+
+[data-component="amicode-entity-view"] .amc-timeline {
+ display: flex;
+ flex-direction: column;
+ margin-left: 6px;
+ padding-left: 2px;
+ border-left: 1.5px solid var(--v2-border-border-muted);
+}
+[data-component="amicode-entity-view"] .amc-event {
+ position: relative;
+ padding: 9px 10px 9px 20px;
+ border-radius: 8px;
+}
+[data-component="amicode-entity-view"] .amc-event::before {
+ content: "";
+ position: absolute;
+ left: -5px;
+ top: 15px;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--v2-background-bg-base);
+ border: 1.5px solid var(--v2-text-text-faint);
+}
+[data-component="amicode-entity-view"] .amc-event[data-anchored] {
+ background: color-mix(in srgb, var(--v2-icon-icon-accent) 9%, transparent);
+}
+[data-component="amicode-entity-view"] .amc-event[data-anchored]::before {
+ background: var(--v2-icon-icon-accent);
+ border-color: var(--v2-icon-icon-accent);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--v2-icon-icon-accent) 18%, transparent);
+}
+[data-component="amicode-entity-view"] .amc-event .erow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+[data-component="amicode-entity-view"] .amc-event .seq {
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: 10px;
+ color: var(--v2-text-text-muted);
+ background: var(--v2-background-bg-layer-02);
+ border-radius: 5px;
+ padding: 1px 6px;
+}
+[data-component="amicode-entity-view"] .amc-event .src {
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: 11px;
+ color: var(--v2-text-text-faint);
+}
+[data-component="amicode-entity-view"] .amc-event .when {
+ font-size: 11px;
+ color: var(--v2-text-text-faint);
+ margin-left: auto;
+}
+[data-component="amicode-entity-view"] .amc-event .summary {
+ font-size: 13px;
+ color: var(--v2-text-text-base);
+ margin-top: 3px;
+}
+[data-component="amicode-entity-view"] .amc-event .summary .k { color: var(--v2-text-text-muted); }
+[data-component="amicode-entity-view"] .amc-event .summary .v {
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: 12px;
+}
+[data-component="amicode-entity-view"] .amc-event .summary .v.from { color: var(--v2-text-text-faint); }
+[data-component="amicode-entity-view"] .amc-event .summary .arw {
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ color: var(--v2-text-text-faint);
+ padding: 0 4px;
+}
+
+[data-component="amicode-entity-view"] .amc-ev-foot {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin-top: 18px;
+ padding: 12px 4px 6px;
+ border-top: 1px solid var(--v2-border-border-muted);
+ font-size: 12px;
+ color: var(--v2-text-text-muted);
+}
+[data-component="amicode-entity-view"] .amc-ev-foot .amc-mark {
+ width: 13px;
+ height: 13px;
+ color: var(--v2-text-text-faint);
+}
+[data-component="amicode-entity-view"] .amc-ev-foot b {
+ color: var(--v2-text-text-base);
+ font-weight: 600;
+}
+
+[data-component="amicode-entity-view"] .amc-ev-empty {
+ font-size: 13px;
+ color: var(--v2-text-text-muted);
+ padding: 4px;
+}
+
+/* error + skeleton states */
+[data-component="amicode-entity-view"] .amc-ev-error {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+}
+[data-component="amicode-entity-view"] .amc-ev-error .dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ background: var(--v2-state-fg-danger);
+}
+[data-component="amicode-entity-view"] .amc-ev-error .msg {
+ flex: 1;
+ font-size: 14px;
+ color: var(--v2-text-text-muted);
+}
+[data-component="amicode-entity-view"] .amc-ev-retry {
+ -webkit-appearance: none;
+ appearance: none;
+ font: inherit;
+ font-size: 12px;
+ color: var(--v2-text-text-base);
+ background: transparent;
+ border: 1px solid var(--v2-border-border-strong);
+ border-radius: 6px;
+ padding: 3px 10px;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+[data-component="amicode-entity-view"] .amc-ev-retry:hover { background: var(--v2-background-bg-layer-02); }
+[data-component="amicode-entity-view"] .amc-ev-retry:focus-visible {
+ outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent));
+ outline-offset: 1px;
+}
+
+[data-component="amicode-entity-view"] .amc-sk {
+ height: 14px;
+ border-radius: 6px;
+ background: var(--v2-background-bg-layer-02);
+ position: relative;
+ overflow: hidden;
+ margin: 12px 0;
+}
+[data-component="amicode-entity-view"] .amc-sk.w60 { width: 60%; }
+[data-component="amicode-entity-view"] .amc-sk.w40 { width: 40%; }
+@media (prefers-reduced-motion: no-preference) {
+ [data-component="amicode-entity-view"] .amc-sk::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ transform: translateX(-100%);
+ background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--v2-text-text-base) 8%, transparent), transparent);
+ animation: amc-shimmer 1.4s infinite;
+ }
+}
+
+/* ============================================================
+ SHARED CARD SURFACES — rail, ask, run window
+ ============================================================ */
+[data-component="amicode-entity-rail"],
+[data-component="amicode-ask-card"],
+[data-component="amicode-run-window"] {
+ /* Same rail-free treatment as the receipt card: gold-tinted hairline + a
+ whisper of accent wash, so the whole AMICO family shares one identity. */
+ border: 1px solid color-mix(in srgb, var(--v2-icon-icon-accent) 20%, var(--v2-border-border-base));
+ border-radius: 9px;
+ background: color-mix(in srgb, var(--v2-icon-icon-accent) 5%, var(--v2-background-bg-layer-01));
+ box-shadow: var(--v2-elevation-elements, 0 0.5px 0.5px rgba(0, 0, 0, 0.2));
+}
+
+/* rail chip hover */
+[data-component="amicode-entity-rail"] [data-slot="amicode-rail-chip"] {
+ border-radius: 5px;
+ padding: 1px 5px;
+ margin: -1px -1px;
+ transition: background 0.14s ease;
+}
+[data-component="amicode-entity-rail"] button[data-slot="amicode-rail-chip"]:hover {
+ background: var(--v2-background-bg-layer-02);
+}
+[data-component="amicode-entity-rail"] [data-slot="amicode-rail-problem"]:hover {
+ color: var(--v2-text-text-accent) !important;
+}
+[data-component="amicode-entity-rail"] button:focus-visible,
+[data-component="amicode-ask-card"] button:focus-visible,
+[data-component="amicode-run-window"] button:focus-visible {
+ outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent));
+ outline-offset: 2px;
+}
+
+/* ask-card option hover */
+[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled) {
+ transition: background 0.14s ease, border-color 0.14s ease, transform 0.12s ease;
+}
+[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled):hover {
+ border-color: var(--v2-icon-icon-accent) !important;
+ background: var(--v2-background-bg-layer-03) !important;
+}
+[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled):active {
+ transform: translateY(0.5px);
+}
+
+/* run window: subtle hover to signal it opens the Run entity */
+[data-component="amicode-run-window"] {
+ transition: background 0.16s ease, border-color 0.16s ease;
+}
+[data-component="amicode-run-window"]:hover {
+ border-color: color-mix(in srgb, var(--v2-icon-icon-accent) 40%, var(--v2-border-border-base));
+ background: color-mix(in srgb, var(--v2-icon-icon-accent) 8%, var(--v2-background-bg-layer-01));
+}
diff --git a/packages/ui/src/amicode/ask-card.tsx b/packages/ui/src/amicode/ask-card.tsx
index 501379d6d0..71b224a6c6 100644
--- a/packages/ui/src/amicode/ask-card.tsx
+++ b/packages/ui/src/amicode/ask-card.tsx
@@ -1,6 +1,7 @@
import { For, Show, createSignal } from "solid-js"
import { amicodeAskBridge } from "./ask-bridge"
import { answeredOption, hasUserReplyAfter, type AskInput } from "./ask"
+import { AmicoMark } from "./spinner"
import { useData } from "../context/data"
// AMICODE: question card for amicode_ask tool parts — question text + one
@@ -43,24 +44,15 @@ export function AmicodeAskCard(props: { ask: AskInput; messageID?: string; sessi
"flex-direction": "column",
gap: "8px",
"min-width": "0",
- border: "1px solid var(--v2-border-border-base)",
- "border-left": "3px solid var(--v2-icon-icon-accent)",
- "border-radius": "6px",
- background: "var(--v2-background-bg-layer-01)",
padding: "8px 12px",
"font-size": "12px",
"line-height": "16px",
}}
>
-
-
- AMICO
+
+
+
+ AMICO·Question
diff --git a/packages/ui/src/amicode/card.tsx b/packages/ui/src/amicode/card.tsx
index c392996a2f..1a021051de 100644
--- a/packages/ui/src/amicode/card.tsx
+++ b/packages/ui/src/amicode/card.tsx
@@ -1,8 +1,9 @@
-import { Match, Show, Switch, createMemo } from "solid-js"
+import { For, Match, Show, Switch, createMemo } from "solid-js"
import { amicodeStage } from "./stage"
import { parseAskInput } from "./ask"
import { AmicodeAskCard } from "./ask-card"
-import { parseDiffSentinel, receiptText } from "./receipt"
+import { parseDiffSentinel, receiptParts } from "./receipt"
+import { AmicoMark } from "./spinner"
import { openAmicodeEntity } from "./ui-bridge"
import { RunWindow } from "./run-window"
@@ -31,74 +32,150 @@ function runRefFromOutput(output: unknown): { run: string; lab?: string } | unde
// is agent-directed text and is deliberately NOT rendered — durable state
// lives in the problem rail (./entity-rail.tsx). Receipt click opens the
// entity view through the ui-bridge (no-op until the rail registers it).
+//
+// The card carries four states, differentiated in FORM, not just words: the
+// H-mark is the spinner while working (running), settles under a green check
+// when done with no diff, becomes a structured diff receipt when there is one,
+// and turns its rail red on failure. It is a real