diff --git a/AMICODE-PATCHES.md b/AMICODE-PATCHES.md
index c098e2dcf0..53d964a514 100644
--- a/AMICODE-PATCHES.md
+++ b/AMICODE-PATCHES.md
@@ -353,3 +353,9 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build.
- STILL UNVERIFIED (needs live API creds): whether the OLD legacy `thinking:{type:"enabled",budgetTokens}` form actually 400s against Opus 5 or merely degrades — i.e. whether this was "hobbled" or "unusable". Also unexercised by CI: the Grok/GLM effort variants and the opus-4-5 `budgetTokens`+`effort` combination.
- FUTURE SYNC COST: this is a knowing trade — porting file end-states now makes a future clean upstream merge harder on these 4 files. Accepted. NOTE the bug class recurs every model generation (version-regex parsing of model IDs will break again at Opus 6), which argues for a standing narrow sync lane on `transform.ts` rather than one-off unfreezes.
- SEPARATE RISK SPOTTED (not fixed here): the release build does a bare `fetch(models.dev/api.json)` with no fallback, so a models.dev outage hard-fails the build. Consider pinning `MODELS_DEV_API_JSON` for the hackathon build.
+24. (harmonic wave indicator — de-shimmer the tool-status title, 2026-07-28) — amicode: the chat had two "working" indicators that read as identical — the fork's thinking line and the STOCK tool-group header (`packages/ui/src/components/tool-status-title.tsx`, e.g. "Working in shell" → "Worked in shell") — because both were shimmering text with no glyph. Earlier steps of this run gave the thinking line a standing-wave glyph (`AmicoWave`, `packages/ui/src/amicode/amico-wave.tsx`) and deleted the `amc-text-shimmer` CSS; this patch removes the shimmer from the last surface still asking for it.
+ - `tool-status-title.tsx`: all five `` render positions (swap-mode active/done, suffix-mode prefix/active/done) replaced with plain `{text}`. `TextShimmer` import removed. The now-orphaned `prefixLen` memo (it existed only to feed the shimmer's `offset` phase-alignment prop) removed too — confirmed via `oxlint` before/after (498→499→498 warnings) that leaving it in place tripped `no-unused-vars`, so dropping it was the correct call rather than "keep everything."
+ - Deliberately PRESERVED: the `common()` prefix-splitting logic, the width-morph animation (`animate()`/`finish()`, the `requestAnimationFrame`, the stored `width`, the `data-ready` flag), the `data-component`/`data-active`/`data-mode` attributes, the `aria-label`, and the active→done text swap itself. None of that is the shimmer — it's the genuinely good word-morph behavior.
+ - `text-shimmer.tsx` untouched — it has many other live callers (`basic-tool.tsx`, `message-part.tsx` ×7, `session-turn.tsx`, `v2/components/basic-tool-v2.tsx` via `text-shimmer-v2.tsx`, plus stories), confirmed via `rg -n 'TextShimmer' packages/`. Not a candidate for deletion.
+ - The dropped `offset` prop existed only so the shimmer's gradient phase stayed continuous across the prefix/tail split; with no shimmer there's nothing to phase-align, so it has no replacement. Verified live (Storybook, `UI/AnimatedCountList` stories) that the prefix and tail still read as one unbroken word with no seam: in the swap-mode `Playground` story, driving an active→done transition showed the mid-animation frame rendering both the active and done spans simultaneously under the animating width (`data-ready="true"`, container `style="width: 0px"` mid-transition, then settling) — the word-morph is intact. The suffix/prefix-tail mode (`data-mode="suffix"`) is currently unreachable from any real call site — both app usages (`message-part.tsx` context-tool-group and shell-group titles) and the only story pass `split={false}` — so it was verified by transiently flipping one story's `split` prop off (`Done` export, "Exploring"/"Explored"), confirming `data-mode="suffix"` renders `"Explor"` + `"ed"` as an unbroken "Explored" with no visible gap, then reverting that story edit before commit (`git status` shows only the two files below).
+ - Tests: ui `bun test src` → 402 pass / 0 fail (unchanged). typecheck (tsgo) clean in `packages/ui`. `oxlint packages/ui/src` → 498 warnings / 0 errors both before and after (no new warnings once `prefixLen` was dropped).
diff --git a/packages/ui/src/amicode/amico-wave.stories.tsx b/packages/ui/src/amicode/amico-wave.stories.tsx
new file mode 100644
index 0000000000..bba204a53a
--- /dev/null
+++ b/packages/ui/src/amicode/amico-wave.stories.tsx
@@ -0,0 +1,313 @@
+// @ts-nocheck
+// AmicoWave: the harmonic working indicator. Geometry/timing live in wave-geometry.ts and are
+// unit-tested there; this file is the only place the SVG + CSS actually render, so it is also
+// the only place a CSS regression (wrong ink, broken quadrature, id collisions across
+// simultaneous mounts) would be visible before it ships. Nothing mounts AmicoWave in the app
+// yet — see amico-wave.tsx's header comment — so these stories are the sole way to see it.
+//
+// ---- why Default/Schemes exist -------------------------------------------------------------
+// A review caught a critical color bug that no unit test could have caught: the component
+// used to pick its dark-scheme ink via `:root:not([data-theme="light"])`, a selector copied
+// from a block in v2/styles/theme.css that lives INSIDE a /* */ comment — dead CSS — so it
+// matched in every state and the glyph rendered brand lemon (~1.1:1 contrast) on light
+// backgrounds. It's fixed to `color: var(--v2-icon-icon-accent)`, which the active theme
+// (oc-2) maps to grey-800 on light and #FFF676 on dark. Default and Schemes below exist so
+// that mapping can never silently regress again.
+//
+// ---- the wrinkle: how these stories actually re-resolve the tokens ------------------------
+// The obvious approach — set `data-color-scheme="light"`/`"dark"` on a wrapper element, per
+// theme/context.tsx (`dataset.colorScheme`) and v2/styles/theme.css's
+// `[data-color-scheme="light"]` / `[data-color-scheme="dark"]` blocks — turns out to be a
+// no-op INSIDE STORYBOOK. Storybook's preview only imports `@opencode-ai/ui/styles/tailwind`,
+// whose theme file is the v1 `styles/theme.css`; the v2 file that defines those
+// `[data-color-scheme]` blocks is only ever pulled in by the real app's
+// `v2/styles/tailwind.css` chain (packages/app/src/index.css), which Storybook never loads.
+// Verified empirically with Playwright against a running Storybook: a nested
+// `data-color-scheme="light"` (or "dark") div's `--v2-*` custom properties were untouched by
+// the attribute and simply inherited whatever the global theme toolbar had already put on
+// — both a "light" and a "dark" test div resolved to the SAME `--v2-icon-icon-accent`.
+// So a story built the literal way the review described would silently show the SAME color
+// twice, which is exactly the failure mode it was meant to catch.
+//
+// Instead, each scheme pane below calls the SAME resolver the app uses at runtime —
+// `resolveThemeVariantV2` over the real oc-2.json theme (theme/v2/resolve.ts, the function
+// theme/context.tsx's applyThemeCss calls) — and applies the FULL resulting token set
+// (188 keys: primitive ramps + semantic aliases, self-contained) as inline custom properties
+// on that pane's own wrapper. That correctly re-resolves `--v2-icon-icon-accent` (and every
+// other v2 token used inside) independent of whatever the global Storybook theme toggle is
+// doing, because inline-set custom properties on an element always win for that subtree. This
+// is arguably MORE faithful than the dead attribute would have been: it reads the live oc-2
+// mapping from its source file, so it tracks theme changes instead of a hand-copied hex.
+import { createSignal, For, onMount } from "solid-js"
+import { AmicoWave } from "./amico-wave"
+import { MODE_WAVELENGTHS, WAVE_BOX } from "./wave-geometry"
+import oc2ThemeJson from "../theme/themes/oc-2.json"
+import { resolveThemeVariantV2 } from "../theme/v2/resolve"
+import type { DesktopTheme } from "../theme/types"
+
+const oc2Theme = oc2ThemeJson as DesktopTheme
+
+/** The real oc-2 --v2-* token set for one color scheme, self-contained (primitive ramps +
+ * semantic aliases both included), so it can be applied to any wrapper and resolve correctly
+ * with zero dependency on ambient state — in particular, independent of the Storybook global
+ * theme toolbar. This is the exact function theme/context.tsx calls to paint ; here we
+ * scope its output to a
instead. */
+function schemeVars(scheme: "light" | "dark"): Record {
+ const isDark = scheme === "dark"
+ const tokens = resolveThemeVariantV2(isDark ? oc2Theme.dark : oc2Theme.light, isDark)
+ const vars: Record = {}
+ for (const [key, value] of Object.entries(tokens)) vars[`--${key}`] = value
+ return vars
+}
+
+// Scale + freeze helpers used by Schemes/Modes/Magnified/ReducedMotion below. AmicoWave sets
+// width/height as SVG presentation attributes (30x12); a plain CSS class rule already beats
+// those with no !important needed, per the CSS spec's presentation-attribute priority rule.
+const StoryCss = () => (
+
+)
+
+function SchemePane(props: { scheme: "light" | "dark"; children: unknown }) {
+ return (
+
+
+ {props.scheme} scheme
+
+ {props.children}
+
+ )
+}
+
+// Mimics the real mount site (thinking-line.tsx, once wired): glyph, bold gerund, muted
+// elapsed time — reusing the actual .amc-thinking* classes from amicode.css.
+const ThinkingRow = () => (
+
+
+ Percolating
+ 5m 13s
+
+)
+
+export default {
+ title: "Amicode/AmicoWave",
+ id: "amicode-amico-wave",
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component: `### AmicoWave
+
+The harmonic working indicator: a 30×12px standing wave in quadrature (a lead curve and a
+fainter companion, a quarter period out of phase, so one is at full swing exactly when the
+other crosses zero — what keeps the glyph from reading as a blink at 12px). It climbs through
+three modes (1/2/3 full wavelengths across the box) on a slower cadence. All geometry and
+timing come from wave-geometry.ts via CSS custom properties set inline by the component —
+never restated here.
+
+Not yet mounted anywhere in the app (a later task will wire it into the thinking line and the
+tool header, replacing the amc-text-shimmer treatment) — these stories are the only current
+way to see it render.
+
+**Hard invariant, guarded by ManyInstances below:** no SVG \`\`, no \`id\` attributes.
+SVG ids are document-global, and several indicators mount at once in real use (one thinking
+line plus one tool header per in-flight tool call) — an id would collide across instances.`,
+ },
+ },
+ },
+}
+
+// ---------------------------------------------------------------------------------------------
+// Default — the glyph at natural size beside its real-use text, in both schemes side by side
+// so an ink regression (wrong token, or a hardcoded color that ignores scheme entirely) is
+// visible without needing the Schemes story's numeric readout.
+export const Default = () => (
+
+
+
+
+
+
+
+
+)
+
+// ---------------------------------------------------------------------------------------------
+// Schemes — the contrast check. getComputedStyle(el).color reads what the browser ACTUALLY
+// resolved for that scheme's pane, not what the token mapping merely intends. This is the
+// story that would have caught the original bug: the buggy selector lived in a dead comment,
+// so the glyph's color never actually changed with scheme — it would have printed the SAME
+// resolved color under both panes here.
+function ContrastSwatch(props: { scheme: "light" | "dark" }) {
+ const [resolved, setResolved] = createSignal("…")
+ let wrap: HTMLDivElement | undefined
+ onMount(() => {
+ const svg = wrap?.querySelector('[data-component="amico-wave"]')
+ if (svg) setResolved(getComputedStyle(svg).color)
+ })
+ return (
+
+
+ {props.scheme}
+
+
+
+
+
+ getComputedStyle → {resolved()}
+
+
+ )
+}
+
+export const Schemes = () => (
+ <>
+
+
+
+
+
+ >
+)
+
+// ---------------------------------------------------------------------------------------------
+// Modes — each of the three standing modes, frozen and isolated, at 4x, so the shape reads.
+// Wavelength is indexed by full wavelengths across the 30px box (1, 2, 3) — deliberately NOT
+// the physical harmonic number; see wave-geometry.ts's MODE_WAVELENGTHS comment for why.
+function FrozenMode(props: { pin: number; wavelength: number; waves: number }) {
+ return (
+
+
+
+
+
+ mode {props.pin} — λ={props.wavelength}px, {props.waves} full wavelength{props.waves === 1 ? "" : "s"} across
+ the box
+
+
+ )
+}
+
+export const Modes = () => (
+ <>
+
+
+
+ {(wavelength, i) => }
+
+
+ >
+)
+
+// ---------------------------------------------------------------------------------------------
+// Magnified — one live glyph at 6x so the quadrature is visible at a glance: the faint
+// companion is at full swing exactly when the bold lead crosses the axis.
+export const Magnified = () => (
+ <>
+
+
+
+
+ The companion (faint, 0.4 opacity) is a quarter period behind the lead — it peaks
+ exactly when the lead crosses zero, which is what stops the glyph reading as a blink at
+ the real 12px size.
+
+
+ >
+)
+
+// ---------------------------------------------------------------------------------------------
+// ManyInstances — the regression guard for the no-/no-id rule. SVG ids are
+// document-global: if one is ever introduced (for a mask, a gradient, anything), every
+// instance after the first resolves to the FIRST element's definition and visibly breaks —
+// invisibly on inspection of a single instance, but obvious the moment two or more are mounted
+// at once, which is the normal case in the real app (one thinking line plus one tool header
+// per in-flight tool call).
+export const ManyInstances = () => (
+
+
+ 12 live instances, mounted simultaneously. AmicoWave has no SVG <defs> and no id
+ attribute anywhere — this is the guard for that: if either is ever added, ids collide
+ across instances and every glyph after the first will visibly break here (even though a
+ single isolated instance would still look correct).
+
+
+ {() => }
+
+
+)
+
+// ---------------------------------------------------------------------------------------------
+// ReducedMotion — a mockup of the CSS's own @media (prefers-reduced-motion: reduce) fallback
+// (amicode.css): one static mode-1 curve, companion hidden, no animation. This is authored
+// locally to DEMONSTRATE the intended fallback, not to test it live — Storybook cannot force
+// the OS/browser reduced-motion setting. To see the real fallback, enable "reduce motion" at
+// the OS level (or the equivalent DevTools rendering emulation) and reload; every AmicoWave in
+// every story on this page will then freeze the same way.
+export const ReducedMotion = () => (
+ <>
+
+
+
+
+
+
+ Mockup only — driven by the OS/browser prefers-reduced-motion setting in
+ the real component, which Storybook cannot toggle. The elapsed counter in the thinking
+ line keeps ticking under reduced motion; only this glyph's own animation stops.
+
+
+ >
+)
diff --git a/packages/ui/src/amicode/amico-wave.tsx b/packages/ui/src/amicode/amico-wave.tsx
new file mode 100644
index 0000000000..edd93baed4
--- /dev/null
+++ b/packages/ui/src/amicode/amico-wave.tsx
@@ -0,0 +1,73 @@
+// AMICODE: the harmonic working indicator — a standing wave in quadrature, shown while
+// Amico works. Will replace the amc-text-shimmer treatment on both indicator surfaces once
+// mounted — that wiring (thinking-line.tsx and the tool header) is a later task.
+//
+// Markup only: all geometry and timing come from ./wave-geometry, handed to the CSS as
+// custom properties so there is exactly one source of truth. Two paths per mode (lead +
+// companion out of phase by a quarter period); the companion is at full swing exactly when
+// the lead crosses zero, which is what stops the glyph reading as a blink at 12px.
+//
+// NO and NO ids — several indicators mount at once (one thinking line plus one tool
+// header per tool call) and SVG ids are document-global, so ids would collide and every
+// instance would resolve to the first definition. If a variant ever needs masking it must
+// use CSS mask-image, not an SVG .
+import { For } from "solid-js"
+import {
+ WAVE_BOX,
+ WAVE_LEAD_STROKE,
+ WAVE_COMPANION_STROKE,
+ WAVE_COMPANION_OPACITY,
+ WAVE_PERIOD_MS,
+ WAVE_EASING,
+ MODE_PATHS,
+ companionDelayMs,
+ modeCadenceMs,
+ modeDelaysMs,
+} from "./wave-geometry"
+
+const DELAYS = modeDelaysMs()
+
+export function AmicoWave(props: { class?: string }) {
+ return (
+
+ )
+}
diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css
index 7524aeb3a8..551dd168fe 100644
--- a/packages/ui/src/amicode/amicode.css
+++ b/packages/ui/src/amicode/amicode.css
@@ -54,8 +54,8 @@
/* ---- thinking line (Claude-Code-esque working indicator) ----------------- */
/* Sits after the AMICO wordmark while a reply streams (thinking-line.tsx). The
- * gerund word cycles + shimmers; the meta line ticks elapsed (· tokens · esc).
- * Leading dot separates it from the wordmark. */
+ * gerund word cycles without shimmer — the wave glyph (amico-wave.tsx) will carry
+ * the motion once mounted (a later task); the meta line ticks elapsed (· tokens · esc). */
.amc-thinking {
display: inline-flex;
align-items: baseline;
@@ -65,43 +65,11 @@
letter-spacing: 0.01em;
color: var(--v2-text-text-muted);
}
-.amc-thinking::before {
- content: "";
- align-self: center;
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: var(--v2-border-border-base);
- flex-shrink: 0;
-}
.amc-thinking-word {
font-weight: 600;
white-space: nowrap;
color: var(--v2-text-text-accent);
}
-/* The shimmer: a bright band sweeps across otherwise-dimmed accent text.
- * Motion-gated — under reduced motion (or .is-still) the word is plain accent. */
-@media (prefers-reduced-motion: no-preference) {
- .amc-thinking:not(.is-still) .amc-thinking-word {
- color: transparent;
- background-image: linear-gradient(
- 100deg,
- color-mix(in srgb, var(--v2-text-text-accent) 42%, transparent) 0%,
- color-mix(in srgb, var(--v2-text-text-accent) 42%, transparent) 38%,
- var(--v2-text-text-accent) 50%,
- color-mix(in srgb, var(--v2-text-text-accent) 42%, transparent) 62%,
- color-mix(in srgb, var(--v2-text-text-accent) 42%, transparent) 100%
- );
- background-size: 220% 100%;
- -webkit-background-clip: text;
- background-clip: text;
- animation: amc-text-shimmer 1.6s linear infinite;
- }
-}
-@keyframes amc-text-shimmer {
- 0% { background-position: 200% 0; }
- 100% { background-position: -20% 0; }
-}
.amc-thinking-meta {
display: inline-flex;
align-items: baseline;
@@ -111,6 +79,91 @@
}
.amc-thinking-sep { opacity: 0.55; }
.amc-thinking-hint { font-style: italic; }
+/* The glyph is a block, not text — baseline alignment would sit it high. */
+.amc-thinking .amc-wave { align-self: center; }
+
+/* ---- an opened skill file (message-part.tsx, ToolRegistry "skill") ------- */
+/* Expanding a skill used to dump its instructions as bare markdown straight into the
+ * transcript, because [data-component="tool-output"] carries no surface of its own — no
+ * border, no background, no padding. So an opened skill read as more conversation. It is a
+ * FILE, so it gets a file's surface: hairline, tint, padding, and a bounded height that
+ * scrolls rather than pushing the transcript around.
+ *
+ * Scoped to skills — every other tool's output keeps the plain treatment. The chip ABOVE this
+ * stays flush left with the other Amico chips; an earlier attempt indented the whole
+ * activation behind an accent spine and broke the shared left edge the chips read along.
+ *
+ * Compound selector, not a bare class: `[data-component="tool-output"]` is also (0,1,0), so a
+ * lone `.amc-skill-file` would tie on specificity and be decided by stylesheet order. */
+[data-component="tool-output"].amc-skill-file {
+ margin-top: 6px;
+ padding: 10px 12px;
+ max-height: 420px;
+ overflow-y: auto;
+ border: 1px solid var(--v2-border-border-base);
+ border-radius: var(--radius-md);
+ background: var(--v2-background-bg-layer-01);
+ /* tool-output sets `white-space: pre` for raw command output; rendered markdown must wrap. */
+ white-space: normal;
+}
+
+/* ---- the harmonic working indicator (amico-wave.tsx) --------------------- */
+/* A standing wave in quadrature: two identical curves, the companion a quarter period out
+ * of phase, so one is at full swing whenever the other crosses zero.
+ *
+ * Every duration, delay and opacity here comes from wave-geometry.ts via a custom property
+ * set inline by the component — do NOT restate those numbers in this file. */
+.amc-wave {
+ display: block;
+ flex-shrink: 0;
+ /* The ink role, already resolved by the active theme: oc-2.json maps
+ * v2-icon-icon-accent to grey-800 on light and #FFF676 on dark. The brand lemon is a
+ * fill, never an ink (1.1:1 on white), and this token is what encodes that — no theme
+ * selector needed here. Same treatment as amico-presence.css. */
+ color: var(--v2-icon-icon-accent);
+}
+
+/* .amc-wave-ln / .amc-wave-mode are component-private — only amico-wave.tsx ever sets the
+ * custom properties these animation shorthands read. Both shorthands carry a var(), which
+ * makes the whole shorthand a pending-substitution value: if any referenced custom property
+ * is ever missing (e.g. someone hand-writes