Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
a4fd244
amicode: re-add Aero wordmark typeface (LICENSE-PENDING)
aarontrowbridge Jul 8, 2026
c811798
amicode: wire composer paste to the extension clipboard bridge
aarontrowbridge Jul 8, 2026
b868a59
amicode: generalize the paste-clipboard bridge past the composer
aarontrowbridge Jul 8, 2026
c52cffa
feat(amicode-ui): pure frontier DAG scheduler + tests
aarontrowbridge Jul 9, 2026
81066f8
feat(amicode-ui): composite-aware problem.ts display derivations
aarontrowbridge Jul 9, 2026
9810040
merge origin/local/amicode into local/amicode
aarontrowbridge Jul 9, 2026
baf6b9e
merge local/amicode into local/composite-system
aarontrowbridge Jul 9, 2026
3a85038
feat(amicode-ui): compactValue kills the JSON-dump receipt chokepoint
aarontrowbridge Jul 9, 2026
8ebebe7
feat(amicode-ui): setDiff — unique-keyed set delta (spec §4 / plan Ta…
aarontrowbridge Jul 9, 2026
e110d11
feat(amicode-ui): modeBadges — mode→badge model (spec §4 / plan Task 3)
aarontrowbridge Jul 9, 2026
dc47bdf
feat(amicode-ui): system schematic + table models (spec §6.1 / plan T…
aarontrowbridge Jul 9, 2026
a344e06
feat(amicode-ui): SystemComposite hero (schematic+table+Hamiltonian)
aarontrowbridge Jul 9, 2026
3cd5d1a
feat(amicode-ui): system entity-view + receipt use the composite hero
aarontrowbridge Jul 9, 2026
3d5d0de
feat(amicode-ui): formulationProjection (dual-shape, derived primaryK…
aarontrowbridge Jul 9, 2026
b94000a
fix(amicode-ui): formulation chip reads structured facets, not phanto…
aarontrowbridge Jul 9, 2026
cccb286
feat(amicode-ui): FormulationView hero (mode header + objective LaTeX…
aarontrowbridge Jul 9, 2026
d50d337
feat(amicode-ui): formulation entity-view + receipt (spec §5,§6.2 / T…
aarontrowbridge Jul 9, 2026
c0ea330
fix(amicode-ui): entity-view dialog overflow — wrap long values, shri…
aarontrowbridge Jul 10, 2026
a64da04
feat(amicode-ui): composite Hamiltonian LaTeX + compact timestamps + …
aarontrowbridge Jul 10, 2026
3509606
fix(amicode): entity-view dialog wrapper contains both axes (stop pan…
aarontrowbridge Jul 10, 2026
a4fc920
fix(amicode): entity dialog — definite width + overflow-x clip (stop …
aarontrowbridge Jul 10, 2026
e485430
merge: sync origin/local/amicode (#13,#16,#17,#18) into composite-system
aarontrowbridge Jul 10, 2026
1b46833
style(brand): Aero wordmark on logo.tsx (Aaron's call, this branch)
aarontrowbridge Jul 10, 2026
b81fb75
feat(prompt): Claude Code-style rich chat output for Claude models (p…
aarontrowbridge Jul 10, 2026
a86e690
docs(patches): stamp #18 final provenance — clean build at b81fb756f2…
aarontrowbridge Jul 10, 2026
d073fb6
feat(prompt): port rich-output style to default.txt + gemini.txt (pat…
aarontrowbridge Jul 10, 2026
1532788
refactor(prompt): model-agnostic communicating.txt appended for every…
aarontrowbridge Jul 10, 2026
c42043e
fix(amicode): rydberg Hamiltonian in System card + communicating.txt …
aarontrowbridge Jul 11, 2026
431b29a
feat(amicode): right-click tab management on the session tab strip
aarontrowbridge Jul 11, 2026
f78155a
fix(ui): markdown heading hierarchy — h1-h3 larger + 600 weight, spac…
aarontrowbridge Jul 11, 2026
37e304c
feat(amicode): add "Close tabs to the left" to the tab context menu
aarontrowbridge Jul 11, 2026
2ff5767
fix(amicode): register quantum-math KaTeX macros so chat \Tr etc. render
aarontrowbridge Jul 11, 2026
54c6559
fix(amicode): render mid-line-opened multi-line $$ display math in chat
aarontrowbridge Jul 11, 2026
236e647
feat(amicode): single-select question options commit on click
aarontrowbridge Jul 11, 2026
b23405f
feat(amicode): skill chips expand to show the injected instructions
aarontrowbridge Jul 11, 2026
e20c794
feat(amicode): declutter the System entity card — signal over noise
aarontrowbridge Jul 11, 2026
83bb3fe
feat(amicode): default-model control on the home dashboard
aarontrowbridge Jul 12, 2026
0923b68
feat(amicode): Claude-Code-esque thinking indicator on streaming turns
aarontrowbridge Jul 12, 2026
1389c9e
fix(amicode): dashboard model control hidden by over-strict visibilit…
aarontrowbridge Jul 12, 2026
23ef70c
feat(amicode): live token count in the thinking line
aarontrowbridge Jul 12, 2026
1a62b41
feat(amicode): humanTail filter for AMICODE_* machine lines
aarontrowbridge Jul 12, 2026
f0608c7
feat(amicode): widen run-series raw tail to 24 lines for client-side …
aarontrowbridge Jul 12, 2026
6a3e591
feat(amicode): extract shared run-plot geometry with pulse-first hero…
aarontrowbridge Jul 12, 2026
bd723d1
feat(amicode): pulse-first run window — pulse hero, F header sparklin…
aarontrowbridge Jul 12, 2026
6d3cf79
feat(amicode): drop self-facing best-F from About You stats and Pulse…
aarontrowbridge Jul 12, 2026
88cea38
feat(amicode): pulse-first Now-solving sparkline (per-drive paths, ob…
aarontrowbridge Jul 12, 2026
343fe36
feat(amicode): share poster reads pulse-first (pulse left, convergenc…
aarontrowbridge Jul 12, 2026
b1e2f12
feat(amicode): toml-lite subset reader for widget manifests
aarontrowbridge Jul 12, 2026
fa8ccb8
feat(amicode): Amico third-actor presence — state machine + lane CSS …
aarontrowbridge Jul 12, 2026
88e96d9
feat(amicode): widget manifest parse + config sanitize
aarontrowbridge Jul 12, 2026
5655d29
feat(amicode): dashboard state with sanitizing merge
aarontrowbridge Jul 12, 2026
59ac1c2
feat(amicode): seven built-in widget sources with executable parse+mo…
aarontrowbridge Jul 12, 2026
fcf202d
feat(amicode): widget registry with fork + content hash
aarontrowbridge Jul 12, 2026
ca5b31d
feat(amicode): widget + dashboard endpoints
aarontrowbridge Jul 12, 2026
7581b9a
feat(amicode): widget schema + allowlist cores
aarontrowbridge Jul 12, 2026
f3b333a
feat(amicode): amc tokens, srcdoc assembly with CSP, in-frame runtime
aarontrowbridge Jul 12, 2026
3545750
feat(amicode): widget bridge host + sandboxed frame with version gate…
aarontrowbridge Jul 12, 2026
a0c5019
feat(amicode): widget grid with inline edit mode + generated config f…
aarontrowbridge Jul 12, 2026
30bd257
feat(amicode): home dashboard renders WidgetGrid (legacy cards as fal…
aarontrowbridge Jul 12, 2026
b91ef7a
fix(amicode): serve widget frames as documents with own CSP — srcdoc …
aarontrowbridge Jul 12, 2026
bff6712
fix(amicode): explicit amc:empty signal breaks the hidden-frame deadl…
aarontrowbridge Jul 12, 2026
8854f25
feat(amicode): T3.6 reserved-key pass-through + manifest size toleran…
aarontrowbridge Jul 13, 2026
b64668d
feat(amicode): durable default-model pin — pin slot in models store, …
aarontrowbridge Jul 13, 2026
582a3fd
feat(amicode): panel-first chrome strip + sessions flyout + responsiv…
aarontrowbridge Jul 13, 2026
3b6d9ed
fix(amicode): redesign widget edit mode — cards stay, grouped corner …
aarontrowbridge Jul 13, 2026
ccc9c30
fix(amicode): collapse empty/missing cells out of the grid, not just …
aarontrowbridge Jul 13, 2026
86c12d7
feat(amicode): wire Amico third-actor presence into the chat flow
aarontrowbridge Jul 13, 2026
515c089
merge(amicode): bring Amico third-actor presence into the widget-kern…
aarontrowbridge Jul 13, 2026
110cb59
feat(amicode): chat widget authoring (Stage 2) — author tool + live i…
aarontrowbridge Jul 13, 2026
a5b3e37
feat(amicode): client-side clipboard bridge — image paste via navigat…
aarontrowbridge Jul 14, 2026
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
51 changes: 50 additions & 1 deletion AMICODE-PATCHES.md

Large diffs are not rendered by default.

Binary file addedpackages/app/public/assets/Aero-Regular.ttf
Binary file not shown.
17 changes: 17 additions & 0 deletions packages/app/src/components/amicode-default-model-value.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
// Pure "providerID/modelID" <-> ModelKey helpers for the dashboard default-model
// control. Split out of amicode-default-model.tsx so they're unit-testable
// without evaluating the SolidJS component (which touches client-only context).

/** "providerID/modelID". modelID may itself contain "/", so callers split on the first. */
export function formatModelValue(key: { providerID: string; modelID: string }): string {
return `${key.providerID}/${key.modelID}`
}

/** Inverse of formatModelValue; null on a malformed/empty value. Splits on the
* FIRST slash so a model id containing slashes (e.g. openrouter/meta-llama/…)
* round-trips. */
export function parseModelValue(value: string): { providerID: string; modelID: string } | null {
const slash = value.indexOf("/")
if (slash <= 0 || slash === value.length - 1) return null
return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) }
}
28 changes: 28 additions & 0 deletions packages/app/src/components/amicode-default-model.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

describe("amicode default-model value round-trip", () => {
test("format then parse is identity, incl. model ids with dots/dashes", () => {
const key = { providerID: "anthropic", modelID: "claude-sonnet-5" }
expect(formatModelValue(key)).toBe("anthropic/claude-sonnet-5")
expect(parseModelValue(formatModelValue(key))).toEqual(key)
expect(parseModelValue("opencode/deepseek-v4-flash-free")).toEqual({
providerID: "opencode",
modelID: "deepseek-v4-flash-free",
})
})

test("splits on the FIRST slash so a model id may itself contain slashes", () => {
expect(parseModelValue("openrouter/meta-llama/llama-3.3")).toEqual({
providerID: "openrouter",
modelID: "meta-llama/llama-3.3",
})
})

test("rejects malformed / empty values (→ null, no selection)", () => {
expect(parseModelValue("")).toBeNull()
expect(parseModelValue("noslash")).toBeNull()
expect(parseModelValue("/leading")).toBeNull()
expect(parseModelValue("trailing/")).toBeNull()
})
})
106 changes: 106 additions & 0 deletions packages/app/src/components/amicode-default-model.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
import { For, Show, createMemo } from "solid-js"
import { useModels } from "@/context/models"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

// Dashboard "Default model" control. Drives opencode's GLOBAL model store
// (useModels — mounted app-wide, persisted via Persist.global), which is what
// new chats inherit as their starting model (see local.tsx recentModel()), so
// a change takes effect immediately with no server restart. It ALSO mirrors the
// choice into the `amicode.defaultModel` VS Code setting over the iframe→parent
// bridge (chat_panel.ts), keeping the headless/first-turn config pin in sync.
// The in-chat picker still overrides per session.

/** Post the chosen model to the extension host so it updates amicode.defaultModel.
* No-op when unframed (plain web/desktop): there's no parent shell to relay it. */
function syncDefaultModelPin(model: string, win: Window = window): void {
if (win.parent === win) return
win.parent.postMessage({ source: "amicode", kind: "set-default-model", model }, "*")
}

export function AmicodeDefaultModel() {
const models = useModels()

const available = createMemo(() => models.list())
// opencode's default visibility hides dated, non-"latest" models — which can
// hide EVERY model and leave the control empty. Prefer the visible set, but
// fall back to all available models so the picker is never blank when models
// exist. (Guarded below on available(), so it only hides with zero models.)
const selectable = createMemo(() => {
const vis = available().filter((m) => models.visible({ providerID: m.provider.id, modelID: m.id }))
return vis.length > 0 ? vis : available()
})

const groups = createMemo(() => {
const byProvider = new Map<string, { provider: string; items: { value: string; name: string }[] }>()
for (const m of selectable()) {
const group = byProvider.get(m.provider.id) ?? { provider: m.provider.name, items: [] }
group.items.push({ value: formatModelValue({ providerID: m.provider.id, modelID: m.id }), name: m.name })
byProvider.set(m.provider.id, group)
}
return [...byProvider.values()]
})

// Bound to the DURABLE pin (models.pin), not the recents head — chats using
// other models must not move the dashboard's declared default. Empty = Auto
// (no pin; new chats fall back to recents/provider default).
const current = createMemo(() => {
const pin = models.pin.get()
return pin ? formatModelValue(pin) : ""
})

const choose = (value: string) => {
if (value === "") {
models.pin.set(undefined) // Auto: clear the pin, mirror the cleared setting
syncDefaultModelPin("")
return
}
const key = parseModelValue(value)
if (!key) return
models.pin.set(key)
syncDefaultModelPin(value)
}

return (
<Show when={available().length > 0}>
<label
data-component="amicode-default-model"
title="Default model for new chats"
style={{
display: "inline-flex",
"align-items": "center",
gap: "8px",
"font-size": "12px",
color: "var(--v2-text-text-muted)",
}}
>
<span data-slot="amicode-default-model-label" style={{ "font-weight": "550" }}>
Model
</span>
<select
data-slot="amicode-default-model-select"
value={current()}
onChange={(event) => choose(event.currentTarget.value)}
style={{
"font-size": "12px",
color: "var(--v2-text-text-base)",
background: "var(--v2-background-bg-layer-01)",
border: "1px solid var(--v2-border-border-base)",
"border-radius": "6px",
padding: "3px 6px",
"max-width": "220px",
cursor: "pointer",
}}
>
<option value="">Auto</option>
<For each={groups()}>
{(group) => (
<optgroup label={group.provider}>
<For each={group.items}>{(item) => <option value={item.value}>{item.name}</option>}</For>
</optgroup>
)}
</For>
</select>
</label>
</Show>
)
}
13 changes: 10 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { inAmicode } from "@/pages/session/use-amicode-commands"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge"
import { readClipboardViaBridge, readClipboardImageViaBridge } from "./prompt-input/clipboard-bridge"
import { normalizePaste } from "./prompt-input/paste"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import {
Expand DownExpand Up@@ -1147,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// browser dispatches no usable paste event on ⌘V (unlike plain web/desktop,
// where onPaste handles it). Intercept the keystroke and read the OS
// clipboard over the extension bridge instead (see clipboard-bridge.ts).
// Image first (screenshots), then text — mirrors handlePaste's precedence.
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
Expand All@@ -1155,9 +1156,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
inAmicode()
) {
event.preventDefault()
void readClipboardViaBridge().then((text) => {
void (async () => {
const image = await readClipboardImageViaBridge()
if (image) {
await addAttachment(image)
return
}
const text = await readClipboardViaBridge()
if (text) addPart({ type: "text", content: normalizePaste(text), start: 0, end: 0 })
})
})()
return
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { readClipboardViaBridge } from "./clipboard-bridge"
import { readClipboardViaBridge, writeClipboardViaBridge } from "./clipboard-bridge"

type Listener = (event: MessageEvent) => void

Expand DownExpand Up@@ -74,3 +74,26 @@ describe("readClipboardViaBridge", () => {
expect(posted).toHaveLength(0)
})
})

describe("writeClipboardViaBridge", () => {
test("pushes the copied text to the host so the OS clipboard matches ⌘V's read", () => {
const bridge = fakeFramedWindow()

expect(writeClipboardViaBridge("solve a CZ gate", bridge.win)).toBe(true)
expect(bridge.posted).toHaveLength(1)
const message = bridge.posted[0]
expect(message.source).toBe("amicode")
expect(message.kind).toBe("clipboard-write")
expect(message.text).toBe("solve a CZ gate")
})

test("is a no-op that posts nothing when the app is not framed", () => {
const posted: unknown[] = []
const win = { postMessage: (message: unknown) => posted.push(message) } as unknown as Window
// parent === self → plain web/desktop, where native copy already works
;(win as unknown as { parent: Window }).parent = win

expect(writeClipboardViaBridge("ignored", win)).toBe(false)
expect(posted).toHaveLength(0)
})
})
65 changes: 65 additions & 0 deletions packages/app/src/components/prompt-input/clipboard-bridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@

const BRIDGE_TIMEOUT_MS = 1500

// ⌘C inside the Amicode chat is the mirror image of ⌘V: the sandboxed iframe's
// native copy (and navigator.clipboard.writeText) never reaches the OS
// clipboard, so a subsequent ⌘V — which DOES read the OS clipboard over the
// bridge — pastes whatever stale thing was there before, not what you copied.
// Push the copied text to the extension host, which writes vscode.env.clipboard
// so read and write hit the same clipboard. Fire-and-forget: the host write is
// near-instant and callers have nothing to await. Resolves false (no-op) when
// unframed — native copy already works there.
export function writeClipboardViaBridge(text: string, win: Window = window): boolean {
if (win.parent === win) return false
win.parent.postMessage({ source: "amicode", kind: "clipboard-write", text }, "*")
return true
}

export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<string> {
return new Promise<string>((resolve) => {
// Unframed: native paste works — don't post into the void or wait out the timeout.
Expand DownExpand Up@@ -40,3 +54,54 @@ export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_
timer = setTimeout(() => finish(""), timeoutMs)
})
}

// ⌘V image paste inside the Amicode chat. Unlike text (which the extension host
// reads via vscode.env.clipboard — text-only, no image API), an image can only
// come from the OUTER webview reading navigator.clipboard.read() client-side.
// So we ask the parent relay (chat_panel.ts) rather than the host: it grabs the
// first image/* clipboard item, encodes it as a data URL, and replies. We
// rebuild a File from the reply so callers reuse the normal attachment path.
// Resolves null when unframed, on a text-only clipboard, or after `timeoutMs`.
export function readClipboardImageViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<File | null> {
return new Promise<File | null>((resolve) => {
if (win.parent === win) {
resolve(null)
return
}

const nonce = Math.random().toString(36).slice(2)
let timer: ReturnType<typeof setTimeout> | undefined

const finish = (file: File | null) => {
win.removeEventListener("message", onMessage)
if (timer !== undefined) clearTimeout(timer)
resolve(file)
}

const onMessage = (event: MessageEvent) => {
const data = event.data as
| { source?: string; kind?: string; nonce?: string; dataUrl?: string; mime?: string; filename?: string }
| undefined
if (data?.source !== "amicode" || data.kind !== "clipboard-image" || data.nonce !== nonce) return
finish(fileFromDataUrl(data.dataUrl, data.mime, data.filename))
}

win.addEventListener("message", onMessage)
win.parent.postMessage({ source: "amicode", kind: "clipboard-image-request", nonce }, "*")
timer = setTimeout(() => finish(null), timeoutMs)
})
}

function fileFromDataUrl(dataUrl?: string, mime?: string, filename?: string): File | null {
if (!dataUrl || !mime) return null
const comma = dataUrl.indexOf(",")
if (comma === -1) return null
try {
const binary = atob(dataUrl.slice(comma + 1))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new File([bytes], filename || "pasted-image.png", { type: mime })
} catch {
return null
}
}
53 changes: 43 additions & 10 deletions packages/app/src/components/titlebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
import { Mark } from "@opencode-ai/ui/logo"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner"

type TauriDesktopWindow = {
Expand DownExpand Up@@ -489,6 +490,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
onCloseOthers={() => tabsStoreActions.closeOthers(i())}
onCloseLeft={() => tabsStoreActions.closeToLeft(i())}
onCloseRight={() => tabsStoreActions.closeToRight(i())}
canCloseOthers={tabsStore.length > 1}
canCloseLeft={i() > 0}
canCloseRight={i() < tabsStore.length - 1}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
Expand DownExpand Up@@ -765,11 +772,18 @@ function TabNavItem(props: {
sessionId?: string
hideClose?: boolean
onClose: () => void
onCloseOthers: () => void
onCloseLeft: () => void
onCloseRight: () => void
canCloseOthers: boolean
canCloseLeft: boolean
canCloseRight: boolean
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
Expand All@@ -796,15 +810,17 @@ function TabNavItem(props: {
)

return (
<div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<ContextMenu modal={false}>
<ContextMenu.Trigger
as="div"
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event: MouseEvent) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={session.latest}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
Expand DownExpand Up@@ -851,7 +867,24 @@ function TabNavItem(props: {
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.onClose()}>
<ContextMenu.ItemLabel>{language.t("common.closeTab")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseOthers} onSelect={() => props.onCloseOthers()}>
<ContextMenu.ItemLabel>{language.t("common.closeOtherTabs")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseLeft} onSelect={() => props.onCloseLeft()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToLeft")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseRight} onSelect={() => props.onCloseRight()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToRight")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
a4fd244
amicode: re-add Aero wordmark typeface (LICENSE-PENDING)
aarontrowbridge Jul 8, 2026
c811798
amicode: wire composer paste to the extension clipboard bridge
aarontrowbridge Jul 8, 2026
b868a59
amicode: generalize the paste-clipboard bridge past the composer
aarontrowbridge Jul 8, 2026
c52cffa
feat(amicode-ui): pure frontier DAG scheduler + tests
aarontrowbridge Jul 9, 2026
81066f8
feat(amicode-ui): composite-aware problem.ts display derivations
aarontrowbridge Jul 9, 2026
9810040
merge origin/local/amicode into local/amicode
aarontrowbridge Jul 9, 2026
baf6b9e
merge local/amicode into local/composite-system
aarontrowbridge Jul 9, 2026
3a85038
feat(amicode-ui): compactValue kills the JSON-dump receipt chokepoint
aarontrowbridge Jul 9, 2026
8ebebe7
feat(amicode-ui): setDiff — unique-keyed set delta (spec §4 / plan Ta…
aarontrowbridge Jul 9, 2026
e110d11
feat(amicode-ui): modeBadges — mode→badge model (spec §4 / plan Task 3)
aarontrowbridge Jul 9, 2026
dc47bdf
feat(amicode-ui): system schematic + table models (spec §6.1 / plan T…
aarontrowbridge Jul 9, 2026
a344e06
feat(amicode-ui): SystemComposite hero (schematic+table+Hamiltonian)
aarontrowbridge Jul 9, 2026
3cd5d1a
feat(amicode-ui): system entity-view + receipt use the composite hero
aarontrowbridge Jul 9, 2026
3d5d0de
feat(amicode-ui): formulationProjection (dual-shape, derived primaryK…
aarontrowbridge Jul 9, 2026
b94000a
fix(amicode-ui): formulation chip reads structured facets, not phanto…
aarontrowbridge Jul 9, 2026
cccb286
feat(amicode-ui): FormulationView hero (mode header + objective LaTeX…
aarontrowbridge Jul 9, 2026
d50d337
feat(amicode-ui): formulation entity-view + receipt (spec §5,§6.2 / T…
aarontrowbridge Jul 9, 2026
c0ea330
fix(amicode-ui): entity-view dialog overflow — wrap long values, shri…
aarontrowbridge Jul 10, 2026
a64da04
feat(amicode-ui): composite Hamiltonian LaTeX + compact timestamps + …
aarontrowbridge Jul 10, 2026
3509606
fix(amicode): entity-view dialog wrapper contains both axes (stop pan…
aarontrowbridge Jul 10, 2026
a4fc920
fix(amicode): entity dialog — definite width + overflow-x clip (stop …
aarontrowbridge Jul 10, 2026
e485430
merge: sync origin/local/amicode (#13,#16,#17,#18) into composite-system
aarontrowbridge Jul 10, 2026
1b46833
style(brand): Aero wordmark on logo.tsx (Aaron's call, this branch)
aarontrowbridge Jul 10, 2026
b81fb75
feat(prompt): Claude Code-style rich chat output for Claude models (p…
aarontrowbridge Jul 10, 2026
a86e690
docs(patches): stamp #18 final provenance — clean build at b81fb756f2…
aarontrowbridge Jul 10, 2026
d073fb6
feat(prompt): port rich-output style to default.txt + gemini.txt (pat…
aarontrowbridge Jul 10, 2026
1532788
refactor(prompt): model-agnostic communicating.txt appended for every…
aarontrowbridge Jul 10, 2026
c42043e
fix(amicode): rydberg Hamiltonian in System card + communicating.txt …
aarontrowbridge Jul 11, 2026
431b29a
feat(amicode): right-click tab management on the session tab strip
aarontrowbridge Jul 11, 2026
f78155a
fix(ui): markdown heading hierarchy — h1-h3 larger + 600 weight, spac…
aarontrowbridge Jul 11, 2026
37e304c
feat(amicode): add "Close tabs to the left" to the tab context menu
aarontrowbridge Jul 11, 2026
2ff5767
fix(amicode): register quantum-math KaTeX macros so chat \Tr etc. render
aarontrowbridge Jul 11, 2026
54c6559
fix(amicode): render mid-line-opened multi-line $$ display math in chat
aarontrowbridge Jul 11, 2026
236e647
feat(amicode): single-select question options commit on click
aarontrowbridge Jul 11, 2026
b23405f
feat(amicode): skill chips expand to show the injected instructions
aarontrowbridge Jul 11, 2026
e20c794
feat(amicode): declutter the System entity card — signal over noise
aarontrowbridge Jul 11, 2026
83bb3fe
feat(amicode): default-model control on the home dashboard
aarontrowbridge Jul 12, 2026
0923b68
feat(amicode): Claude-Code-esque thinking indicator on streaming turns
aarontrowbridge Jul 12, 2026
1389c9e
fix(amicode): dashboard model control hidden by over-strict visibilit…
aarontrowbridge Jul 12, 2026
23ef70c
feat(amicode): live token count in the thinking line
aarontrowbridge Jul 12, 2026
1a62b41
feat(amicode): humanTail filter for AMICODE_* machine lines
aarontrowbridge Jul 12, 2026
f0608c7
feat(amicode): widen run-series raw tail to 24 lines for client-side …
aarontrowbridge Jul 12, 2026
6a3e591
feat(amicode): extract shared run-plot geometry with pulse-first hero…
aarontrowbridge Jul 12, 2026
bd723d1
feat(amicode): pulse-first run window — pulse hero, F header sparklin…
aarontrowbridge Jul 12, 2026
6d3cf79
feat(amicode): drop self-facing best-F from About You stats and Pulse…
aarontrowbridge Jul 12, 2026
88cea38
feat(amicode): pulse-first Now-solving sparkline (per-drive paths, ob…
aarontrowbridge Jul 12, 2026
343fe36
feat(amicode): share poster reads pulse-first (pulse left, convergenc…
aarontrowbridge Jul 12, 2026
b1e2f12
feat(amicode): toml-lite subset reader for widget manifests
aarontrowbridge Jul 12, 2026
fa8ccb8
feat(amicode): Amico third-actor presence — state machine + lane CSS …
aarontrowbridge Jul 12, 2026
88e96d9
feat(amicode): widget manifest parse + config sanitize
aarontrowbridge Jul 12, 2026
5655d29
feat(amicode): dashboard state with sanitizing merge
aarontrowbridge Jul 12, 2026
59ac1c2
feat(amicode): seven built-in widget sources with executable parse+mo…
aarontrowbridge Jul 12, 2026
fcf202d
feat(amicode): widget registry with fork + content hash
aarontrowbridge Jul 12, 2026
ca5b31d
feat(amicode): widget + dashboard endpoints
aarontrowbridge Jul 12, 2026
7581b9a
feat(amicode): widget schema + allowlist cores
aarontrowbridge Jul 12, 2026
f3b333a
feat(amicode): amc tokens, srcdoc assembly with CSP, in-frame runtime
aarontrowbridge Jul 12, 2026
3545750
feat(amicode): widget bridge host + sandboxed frame with version gate…
aarontrowbridge Jul 12, 2026
a0c5019
feat(amicode): widget grid with inline edit mode + generated config f…
aarontrowbridge Jul 12, 2026
30bd257
feat(amicode): home dashboard renders WidgetGrid (legacy cards as fal…
aarontrowbridge Jul 12, 2026
b91ef7a
fix(amicode): serve widget frames as documents with own CSP — srcdoc …
aarontrowbridge Jul 12, 2026
bff6712
fix(amicode): explicit amc:empty signal breaks the hidden-frame deadl…
aarontrowbridge Jul 12, 2026
8854f25
feat(amicode): T3.6 reserved-key pass-through + manifest size toleran…
aarontrowbridge Jul 13, 2026
b64668d
feat(amicode): durable default-model pin — pin slot in models store, …
aarontrowbridge Jul 13, 2026
582a3fd
feat(amicode): panel-first chrome strip + sessions flyout + responsiv…
aarontrowbridge Jul 13, 2026
3b6d9ed
fix(amicode): redesign widget edit mode — cards stay, grouped corner …
aarontrowbridge Jul 13, 2026
ccc9c30
fix(amicode): collapse empty/missing cells out of the grid, not just …
aarontrowbridge Jul 13, 2026
86c12d7
feat(amicode): wire Amico third-actor presence into the chat flow
aarontrowbridge Jul 13, 2026
515c089
merge(amicode): bring Amico third-actor presence into the widget-kern…
aarontrowbridge Jul 13, 2026
110cb59
feat(amicode): chat widget authoring (Stage 2) — author tool + live i…
aarontrowbridge Jul 13, 2026
a5b3e37
feat(amicode): client-side clipboard bridge — image paste via navigat…
aarontrowbridge Jul 14, 2026
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
51 changes: 50 additions & 1 deletion AMICODE-PATCHES.md

Large diffs are not rendered by default.

Binary file addedpackages/app/public/assets/Aero-Regular.ttf
Binary file not shown.
17 changes: 17 additions & 0 deletions packages/app/src/components/amicode-default-model-value.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
// Pure "providerID/modelID" <-> ModelKey helpers for the dashboard default-model
// control. Split out of amicode-default-model.tsx so they're unit-testable
// without evaluating the SolidJS component (which touches client-only context).

/** "providerID/modelID". modelID may itself contain "/", so callers split on the first. */
export function formatModelValue(key: { providerID: string; modelID: string }): string {
return `${key.providerID}/${key.modelID}`
}

/** Inverse of formatModelValue; null on a malformed/empty value. Splits on the
* FIRST slash so a model id containing slashes (e.g. openrouter/meta-llama/…)
* round-trips. */
export function parseModelValue(value: string): { providerID: string; modelID: string } | null {
const slash = value.indexOf("/")
if (slash <= 0 || slash === value.length - 1) return null
return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) }
}
28 changes: 28 additions & 0 deletions packages/app/src/components/amicode-default-model.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

describe("amicode default-model value round-trip", () => {
test("format then parse is identity, incl. model ids with dots/dashes", () => {
const key = { providerID: "anthropic", modelID: "claude-sonnet-5" }
expect(formatModelValue(key)).toBe("anthropic/claude-sonnet-5")
expect(parseModelValue(formatModelValue(key))).toEqual(key)
expect(parseModelValue("opencode/deepseek-v4-flash-free")).toEqual({
providerID: "opencode",
modelID: "deepseek-v4-flash-free",
})
})

test("splits on the FIRST slash so a model id may itself contain slashes", () => {
expect(parseModelValue("openrouter/meta-llama/llama-3.3")).toEqual({
providerID: "openrouter",
modelID: "meta-llama/llama-3.3",
})
})

test("rejects malformed / empty values (→ null, no selection)", () => {
expect(parseModelValue("")).toBeNull()
expect(parseModelValue("noslash")).toBeNull()
expect(parseModelValue("/leading")).toBeNull()
expect(parseModelValue("trailing/")).toBeNull()
})
})
106 changes: 106 additions & 0 deletions packages/app/src/components/amicode-default-model.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
import { For, Show, createMemo } from "solid-js"
import { useModels } from "@/context/models"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

// Dashboard "Default model" control. Drives opencode's GLOBAL model store
// (useModels — mounted app-wide, persisted via Persist.global), which is what
// new chats inherit as their starting model (see local.tsx recentModel()), so
// a change takes effect immediately with no server restart. It ALSO mirrors the
// choice into the `amicode.defaultModel` VS Code setting over the iframe→parent
// bridge (chat_panel.ts), keeping the headless/first-turn config pin in sync.
// The in-chat picker still overrides per session.

/** Post the chosen model to the extension host so it updates amicode.defaultModel.
* No-op when unframed (plain web/desktop): there's no parent shell to relay it. */
function syncDefaultModelPin(model: string, win: Window = window): void {
if (win.parent === win) return
win.parent.postMessage({ source: "amicode", kind: "set-default-model", model }, "*")
}

export function AmicodeDefaultModel() {
const models = useModels()

const available = createMemo(() => models.list())
// opencode's default visibility hides dated, non-"latest" models — which can
// hide EVERY model and leave the control empty. Prefer the visible set, but
// fall back to all available models so the picker is never blank when models
// exist. (Guarded below on available(), so it only hides with zero models.)
const selectable = createMemo(() => {
const vis = available().filter((m) => models.visible({ providerID: m.provider.id, modelID: m.id }))
return vis.length > 0 ? vis : available()
})

const groups = createMemo(() => {
const byProvider = new Map<string, { provider: string; items: { value: string; name: string }[] }>()
for (const m of selectable()) {
const group = byProvider.get(m.provider.id) ?? { provider: m.provider.name, items: [] }
group.items.push({ value: formatModelValue({ providerID: m.provider.id, modelID: m.id }), name: m.name })
byProvider.set(m.provider.id, group)
}
return [...byProvider.values()]
})

// Bound to the DURABLE pin (models.pin), not the recents head — chats using
// other models must not move the dashboard's declared default. Empty = Auto
// (no pin; new chats fall back to recents/provider default).
const current = createMemo(() => {
const pin = models.pin.get()
return pin ? formatModelValue(pin) : ""
})

const choose = (value: string) => {
if (value === "") {
models.pin.set(undefined) // Auto: clear the pin, mirror the cleared setting
syncDefaultModelPin("")
return
}
const key = parseModelValue(value)
if (!key) return
models.pin.set(key)
syncDefaultModelPin(value)
}

return (
<Show when={available().length > 0}>
<label
data-component="amicode-default-model"
title="Default model for new chats"
style={{
display: "inline-flex",
"align-items": "center",
gap: "8px",
"font-size": "12px",
color: "var(--v2-text-text-muted)",
}}
>
<span data-slot="amicode-default-model-label" style={{ "font-weight": "550" }}>
Model
</span>
<select
data-slot="amicode-default-model-select"
value={current()}
onChange={(event) => choose(event.currentTarget.value)}
style={{
"font-size": "12px",
color: "var(--v2-text-text-base)",
background: "var(--v2-background-bg-layer-01)",
border: "1px solid var(--v2-border-border-base)",
"border-radius": "6px",
padding: "3px 6px",
"max-width": "220px",
cursor: "pointer",
}}
>
<option value="">Auto</option>
<For each={groups()}>
{(group) => (
<optgroup label={group.provider}>
<For each={group.items}>{(item) => <option value={item.value}>{item.name}</option>}</For>
</optgroup>
)}
</For>
</select>
</label>
</Show>
)
}
13 changes: 10 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { inAmicode } from "@/pages/session/use-amicode-commands"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge"
import { readClipboardViaBridge, readClipboardImageViaBridge } from "./prompt-input/clipboard-bridge"
import { normalizePaste } from "./prompt-input/paste"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import {
Expand DownExpand Up@@ -1147,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// browser dispatches no usable paste event on ⌘V (unlike plain web/desktop,
// where onPaste handles it). Intercept the keystroke and read the OS
// clipboard over the extension bridge instead (see clipboard-bridge.ts).
// Image first (screenshots), then text — mirrors handlePaste's precedence.
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
Expand All@@ -1155,9 +1156,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
inAmicode()
) {
event.preventDefault()
void readClipboardViaBridge().then((text) => {
void (async () => {
const image = await readClipboardImageViaBridge()
if (image) {
await addAttachment(image)
return
}
const text = await readClipboardViaBridge()
if (text) addPart({ type: "text", content: normalizePaste(text), start: 0, end: 0 })
})
})()
return
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { readClipboardViaBridge } from "./clipboard-bridge"
import { readClipboardViaBridge, writeClipboardViaBridge } from "./clipboard-bridge"

type Listener = (event: MessageEvent) => void

Expand DownExpand Up@@ -74,3 +74,26 @@ describe("readClipboardViaBridge", () => {
expect(posted).toHaveLength(0)
})
})

describe("writeClipboardViaBridge", () => {
test("pushes the copied text to the host so the OS clipboard matches ⌘V's read", () => {
const bridge = fakeFramedWindow()

expect(writeClipboardViaBridge("solve a CZ gate", bridge.win)).toBe(true)
expect(bridge.posted).toHaveLength(1)
const message = bridge.posted[0]
expect(message.source).toBe("amicode")
expect(message.kind).toBe("clipboard-write")
expect(message.text).toBe("solve a CZ gate")
})

test("is a no-op that posts nothing when the app is not framed", () => {
const posted: unknown[] = []
const win = { postMessage: (message: unknown) => posted.push(message) } as unknown as Window
// parent === self → plain web/desktop, where native copy already works
;(win as unknown as { parent: Window }).parent = win

expect(writeClipboardViaBridge("ignored", win)).toBe(false)
expect(posted).toHaveLength(0)
})
})
65 changes: 65 additions & 0 deletions packages/app/src/components/prompt-input/clipboard-bridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@

const BRIDGE_TIMEOUT_MS = 1500

// ⌘C inside the Amicode chat is the mirror image of ⌘V: the sandboxed iframe's
// native copy (and navigator.clipboard.writeText) never reaches the OS
// clipboard, so a subsequent ⌘V — which DOES read the OS clipboard over the
// bridge — pastes whatever stale thing was there before, not what you copied.
// Push the copied text to the extension host, which writes vscode.env.clipboard
// so read and write hit the same clipboard. Fire-and-forget: the host write is
// near-instant and callers have nothing to await. Resolves false (no-op) when
// unframed — native copy already works there.
export function writeClipboardViaBridge(text: string, win: Window = window): boolean {
if (win.parent === win) return false
win.parent.postMessage({ source: "amicode", kind: "clipboard-write", text }, "*")
return true
}

export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<string> {
return new Promise<string>((resolve) => {
// Unframed: native paste works — don't post into the void or wait out the timeout.
Expand DownExpand Up@@ -40,3 +54,54 @@ export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_
timer = setTimeout(() => finish(""), timeoutMs)
})
}

// ⌘V image paste inside the Amicode chat. Unlike text (which the extension host
// reads via vscode.env.clipboard — text-only, no image API), an image can only
// come from the OUTER webview reading navigator.clipboard.read() client-side.
// So we ask the parent relay (chat_panel.ts) rather than the host: it grabs the
// first image/* clipboard item, encodes it as a data URL, and replies. We
// rebuild a File from the reply so callers reuse the normal attachment path.
// Resolves null when unframed, on a text-only clipboard, or after `timeoutMs`.
export function readClipboardImageViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<File | null> {
return new Promise<File | null>((resolve) => {
if (win.parent === win) {
resolve(null)
return
}

const nonce = Math.random().toString(36).slice(2)
let timer: ReturnType<typeof setTimeout> | undefined

const finish = (file: File | null) => {
win.removeEventListener("message", onMessage)
if (timer !== undefined) clearTimeout(timer)
resolve(file)
}

const onMessage = (event: MessageEvent) => {
const data = event.data as
| { source?: string; kind?: string; nonce?: string; dataUrl?: string; mime?: string; filename?: string }
| undefined
if (data?.source !== "amicode" || data.kind !== "clipboard-image" || data.nonce !== nonce) return
finish(fileFromDataUrl(data.dataUrl, data.mime, data.filename))
}

win.addEventListener("message", onMessage)
win.parent.postMessage({ source: "amicode", kind: "clipboard-image-request", nonce }, "*")
timer = setTimeout(() => finish(null), timeoutMs)
})
}

function fileFromDataUrl(dataUrl?: string, mime?: string, filename?: string): File | null {
if (!dataUrl || !mime) return null
const comma = dataUrl.indexOf(",")
if (comma === -1) return null
try {
const binary = atob(dataUrl.slice(comma + 1))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new File([bytes], filename || "pasted-image.png", { type: mime })
} catch {
return null
}
}
53 changes: 43 additions & 10 deletions packages/app/src/components/titlebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
import { Mark } from "@opencode-ai/ui/logo"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner"

type TauriDesktopWindow = {
Expand DownExpand Up@@ -489,6 +490,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
onCloseOthers={() => tabsStoreActions.closeOthers(i())}
onCloseLeft={() => tabsStoreActions.closeToLeft(i())}
onCloseRight={() => tabsStoreActions.closeToRight(i())}
canCloseOthers={tabsStore.length > 1}
canCloseLeft={i() > 0}
canCloseRight={i() < tabsStore.length - 1}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
Expand DownExpand Up@@ -765,11 +772,18 @@ function TabNavItem(props: {
sessionId?: string
hideClose?: boolean
onClose: () => void
onCloseOthers: () => void
onCloseLeft: () => void
onCloseRight: () => void
canCloseOthers: boolean
canCloseLeft: boolean
canCloseRight: boolean
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
Expand All@@ -796,15 +810,17 @@ function TabNavItem(props: {
)

return (
<div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<ContextMenu modal={false}>
<ContextMenu.Trigger
as="div"
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event: MouseEvent) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={session.latest}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
Expand DownExpand Up@@ -851,7 +867,24 @@ function TabNavItem(props: {
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.onClose()}>
<ContextMenu.ItemLabel>{language.t("common.closeTab")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseOthers} onSelect={() => props.onCloseOthers()}>
<ContextMenu.ItemLabel>{language.t("common.closeOtherTabs")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseLeft} onSelect={() => props.onCloseLeft()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToLeft")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseRight} onSelect={() => props.onCloseRight()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToRight")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
a4fd244
amicode: re-add Aero wordmark typeface (LICENSE-PENDING)
aarontrowbridge Jul 8, 2026
c811798
amicode: wire composer paste to the extension clipboard bridge
aarontrowbridge Jul 8, 2026
b868a59
amicode: generalize the paste-clipboard bridge past the composer
aarontrowbridge Jul 8, 2026
c52cffa
feat(amicode-ui): pure frontier DAG scheduler + tests
aarontrowbridge Jul 9, 2026
81066f8
feat(amicode-ui): composite-aware problem.ts display derivations
aarontrowbridge Jul 9, 2026
9810040
merge origin/local/amicode into local/amicode
aarontrowbridge Jul 9, 2026
baf6b9e
merge local/amicode into local/composite-system
aarontrowbridge Jul 9, 2026
3a85038
feat(amicode-ui): compactValue kills the JSON-dump receipt chokepoint
aarontrowbridge Jul 9, 2026
8ebebe7
feat(amicode-ui): setDiff — unique-keyed set delta (spec §4 / plan Ta…
aarontrowbridge Jul 9, 2026
e110d11
feat(amicode-ui): modeBadges — mode→badge model (spec §4 / plan Task 3)
aarontrowbridge Jul 9, 2026
dc47bdf
feat(amicode-ui): system schematic + table models (spec §6.1 / plan T…
aarontrowbridge Jul 9, 2026
a344e06
feat(amicode-ui): SystemComposite hero (schematic+table+Hamiltonian)
aarontrowbridge Jul 9, 2026
3cd5d1a
feat(amicode-ui): system entity-view + receipt use the composite hero
aarontrowbridge Jul 9, 2026
3d5d0de
feat(amicode-ui): formulationProjection (dual-shape, derived primaryK…
aarontrowbridge Jul 9, 2026
b94000a
fix(amicode-ui): formulation chip reads structured facets, not phanto…
aarontrowbridge Jul 9, 2026
cccb286
feat(amicode-ui): FormulationView hero (mode header + objective LaTeX…
aarontrowbridge Jul 9, 2026
d50d337
feat(amicode-ui): formulation entity-view + receipt (spec §5,§6.2 / T…
aarontrowbridge Jul 9, 2026
c0ea330
fix(amicode-ui): entity-view dialog overflow — wrap long values, shri…
aarontrowbridge Jul 10, 2026
a64da04
feat(amicode-ui): composite Hamiltonian LaTeX + compact timestamps + …
aarontrowbridge Jul 10, 2026
3509606
fix(amicode): entity-view dialog wrapper contains both axes (stop pan…
aarontrowbridge Jul 10, 2026
a4fc920
fix(amicode): entity dialog — definite width + overflow-x clip (stop …
aarontrowbridge Jul 10, 2026
e485430
merge: sync origin/local/amicode (#13,#16,#17,#18) into composite-system
aarontrowbridge Jul 10, 2026
1b46833
style(brand): Aero wordmark on logo.tsx (Aaron's call, this branch)
aarontrowbridge Jul 10, 2026
b81fb75
feat(prompt): Claude Code-style rich chat output for Claude models (p…
aarontrowbridge Jul 10, 2026
a86e690
docs(patches): stamp #18 final provenance — clean build at b81fb756f2…
aarontrowbridge Jul 10, 2026
d073fb6
feat(prompt): port rich-output style to default.txt + gemini.txt (pat…
aarontrowbridge Jul 10, 2026
1532788
refactor(prompt): model-agnostic communicating.txt appended for every…
aarontrowbridge Jul 10, 2026
c42043e
fix(amicode): rydberg Hamiltonian in System card + communicating.txt …
aarontrowbridge Jul 11, 2026
431b29a
feat(amicode): right-click tab management on the session tab strip
aarontrowbridge Jul 11, 2026
f78155a
fix(ui): markdown heading hierarchy — h1-h3 larger + 600 weight, spac…
aarontrowbridge Jul 11, 2026
37e304c
feat(amicode): add "Close tabs to the left" to the tab context menu
aarontrowbridge Jul 11, 2026
2ff5767
fix(amicode): register quantum-math KaTeX macros so chat \Tr etc. render
aarontrowbridge Jul 11, 2026
54c6559
fix(amicode): render mid-line-opened multi-line $$ display math in chat
aarontrowbridge Jul 11, 2026
236e647
feat(amicode): single-select question options commit on click
aarontrowbridge Jul 11, 2026
b23405f
feat(amicode): skill chips expand to show the injected instructions
aarontrowbridge Jul 11, 2026
e20c794
feat(amicode): declutter the System entity card — signal over noise
aarontrowbridge Jul 11, 2026
83bb3fe
feat(amicode): default-model control on the home dashboard
aarontrowbridge Jul 12, 2026
0923b68
feat(amicode): Claude-Code-esque thinking indicator on streaming turns
aarontrowbridge Jul 12, 2026
1389c9e
fix(amicode): dashboard model control hidden by over-strict visibilit…
aarontrowbridge Jul 12, 2026
23ef70c
feat(amicode): live token count in the thinking line
aarontrowbridge Jul 12, 2026
1a62b41
feat(amicode): humanTail filter for AMICODE_* machine lines
aarontrowbridge Jul 12, 2026
f0608c7
feat(amicode): widen run-series raw tail to 24 lines for client-side …
aarontrowbridge Jul 12, 2026
6a3e591
feat(amicode): extract shared run-plot geometry with pulse-first hero…
aarontrowbridge Jul 12, 2026
bd723d1
feat(amicode): pulse-first run window — pulse hero, F header sparklin…
aarontrowbridge Jul 12, 2026
6d3cf79
feat(amicode): drop self-facing best-F from About You stats and Pulse…
aarontrowbridge Jul 12, 2026
88cea38
feat(amicode): pulse-first Now-solving sparkline (per-drive paths, ob…
aarontrowbridge Jul 12, 2026
343fe36
feat(amicode): share poster reads pulse-first (pulse left, convergenc…
aarontrowbridge Jul 12, 2026
b1e2f12
feat(amicode): toml-lite subset reader for widget manifests
aarontrowbridge Jul 12, 2026
fa8ccb8
feat(amicode): Amico third-actor presence — state machine + lane CSS …
aarontrowbridge Jul 12, 2026
88e96d9
feat(amicode): widget manifest parse + config sanitize
aarontrowbridge Jul 12, 2026
5655d29
feat(amicode): dashboard state with sanitizing merge
aarontrowbridge Jul 12, 2026
59ac1c2
feat(amicode): seven built-in widget sources with executable parse+mo…
aarontrowbridge Jul 12, 2026
fcf202d
feat(amicode): widget registry with fork + content hash
aarontrowbridge Jul 12, 2026
ca5b31d
feat(amicode): widget + dashboard endpoints
aarontrowbridge Jul 12, 2026
7581b9a
feat(amicode): widget schema + allowlist cores
aarontrowbridge Jul 12, 2026
f3b333a
feat(amicode): amc tokens, srcdoc assembly with CSP, in-frame runtime
aarontrowbridge Jul 12, 2026
3545750
feat(amicode): widget bridge host + sandboxed frame with version gate…
aarontrowbridge Jul 12, 2026
a0c5019
feat(amicode): widget grid with inline edit mode + generated config f…
aarontrowbridge Jul 12, 2026
30bd257
feat(amicode): home dashboard renders WidgetGrid (legacy cards as fal…
aarontrowbridge Jul 12, 2026
b91ef7a
fix(amicode): serve widget frames as documents with own CSP — srcdoc …
aarontrowbridge Jul 12, 2026
bff6712
fix(amicode): explicit amc:empty signal breaks the hidden-frame deadl…
aarontrowbridge Jul 12, 2026
8854f25
feat(amicode): T3.6 reserved-key pass-through + manifest size toleran…
aarontrowbridge Jul 13, 2026
b64668d
feat(amicode): durable default-model pin — pin slot in models store, …
aarontrowbridge Jul 13, 2026
582a3fd
feat(amicode): panel-first chrome strip + sessions flyout + responsiv…
aarontrowbridge Jul 13, 2026
3b6d9ed
fix(amicode): redesign widget edit mode — cards stay, grouped corner …
aarontrowbridge Jul 13, 2026
ccc9c30
fix(amicode): collapse empty/missing cells out of the grid, not just …
aarontrowbridge Jul 13, 2026
86c12d7
feat(amicode): wire Amico third-actor presence into the chat flow
aarontrowbridge Jul 13, 2026
515c089
merge(amicode): bring Amico third-actor presence into the widget-kern…
aarontrowbridge Jul 13, 2026
110cb59
feat(amicode): chat widget authoring (Stage 2) — author tool + live i…
aarontrowbridge Jul 13, 2026
a5b3e37
feat(amicode): client-side clipboard bridge — image paste via navigat…
aarontrowbridge Jul 14, 2026
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
51 changes: 50 additions & 1 deletion AMICODE-PATCHES.md

Large diffs are not rendered by default.

Binary file addedpackages/app/public/assets/Aero-Regular.ttf
Binary file not shown.
17 changes: 17 additions & 0 deletions packages/app/src/components/amicode-default-model-value.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
// Pure "providerID/modelID" <-> ModelKey helpers for the dashboard default-model
// control. Split out of amicode-default-model.tsx so they're unit-testable
// without evaluating the SolidJS component (which touches client-only context).

/** "providerID/modelID". modelID may itself contain "/", so callers split on the first. */
export function formatModelValue(key: { providerID: string; modelID: string }): string {
return `${key.providerID}/${key.modelID}`
}

/** Inverse of formatModelValue; null on a malformed/empty value. Splits on the
* FIRST slash so a model id containing slashes (e.g. openrouter/meta-llama/…)
* round-trips. */
export function parseModelValue(value: string): { providerID: string; modelID: string } | null {
const slash = value.indexOf("/")
if (slash <= 0 || slash === value.length - 1) return null
return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) }
}
28 changes: 28 additions & 0 deletions packages/app/src/components/amicode-default-model.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

describe("amicode default-model value round-trip", () => {
test("format then parse is identity, incl. model ids with dots/dashes", () => {
const key = { providerID: "anthropic", modelID: "claude-sonnet-5" }
expect(formatModelValue(key)).toBe("anthropic/claude-sonnet-5")
expect(parseModelValue(formatModelValue(key))).toEqual(key)
expect(parseModelValue("opencode/deepseek-v4-flash-free")).toEqual({
providerID: "opencode",
modelID: "deepseek-v4-flash-free",
})
})

test("splits on the FIRST slash so a model id may itself contain slashes", () => {
expect(parseModelValue("openrouter/meta-llama/llama-3.3")).toEqual({
providerID: "openrouter",
modelID: "meta-llama/llama-3.3",
})
})

test("rejects malformed / empty values (→ null, no selection)", () => {
expect(parseModelValue("")).toBeNull()
expect(parseModelValue("noslash")).toBeNull()
expect(parseModelValue("/leading")).toBeNull()
expect(parseModelValue("trailing/")).toBeNull()
})
})
106 changes: 106 additions & 0 deletions packages/app/src/components/amicode-default-model.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
import { For, Show, createMemo } from "solid-js"
import { useModels } from "@/context/models"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

// Dashboard "Default model" control. Drives opencode's GLOBAL model store
// (useModels — mounted app-wide, persisted via Persist.global), which is what
// new chats inherit as their starting model (see local.tsx recentModel()), so
// a change takes effect immediately with no server restart. It ALSO mirrors the
// choice into the `amicode.defaultModel` VS Code setting over the iframe→parent
// bridge (chat_panel.ts), keeping the headless/first-turn config pin in sync.
// The in-chat picker still overrides per session.

/** Post the chosen model to the extension host so it updates amicode.defaultModel.
* No-op when unframed (plain web/desktop): there's no parent shell to relay it. */
function syncDefaultModelPin(model: string, win: Window = window): void {
if (win.parent === win) return
win.parent.postMessage({ source: "amicode", kind: "set-default-model", model }, "*")
}

export function AmicodeDefaultModel() {
const models = useModels()

const available = createMemo(() => models.list())
// opencode's default visibility hides dated, non-"latest" models — which can
// hide EVERY model and leave the control empty. Prefer the visible set, but
// fall back to all available models so the picker is never blank when models
// exist. (Guarded below on available(), so it only hides with zero models.)
const selectable = createMemo(() => {
const vis = available().filter((m) => models.visible({ providerID: m.provider.id, modelID: m.id }))
return vis.length > 0 ? vis : available()
})

const groups = createMemo(() => {
const byProvider = new Map<string, { provider: string; items: { value: string; name: string }[] }>()
for (const m of selectable()) {
const group = byProvider.get(m.provider.id) ?? { provider: m.provider.name, items: [] }
group.items.push({ value: formatModelValue({ providerID: m.provider.id, modelID: m.id }), name: m.name })
byProvider.set(m.provider.id, group)
}
return [...byProvider.values()]
})

// Bound to the DURABLE pin (models.pin), not the recents head — chats using
// other models must not move the dashboard's declared default. Empty = Auto
// (no pin; new chats fall back to recents/provider default).
const current = createMemo(() => {
const pin = models.pin.get()
return pin ? formatModelValue(pin) : ""
})

const choose = (value: string) => {
if (value === "") {
models.pin.set(undefined) // Auto: clear the pin, mirror the cleared setting
syncDefaultModelPin("")
return
}
const key = parseModelValue(value)
if (!key) return
models.pin.set(key)
syncDefaultModelPin(value)
}

return (
<Show when={available().length > 0}>
<label
data-component="amicode-default-model"
title="Default model for new chats"
style={{
display: "inline-flex",
"align-items": "center",
gap: "8px",
"font-size": "12px",
color: "var(--v2-text-text-muted)",
}}
>
<span data-slot="amicode-default-model-label" style={{ "font-weight": "550" }}>
Model
</span>
<select
data-slot="amicode-default-model-select"
value={current()}
onChange={(event) => choose(event.currentTarget.value)}
style={{
"font-size": "12px",
color: "var(--v2-text-text-base)",
background: "var(--v2-background-bg-layer-01)",
border: "1px solid var(--v2-border-border-base)",
"border-radius": "6px",
padding: "3px 6px",
"max-width": "220px",
cursor: "pointer",
}}
>
<option value="">Auto</option>
<For each={groups()}>
{(group) => (
<optgroup label={group.provider}>
<For each={group.items}>{(item) => <option value={item.value}>{item.name}</option>}</For>
</optgroup>
)}
</For>
</select>
</label>
</Show>
)
}
13 changes: 10 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { inAmicode } from "@/pages/session/use-amicode-commands"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge"
import { readClipboardViaBridge, readClipboardImageViaBridge } from "./prompt-input/clipboard-bridge"
import { normalizePaste } from "./prompt-input/paste"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import {
Expand DownExpand Up@@ -1147,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// browser dispatches no usable paste event on ⌘V (unlike plain web/desktop,
// where onPaste handles it). Intercept the keystroke and read the OS
// clipboard over the extension bridge instead (see clipboard-bridge.ts).
// Image first (screenshots), then text — mirrors handlePaste's precedence.
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
Expand All@@ -1155,9 +1156,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
inAmicode()
) {
event.preventDefault()
void readClipboardViaBridge().then((text) => {
void (async () => {
const image = await readClipboardImageViaBridge()
if (image) {
await addAttachment(image)
return
}
const text = await readClipboardViaBridge()
if (text) addPart({ type: "text", content: normalizePaste(text), start: 0, end: 0 })
})
})()
return
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { readClipboardViaBridge } from "./clipboard-bridge"
import { readClipboardViaBridge, writeClipboardViaBridge } from "./clipboard-bridge"

type Listener = (event: MessageEvent) => void

Expand DownExpand Up@@ -74,3 +74,26 @@ describe("readClipboardViaBridge", () => {
expect(posted).toHaveLength(0)
})
})

describe("writeClipboardViaBridge", () => {
test("pushes the copied text to the host so the OS clipboard matches ⌘V's read", () => {
const bridge = fakeFramedWindow()

expect(writeClipboardViaBridge("solve a CZ gate", bridge.win)).toBe(true)
expect(bridge.posted).toHaveLength(1)
const message = bridge.posted[0]
expect(message.source).toBe("amicode")
expect(message.kind).toBe("clipboard-write")
expect(message.text).toBe("solve a CZ gate")
})

test("is a no-op that posts nothing when the app is not framed", () => {
const posted: unknown[] = []
const win = { postMessage: (message: unknown) => posted.push(message) } as unknown as Window
// parent === self → plain web/desktop, where native copy already works
;(win as unknown as { parent: Window }).parent = win

expect(writeClipboardViaBridge("ignored", win)).toBe(false)
expect(posted).toHaveLength(0)
})
})
65 changes: 65 additions & 0 deletions packages/app/src/components/prompt-input/clipboard-bridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@

const BRIDGE_TIMEOUT_MS = 1500

// ⌘C inside the Amicode chat is the mirror image of ⌘V: the sandboxed iframe's
// native copy (and navigator.clipboard.writeText) never reaches the OS
// clipboard, so a subsequent ⌘V — which DOES read the OS clipboard over the
// bridge — pastes whatever stale thing was there before, not what you copied.
// Push the copied text to the extension host, which writes vscode.env.clipboard
// so read and write hit the same clipboard. Fire-and-forget: the host write is
// near-instant and callers have nothing to await. Resolves false (no-op) when
// unframed — native copy already works there.
export function writeClipboardViaBridge(text: string, win: Window = window): boolean {
if (win.parent === win) return false
win.parent.postMessage({ source: "amicode", kind: "clipboard-write", text }, "*")
return true
}

export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<string> {
return new Promise<string>((resolve) => {
// Unframed: native paste works — don't post into the void or wait out the timeout.
Expand DownExpand Up@@ -40,3 +54,54 @@ export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_
timer = setTimeout(() => finish(""), timeoutMs)
})
}

// ⌘V image paste inside the Amicode chat. Unlike text (which the extension host
// reads via vscode.env.clipboard — text-only, no image API), an image can only
// come from the OUTER webview reading navigator.clipboard.read() client-side.
// So we ask the parent relay (chat_panel.ts) rather than the host: it grabs the
// first image/* clipboard item, encodes it as a data URL, and replies. We
// rebuild a File from the reply so callers reuse the normal attachment path.
// Resolves null when unframed, on a text-only clipboard, or after `timeoutMs`.
export function readClipboardImageViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<File | null> {
return new Promise<File | null>((resolve) => {
if (win.parent === win) {
resolve(null)
return
}

const nonce = Math.random().toString(36).slice(2)
let timer: ReturnType<typeof setTimeout> | undefined

const finish = (file: File | null) => {
win.removeEventListener("message", onMessage)
if (timer !== undefined) clearTimeout(timer)
resolve(file)
}

const onMessage = (event: MessageEvent) => {
const data = event.data as
| { source?: string; kind?: string; nonce?: string; dataUrl?: string; mime?: string; filename?: string }
| undefined
if (data?.source !== "amicode" || data.kind !== "clipboard-image" || data.nonce !== nonce) return
finish(fileFromDataUrl(data.dataUrl, data.mime, data.filename))
}

win.addEventListener("message", onMessage)
win.parent.postMessage({ source: "amicode", kind: "clipboard-image-request", nonce }, "*")
timer = setTimeout(() => finish(null), timeoutMs)
})
}

function fileFromDataUrl(dataUrl?: string, mime?: string, filename?: string): File | null {
if (!dataUrl || !mime) return null
const comma = dataUrl.indexOf(",")
if (comma === -1) return null
try {
const binary = atob(dataUrl.slice(comma + 1))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new File([bytes], filename || "pasted-image.png", { type: mime })
} catch {
return null
}
}
53 changes: 43 additions & 10 deletions packages/app/src/components/titlebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
import { Mark } from "@opencode-ai/ui/logo"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner"

type TauriDesktopWindow = {
Expand DownExpand Up@@ -489,6 +490,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
onCloseOthers={() => tabsStoreActions.closeOthers(i())}
onCloseLeft={() => tabsStoreActions.closeToLeft(i())}
onCloseRight={() => tabsStoreActions.closeToRight(i())}
canCloseOthers={tabsStore.length > 1}
canCloseLeft={i() > 0}
canCloseRight={i() < tabsStore.length - 1}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
Expand DownExpand Up@@ -765,11 +772,18 @@ function TabNavItem(props: {
sessionId?: string
hideClose?: boolean
onClose: () => void
onCloseOthers: () => void
onCloseLeft: () => void
onCloseRight: () => void
canCloseOthers: boolean
canCloseLeft: boolean
canCloseRight: boolean
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
Expand All@@ -796,15 +810,17 @@ function TabNavItem(props: {
)

return (
<div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<ContextMenu modal={false}>
<ContextMenu.Trigger
as="div"
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event: MouseEvent) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={session.latest}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
Expand DownExpand Up@@ -851,7 +867,24 @@ function TabNavItem(props: {
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.onClose()}>
<ContextMenu.ItemLabel>{language.t("common.closeTab")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseOthers} onSelect={() => props.onCloseOthers()}>
<ContextMenu.ItemLabel>{language.t("common.closeOtherTabs")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseLeft} onSelect={() => props.onCloseLeft()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToLeft")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseRight} onSelect={() => props.onCloseRight()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToRight")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
a4fd244
amicode: re-add Aero wordmark typeface (LICENSE-PENDING)
aarontrowbridge Jul 8, 2026
c811798
amicode: wire composer paste to the extension clipboard bridge
aarontrowbridge Jul 8, 2026
b868a59
amicode: generalize the paste-clipboard bridge past the composer
aarontrowbridge Jul 8, 2026
c52cffa
feat(amicode-ui): pure frontier DAG scheduler + tests
aarontrowbridge Jul 9, 2026
81066f8
feat(amicode-ui): composite-aware problem.ts display derivations
aarontrowbridge Jul 9, 2026
9810040
merge origin/local/amicode into local/amicode
aarontrowbridge Jul 9, 2026
baf6b9e
merge local/amicode into local/composite-system
aarontrowbridge Jul 9, 2026
3a85038
feat(amicode-ui): compactValue kills the JSON-dump receipt chokepoint
aarontrowbridge Jul 9, 2026
8ebebe7
feat(amicode-ui): setDiff — unique-keyed set delta (spec §4 / plan Ta…
aarontrowbridge Jul 9, 2026
e110d11
feat(amicode-ui): modeBadges — mode→badge model (spec §4 / plan Task 3)
aarontrowbridge Jul 9, 2026
dc47bdf
feat(amicode-ui): system schematic + table models (spec §6.1 / plan T…
aarontrowbridge Jul 9, 2026
a344e06
feat(amicode-ui): SystemComposite hero (schematic+table+Hamiltonian)
aarontrowbridge Jul 9, 2026
3cd5d1a
feat(amicode-ui): system entity-view + receipt use the composite hero
aarontrowbridge Jul 9, 2026
3d5d0de
feat(amicode-ui): formulationProjection (dual-shape, derived primaryK…
aarontrowbridge Jul 9, 2026
b94000a
fix(amicode-ui): formulation chip reads structured facets, not phanto…
aarontrowbridge Jul 9, 2026
cccb286
feat(amicode-ui): FormulationView hero (mode header + objective LaTeX…
aarontrowbridge Jul 9, 2026
d50d337
feat(amicode-ui): formulation entity-view + receipt (spec §5,§6.2 / T…
aarontrowbridge Jul 9, 2026
c0ea330
fix(amicode-ui): entity-view dialog overflow — wrap long values, shri…
aarontrowbridge Jul 10, 2026
a64da04
feat(amicode-ui): composite Hamiltonian LaTeX + compact timestamps + …
aarontrowbridge Jul 10, 2026
3509606
fix(amicode): entity-view dialog wrapper contains both axes (stop pan…
aarontrowbridge Jul 10, 2026
a4fc920
fix(amicode): entity dialog — definite width + overflow-x clip (stop …
aarontrowbridge Jul 10, 2026
e485430
merge: sync origin/local/amicode (#13,#16,#17,#18) into composite-system
aarontrowbridge Jul 10, 2026
1b46833
style(brand): Aero wordmark on logo.tsx (Aaron's call, this branch)
aarontrowbridge Jul 10, 2026
b81fb75
feat(prompt): Claude Code-style rich chat output for Claude models (p…
aarontrowbridge Jul 10, 2026
a86e690
docs(patches): stamp #18 final provenance — clean build at b81fb756f2…
aarontrowbridge Jul 10, 2026
d073fb6
feat(prompt): port rich-output style to default.txt + gemini.txt (pat…
aarontrowbridge Jul 10, 2026
1532788
refactor(prompt): model-agnostic communicating.txt appended for every…
aarontrowbridge Jul 10, 2026
c42043e
fix(amicode): rydberg Hamiltonian in System card + communicating.txt …
aarontrowbridge Jul 11, 2026
431b29a
feat(amicode): right-click tab management on the session tab strip
aarontrowbridge Jul 11, 2026
f78155a
fix(ui): markdown heading hierarchy — h1-h3 larger + 600 weight, spac…
aarontrowbridge Jul 11, 2026
37e304c
feat(amicode): add "Close tabs to the left" to the tab context menu
aarontrowbridge Jul 11, 2026
2ff5767
fix(amicode): register quantum-math KaTeX macros so chat \Tr etc. render
aarontrowbridge Jul 11, 2026
54c6559
fix(amicode): render mid-line-opened multi-line $$ display math in chat
aarontrowbridge Jul 11, 2026
236e647
feat(amicode): single-select question options commit on click
aarontrowbridge Jul 11, 2026
b23405f
feat(amicode): skill chips expand to show the injected instructions
aarontrowbridge Jul 11, 2026
e20c794
feat(amicode): declutter the System entity card — signal over noise
aarontrowbridge Jul 11, 2026
83bb3fe
feat(amicode): default-model control on the home dashboard
aarontrowbridge Jul 12, 2026
0923b68
feat(amicode): Claude-Code-esque thinking indicator on streaming turns
aarontrowbridge Jul 12, 2026
1389c9e
fix(amicode): dashboard model control hidden by over-strict visibilit…
aarontrowbridge Jul 12, 2026
23ef70c
feat(amicode): live token count in the thinking line
aarontrowbridge Jul 12, 2026
1a62b41
feat(amicode): humanTail filter for AMICODE_* machine lines
aarontrowbridge Jul 12, 2026
f0608c7
feat(amicode): widen run-series raw tail to 24 lines for client-side …
aarontrowbridge Jul 12, 2026
6a3e591
feat(amicode): extract shared run-plot geometry with pulse-first hero…
aarontrowbridge Jul 12, 2026
bd723d1
feat(amicode): pulse-first run window — pulse hero, F header sparklin…
aarontrowbridge Jul 12, 2026
6d3cf79
feat(amicode): drop self-facing best-F from About You stats and Pulse…
aarontrowbridge Jul 12, 2026
88cea38
feat(amicode): pulse-first Now-solving sparkline (per-drive paths, ob…
aarontrowbridge Jul 12, 2026
343fe36
feat(amicode): share poster reads pulse-first (pulse left, convergenc…
aarontrowbridge Jul 12, 2026
b1e2f12
feat(amicode): toml-lite subset reader for widget manifests
aarontrowbridge Jul 12, 2026
fa8ccb8
feat(amicode): Amico third-actor presence — state machine + lane CSS …
aarontrowbridge Jul 12, 2026
88e96d9
feat(amicode): widget manifest parse + config sanitize
aarontrowbridge Jul 12, 2026
5655d29
feat(amicode): dashboard state with sanitizing merge
aarontrowbridge Jul 12, 2026
59ac1c2
feat(amicode): seven built-in widget sources with executable parse+mo…
aarontrowbridge Jul 12, 2026
fcf202d
feat(amicode): widget registry with fork + content hash
aarontrowbridge Jul 12, 2026
ca5b31d
feat(amicode): widget + dashboard endpoints
aarontrowbridge Jul 12, 2026
7581b9a
feat(amicode): widget schema + allowlist cores
aarontrowbridge Jul 12, 2026
f3b333a
feat(amicode): amc tokens, srcdoc assembly with CSP, in-frame runtime
aarontrowbridge Jul 12, 2026
3545750
feat(amicode): widget bridge host + sandboxed frame with version gate…
aarontrowbridge Jul 12, 2026
a0c5019
feat(amicode): widget grid with inline edit mode + generated config f…
aarontrowbridge Jul 12, 2026
30bd257
feat(amicode): home dashboard renders WidgetGrid (legacy cards as fal…
aarontrowbridge Jul 12, 2026
b91ef7a
fix(amicode): serve widget frames as documents with own CSP — srcdoc …
aarontrowbridge Jul 12, 2026
bff6712
fix(amicode): explicit amc:empty signal breaks the hidden-frame deadl…
aarontrowbridge Jul 12, 2026
8854f25
feat(amicode): T3.6 reserved-key pass-through + manifest size toleran…
aarontrowbridge Jul 13, 2026
b64668d
feat(amicode): durable default-model pin — pin slot in models store, …
aarontrowbridge Jul 13, 2026
582a3fd
feat(amicode): panel-first chrome strip + sessions flyout + responsiv…
aarontrowbridge Jul 13, 2026
3b6d9ed
fix(amicode): redesign widget edit mode — cards stay, grouped corner …
aarontrowbridge Jul 13, 2026
ccc9c30
fix(amicode): collapse empty/missing cells out of the grid, not just …
aarontrowbridge Jul 13, 2026
86c12d7
feat(amicode): wire Amico third-actor presence into the chat flow
aarontrowbridge Jul 13, 2026
515c089
merge(amicode): bring Amico third-actor presence into the widget-kern…
aarontrowbridge Jul 13, 2026
110cb59
feat(amicode): chat widget authoring (Stage 2) — author tool + live i…
aarontrowbridge Jul 13, 2026
a5b3e37
feat(amicode): client-side clipboard bridge — image paste via navigat…
aarontrowbridge Jul 14, 2026
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
51 changes: 50 additions & 1 deletion AMICODE-PATCHES.md

Large diffs are not rendered by default.

Binary file addedpackages/app/public/assets/Aero-Regular.ttf
Binary file not shown.
17 changes: 17 additions & 0 deletions packages/app/src/components/amicode-default-model-value.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
// Pure "providerID/modelID" <-> ModelKey helpers for the dashboard default-model
// control. Split out of amicode-default-model.tsx so they're unit-testable
// without evaluating the SolidJS component (which touches client-only context).

/** "providerID/modelID". modelID may itself contain "/", so callers split on the first. */
export function formatModelValue(key: { providerID: string; modelID: string }): string {
return `${key.providerID}/${key.modelID}`
}

/** Inverse of formatModelValue; null on a malformed/empty value. Splits on the
* FIRST slash so a model id containing slashes (e.g. openrouter/meta-llama/…)
* round-trips. */
export function parseModelValue(value: string): { providerID: string; modelID: string } | null {
const slash = value.indexOf("/")
if (slash <= 0 || slash === value.length - 1) return null
return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) }
}
28 changes: 28 additions & 0 deletions packages/app/src/components/amicode-default-model.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

describe("amicode default-model value round-trip", () => {
test("format then parse is identity, incl. model ids with dots/dashes", () => {
const key = { providerID: "anthropic", modelID: "claude-sonnet-5" }
expect(formatModelValue(key)).toBe("anthropic/claude-sonnet-5")
expect(parseModelValue(formatModelValue(key))).toEqual(key)
expect(parseModelValue("opencode/deepseek-v4-flash-free")).toEqual({
providerID: "opencode",
modelID: "deepseek-v4-flash-free",
})
})

test("splits on the FIRST slash so a model id may itself contain slashes", () => {
expect(parseModelValue("openrouter/meta-llama/llama-3.3")).toEqual({
providerID: "openrouter",
modelID: "meta-llama/llama-3.3",
})
})

test("rejects malformed / empty values (→ null, no selection)", () => {
expect(parseModelValue("")).toBeNull()
expect(parseModelValue("noslash")).toBeNull()
expect(parseModelValue("/leading")).toBeNull()
expect(parseModelValue("trailing/")).toBeNull()
})
})
106 changes: 106 additions & 0 deletions packages/app/src/components/amicode-default-model.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
import { For, Show, createMemo } from "solid-js"
import { useModels } from "@/context/models"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

// Dashboard "Default model" control. Drives opencode's GLOBAL model store
// (useModels — mounted app-wide, persisted via Persist.global), which is what
// new chats inherit as their starting model (see local.tsx recentModel()), so
// a change takes effect immediately with no server restart. It ALSO mirrors the
// choice into the `amicode.defaultModel` VS Code setting over the iframe→parent
// bridge (chat_panel.ts), keeping the headless/first-turn config pin in sync.
// The in-chat picker still overrides per session.

/** Post the chosen model to the extension host so it updates amicode.defaultModel.
* No-op when unframed (plain web/desktop): there's no parent shell to relay it. */
function syncDefaultModelPin(model: string, win: Window = window): void {
if (win.parent === win) return
win.parent.postMessage({ source: "amicode", kind: "set-default-model", model }, "*")
}

export function AmicodeDefaultModel() {
const models = useModels()

const available = createMemo(() => models.list())
// opencode's default visibility hides dated, non-"latest" models — which can
// hide EVERY model and leave the control empty. Prefer the visible set, but
// fall back to all available models so the picker is never blank when models
// exist. (Guarded below on available(), so it only hides with zero models.)
const selectable = createMemo(() => {
const vis = available().filter((m) => models.visible({ providerID: m.provider.id, modelID: m.id }))
return vis.length > 0 ? vis : available()
})

const groups = createMemo(() => {
const byProvider = new Map<string, { provider: string; items: { value: string; name: string }[] }>()
for (const m of selectable()) {
const group = byProvider.get(m.provider.id) ?? { provider: m.provider.name, items: [] }
group.items.push({ value: formatModelValue({ providerID: m.provider.id, modelID: m.id }), name: m.name })
byProvider.set(m.provider.id, group)
}
return [...byProvider.values()]
})

// Bound to the DURABLE pin (models.pin), not the recents head — chats using
// other models must not move the dashboard's declared default. Empty = Auto
// (no pin; new chats fall back to recents/provider default).
const current = createMemo(() => {
const pin = models.pin.get()
return pin ? formatModelValue(pin) : ""
})

const choose = (value: string) => {
if (value === "") {
models.pin.set(undefined) // Auto: clear the pin, mirror the cleared setting
syncDefaultModelPin("")
return
}
const key = parseModelValue(value)
if (!key) return
models.pin.set(key)
syncDefaultModelPin(value)
}

return (
<Show when={available().length > 0}>
<label
data-component="amicode-default-model"
title="Default model for new chats"
style={{
display: "inline-flex",
"align-items": "center",
gap: "8px",
"font-size": "12px",
color: "var(--v2-text-text-muted)",
}}
>
<span data-slot="amicode-default-model-label" style={{ "font-weight": "550" }}>
Model
</span>
<select
data-slot="amicode-default-model-select"
value={current()}
onChange={(event) => choose(event.currentTarget.value)}
style={{
"font-size": "12px",
color: "var(--v2-text-text-base)",
background: "var(--v2-background-bg-layer-01)",
border: "1px solid var(--v2-border-border-base)",
"border-radius": "6px",
padding: "3px 6px",
"max-width": "220px",
cursor: "pointer",
}}
>
<option value="">Auto</option>
<For each={groups()}>
{(group) => (
<optgroup label={group.provider}>
<For each={group.items}>{(item) => <option value={item.value}>{item.name}</option>}</For>
</optgroup>
)}
</For>
</select>
</label>
</Show>
)
}
13 changes: 10 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { inAmicode } from "@/pages/session/use-amicode-commands"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge"
import { readClipboardViaBridge, readClipboardImageViaBridge } from "./prompt-input/clipboard-bridge"
import { normalizePaste } from "./prompt-input/paste"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import {
Expand DownExpand Up@@ -1147,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// browser dispatches no usable paste event on ⌘V (unlike plain web/desktop,
// where onPaste handles it). Intercept the keystroke and read the OS
// clipboard over the extension bridge instead (see clipboard-bridge.ts).
// Image first (screenshots), then text — mirrors handlePaste's precedence.
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
Expand All@@ -1155,9 +1156,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
inAmicode()
) {
event.preventDefault()
void readClipboardViaBridge().then((text) => {
void (async () => {
const image = await readClipboardImageViaBridge()
if (image) {
await addAttachment(image)
return
}
const text = await readClipboardViaBridge()
if (text) addPart({ type: "text", content: normalizePaste(text), start: 0, end: 0 })
})
})()
return
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { readClipboardViaBridge } from "./clipboard-bridge"
import { readClipboardViaBridge, writeClipboardViaBridge } from "./clipboard-bridge"

type Listener = (event: MessageEvent) => void

Expand DownExpand Up@@ -74,3 +74,26 @@ describe("readClipboardViaBridge", () => {
expect(posted).toHaveLength(0)
})
})

describe("writeClipboardViaBridge", () => {
test("pushes the copied text to the host so the OS clipboard matches ⌘V's read", () => {
const bridge = fakeFramedWindow()

expect(writeClipboardViaBridge("solve a CZ gate", bridge.win)).toBe(true)
expect(bridge.posted).toHaveLength(1)
const message = bridge.posted[0]
expect(message.source).toBe("amicode")
expect(message.kind).toBe("clipboard-write")
expect(message.text).toBe("solve a CZ gate")
})

test("is a no-op that posts nothing when the app is not framed", () => {
const posted: unknown[] = []
const win = { postMessage: (message: unknown) => posted.push(message) } as unknown as Window
// parent === self → plain web/desktop, where native copy already works
;(win as unknown as { parent: Window }).parent = win

expect(writeClipboardViaBridge("ignored", win)).toBe(false)
expect(posted).toHaveLength(0)
})
})
65 changes: 65 additions & 0 deletions packages/app/src/components/prompt-input/clipboard-bridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@

const BRIDGE_TIMEOUT_MS = 1500

// ⌘C inside the Amicode chat is the mirror image of ⌘V: the sandboxed iframe's
// native copy (and navigator.clipboard.writeText) never reaches the OS
// clipboard, so a subsequent ⌘V — which DOES read the OS clipboard over the
// bridge — pastes whatever stale thing was there before, not what you copied.
// Push the copied text to the extension host, which writes vscode.env.clipboard
// so read and write hit the same clipboard. Fire-and-forget: the host write is
// near-instant and callers have nothing to await. Resolves false (no-op) when
// unframed — native copy already works there.
export function writeClipboardViaBridge(text: string, win: Window = window): boolean {
if (win.parent === win) return false
win.parent.postMessage({ source: "amicode", kind: "clipboard-write", text }, "*")
return true
}

export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<string> {
return new Promise<string>((resolve) => {
// Unframed: native paste works — don't post into the void or wait out the timeout.
Expand DownExpand Up@@ -40,3 +54,54 @@ export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_
timer = setTimeout(() => finish(""), timeoutMs)
})
}

// ⌘V image paste inside the Amicode chat. Unlike text (which the extension host
// reads via vscode.env.clipboard — text-only, no image API), an image can only
// come from the OUTER webview reading navigator.clipboard.read() client-side.
// So we ask the parent relay (chat_panel.ts) rather than the host: it grabs the
// first image/* clipboard item, encodes it as a data URL, and replies. We
// rebuild a File from the reply so callers reuse the normal attachment path.
// Resolves null when unframed, on a text-only clipboard, or after `timeoutMs`.
export function readClipboardImageViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<File | null> {
return new Promise<File | null>((resolve) => {
if (win.parent === win) {
resolve(null)
return
}

const nonce = Math.random().toString(36).slice(2)
let timer: ReturnType<typeof setTimeout> | undefined

const finish = (file: File | null) => {
win.removeEventListener("message", onMessage)
if (timer !== undefined) clearTimeout(timer)
resolve(file)
}

const onMessage = (event: MessageEvent) => {
const data = event.data as
| { source?: string; kind?: string; nonce?: string; dataUrl?: string; mime?: string; filename?: string }
| undefined
if (data?.source !== "amicode" || data.kind !== "clipboard-image" || data.nonce !== nonce) return
finish(fileFromDataUrl(data.dataUrl, data.mime, data.filename))
}

win.addEventListener("message", onMessage)
win.parent.postMessage({ source: "amicode", kind: "clipboard-image-request", nonce }, "*")
timer = setTimeout(() => finish(null), timeoutMs)
})
}

function fileFromDataUrl(dataUrl?: string, mime?: string, filename?: string): File | null {
if (!dataUrl || !mime) return null
const comma = dataUrl.indexOf(",")
if (comma === -1) return null
try {
const binary = atob(dataUrl.slice(comma + 1))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new File([bytes], filename || "pasted-image.png", { type: mime })
} catch {
return null
}
}
53 changes: 43 additions & 10 deletions packages/app/src/components/titlebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
import { Mark } from "@opencode-ai/ui/logo"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner"

type TauriDesktopWindow = {
Expand DownExpand Up@@ -489,6 +490,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
onCloseOthers={() => tabsStoreActions.closeOthers(i())}
onCloseLeft={() => tabsStoreActions.closeToLeft(i())}
onCloseRight={() => tabsStoreActions.closeToRight(i())}
canCloseOthers={tabsStore.length > 1}
canCloseLeft={i() > 0}
canCloseRight={i() < tabsStore.length - 1}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
Expand DownExpand Up@@ -765,11 +772,18 @@ function TabNavItem(props: {
sessionId?: string
hideClose?: boolean
onClose: () => void
onCloseOthers: () => void
onCloseLeft: () => void
onCloseRight: () => void
canCloseOthers: boolean
canCloseLeft: boolean
canCloseRight: boolean
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
Expand All@@ -796,15 +810,17 @@ function TabNavItem(props: {
)

return (
<div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<ContextMenu modal={false}>
<ContextMenu.Trigger
as="div"
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event: MouseEvent) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={session.latest}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
Expand DownExpand Up@@ -851,7 +867,24 @@ function TabNavItem(props: {
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.onClose()}>
<ContextMenu.ItemLabel>{language.t("common.closeTab")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseOthers} onSelect={() => props.onCloseOthers()}>
<ContextMenu.ItemLabel>{language.t("common.closeOtherTabs")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseLeft} onSelect={() => props.onCloseLeft()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToLeft")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseRight} onSelect={() => props.onCloseRight()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToRight")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
a4fd244
amicode: re-add Aero wordmark typeface (LICENSE-PENDING)
aarontrowbridge Jul 8, 2026
c811798
amicode: wire composer paste to the extension clipboard bridge
aarontrowbridge Jul 8, 2026
b868a59
amicode: generalize the paste-clipboard bridge past the composer
aarontrowbridge Jul 8, 2026
c52cffa
feat(amicode-ui): pure frontier DAG scheduler + tests
aarontrowbridge Jul 9, 2026
81066f8
feat(amicode-ui): composite-aware problem.ts display derivations
aarontrowbridge Jul 9, 2026
9810040
merge origin/local/amicode into local/amicode
aarontrowbridge Jul 9, 2026
baf6b9e
merge local/amicode into local/composite-system
aarontrowbridge Jul 9, 2026
3a85038
feat(amicode-ui): compactValue kills the JSON-dump receipt chokepoint
aarontrowbridge Jul 9, 2026
8ebebe7
feat(amicode-ui): setDiff — unique-keyed set delta (spec §4 / plan Ta…
aarontrowbridge Jul 9, 2026
e110d11
feat(amicode-ui): modeBadges — mode→badge model (spec §4 / plan Task 3)
aarontrowbridge Jul 9, 2026
dc47bdf
feat(amicode-ui): system schematic + table models (spec §6.1 / plan T…
aarontrowbridge Jul 9, 2026
a344e06
feat(amicode-ui): SystemComposite hero (schematic+table+Hamiltonian)
aarontrowbridge Jul 9, 2026
3cd5d1a
feat(amicode-ui): system entity-view + receipt use the composite hero
aarontrowbridge Jul 9, 2026
3d5d0de
feat(amicode-ui): formulationProjection (dual-shape, derived primaryK…
aarontrowbridge Jul 9, 2026
b94000a
fix(amicode-ui): formulation chip reads structured facets, not phanto…
aarontrowbridge Jul 9, 2026
cccb286
feat(amicode-ui): FormulationView hero (mode header + objective LaTeX…
aarontrowbridge Jul 9, 2026
d50d337
feat(amicode-ui): formulation entity-view + receipt (spec §5,§6.2 / T…
aarontrowbridge Jul 9, 2026
c0ea330
fix(amicode-ui): entity-view dialog overflow — wrap long values, shri…
aarontrowbridge Jul 10, 2026
a64da04
feat(amicode-ui): composite Hamiltonian LaTeX + compact timestamps + …
aarontrowbridge Jul 10, 2026
3509606
fix(amicode): entity-view dialog wrapper contains both axes (stop pan…
aarontrowbridge Jul 10, 2026
a4fc920
fix(amicode): entity dialog — definite width + overflow-x clip (stop …
aarontrowbridge Jul 10, 2026
e485430
merge: sync origin/local/amicode (#13,#16,#17,#18) into composite-system
aarontrowbridge Jul 10, 2026
1b46833
style(brand): Aero wordmark on logo.tsx (Aaron's call, this branch)
aarontrowbridge Jul 10, 2026
b81fb75
feat(prompt): Claude Code-style rich chat output for Claude models (p…
aarontrowbridge Jul 10, 2026
a86e690
docs(patches): stamp #18 final provenance — clean build at b81fb756f2…
aarontrowbridge Jul 10, 2026
d073fb6
feat(prompt): port rich-output style to default.txt + gemini.txt (pat…
aarontrowbridge Jul 10, 2026
1532788
refactor(prompt): model-agnostic communicating.txt appended for every…
aarontrowbridge Jul 10, 2026
c42043e
fix(amicode): rydberg Hamiltonian in System card + communicating.txt …
aarontrowbridge Jul 11, 2026
431b29a
feat(amicode): right-click tab management on the session tab strip
aarontrowbridge Jul 11, 2026
f78155a
fix(ui): markdown heading hierarchy — h1-h3 larger + 600 weight, spac…
aarontrowbridge Jul 11, 2026
37e304c
feat(amicode): add "Close tabs to the left" to the tab context menu
aarontrowbridge Jul 11, 2026
2ff5767
fix(amicode): register quantum-math KaTeX macros so chat \Tr etc. render
aarontrowbridge Jul 11, 2026
54c6559
fix(amicode): render mid-line-opened multi-line $$ display math in chat
aarontrowbridge Jul 11, 2026
236e647
feat(amicode): single-select question options commit on click
aarontrowbridge Jul 11, 2026
b23405f
feat(amicode): skill chips expand to show the injected instructions
aarontrowbridge Jul 11, 2026
e20c794
feat(amicode): declutter the System entity card — signal over noise
aarontrowbridge Jul 11, 2026
83bb3fe
feat(amicode): default-model control on the home dashboard
aarontrowbridge Jul 12, 2026
0923b68
feat(amicode): Claude-Code-esque thinking indicator on streaming turns
aarontrowbridge Jul 12, 2026
1389c9e
fix(amicode): dashboard model control hidden by over-strict visibilit…
aarontrowbridge Jul 12, 2026
23ef70c
feat(amicode): live token count in the thinking line
aarontrowbridge Jul 12, 2026
1a62b41
feat(amicode): humanTail filter for AMICODE_* machine lines
aarontrowbridge Jul 12, 2026
f0608c7
feat(amicode): widen run-series raw tail to 24 lines for client-side …
aarontrowbridge Jul 12, 2026
6a3e591
feat(amicode): extract shared run-plot geometry with pulse-first hero…
aarontrowbridge Jul 12, 2026
bd723d1
feat(amicode): pulse-first run window — pulse hero, F header sparklin…
aarontrowbridge Jul 12, 2026
6d3cf79
feat(amicode): drop self-facing best-F from About You stats and Pulse…
aarontrowbridge Jul 12, 2026
88cea38
feat(amicode): pulse-first Now-solving sparkline (per-drive paths, ob…
aarontrowbridge Jul 12, 2026
343fe36
feat(amicode): share poster reads pulse-first (pulse left, convergenc…
aarontrowbridge Jul 12, 2026
b1e2f12
feat(amicode): toml-lite subset reader for widget manifests
aarontrowbridge Jul 12, 2026
fa8ccb8
feat(amicode): Amico third-actor presence — state machine + lane CSS …
aarontrowbridge Jul 12, 2026
88e96d9
feat(amicode): widget manifest parse + config sanitize
aarontrowbridge Jul 12, 2026
5655d29
feat(amicode): dashboard state with sanitizing merge
aarontrowbridge Jul 12, 2026
59ac1c2
feat(amicode): seven built-in widget sources with executable parse+mo…
aarontrowbridge Jul 12, 2026
fcf202d
feat(amicode): widget registry with fork + content hash
aarontrowbridge Jul 12, 2026
ca5b31d
feat(amicode): widget + dashboard endpoints
aarontrowbridge Jul 12, 2026
7581b9a
feat(amicode): widget schema + allowlist cores
aarontrowbridge Jul 12, 2026
f3b333a
feat(amicode): amc tokens, srcdoc assembly with CSP, in-frame runtime
aarontrowbridge Jul 12, 2026
3545750
feat(amicode): widget bridge host + sandboxed frame with version gate…
aarontrowbridge Jul 12, 2026
a0c5019
feat(amicode): widget grid with inline edit mode + generated config f…
aarontrowbridge Jul 12, 2026
30bd257
feat(amicode): home dashboard renders WidgetGrid (legacy cards as fal…
aarontrowbridge Jul 12, 2026
b91ef7a
fix(amicode): serve widget frames as documents with own CSP — srcdoc …
aarontrowbridge Jul 12, 2026
bff6712
fix(amicode): explicit amc:empty signal breaks the hidden-frame deadl…
aarontrowbridge Jul 12, 2026
8854f25
feat(amicode): T3.6 reserved-key pass-through + manifest size toleran…
aarontrowbridge Jul 13, 2026
b64668d
feat(amicode): durable default-model pin — pin slot in models store, …
aarontrowbridge Jul 13, 2026
582a3fd
feat(amicode): panel-first chrome strip + sessions flyout + responsiv…
aarontrowbridge Jul 13, 2026
3b6d9ed
fix(amicode): redesign widget edit mode — cards stay, grouped corner …
aarontrowbridge Jul 13, 2026
ccc9c30
fix(amicode): collapse empty/missing cells out of the grid, not just …
aarontrowbridge Jul 13, 2026
86c12d7
feat(amicode): wire Amico third-actor presence into the chat flow
aarontrowbridge Jul 13, 2026
515c089
merge(amicode): bring Amico third-actor presence into the widget-kern…
aarontrowbridge Jul 13, 2026
110cb59
feat(amicode): chat widget authoring (Stage 2) — author tool + live i…
aarontrowbridge Jul 13, 2026
a5b3e37
feat(amicode): client-side clipboard bridge — image paste via navigat…
aarontrowbridge Jul 14, 2026
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
51 changes: 50 additions & 1 deletion AMICODE-PATCHES.md

Large diffs are not rendered by default.

Binary file addedpackages/app/public/assets/Aero-Regular.ttf
Binary file not shown.
17 changes: 17 additions & 0 deletions packages/app/src/components/amicode-default-model-value.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
// Pure "providerID/modelID" <-> ModelKey helpers for the dashboard default-model
// control. Split out of amicode-default-model.tsx so they're unit-testable
// without evaluating the SolidJS component (which touches client-only context).

/** "providerID/modelID". modelID may itself contain "/", so callers split on the first. */
export function formatModelValue(key: { providerID: string; modelID: string }): string {
return `${key.providerID}/${key.modelID}`
}

/** Inverse of formatModelValue; null on a malformed/empty value. Splits on the
* FIRST slash so a model id containing slashes (e.g. openrouter/meta-llama/…)
* round-trips. */
export function parseModelValue(value: string): { providerID: string; modelID: string } | null {
const slash = value.indexOf("/")
if (slash <= 0 || slash === value.length - 1) return null
return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) }
}
28 changes: 28 additions & 0 deletions packages/app/src/components/amicode-default-model.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

describe("amicode default-model value round-trip", () => {
test("format then parse is identity, incl. model ids with dots/dashes", () => {
const key = { providerID: "anthropic", modelID: "claude-sonnet-5" }
expect(formatModelValue(key)).toBe("anthropic/claude-sonnet-5")
expect(parseModelValue(formatModelValue(key))).toEqual(key)
expect(parseModelValue("opencode/deepseek-v4-flash-free")).toEqual({
providerID: "opencode",
modelID: "deepseek-v4-flash-free",
})
})

test("splits on the FIRST slash so a model id may itself contain slashes", () => {
expect(parseModelValue("openrouter/meta-llama/llama-3.3")).toEqual({
providerID: "openrouter",
modelID: "meta-llama/llama-3.3",
})
})

test("rejects malformed / empty values (→ null, no selection)", () => {
expect(parseModelValue("")).toBeNull()
expect(parseModelValue("noslash")).toBeNull()
expect(parseModelValue("/leading")).toBeNull()
expect(parseModelValue("trailing/")).toBeNull()
})
})
106 changes: 106 additions & 0 deletions packages/app/src/components/amicode-default-model.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
import { For, Show, createMemo } from "solid-js"
import { useModels } from "@/context/models"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

// Dashboard "Default model" control. Drives opencode's GLOBAL model store
// (useModels — mounted app-wide, persisted via Persist.global), which is what
// new chats inherit as their starting model (see local.tsx recentModel()), so
// a change takes effect immediately with no server restart. It ALSO mirrors the
// choice into the `amicode.defaultModel` VS Code setting over the iframe→parent
// bridge (chat_panel.ts), keeping the headless/first-turn config pin in sync.
// The in-chat picker still overrides per session.

/** Post the chosen model to the extension host so it updates amicode.defaultModel.
* No-op when unframed (plain web/desktop): there's no parent shell to relay it. */
function syncDefaultModelPin(model: string, win: Window = window): void {
if (win.parent === win) return
win.parent.postMessage({ source: "amicode", kind: "set-default-model", model }, "*")
}

export function AmicodeDefaultModel() {
const models = useModels()

const available = createMemo(() => models.list())
// opencode's default visibility hides dated, non-"latest" models — which can
// hide EVERY model and leave the control empty. Prefer the visible set, but
// fall back to all available models so the picker is never blank when models
// exist. (Guarded below on available(), so it only hides with zero models.)
const selectable = createMemo(() => {
const vis = available().filter((m) => models.visible({ providerID: m.provider.id, modelID: m.id }))
return vis.length > 0 ? vis : available()
})

const groups = createMemo(() => {
const byProvider = new Map<string, { provider: string; items: { value: string; name: string }[] }>()
for (const m of selectable()) {
const group = byProvider.get(m.provider.id) ?? { provider: m.provider.name, items: [] }
group.items.push({ value: formatModelValue({ providerID: m.provider.id, modelID: m.id }), name: m.name })
byProvider.set(m.provider.id, group)
}
return [...byProvider.values()]
})

// Bound to the DURABLE pin (models.pin), not the recents head — chats using
// other models must not move the dashboard's declared default. Empty = Auto
// (no pin; new chats fall back to recents/provider default).
const current = createMemo(() => {
const pin = models.pin.get()
return pin ? formatModelValue(pin) : ""
})

const choose = (value: string) => {
if (value === "") {
models.pin.set(undefined) // Auto: clear the pin, mirror the cleared setting
syncDefaultModelPin("")
return
}
const key = parseModelValue(value)
if (!key) return
models.pin.set(key)
syncDefaultModelPin(value)
}

return (
<Show when={available().length > 0}>
<label
data-component="amicode-default-model"
title="Default model for new chats"
style={{
display: "inline-flex",
"align-items": "center",
gap: "8px",
"font-size": "12px",
color: "var(--v2-text-text-muted)",
}}
>
<span data-slot="amicode-default-model-label" style={{ "font-weight": "550" }}>
Model
</span>
<select
data-slot="amicode-default-model-select"
value={current()}
onChange={(event) => choose(event.currentTarget.value)}
style={{
"font-size": "12px",
color: "var(--v2-text-text-base)",
background: "var(--v2-background-bg-layer-01)",
border: "1px solid var(--v2-border-border-base)",
"border-radius": "6px",
padding: "3px 6px",
"max-width": "220px",
cursor: "pointer",
}}
>
<option value="">Auto</option>
<For each={groups()}>
{(group) => (
<optgroup label={group.provider}>
<For each={group.items}>{(item) => <option value={item.value}>{item.name}</option>}</For>
</optgroup>
)}
</For>
</select>
</label>
</Show>
)
}
13 changes: 10 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { inAmicode } from "@/pages/session/use-amicode-commands"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge"
import { readClipboardViaBridge, readClipboardImageViaBridge } from "./prompt-input/clipboard-bridge"
import { normalizePaste } from "./prompt-input/paste"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import {
Expand DownExpand Up@@ -1147,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// browser dispatches no usable paste event on ⌘V (unlike plain web/desktop,
// where onPaste handles it). Intercept the keystroke and read the OS
// clipboard over the extension bridge instead (see clipboard-bridge.ts).
// Image first (screenshots), then text — mirrors handlePaste's precedence.
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
Expand All@@ -1155,9 +1156,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
inAmicode()
) {
event.preventDefault()
void readClipboardViaBridge().then((text) => {
void (async () => {
const image = await readClipboardImageViaBridge()
if (image) {
await addAttachment(image)
return
}
const text = await readClipboardViaBridge()
if (text) addPart({ type: "text", content: normalizePaste(text), start: 0, end: 0 })
})
})()
return
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { readClipboardViaBridge } from "./clipboard-bridge"
import { readClipboardViaBridge, writeClipboardViaBridge } from "./clipboard-bridge"

type Listener = (event: MessageEvent) => void

Expand DownExpand Up@@ -74,3 +74,26 @@ describe("readClipboardViaBridge", () => {
expect(posted).toHaveLength(0)
})
})

describe("writeClipboardViaBridge", () => {
test("pushes the copied text to the host so the OS clipboard matches ⌘V's read", () => {
const bridge = fakeFramedWindow()

expect(writeClipboardViaBridge("solve a CZ gate", bridge.win)).toBe(true)
expect(bridge.posted).toHaveLength(1)
const message = bridge.posted[0]
expect(message.source).toBe("amicode")
expect(message.kind).toBe("clipboard-write")
expect(message.text).toBe("solve a CZ gate")
})

test("is a no-op that posts nothing when the app is not framed", () => {
const posted: unknown[] = []
const win = { postMessage: (message: unknown) => posted.push(message) } as unknown as Window
// parent === self → plain web/desktop, where native copy already works
;(win as unknown as { parent: Window }).parent = win

expect(writeClipboardViaBridge("ignored", win)).toBe(false)
expect(posted).toHaveLength(0)
})
})
65 changes: 65 additions & 0 deletions packages/app/src/components/prompt-input/clipboard-bridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@

const BRIDGE_TIMEOUT_MS = 1500

// ⌘C inside the Amicode chat is the mirror image of ⌘V: the sandboxed iframe's
// native copy (and navigator.clipboard.writeText) never reaches the OS
// clipboard, so a subsequent ⌘V — which DOES read the OS clipboard over the
// bridge — pastes whatever stale thing was there before, not what you copied.
// Push the copied text to the extension host, which writes vscode.env.clipboard
// so read and write hit the same clipboard. Fire-and-forget: the host write is
// near-instant and callers have nothing to await. Resolves false (no-op) when
// unframed — native copy already works there.
export function writeClipboardViaBridge(text: string, win: Window = window): boolean {
if (win.parent === win) return false
win.parent.postMessage({ source: "amicode", kind: "clipboard-write", text }, "*")
return true
}

export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<string> {
return new Promise<string>((resolve) => {
// Unframed: native paste works — don't post into the void or wait out the timeout.
Expand DownExpand Up@@ -40,3 +54,54 @@ export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_
timer = setTimeout(() => finish(""), timeoutMs)
})
}

// ⌘V image paste inside the Amicode chat. Unlike text (which the extension host
// reads via vscode.env.clipboard — text-only, no image API), an image can only
// come from the OUTER webview reading navigator.clipboard.read() client-side.
// So we ask the parent relay (chat_panel.ts) rather than the host: it grabs the
// first image/* clipboard item, encodes it as a data URL, and replies. We
// rebuild a File from the reply so callers reuse the normal attachment path.
// Resolves null when unframed, on a text-only clipboard, or after `timeoutMs`.
export function readClipboardImageViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<File | null> {
return new Promise<File | null>((resolve) => {
if (win.parent === win) {
resolve(null)
return
}

const nonce = Math.random().toString(36).slice(2)
let timer: ReturnType<typeof setTimeout> | undefined

const finish = (file: File | null) => {
win.removeEventListener("message", onMessage)
if (timer !== undefined) clearTimeout(timer)
resolve(file)
}

const onMessage = (event: MessageEvent) => {
const data = event.data as
| { source?: string; kind?: string; nonce?: string; dataUrl?: string; mime?: string; filename?: string }
| undefined
if (data?.source !== "amicode" || data.kind !== "clipboard-image" || data.nonce !== nonce) return
finish(fileFromDataUrl(data.dataUrl, data.mime, data.filename))
}

win.addEventListener("message", onMessage)
win.parent.postMessage({ source: "amicode", kind: "clipboard-image-request", nonce }, "*")
timer = setTimeout(() => finish(null), timeoutMs)
})
}

function fileFromDataUrl(dataUrl?: string, mime?: string, filename?: string): File | null {
if (!dataUrl || !mime) return null
const comma = dataUrl.indexOf(",")
if (comma === -1) return null
try {
const binary = atob(dataUrl.slice(comma + 1))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new File([bytes], filename || "pasted-image.png", { type: mime })
} catch {
return null
}
}
53 changes: 43 additions & 10 deletions packages/app/src/components/titlebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
import { Mark } from "@opencode-ai/ui/logo"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner"

type TauriDesktopWindow = {
Expand DownExpand Up@@ -489,6 +490,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
onCloseOthers={() => tabsStoreActions.closeOthers(i())}
onCloseLeft={() => tabsStoreActions.closeToLeft(i())}
onCloseRight={() => tabsStoreActions.closeToRight(i())}
canCloseOthers={tabsStore.length > 1}
canCloseLeft={i() > 0}
canCloseRight={i() < tabsStore.length - 1}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
Expand DownExpand Up@@ -765,11 +772,18 @@ function TabNavItem(props: {
sessionId?: string
hideClose?: boolean
onClose: () => void
onCloseOthers: () => void
onCloseLeft: () => void
onCloseRight: () => void
canCloseOthers: boolean
canCloseLeft: boolean
canCloseRight: boolean
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
Expand All@@ -796,15 +810,17 @@ function TabNavItem(props: {
)

return (
<div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<ContextMenu modal={false}>
<ContextMenu.Trigger
as="div"
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event: MouseEvent) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={session.latest}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
Expand DownExpand Up@@ -851,7 +867,24 @@ function TabNavItem(props: {
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.onClose()}>
<ContextMenu.ItemLabel>{language.t("common.closeTab")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseOthers} onSelect={() => props.onCloseOthers()}>
<ContextMenu.ItemLabel>{language.t("common.closeOtherTabs")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseLeft} onSelect={() => props.onCloseLeft()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToLeft")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseRight} onSelect={() => props.onCloseRight()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToRight")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
a4fd244
amicode: re-add Aero wordmark typeface (LICENSE-PENDING)
aarontrowbridge Jul 8, 2026
c811798
amicode: wire composer paste to the extension clipboard bridge
aarontrowbridge Jul 8, 2026
b868a59
amicode: generalize the paste-clipboard bridge past the composer
aarontrowbridge Jul 8, 2026
c52cffa
feat(amicode-ui): pure frontier DAG scheduler + tests
aarontrowbridge Jul 9, 2026
81066f8
feat(amicode-ui): composite-aware problem.ts display derivations
aarontrowbridge Jul 9, 2026
9810040
merge origin/local/amicode into local/amicode
aarontrowbridge Jul 9, 2026
baf6b9e
merge local/amicode into local/composite-system
aarontrowbridge Jul 9, 2026
3a85038
feat(amicode-ui): compactValue kills the JSON-dump receipt chokepoint
aarontrowbridge Jul 9, 2026
8ebebe7
feat(amicode-ui): setDiff — unique-keyed set delta (spec §4 / plan Ta…
aarontrowbridge Jul 9, 2026
e110d11
feat(amicode-ui): modeBadges — mode→badge model (spec §4 / plan Task 3)
aarontrowbridge Jul 9, 2026
dc47bdf
feat(amicode-ui): system schematic + table models (spec §6.1 / plan T…
aarontrowbridge Jul 9, 2026
a344e06
feat(amicode-ui): SystemComposite hero (schematic+table+Hamiltonian)
aarontrowbridge Jul 9, 2026
3cd5d1a
feat(amicode-ui): system entity-view + receipt use the composite hero
aarontrowbridge Jul 9, 2026
3d5d0de
feat(amicode-ui): formulationProjection (dual-shape, derived primaryK…
aarontrowbridge Jul 9, 2026
b94000a
fix(amicode-ui): formulation chip reads structured facets, not phanto…
aarontrowbridge Jul 9, 2026
cccb286
feat(amicode-ui): FormulationView hero (mode header + objective LaTeX…
aarontrowbridge Jul 9, 2026
d50d337
feat(amicode-ui): formulation entity-view + receipt (spec §5,§6.2 / T…
aarontrowbridge Jul 9, 2026
c0ea330
fix(amicode-ui): entity-view dialog overflow — wrap long values, shri…
aarontrowbridge Jul 10, 2026
a64da04
feat(amicode-ui): composite Hamiltonian LaTeX + compact timestamps + …
aarontrowbridge Jul 10, 2026
3509606
fix(amicode): entity-view dialog wrapper contains both axes (stop pan…
aarontrowbridge Jul 10, 2026
a4fc920
fix(amicode): entity dialog — definite width + overflow-x clip (stop …
aarontrowbridge Jul 10, 2026
e485430
merge: sync origin/local/amicode (#13,#16,#17,#18) into composite-system
aarontrowbridge Jul 10, 2026
1b46833
style(brand): Aero wordmark on logo.tsx (Aaron's call, this branch)
aarontrowbridge Jul 10, 2026
b81fb75
feat(prompt): Claude Code-style rich chat output for Claude models (p…
aarontrowbridge Jul 10, 2026
a86e690
docs(patches): stamp #18 final provenance — clean build at b81fb756f2…
aarontrowbridge Jul 10, 2026
d073fb6
feat(prompt): port rich-output style to default.txt + gemini.txt (pat…
aarontrowbridge Jul 10, 2026
1532788
refactor(prompt): model-agnostic communicating.txt appended for every…
aarontrowbridge Jul 10, 2026
c42043e
fix(amicode): rydberg Hamiltonian in System card + communicating.txt …
aarontrowbridge Jul 11, 2026
431b29a
feat(amicode): right-click tab management on the session tab strip
aarontrowbridge Jul 11, 2026
f78155a
fix(ui): markdown heading hierarchy — h1-h3 larger + 600 weight, spac…
aarontrowbridge Jul 11, 2026
37e304c
feat(amicode): add "Close tabs to the left" to the tab context menu
aarontrowbridge Jul 11, 2026
2ff5767
fix(amicode): register quantum-math KaTeX macros so chat \Tr etc. render
aarontrowbridge Jul 11, 2026
54c6559
fix(amicode): render mid-line-opened multi-line $$ display math in chat
aarontrowbridge Jul 11, 2026
236e647
feat(amicode): single-select question options commit on click
aarontrowbridge Jul 11, 2026
b23405f
feat(amicode): skill chips expand to show the injected instructions
aarontrowbridge Jul 11, 2026
e20c794
feat(amicode): declutter the System entity card — signal over noise
aarontrowbridge Jul 11, 2026
83bb3fe
feat(amicode): default-model control on the home dashboard
aarontrowbridge Jul 12, 2026
0923b68
feat(amicode): Claude-Code-esque thinking indicator on streaming turns
aarontrowbridge Jul 12, 2026
1389c9e
fix(amicode): dashboard model control hidden by over-strict visibilit…
aarontrowbridge Jul 12, 2026
23ef70c
feat(amicode): live token count in the thinking line
aarontrowbridge Jul 12, 2026
1a62b41
feat(amicode): humanTail filter for AMICODE_* machine lines
aarontrowbridge Jul 12, 2026
f0608c7
feat(amicode): widen run-series raw tail to 24 lines for client-side …
aarontrowbridge Jul 12, 2026
6a3e591
feat(amicode): extract shared run-plot geometry with pulse-first hero…
aarontrowbridge Jul 12, 2026
bd723d1
feat(amicode): pulse-first run window — pulse hero, F header sparklin…
aarontrowbridge Jul 12, 2026
6d3cf79
feat(amicode): drop self-facing best-F from About You stats and Pulse…
aarontrowbridge Jul 12, 2026
88cea38
feat(amicode): pulse-first Now-solving sparkline (per-drive paths, ob…
aarontrowbridge Jul 12, 2026
343fe36
feat(amicode): share poster reads pulse-first (pulse left, convergenc…
aarontrowbridge Jul 12, 2026
b1e2f12
feat(amicode): toml-lite subset reader for widget manifests
aarontrowbridge Jul 12, 2026
fa8ccb8
feat(amicode): Amico third-actor presence — state machine + lane CSS …
aarontrowbridge Jul 12, 2026
88e96d9
feat(amicode): widget manifest parse + config sanitize
aarontrowbridge Jul 12, 2026
5655d29
feat(amicode): dashboard state with sanitizing merge
aarontrowbridge Jul 12, 2026
59ac1c2
feat(amicode): seven built-in widget sources with executable parse+mo…
aarontrowbridge Jul 12, 2026
fcf202d
feat(amicode): widget registry with fork + content hash
aarontrowbridge Jul 12, 2026
ca5b31d
feat(amicode): widget + dashboard endpoints
aarontrowbridge Jul 12, 2026
7581b9a
feat(amicode): widget schema + allowlist cores
aarontrowbridge Jul 12, 2026
f3b333a
feat(amicode): amc tokens, srcdoc assembly with CSP, in-frame runtime
aarontrowbridge Jul 12, 2026
3545750
feat(amicode): widget bridge host + sandboxed frame with version gate…
aarontrowbridge Jul 12, 2026
a0c5019
feat(amicode): widget grid with inline edit mode + generated config f…
aarontrowbridge Jul 12, 2026
30bd257
feat(amicode): home dashboard renders WidgetGrid (legacy cards as fal…
aarontrowbridge Jul 12, 2026
b91ef7a
fix(amicode): serve widget frames as documents with own CSP — srcdoc …
aarontrowbridge Jul 12, 2026
bff6712
fix(amicode): explicit amc:empty signal breaks the hidden-frame deadl…
aarontrowbridge Jul 12, 2026
8854f25
feat(amicode): T3.6 reserved-key pass-through + manifest size toleran…
aarontrowbridge Jul 13, 2026
b64668d
feat(amicode): durable default-model pin — pin slot in models store, …
aarontrowbridge Jul 13, 2026
582a3fd
feat(amicode): panel-first chrome strip + sessions flyout + responsiv…
aarontrowbridge Jul 13, 2026
3b6d9ed
fix(amicode): redesign widget edit mode — cards stay, grouped corner …
aarontrowbridge Jul 13, 2026
ccc9c30
fix(amicode): collapse empty/missing cells out of the grid, not just …
aarontrowbridge Jul 13, 2026
86c12d7
feat(amicode): wire Amico third-actor presence into the chat flow
aarontrowbridge Jul 13, 2026
515c089
merge(amicode): bring Amico third-actor presence into the widget-kern…
aarontrowbridge Jul 13, 2026
110cb59
feat(amicode): chat widget authoring (Stage 2) — author tool + live i…
aarontrowbridge Jul 13, 2026
a5b3e37
feat(amicode): client-side clipboard bridge — image paste via navigat…
aarontrowbridge Jul 14, 2026
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
51 changes: 50 additions & 1 deletion AMICODE-PATCHES.md

Large diffs are not rendered by default.

Binary file addedpackages/app/public/assets/Aero-Regular.ttf
Binary file not shown.
17 changes: 17 additions & 0 deletions packages/app/src/components/amicode-default-model-value.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
// Pure "providerID/modelID" <-> ModelKey helpers for the dashboard default-model
// control. Split out of amicode-default-model.tsx so they're unit-testable
// without evaluating the SolidJS component (which touches client-only context).

/** "providerID/modelID". modelID may itself contain "/", so callers split on the first. */
export function formatModelValue(key: { providerID: string; modelID: string }): string {
return `${key.providerID}/${key.modelID}`
}

/** Inverse of formatModelValue; null on a malformed/empty value. Splits on the
* FIRST slash so a model id containing slashes (e.g. openrouter/meta-llama/…)
* round-trips. */
export function parseModelValue(value: string): { providerID: string; modelID: string } | null {
const slash = value.indexOf("/")
if (slash <= 0 || slash === value.length - 1) return null
return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) }
}
28 changes: 28 additions & 0 deletions packages/app/src/components/amicode-default-model.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

describe("amicode default-model value round-trip", () => {
test("format then parse is identity, incl. model ids with dots/dashes", () => {
const key = { providerID: "anthropic", modelID: "claude-sonnet-5" }
expect(formatModelValue(key)).toBe("anthropic/claude-sonnet-5")
expect(parseModelValue(formatModelValue(key))).toEqual(key)
expect(parseModelValue("opencode/deepseek-v4-flash-free")).toEqual({
providerID: "opencode",
modelID: "deepseek-v4-flash-free",
})
})

test("splits on the FIRST slash so a model id may itself contain slashes", () => {
expect(parseModelValue("openrouter/meta-llama/llama-3.3")).toEqual({
providerID: "openrouter",
modelID: "meta-llama/llama-3.3",
})
})

test("rejects malformed / empty values (→ null, no selection)", () => {
expect(parseModelValue("")).toBeNull()
expect(parseModelValue("noslash")).toBeNull()
expect(parseModelValue("/leading")).toBeNull()
expect(parseModelValue("trailing/")).toBeNull()
})
})
106 changes: 106 additions & 0 deletions packages/app/src/components/amicode-default-model.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
import { For, Show, createMemo } from "solid-js"
import { useModels } from "@/context/models"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

// Dashboard "Default model" control. Drives opencode's GLOBAL model store
// (useModels — mounted app-wide, persisted via Persist.global), which is what
// new chats inherit as their starting model (see local.tsx recentModel()), so
// a change takes effect immediately with no server restart. It ALSO mirrors the
// choice into the `amicode.defaultModel` VS Code setting over the iframe→parent
// bridge (chat_panel.ts), keeping the headless/first-turn config pin in sync.
// The in-chat picker still overrides per session.

/** Post the chosen model to the extension host so it updates amicode.defaultModel.
* No-op when unframed (plain web/desktop): there's no parent shell to relay it. */
function syncDefaultModelPin(model: string, win: Window = window): void {
if (win.parent === win) return
win.parent.postMessage({ source: "amicode", kind: "set-default-model", model }, "*")
}

export function AmicodeDefaultModel() {
const models = useModels()

const available = createMemo(() => models.list())
// opencode's default visibility hides dated, non-"latest" models — which can
// hide EVERY model and leave the control empty. Prefer the visible set, but
// fall back to all available models so the picker is never blank when models
// exist. (Guarded below on available(), so it only hides with zero models.)
const selectable = createMemo(() => {
const vis = available().filter((m) => models.visible({ providerID: m.provider.id, modelID: m.id }))
return vis.length > 0 ? vis : available()
})

const groups = createMemo(() => {
const byProvider = new Map<string, { provider: string; items: { value: string; name: string }[] }>()
for (const m of selectable()) {
const group = byProvider.get(m.provider.id) ?? { provider: m.provider.name, items: [] }
group.items.push({ value: formatModelValue({ providerID: m.provider.id, modelID: m.id }), name: m.name })
byProvider.set(m.provider.id, group)
}
return [...byProvider.values()]
})

// Bound to the DURABLE pin (models.pin), not the recents head — chats using
// other models must not move the dashboard's declared default. Empty = Auto
// (no pin; new chats fall back to recents/provider default).
const current = createMemo(() => {
const pin = models.pin.get()
return pin ? formatModelValue(pin) : ""
})

const choose = (value: string) => {
if (value === "") {
models.pin.set(undefined) // Auto: clear the pin, mirror the cleared setting
syncDefaultModelPin("")
return
}
const key = parseModelValue(value)
if (!key) return
models.pin.set(key)
syncDefaultModelPin(value)
}

return (
<Show when={available().length > 0}>
<label
data-component="amicode-default-model"
title="Default model for new chats"
style={{
display: "inline-flex",
"align-items": "center",
gap: "8px",
"font-size": "12px",
color: "var(--v2-text-text-muted)",
}}
>
<span data-slot="amicode-default-model-label" style={{ "font-weight": "550" }}>
Model
</span>
<select
data-slot="amicode-default-model-select"
value={current()}
onChange={(event) => choose(event.currentTarget.value)}
style={{
"font-size": "12px",
color: "var(--v2-text-text-base)",
background: "var(--v2-background-bg-layer-01)",
border: "1px solid var(--v2-border-border-base)",
"border-radius": "6px",
padding: "3px 6px",
"max-width": "220px",
cursor: "pointer",
}}
>
<option value="">Auto</option>
<For each={groups()}>
{(group) => (
<optgroup label={group.provider}>
<For each={group.items}>{(item) => <option value={item.value}>{item.name}</option>}</For>
</optgroup>
)}
</For>
</select>
</label>
</Show>
)
}
13 changes: 10 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { inAmicode } from "@/pages/session/use-amicode-commands"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge"
import { readClipboardViaBridge, readClipboardImageViaBridge } from "./prompt-input/clipboard-bridge"
import { normalizePaste } from "./prompt-input/paste"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import {
Expand DownExpand Up@@ -1147,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// browser dispatches no usable paste event on ⌘V (unlike plain web/desktop,
// where onPaste handles it). Intercept the keystroke and read the OS
// clipboard over the extension bridge instead (see clipboard-bridge.ts).
// Image first (screenshots), then text — mirrors handlePaste's precedence.
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
Expand All@@ -1155,9 +1156,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
inAmicode()
) {
event.preventDefault()
void readClipboardViaBridge().then((text) => {
void (async () => {
const image = await readClipboardImageViaBridge()
if (image) {
await addAttachment(image)
return
}
const text = await readClipboardViaBridge()
if (text) addPart({ type: "text", content: normalizePaste(text), start: 0, end: 0 })
})
})()
return
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { readClipboardViaBridge } from "./clipboard-bridge"
import { readClipboardViaBridge, writeClipboardViaBridge } from "./clipboard-bridge"

type Listener = (event: MessageEvent) => void

Expand DownExpand Up@@ -74,3 +74,26 @@ describe("readClipboardViaBridge", () => {
expect(posted).toHaveLength(0)
})
})

describe("writeClipboardViaBridge", () => {
test("pushes the copied text to the host so the OS clipboard matches ⌘V's read", () => {
const bridge = fakeFramedWindow()

expect(writeClipboardViaBridge("solve a CZ gate", bridge.win)).toBe(true)
expect(bridge.posted).toHaveLength(1)
const message = bridge.posted[0]
expect(message.source).toBe("amicode")
expect(message.kind).toBe("clipboard-write")
expect(message.text).toBe("solve a CZ gate")
})

test("is a no-op that posts nothing when the app is not framed", () => {
const posted: unknown[] = []
const win = { postMessage: (message: unknown) => posted.push(message) } as unknown as Window
// parent === self → plain web/desktop, where native copy already works
;(win as unknown as { parent: Window }).parent = win

expect(writeClipboardViaBridge("ignored", win)).toBe(false)
expect(posted).toHaveLength(0)
})
})
65 changes: 65 additions & 0 deletions packages/app/src/components/prompt-input/clipboard-bridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@

const BRIDGE_TIMEOUT_MS = 1500

// ⌘C inside the Amicode chat is the mirror image of ⌘V: the sandboxed iframe's
// native copy (and navigator.clipboard.writeText) never reaches the OS
// clipboard, so a subsequent ⌘V — which DOES read the OS clipboard over the
// bridge — pastes whatever stale thing was there before, not what you copied.
// Push the copied text to the extension host, which writes vscode.env.clipboard
// so read and write hit the same clipboard. Fire-and-forget: the host write is
// near-instant and callers have nothing to await. Resolves false (no-op) when
// unframed — native copy already works there.
export function writeClipboardViaBridge(text: string, win: Window = window): boolean {
if (win.parent === win) return false
win.parent.postMessage({ source: "amicode", kind: "clipboard-write", text }, "*")
return true
}

export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<string> {
return new Promise<string>((resolve) => {
// Unframed: native paste works — don't post into the void or wait out the timeout.
Expand DownExpand Up@@ -40,3 +54,54 @@ export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_
timer = setTimeout(() => finish(""), timeoutMs)
})
}

// ⌘V image paste inside the Amicode chat. Unlike text (which the extension host
// reads via vscode.env.clipboard — text-only, no image API), an image can only
// come from the OUTER webview reading navigator.clipboard.read() client-side.
// So we ask the parent relay (chat_panel.ts) rather than the host: it grabs the
// first image/* clipboard item, encodes it as a data URL, and replies. We
// rebuild a File from the reply so callers reuse the normal attachment path.
// Resolves null when unframed, on a text-only clipboard, or after `timeoutMs`.
export function readClipboardImageViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<File | null> {
return new Promise<File | null>((resolve) => {
if (win.parent === win) {
resolve(null)
return
}

const nonce = Math.random().toString(36).slice(2)
let timer: ReturnType<typeof setTimeout> | undefined

const finish = (file: File | null) => {
win.removeEventListener("message", onMessage)
if (timer !== undefined) clearTimeout(timer)
resolve(file)
}

const onMessage = (event: MessageEvent) => {
const data = event.data as
| { source?: string; kind?: string; nonce?: string; dataUrl?: string; mime?: string; filename?: string }
| undefined
if (data?.source !== "amicode" || data.kind !== "clipboard-image" || data.nonce !== nonce) return
finish(fileFromDataUrl(data.dataUrl, data.mime, data.filename))
}

win.addEventListener("message", onMessage)
win.parent.postMessage({ source: "amicode", kind: "clipboard-image-request", nonce }, "*")
timer = setTimeout(() => finish(null), timeoutMs)
})
}

function fileFromDataUrl(dataUrl?: string, mime?: string, filename?: string): File | null {
if (!dataUrl || !mime) return null
const comma = dataUrl.indexOf(",")
if (comma === -1) return null
try {
const binary = atob(dataUrl.slice(comma + 1))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new File([bytes], filename || "pasted-image.png", { type: mime })
} catch {
return null
}
}
53 changes: 43 additions & 10 deletions packages/app/src/components/titlebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
import { Mark } from "@opencode-ai/ui/logo"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner"

type TauriDesktopWindow = {
Expand DownExpand Up@@ -489,6 +490,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
onCloseOthers={() => tabsStoreActions.closeOthers(i())}
onCloseLeft={() => tabsStoreActions.closeToLeft(i())}
onCloseRight={() => tabsStoreActions.closeToRight(i())}
canCloseOthers={tabsStore.length > 1}
canCloseLeft={i() > 0}
canCloseRight={i() < tabsStore.length - 1}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
Expand DownExpand Up@@ -765,11 +772,18 @@ function TabNavItem(props: {
sessionId?: string
hideClose?: boolean
onClose: () => void
onCloseOthers: () => void
onCloseLeft: () => void
onCloseRight: () => void
canCloseOthers: boolean
canCloseLeft: boolean
canCloseRight: boolean
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
Expand All@@ -796,15 +810,17 @@ function TabNavItem(props: {
)

return (
<div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<ContextMenu modal={false}>
<ContextMenu.Trigger
as="div"
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event: MouseEvent) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={session.latest}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
Expand DownExpand Up@@ -851,7 +867,24 @@ function TabNavItem(props: {
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.onClose()}>
<ContextMenu.ItemLabel>{language.t("common.closeTab")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseOthers} onSelect={() => props.onCloseOthers()}>
<ContextMenu.ItemLabel>{language.t("common.closeOtherTabs")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseLeft} onSelect={() => props.onCloseLeft()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToLeft")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseRight} onSelect={() => props.onCloseRight()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToRight")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
a4fd244
amicode: re-add Aero wordmark typeface (LICENSE-PENDING)
aarontrowbridge Jul 8, 2026
c811798
amicode: wire composer paste to the extension clipboard bridge
aarontrowbridge Jul 8, 2026
b868a59
amicode: generalize the paste-clipboard bridge past the composer
aarontrowbridge Jul 8, 2026
c52cffa
feat(amicode-ui): pure frontier DAG scheduler + tests
aarontrowbridge Jul 9, 2026
81066f8
feat(amicode-ui): composite-aware problem.ts display derivations
aarontrowbridge Jul 9, 2026
9810040
merge origin/local/amicode into local/amicode
aarontrowbridge Jul 9, 2026
baf6b9e
merge local/amicode into local/composite-system
aarontrowbridge Jul 9, 2026
3a85038
feat(amicode-ui): compactValue kills the JSON-dump receipt chokepoint
aarontrowbridge Jul 9, 2026
8ebebe7
feat(amicode-ui): setDiff — unique-keyed set delta (spec §4 / plan Ta…
aarontrowbridge Jul 9, 2026
e110d11
feat(amicode-ui): modeBadges — mode→badge model (spec §4 / plan Task 3)
aarontrowbridge Jul 9, 2026
dc47bdf
feat(amicode-ui): system schematic + table models (spec §6.1 / plan T…
aarontrowbridge Jul 9, 2026
a344e06
feat(amicode-ui): SystemComposite hero (schematic+table+Hamiltonian)
aarontrowbridge Jul 9, 2026
3cd5d1a
feat(amicode-ui): system entity-view + receipt use the composite hero
aarontrowbridge Jul 9, 2026
3d5d0de
feat(amicode-ui): formulationProjection (dual-shape, derived primaryK…
aarontrowbridge Jul 9, 2026
b94000a
fix(amicode-ui): formulation chip reads structured facets, not phanto…
aarontrowbridge Jul 9, 2026
cccb286
feat(amicode-ui): FormulationView hero (mode header + objective LaTeX…
aarontrowbridge Jul 9, 2026
d50d337
feat(amicode-ui): formulation entity-view + receipt (spec §5,§6.2 / T…
aarontrowbridge Jul 9, 2026
c0ea330
fix(amicode-ui): entity-view dialog overflow — wrap long values, shri…
aarontrowbridge Jul 10, 2026
a64da04
feat(amicode-ui): composite Hamiltonian LaTeX + compact timestamps + …
aarontrowbridge Jul 10, 2026
3509606
fix(amicode): entity-view dialog wrapper contains both axes (stop pan…
aarontrowbridge Jul 10, 2026
a4fc920
fix(amicode): entity dialog — definite width + overflow-x clip (stop …
aarontrowbridge Jul 10, 2026
e485430
merge: sync origin/local/amicode (#13,#16,#17,#18) into composite-system
aarontrowbridge Jul 10, 2026
1b46833
style(brand): Aero wordmark on logo.tsx (Aaron's call, this branch)
aarontrowbridge Jul 10, 2026
b81fb75
feat(prompt): Claude Code-style rich chat output for Claude models (p…
aarontrowbridge Jul 10, 2026
a86e690
docs(patches): stamp #18 final provenance — clean build at b81fb756f2…
aarontrowbridge Jul 10, 2026
d073fb6
feat(prompt): port rich-output style to default.txt + gemini.txt (pat…
aarontrowbridge Jul 10, 2026
1532788
refactor(prompt): model-agnostic communicating.txt appended for every…
aarontrowbridge Jul 10, 2026
c42043e
fix(amicode): rydberg Hamiltonian in System card + communicating.txt …
aarontrowbridge Jul 11, 2026
431b29a
feat(amicode): right-click tab management on the session tab strip
aarontrowbridge Jul 11, 2026
f78155a
fix(ui): markdown heading hierarchy — h1-h3 larger + 600 weight, spac…
aarontrowbridge Jul 11, 2026
37e304c
feat(amicode): add "Close tabs to the left" to the tab context menu
aarontrowbridge Jul 11, 2026
2ff5767
fix(amicode): register quantum-math KaTeX macros so chat \Tr etc. render
aarontrowbridge Jul 11, 2026
54c6559
fix(amicode): render mid-line-opened multi-line $$ display math in chat
aarontrowbridge Jul 11, 2026
236e647
feat(amicode): single-select question options commit on click
aarontrowbridge Jul 11, 2026
b23405f
feat(amicode): skill chips expand to show the injected instructions
aarontrowbridge Jul 11, 2026
e20c794
feat(amicode): declutter the System entity card — signal over noise
aarontrowbridge Jul 11, 2026
83bb3fe
feat(amicode): default-model control on the home dashboard
aarontrowbridge Jul 12, 2026
0923b68
feat(amicode): Claude-Code-esque thinking indicator on streaming turns
aarontrowbridge Jul 12, 2026
1389c9e
fix(amicode): dashboard model control hidden by over-strict visibilit…
aarontrowbridge Jul 12, 2026
23ef70c
feat(amicode): live token count in the thinking line
aarontrowbridge Jul 12, 2026
1a62b41
feat(amicode): humanTail filter for AMICODE_* machine lines
aarontrowbridge Jul 12, 2026
f0608c7
feat(amicode): widen run-series raw tail to 24 lines for client-side …
aarontrowbridge Jul 12, 2026
6a3e591
feat(amicode): extract shared run-plot geometry with pulse-first hero…
aarontrowbridge Jul 12, 2026
bd723d1
feat(amicode): pulse-first run window — pulse hero, F header sparklin…
aarontrowbridge Jul 12, 2026
6d3cf79
feat(amicode): drop self-facing best-F from About You stats and Pulse…
aarontrowbridge Jul 12, 2026
88cea38
feat(amicode): pulse-first Now-solving sparkline (per-drive paths, ob…
aarontrowbridge Jul 12, 2026
343fe36
feat(amicode): share poster reads pulse-first (pulse left, convergenc…
aarontrowbridge Jul 12, 2026
b1e2f12
feat(amicode): toml-lite subset reader for widget manifests
aarontrowbridge Jul 12, 2026
fa8ccb8
feat(amicode): Amico third-actor presence — state machine + lane CSS …
aarontrowbridge Jul 12, 2026
88e96d9
feat(amicode): widget manifest parse + config sanitize
aarontrowbridge Jul 12, 2026
5655d29
feat(amicode): dashboard state with sanitizing merge
aarontrowbridge Jul 12, 2026
59ac1c2
feat(amicode): seven built-in widget sources with executable parse+mo…
aarontrowbridge Jul 12, 2026
fcf202d
feat(amicode): widget registry with fork + content hash
aarontrowbridge Jul 12, 2026
ca5b31d
feat(amicode): widget + dashboard endpoints
aarontrowbridge Jul 12, 2026
7581b9a
feat(amicode): widget schema + allowlist cores
aarontrowbridge Jul 12, 2026
f3b333a
feat(amicode): amc tokens, srcdoc assembly with CSP, in-frame runtime
aarontrowbridge Jul 12, 2026
3545750
feat(amicode): widget bridge host + sandboxed frame with version gate…
aarontrowbridge Jul 12, 2026
a0c5019
feat(amicode): widget grid with inline edit mode + generated config f…
aarontrowbridge Jul 12, 2026
30bd257
feat(amicode): home dashboard renders WidgetGrid (legacy cards as fal…
aarontrowbridge Jul 12, 2026
b91ef7a
fix(amicode): serve widget frames as documents with own CSP — srcdoc …
aarontrowbridge Jul 12, 2026
bff6712
fix(amicode): explicit amc:empty signal breaks the hidden-frame deadl…
aarontrowbridge Jul 12, 2026
8854f25
feat(amicode): T3.6 reserved-key pass-through + manifest size toleran…
aarontrowbridge Jul 13, 2026
b64668d
feat(amicode): durable default-model pin — pin slot in models store, …
aarontrowbridge Jul 13, 2026
582a3fd
feat(amicode): panel-first chrome strip + sessions flyout + responsiv…
aarontrowbridge Jul 13, 2026
3b6d9ed
fix(amicode): redesign widget edit mode — cards stay, grouped corner …
aarontrowbridge Jul 13, 2026
ccc9c30
fix(amicode): collapse empty/missing cells out of the grid, not just …
aarontrowbridge Jul 13, 2026
86c12d7
feat(amicode): wire Amico third-actor presence into the chat flow
aarontrowbridge Jul 13, 2026
515c089
merge(amicode): bring Amico third-actor presence into the widget-kern…
aarontrowbridge Jul 13, 2026
110cb59
feat(amicode): chat widget authoring (Stage 2) — author tool + live i…
aarontrowbridge Jul 13, 2026
a5b3e37
feat(amicode): client-side clipboard bridge — image paste via navigat…
aarontrowbridge Jul 14, 2026
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
51 changes: 50 additions & 1 deletion AMICODE-PATCHES.md

Large diffs are not rendered by default.

Binary file addedpackages/app/public/assets/Aero-Regular.ttf
Binary file not shown.
17 changes: 17 additions & 0 deletions packages/app/src/components/amicode-default-model-value.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
// Pure "providerID/modelID" <-> ModelKey helpers for the dashboard default-model
// control. Split out of amicode-default-model.tsx so they're unit-testable
// without evaluating the SolidJS component (which touches client-only context).

/** "providerID/modelID". modelID may itself contain "/", so callers split on the first. */
export function formatModelValue(key: { providerID: string; modelID: string }): string {
return `${key.providerID}/${key.modelID}`
}

/** Inverse of formatModelValue; null on a malformed/empty value. Splits on the
* FIRST slash so a model id containing slashes (e.g. openrouter/meta-llama/…)
* round-trips. */
export function parseModelValue(value: string): { providerID: string; modelID: string } | null {
const slash = value.indexOf("/")
if (slash <= 0 || slash === value.length - 1) return null
return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) }
}
28 changes: 28 additions & 0 deletions packages/app/src/components/amicode-default-model.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

describe("amicode default-model value round-trip", () => {
test("format then parse is identity, incl. model ids with dots/dashes", () => {
const key = { providerID: "anthropic", modelID: "claude-sonnet-5" }
expect(formatModelValue(key)).toBe("anthropic/claude-sonnet-5")
expect(parseModelValue(formatModelValue(key))).toEqual(key)
expect(parseModelValue("opencode/deepseek-v4-flash-free")).toEqual({
providerID: "opencode",
modelID: "deepseek-v4-flash-free",
})
})

test("splits on the FIRST slash so a model id may itself contain slashes", () => {
expect(parseModelValue("openrouter/meta-llama/llama-3.3")).toEqual({
providerID: "openrouter",
modelID: "meta-llama/llama-3.3",
})
})

test("rejects malformed / empty values (→ null, no selection)", () => {
expect(parseModelValue("")).toBeNull()
expect(parseModelValue("noslash")).toBeNull()
expect(parseModelValue("/leading")).toBeNull()
expect(parseModelValue("trailing/")).toBeNull()
})
})
106 changes: 106 additions & 0 deletions packages/app/src/components/amicode-default-model.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
import { For, Show, createMemo } from "solid-js"
import { useModels } from "@/context/models"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

// Dashboard "Default model" control. Drives opencode's GLOBAL model store
// (useModels — mounted app-wide, persisted via Persist.global), which is what
// new chats inherit as their starting model (see local.tsx recentModel()), so
// a change takes effect immediately with no server restart. It ALSO mirrors the
// choice into the `amicode.defaultModel` VS Code setting over the iframe→parent
// bridge (chat_panel.ts), keeping the headless/first-turn config pin in sync.
// The in-chat picker still overrides per session.

/** Post the chosen model to the extension host so it updates amicode.defaultModel.
* No-op when unframed (plain web/desktop): there's no parent shell to relay it. */
function syncDefaultModelPin(model: string, win: Window = window): void {
if (win.parent === win) return
win.parent.postMessage({ source: "amicode", kind: "set-default-model", model }, "*")
}

export function AmicodeDefaultModel() {
const models = useModels()

const available = createMemo(() => models.list())
// opencode's default visibility hides dated, non-"latest" models — which can
// hide EVERY model and leave the control empty. Prefer the visible set, but
// fall back to all available models so the picker is never blank when models
// exist. (Guarded below on available(), so it only hides with zero models.)
const selectable = createMemo(() => {
const vis = available().filter((m) => models.visible({ providerID: m.provider.id, modelID: m.id }))
return vis.length > 0 ? vis : available()
})

const groups = createMemo(() => {
const byProvider = new Map<string, { provider: string; items: { value: string; name: string }[] }>()
for (const m of selectable()) {
const group = byProvider.get(m.provider.id) ?? { provider: m.provider.name, items: [] }
group.items.push({ value: formatModelValue({ providerID: m.provider.id, modelID: m.id }), name: m.name })
byProvider.set(m.provider.id, group)
}
return [...byProvider.values()]
})

// Bound to the DURABLE pin (models.pin), not the recents head — chats using
// other models must not move the dashboard's declared default. Empty = Auto
// (no pin; new chats fall back to recents/provider default).
const current = createMemo(() => {
const pin = models.pin.get()
return pin ? formatModelValue(pin) : ""
})

const choose = (value: string) => {
if (value === "") {
models.pin.set(undefined) // Auto: clear the pin, mirror the cleared setting
syncDefaultModelPin("")
return
}
const key = parseModelValue(value)
if (!key) return
models.pin.set(key)
syncDefaultModelPin(value)
}

return (
<Show when={available().length > 0}>
<label
data-component="amicode-default-model"
title="Default model for new chats"
style={{
display: "inline-flex",
"align-items": "center",
gap: "8px",
"font-size": "12px",
color: "var(--v2-text-text-muted)",
}}
>
<span data-slot="amicode-default-model-label" style={{ "font-weight": "550" }}>
Model
</span>
<select
data-slot="amicode-default-model-select"
value={current()}
onChange={(event) => choose(event.currentTarget.value)}
style={{
"font-size": "12px",
color: "var(--v2-text-text-base)",
background: "var(--v2-background-bg-layer-01)",
border: "1px solid var(--v2-border-border-base)",
"border-radius": "6px",
padding: "3px 6px",
"max-width": "220px",
cursor: "pointer",
}}
>
<option value="">Auto</option>
<For each={groups()}>
{(group) => (
<optgroup label={group.provider}>
<For each={group.items}>{(item) => <option value={item.value}>{item.name}</option>}</For>
</optgroup>
)}
</For>
</select>
</label>
</Show>
)
}
13 changes: 10 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { inAmicode } from "@/pages/session/use-amicode-commands"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge"
import { readClipboardViaBridge, readClipboardImageViaBridge } from "./prompt-input/clipboard-bridge"
import { normalizePaste } from "./prompt-input/paste"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import {
Expand DownExpand Up@@ -1147,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// browser dispatches no usable paste event on ⌘V (unlike plain web/desktop,
// where onPaste handles it). Intercept the keystroke and read the OS
// clipboard over the extension bridge instead (see clipboard-bridge.ts).
// Image first (screenshots), then text — mirrors handlePaste's precedence.
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
Expand All@@ -1155,9 +1156,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
inAmicode()
) {
event.preventDefault()
void readClipboardViaBridge().then((text) => {
void (async () => {
const image = await readClipboardImageViaBridge()
if (image) {
await addAttachment(image)
return
}
const text = await readClipboardViaBridge()
if (text) addPart({ type: "text", content: normalizePaste(text), start: 0, end: 0 })
})
})()
return
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { readClipboardViaBridge } from "./clipboard-bridge"
import { readClipboardViaBridge, writeClipboardViaBridge } from "./clipboard-bridge"

type Listener = (event: MessageEvent) => void

Expand DownExpand Up@@ -74,3 +74,26 @@ describe("readClipboardViaBridge", () => {
expect(posted).toHaveLength(0)
})
})

describe("writeClipboardViaBridge", () => {
test("pushes the copied text to the host so the OS clipboard matches ⌘V's read", () => {
const bridge = fakeFramedWindow()

expect(writeClipboardViaBridge("solve a CZ gate", bridge.win)).toBe(true)
expect(bridge.posted).toHaveLength(1)
const message = bridge.posted[0]
expect(message.source).toBe("amicode")
expect(message.kind).toBe("clipboard-write")
expect(message.text).toBe("solve a CZ gate")
})

test("is a no-op that posts nothing when the app is not framed", () => {
const posted: unknown[] = []
const win = { postMessage: (message: unknown) => posted.push(message) } as unknown as Window
// parent === self → plain web/desktop, where native copy already works
;(win as unknown as { parent: Window }).parent = win

expect(writeClipboardViaBridge("ignored", win)).toBe(false)
expect(posted).toHaveLength(0)
})
})
65 changes: 65 additions & 0 deletions packages/app/src/components/prompt-input/clipboard-bridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@

const BRIDGE_TIMEOUT_MS = 1500

// ⌘C inside the Amicode chat is the mirror image of ⌘V: the sandboxed iframe's
// native copy (and navigator.clipboard.writeText) never reaches the OS
// clipboard, so a subsequent ⌘V — which DOES read the OS clipboard over the
// bridge — pastes whatever stale thing was there before, not what you copied.
// Push the copied text to the extension host, which writes vscode.env.clipboard
// so read and write hit the same clipboard. Fire-and-forget: the host write is
// near-instant and callers have nothing to await. Resolves false (no-op) when
// unframed — native copy already works there.
export function writeClipboardViaBridge(text: string, win: Window = window): boolean {
if (win.parent === win) return false
win.parent.postMessage({ source: "amicode", kind: "clipboard-write", text }, "*")
return true
}

export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<string> {
return new Promise<string>((resolve) => {
// Unframed: native paste works — don't post into the void or wait out the timeout.
Expand DownExpand Up@@ -40,3 +54,54 @@ export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_
timer = setTimeout(() => finish(""), timeoutMs)
})
}

// ⌘V image paste inside the Amicode chat. Unlike text (which the extension host
// reads via vscode.env.clipboard — text-only, no image API), an image can only
// come from the OUTER webview reading navigator.clipboard.read() client-side.
// So we ask the parent relay (chat_panel.ts) rather than the host: it grabs the
// first image/* clipboard item, encodes it as a data URL, and replies. We
// rebuild a File from the reply so callers reuse the normal attachment path.
// Resolves null when unframed, on a text-only clipboard, or after `timeoutMs`.
export function readClipboardImageViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<File | null> {
return new Promise<File | null>((resolve) => {
if (win.parent === win) {
resolve(null)
return
}

const nonce = Math.random().toString(36).slice(2)
let timer: ReturnType<typeof setTimeout> | undefined

const finish = (file: File | null) => {
win.removeEventListener("message", onMessage)
if (timer !== undefined) clearTimeout(timer)
resolve(file)
}

const onMessage = (event: MessageEvent) => {
const data = event.data as
| { source?: string; kind?: string; nonce?: string; dataUrl?: string; mime?: string; filename?: string }
| undefined
if (data?.source !== "amicode" || data.kind !== "clipboard-image" || data.nonce !== nonce) return
finish(fileFromDataUrl(data.dataUrl, data.mime, data.filename))
}

win.addEventListener("message", onMessage)
win.parent.postMessage({ source: "amicode", kind: "clipboard-image-request", nonce }, "*")
timer = setTimeout(() => finish(null), timeoutMs)
})
}

function fileFromDataUrl(dataUrl?: string, mime?: string, filename?: string): File | null {
if (!dataUrl || !mime) return null
const comma = dataUrl.indexOf(",")
if (comma === -1) return null
try {
const binary = atob(dataUrl.slice(comma + 1))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new File([bytes], filename || "pasted-image.png", { type: mime })
} catch {
return null
}
}
53 changes: 43 additions & 10 deletions packages/app/src/components/titlebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
import { Mark } from "@opencode-ai/ui/logo"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner"

type TauriDesktopWindow = {
Expand DownExpand Up@@ -489,6 +490,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
onCloseOthers={() => tabsStoreActions.closeOthers(i())}
onCloseLeft={() => tabsStoreActions.closeToLeft(i())}
onCloseRight={() => tabsStoreActions.closeToRight(i())}
canCloseOthers={tabsStore.length > 1}
canCloseLeft={i() > 0}
canCloseRight={i() < tabsStore.length - 1}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
Expand DownExpand Up@@ -765,11 +772,18 @@ function TabNavItem(props: {
sessionId?: string
hideClose?: boolean
onClose: () => void
onCloseOthers: () => void
onCloseLeft: () => void
onCloseRight: () => void
canCloseOthers: boolean
canCloseLeft: boolean
canCloseRight: boolean
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
Expand All@@ -796,15 +810,17 @@ function TabNavItem(props: {
)

return (
<div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<ContextMenu modal={false}>
<ContextMenu.Trigger
as="div"
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event: MouseEvent) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={session.latest}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
Expand DownExpand Up@@ -851,7 +867,24 @@ function TabNavItem(props: {
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.onClose()}>
<ContextMenu.ItemLabel>{language.t("common.closeTab")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseOthers} onSelect={() => props.onCloseOthers()}>
<ContextMenu.ItemLabel>{language.t("common.closeOtherTabs")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseLeft} onSelect={() => props.onCloseLeft()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToLeft")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseRight} onSelect={() => props.onCloseRight()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToRight")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
a4fd244
amicode: re-add Aero wordmark typeface (LICENSE-PENDING)
aarontrowbridge Jul 8, 2026
c811798
amicode: wire composer paste to the extension clipboard bridge
aarontrowbridge Jul 8, 2026
b868a59
amicode: generalize the paste-clipboard bridge past the composer
aarontrowbridge Jul 8, 2026
c52cffa
feat(amicode-ui): pure frontier DAG scheduler + tests
aarontrowbridge Jul 9, 2026
81066f8
feat(amicode-ui): composite-aware problem.ts display derivations
aarontrowbridge Jul 9, 2026
9810040
merge origin/local/amicode into local/amicode
aarontrowbridge Jul 9, 2026
baf6b9e
merge local/amicode into local/composite-system
aarontrowbridge Jul 9, 2026
3a85038
feat(amicode-ui): compactValue kills the JSON-dump receipt chokepoint
aarontrowbridge Jul 9, 2026
8ebebe7
feat(amicode-ui): setDiff — unique-keyed set delta (spec §4 / plan Ta…
aarontrowbridge Jul 9, 2026
e110d11
feat(amicode-ui): modeBadges — mode→badge model (spec §4 / plan Task 3)
aarontrowbridge Jul 9, 2026
dc47bdf
feat(amicode-ui): system schematic + table models (spec §6.1 / plan T…
aarontrowbridge Jul 9, 2026
a344e06
feat(amicode-ui): SystemComposite hero (schematic+table+Hamiltonian)
aarontrowbridge Jul 9, 2026
3cd5d1a
feat(amicode-ui): system entity-view + receipt use the composite hero
aarontrowbridge Jul 9, 2026
3d5d0de
feat(amicode-ui): formulationProjection (dual-shape, derived primaryK…
aarontrowbridge Jul 9, 2026
b94000a
fix(amicode-ui): formulation chip reads structured facets, not phanto…
aarontrowbridge Jul 9, 2026
cccb286
feat(amicode-ui): FormulationView hero (mode header + objective LaTeX…
aarontrowbridge Jul 9, 2026
d50d337
feat(amicode-ui): formulation entity-view + receipt (spec §5,§6.2 / T…
aarontrowbridge Jul 9, 2026
c0ea330
fix(amicode-ui): entity-view dialog overflow — wrap long values, shri…
aarontrowbridge Jul 10, 2026
a64da04
feat(amicode-ui): composite Hamiltonian LaTeX + compact timestamps + …
aarontrowbridge Jul 10, 2026
3509606
fix(amicode): entity-view dialog wrapper contains both axes (stop pan…
aarontrowbridge Jul 10, 2026
a4fc920
fix(amicode): entity dialog — definite width + overflow-x clip (stop …
aarontrowbridge Jul 10, 2026
e485430
merge: sync origin/local/amicode (#13,#16,#17,#18) into composite-system
aarontrowbridge Jul 10, 2026
1b46833
style(brand): Aero wordmark on logo.tsx (Aaron's call, this branch)
aarontrowbridge Jul 10, 2026
b81fb75
feat(prompt): Claude Code-style rich chat output for Claude models (p…
aarontrowbridge Jul 10, 2026
a86e690
docs(patches): stamp #18 final provenance — clean build at b81fb756f2…
aarontrowbridge Jul 10, 2026
d073fb6
feat(prompt): port rich-output style to default.txt + gemini.txt (pat…
aarontrowbridge Jul 10, 2026
1532788
refactor(prompt): model-agnostic communicating.txt appended for every…
aarontrowbridge Jul 10, 2026
c42043e
fix(amicode): rydberg Hamiltonian in System card + communicating.txt …
aarontrowbridge Jul 11, 2026
431b29a
feat(amicode): right-click tab management on the session tab strip
aarontrowbridge Jul 11, 2026
f78155a
fix(ui): markdown heading hierarchy — h1-h3 larger + 600 weight, spac…
aarontrowbridge Jul 11, 2026
37e304c
feat(amicode): add "Close tabs to the left" to the tab context menu
aarontrowbridge Jul 11, 2026
2ff5767
fix(amicode): register quantum-math KaTeX macros so chat \Tr etc. render
aarontrowbridge Jul 11, 2026
54c6559
fix(amicode): render mid-line-opened multi-line $$ display math in chat
aarontrowbridge Jul 11, 2026
236e647
feat(amicode): single-select question options commit on click
aarontrowbridge Jul 11, 2026
b23405f
feat(amicode): skill chips expand to show the injected instructions
aarontrowbridge Jul 11, 2026
e20c794
feat(amicode): declutter the System entity card — signal over noise
aarontrowbridge Jul 11, 2026
83bb3fe
feat(amicode): default-model control on the home dashboard
aarontrowbridge Jul 12, 2026
0923b68
feat(amicode): Claude-Code-esque thinking indicator on streaming turns
aarontrowbridge Jul 12, 2026
1389c9e
fix(amicode): dashboard model control hidden by over-strict visibilit…
aarontrowbridge Jul 12, 2026
23ef70c
feat(amicode): live token count in the thinking line
aarontrowbridge Jul 12, 2026
1a62b41
feat(amicode): humanTail filter for AMICODE_* machine lines
aarontrowbridge Jul 12, 2026
f0608c7
feat(amicode): widen run-series raw tail to 24 lines for client-side …
aarontrowbridge Jul 12, 2026
6a3e591
feat(amicode): extract shared run-plot geometry with pulse-first hero…
aarontrowbridge Jul 12, 2026
bd723d1
feat(amicode): pulse-first run window — pulse hero, F header sparklin…
aarontrowbridge Jul 12, 2026
6d3cf79
feat(amicode): drop self-facing best-F from About You stats and Pulse…
aarontrowbridge Jul 12, 2026
88cea38
feat(amicode): pulse-first Now-solving sparkline (per-drive paths, ob…
aarontrowbridge Jul 12, 2026
343fe36
feat(amicode): share poster reads pulse-first (pulse left, convergenc…
aarontrowbridge Jul 12, 2026
b1e2f12
feat(amicode): toml-lite subset reader for widget manifests
aarontrowbridge Jul 12, 2026
fa8ccb8
feat(amicode): Amico third-actor presence — state machine + lane CSS …
aarontrowbridge Jul 12, 2026
88e96d9
feat(amicode): widget manifest parse + config sanitize
aarontrowbridge Jul 12, 2026
5655d29
feat(amicode): dashboard state with sanitizing merge
aarontrowbridge Jul 12, 2026
59ac1c2
feat(amicode): seven built-in widget sources with executable parse+mo…
aarontrowbridge Jul 12, 2026
fcf202d
feat(amicode): widget registry with fork + content hash
aarontrowbridge Jul 12, 2026
ca5b31d
feat(amicode): widget + dashboard endpoints
aarontrowbridge Jul 12, 2026
7581b9a
feat(amicode): widget schema + allowlist cores
aarontrowbridge Jul 12, 2026
f3b333a
feat(amicode): amc tokens, srcdoc assembly with CSP, in-frame runtime
aarontrowbridge Jul 12, 2026
3545750
feat(amicode): widget bridge host + sandboxed frame with version gate…
aarontrowbridge Jul 12, 2026
a0c5019
feat(amicode): widget grid with inline edit mode + generated config f…
aarontrowbridge Jul 12, 2026
30bd257
feat(amicode): home dashboard renders WidgetGrid (legacy cards as fal…
aarontrowbridge Jul 12, 2026
b91ef7a
fix(amicode): serve widget frames as documents with own CSP — srcdoc …
aarontrowbridge Jul 12, 2026
bff6712
fix(amicode): explicit amc:empty signal breaks the hidden-frame deadl…
aarontrowbridge Jul 12, 2026
8854f25
feat(amicode): T3.6 reserved-key pass-through + manifest size toleran…
aarontrowbridge Jul 13, 2026
b64668d
feat(amicode): durable default-model pin — pin slot in models store, …
aarontrowbridge Jul 13, 2026
582a3fd
feat(amicode): panel-first chrome strip + sessions flyout + responsiv…
aarontrowbridge Jul 13, 2026
3b6d9ed
fix(amicode): redesign widget edit mode — cards stay, grouped corner …
aarontrowbridge Jul 13, 2026
ccc9c30
fix(amicode): collapse empty/missing cells out of the grid, not just …
aarontrowbridge Jul 13, 2026
86c12d7
feat(amicode): wire Amico third-actor presence into the chat flow
aarontrowbridge Jul 13, 2026
515c089
merge(amicode): bring Amico third-actor presence into the widget-kern…
aarontrowbridge Jul 13, 2026
110cb59
feat(amicode): chat widget authoring (Stage 2) — author tool + live i…
aarontrowbridge Jul 13, 2026
a5b3e37
feat(amicode): client-side clipboard bridge — image paste via navigat…
aarontrowbridge Jul 14, 2026
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
51 changes: 50 additions & 1 deletion AMICODE-PATCHES.md

Large diffs are not rendered by default.

Binary file addedpackages/app/public/assets/Aero-Regular.ttf
Binary file not shown.
17 changes: 17 additions & 0 deletions packages/app/src/components/amicode-default-model-value.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
// Pure "providerID/modelID" <-> ModelKey helpers for the dashboard default-model
// control. Split out of amicode-default-model.tsx so they're unit-testable
// without evaluating the SolidJS component (which touches client-only context).

/** "providerID/modelID". modelID may itself contain "/", so callers split on the first. */
export function formatModelValue(key: { providerID: string; modelID: string }): string {
return `${key.providerID}/${key.modelID}`
}

/** Inverse of formatModelValue; null on a malformed/empty value. Splits on the
* FIRST slash so a model id containing slashes (e.g. openrouter/meta-llama/…)
* round-trips. */
export function parseModelValue(value: string): { providerID: string; modelID: string } | null {
const slash = value.indexOf("/")
if (slash <= 0 || slash === value.length - 1) return null
return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) }
}
28 changes: 28 additions & 0 deletions packages/app/src/components/amicode-default-model.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

describe("amicode default-model value round-trip", () => {
test("format then parse is identity, incl. model ids with dots/dashes", () => {
const key = { providerID: "anthropic", modelID: "claude-sonnet-5" }
expect(formatModelValue(key)).toBe("anthropic/claude-sonnet-5")
expect(parseModelValue(formatModelValue(key))).toEqual(key)
expect(parseModelValue("opencode/deepseek-v4-flash-free")).toEqual({
providerID: "opencode",
modelID: "deepseek-v4-flash-free",
})
})

test("splits on the FIRST slash so a model id may itself contain slashes", () => {
expect(parseModelValue("openrouter/meta-llama/llama-3.3")).toEqual({
providerID: "openrouter",
modelID: "meta-llama/llama-3.3",
})
})

test("rejects malformed / empty values (→ null, no selection)", () => {
expect(parseModelValue("")).toBeNull()
expect(parseModelValue("noslash")).toBeNull()
expect(parseModelValue("/leading")).toBeNull()
expect(parseModelValue("trailing/")).toBeNull()
})
})
106 changes: 106 additions & 0 deletions packages/app/src/components/amicode-default-model.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
import { For, Show, createMemo } from "solid-js"
import { useModels } from "@/context/models"
import { formatModelValue, parseModelValue } from "./amicode-default-model-value"

// Dashboard "Default model" control. Drives opencode's GLOBAL model store
// (useModels — mounted app-wide, persisted via Persist.global), which is what
// new chats inherit as their starting model (see local.tsx recentModel()), so
// a change takes effect immediately with no server restart. It ALSO mirrors the
// choice into the `amicode.defaultModel` VS Code setting over the iframe→parent
// bridge (chat_panel.ts), keeping the headless/first-turn config pin in sync.
// The in-chat picker still overrides per session.

/** Post the chosen model to the extension host so it updates amicode.defaultModel.
* No-op when unframed (plain web/desktop): there's no parent shell to relay it. */
function syncDefaultModelPin(model: string, win: Window = window): void {
if (win.parent === win) return
win.parent.postMessage({ source: "amicode", kind: "set-default-model", model }, "*")
}

export function AmicodeDefaultModel() {
const models = useModels()

const available = createMemo(() => models.list())
// opencode's default visibility hides dated, non-"latest" models — which can
// hide EVERY model and leave the control empty. Prefer the visible set, but
// fall back to all available models so the picker is never blank when models
// exist. (Guarded below on available(), so it only hides with zero models.)
const selectable = createMemo(() => {
const vis = available().filter((m) => models.visible({ providerID: m.provider.id, modelID: m.id }))
return vis.length > 0 ? vis : available()
})

const groups = createMemo(() => {
const byProvider = new Map<string, { provider: string; items: { value: string; name: string }[] }>()
for (const m of selectable()) {
const group = byProvider.get(m.provider.id) ?? { provider: m.provider.name, items: [] }
group.items.push({ value: formatModelValue({ providerID: m.provider.id, modelID: m.id }), name: m.name })
byProvider.set(m.provider.id, group)
}
return [...byProvider.values()]
})

// Bound to the DURABLE pin (models.pin), not the recents head — chats using
// other models must not move the dashboard's declared default. Empty = Auto
// (no pin; new chats fall back to recents/provider default).
const current = createMemo(() => {
const pin = models.pin.get()
return pin ? formatModelValue(pin) : ""
})

const choose = (value: string) => {
if (value === "") {
models.pin.set(undefined) // Auto: clear the pin, mirror the cleared setting
syncDefaultModelPin("")
return
}
const key = parseModelValue(value)
if (!key) return
models.pin.set(key)
syncDefaultModelPin(value)
}

return (
<Show when={available().length > 0}>
<label
data-component="amicode-default-model"
title="Default model for new chats"
style={{
display: "inline-flex",
"align-items": "center",
gap: "8px",
"font-size": "12px",
color: "var(--v2-text-text-muted)",
}}
>
<span data-slot="amicode-default-model-label" style={{ "font-weight": "550" }}>
Model
</span>
<select
data-slot="amicode-default-model-select"
value={current()}
onChange={(event) => choose(event.currentTarget.value)}
style={{
"font-size": "12px",
color: "var(--v2-text-text-base)",
background: "var(--v2-background-bg-layer-01)",
border: "1px solid var(--v2-border-border-base)",
"border-radius": "6px",
padding: "3px 6px",
"max-width": "220px",
cursor: "pointer",
}}
>
<option value="">Auto</option>
<For each={groups()}>
{(group) => (
<optgroup label={group.provider}>
<For each={group.items}>{(item) => <option value={item.value}>{item.name}</option>}</For>
</optgroup>
)}
</For>
</select>
</label>
</Show>
)
}
13 changes: 10 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { inAmicode } from "@/pages/session/use-amicode-commands"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge"
import { readClipboardViaBridge, readClipboardImageViaBridge } from "./prompt-input/clipboard-bridge"
import { normalizePaste } from "./prompt-input/paste"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import {
Expand DownExpand Up@@ -1147,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// browser dispatches no usable paste event on ⌘V (unlike plain web/desktop,
// where onPaste handles it). Intercept the keystroke and read the OS
// clipboard over the extension bridge instead (see clipboard-bridge.ts).
// Image first (screenshots), then text — mirrors handlePaste's precedence.
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
Expand All@@ -1155,9 +1156,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
inAmicode()
) {
event.preventDefault()
void readClipboardViaBridge().then((text) => {
void (async () => {
const image = await readClipboardImageViaBridge()
if (image) {
await addAttachment(image)
return
}
const text = await readClipboardViaBridge()
if (text) addPart({ type: "text", content: normalizePaste(text), start: 0, end: 0 })
})
})()
return
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { readClipboardViaBridge } from "./clipboard-bridge"
import { readClipboardViaBridge, writeClipboardViaBridge } from "./clipboard-bridge"

type Listener = (event: MessageEvent) => void

Expand DownExpand Up@@ -74,3 +74,26 @@ describe("readClipboardViaBridge", () => {
expect(posted).toHaveLength(0)
})
})

describe("writeClipboardViaBridge", () => {
test("pushes the copied text to the host so the OS clipboard matches ⌘V's read", () => {
const bridge = fakeFramedWindow()

expect(writeClipboardViaBridge("solve a CZ gate", bridge.win)).toBe(true)
expect(bridge.posted).toHaveLength(1)
const message = bridge.posted[0]
expect(message.source).toBe("amicode")
expect(message.kind).toBe("clipboard-write")
expect(message.text).toBe("solve a CZ gate")
})

test("is a no-op that posts nothing when the app is not framed", () => {
const posted: unknown[] = []
const win = { postMessage: (message: unknown) => posted.push(message) } as unknown as Window
// parent === self → plain web/desktop, where native copy already works
;(win as unknown as { parent: Window }).parent = win

expect(writeClipboardViaBridge("ignored", win)).toBe(false)
expect(posted).toHaveLength(0)
})
})
65 changes: 65 additions & 0 deletions packages/app/src/components/prompt-input/clipboard-bridge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@

const BRIDGE_TIMEOUT_MS = 1500

// ⌘C inside the Amicode chat is the mirror image of ⌘V: the sandboxed iframe's
// native copy (and navigator.clipboard.writeText) never reaches the OS
// clipboard, so a subsequent ⌘V — which DOES read the OS clipboard over the
// bridge — pastes whatever stale thing was there before, not what you copied.
// Push the copied text to the extension host, which writes vscode.env.clipboard
// so read and write hit the same clipboard. Fire-and-forget: the host write is
// near-instant and callers have nothing to await. Resolves false (no-op) when
// unframed — native copy already works there.
export function writeClipboardViaBridge(text: string, win: Window = window): boolean {
if (win.parent === win) return false
win.parent.postMessage({ source: "amicode", kind: "clipboard-write", text }, "*")
return true
}

export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<string> {
return new Promise<string>((resolve) => {
// Unframed: native paste works — don't post into the void or wait out the timeout.
Expand DownExpand Up@@ -40,3 +54,54 @@ export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_
timer = setTimeout(() => finish(""), timeoutMs)
})
}

// ⌘V image paste inside the Amicode chat. Unlike text (which the extension host
// reads via vscode.env.clipboard — text-only, no image API), an image can only
// come from the OUTER webview reading navigator.clipboard.read() client-side.
// So we ask the parent relay (chat_panel.ts) rather than the host: it grabs the
// first image/* clipboard item, encodes it as a data URL, and replies. We
// rebuild a File from the reply so callers reuse the normal attachment path.
// Resolves null when unframed, on a text-only clipboard, or after `timeoutMs`.
export function readClipboardImageViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise<File | null> {
return new Promise<File | null>((resolve) => {
if (win.parent === win) {
resolve(null)
return
}

const nonce = Math.random().toString(36).slice(2)
let timer: ReturnType<typeof setTimeout> | undefined

const finish = (file: File | null) => {
win.removeEventListener("message", onMessage)
if (timer !== undefined) clearTimeout(timer)
resolve(file)
}

const onMessage = (event: MessageEvent) => {
const data = event.data as
| { source?: string; kind?: string; nonce?: string; dataUrl?: string; mime?: string; filename?: string }
| undefined
if (data?.source !== "amicode" || data.kind !== "clipboard-image" || data.nonce !== nonce) return
finish(fileFromDataUrl(data.dataUrl, data.mime, data.filename))
}

win.addEventListener("message", onMessage)
win.parent.postMessage({ source: "amicode", kind: "clipboard-image-request", nonce }, "*")
timer = setTimeout(() => finish(null), timeoutMs)
})
}

function fileFromDataUrl(dataUrl?: string, mime?: string, filename?: string): File | null {
if (!dataUrl || !mime) return null
const comma = dataUrl.indexOf(",")
if (comma === -1) return null
try {
const binary = atob(dataUrl.slice(comma + 1))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new File([bytes], filename || "pasted-image.png", { type: mime })
} catch {
return null
}
}
53 changes: 43 additions & 10 deletions packages/app/src/components/titlebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
import { Mark } from "@opencode-ai/ui/logo"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner"

type TauriDesktopWindow = {
Expand DownExpand Up@@ -489,6 +490,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
onCloseOthers={() => tabsStoreActions.closeOthers(i())}
onCloseLeft={() => tabsStoreActions.closeToLeft(i())}
onCloseRight={() => tabsStoreActions.closeToRight(i())}
canCloseOthers={tabsStore.length > 1}
canCloseLeft={i() > 0}
canCloseRight={i() < tabsStore.length - 1}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
Expand DownExpand Up@@ -765,11 +772,18 @@ function TabNavItem(props: {
sessionId?: string
hideClose?: boolean
onClose: () => void
onCloseOthers: () => void
onCloseLeft: () => void
onCloseRight: () => void
canCloseOthers: boolean
canCloseLeft: boolean
canCloseRight: boolean
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
Expand All@@ -796,15 +810,17 @@ function TabNavItem(props: {
)

return (
<div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<ContextMenu modal={false}>
<ContextMenu.Trigger
as="div"
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event: MouseEvent) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={session.latest}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
Expand DownExpand Up@@ -851,7 +867,24 @@ function TabNavItem(props: {
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.onClose()}>
<ContextMenu.ItemLabel>{language.t("common.closeTab")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseOthers} onSelect={() => props.onCloseOthers()}>
<ContextMenu.ItemLabel>{language.t("common.closeOtherTabs")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseLeft} onSelect={() => props.onCloseLeft()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToLeft")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item disabled={!props.canCloseRight} onSelect={() => props.onCloseRight()}>
<ContextMenu.ItemLabel>{language.t("common.closeTabsToRight")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}

Expand Down
Loading
Loading