Compare commits

..
Author SHA1 Message Date
James Long a4ade62900 refactor(theme): replace theme-file contexts with code-owned surfaces
Theme files no longer declare `@context:elevated` / `@context:overlay`. Every
resolved theme now exposes `surface(name)`, a code-owned view of the same
palette re-resolved on a raised background so `$background.default` references
follow it. `surface()` is absolute: a surface's surfaces are the base theme's.

- `SURFACES` in resolve.ts reproduces the remap every shipped theme already
  had (v1-migrated documents are standalone, so the richer default blocks never
  applied): raised → raised.base with primary hover on raised.high; overlay →
  raised.high.
- `raise()` moves onto ResolvedTheme, compiled from the resolve-time mode, so
  the TUI wrapper is a pure reactive forwarder and plugins get the same shape
  as `useTheme()`.
- `useTheme()` takes no argument and `ThemeContextProvider` is gone; the two
  host uses (panel host, block tools) read `useTheme().surface("raised")`
  explicitly. StatusBadge takes `raised` since its callers sit on different
  surfaces.

Verified pixel-identical across opencode dark/light and v1-migrated
dracula/catppuccin in 10 TUI states.
2026-09-18 02:12:15 +00:00
128 changed files with 805 additions and 2682 deletions
@@ -485,7 +485,6 @@ export function stepStarted(message: SessionMessageAssistant) {
assistantMessageID: message.id,
agent: message.agent,
model: message.model,
started: message.time.created,
})
}
@@ -108,15 +108,6 @@ test("non-Git folders show their status without offering worktree actions", asyn
).toBeEnabled()
})
test("submits locally after changing a new worktree draft to Local", async ({ page }) => {
const mock = await openDraft(page, "create", { currentDirectory: workspace })
await page.getByRole("button", { name: "New worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "Local repository", exact: true }).click()
await page.locator('[data-component="composer-editor"]').fill("Run locally")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.calls.find((call) => call.type === "session")?.directory).toBe(directory)
})
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
const mock = await openDraft(page, "create")
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
@@ -305,12 +296,7 @@ test("new worktree sign-in completes before the draft can send", async ({ page,
expect(attempts).toHaveLength(1)
})
async function openDraft(
page: Page,
worktree = "main",
options: { git?: boolean; direction?: "ltr" | "rtl"; currentDirectory?: string } = {},
) {
const currentDirectory = options.currentDirectory ?? directory
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
const project = {
id: "proj_new_summary",
worktree: directory,
@@ -329,7 +315,7 @@ async function openDraft(
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
await mockOpenCodeServer(page, {
directory: currentDirectory,
directory,
project,
sessions,
provider: {
@@ -456,7 +442,7 @@ async function openDraft(
},
)
await page.addInitScript(
({ directory, currentDirectory, server, draftID, secondDraftID, worktree }) => {
({ directory, server, draftID, secondDraftID, worktree }) => {
if (!localStorage.getItem("opencode.global.dat:server"))
localStorage.setItem(
"opencode.global.dat:server",
@@ -469,12 +455,12 @@ async function openDraft(
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory: currentDirectory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory: currentDirectory, worktree },
{ type: "draft", draftID, server, directory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
]),
)
},
{ directory, currentDirectory, server, draftID, secondDraftID, worktree },
{ directory, server, draftID, secondDraftID, worktree },
)
if (options.direction) await openWithDirection(page, draftPath, options.direction)
if (!options.direction) await page.goto(draftPath)
@@ -284,7 +284,7 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(thinking).toHaveCount(0)
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model, started: Date.now() })
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
for (const tool of [
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
@@ -341,7 +341,7 @@ for (const delivery of ["steer", "queue"] as const) {
)
const later = { sessionID, assistantMessageID: "msg_queue_follow_up_assistant" }
mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() })
mock.emit("session.step.started", { ...later, agent: "build", model })
mock.emit("session.text.started", { ...later, ordinal: 0 })
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
const response = transcript
@@ -16,86 +16,6 @@
}
}
[data-component="upload-row"] {
display: flex;
flex-direction: column;
gap: 4px;
& + & {
margin-top: 8px;
}
[data-slot="upload-row-label"] {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 13px;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
}
[data-slot="upload-row-name"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--v2-text-text-base);
font-weight: 530;
}
[data-slot="upload-row-percent"] {
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-variant-numeric: tabular-nums;
}
[data-slot="upload-row-track"] {
display: flex;
align-items: center;
gap: 8px;
}
[data-component="upload-progress"] {
flex: 1;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: var(--v2-background-bg-layer-02);
}
[data-slot="upload-progress-bar"] {
height: 100%;
border-radius: inherit;
background: var(--v2-icon-icon-base);
transition: width 160ms ease-out;
}
[data-slot="upload-row-cancel"] {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-icon-icon-muted);
cursor: pointer;
&:hover {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-icon-icon-base);
}
svg {
width: 12px;
height: 12px;
}
}
}
[data-component="composer-attachments"] {
timeline-scope: --composer-attachments-scroll;
@@ -1,12 +1,8 @@
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createBlobReference } from "@/runtime/persistence/drafts"
import { uuid } from "@/runtime/persistence/uuid"
import type { ComposerPrompt } from "../types"
import type { ImageAttachmentPart, PathAttachmentPart } from "../state"
import type { AttachmentDestination } from "./destination"
import { uploads } from "./uploads"
import type { ComposerAttachment, ComposerPrompt } from "../types"
type PromptTarget = {
current: () => ComposerPrompt
@@ -20,11 +16,9 @@ export type ComposerAttachmentConfig = {
onFile: (file: File) => Promise<unknown>,
) => Promise<void>
directory: () => string
destination: () => AttachmentDestination
isDialogActive: () => boolean
duplicate: () => void
onError: (error: unknown) => void
onUploadError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
onDragCancel?: (callback: () => void) => () => void
@@ -49,23 +43,9 @@ export function createComposerAttachments(
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
}
// Uploads this composer started; they finish (or fail) even if the composer unmounts.
const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] })
// A file the model reads natively travels inline with the prompt, so its bytes live in the draft
// store. Anything else reaches the model as a path on the server and never enters the store:
// hashing and copying a large archive through it is what used to freeze the window.
const add = async (file: File, target = capture(), clipboard = false) => {
if (!target) return false
const mime = await attachmentMime(file)
const destination = input.destination()
if (native(mime, destination.input)) return addInline(file, mime, target, clipboard)
const sourcePath = input.getPathForFile?.(file) || undefined
if (destination.local && sourcePath) return addPath(target, { filename: file.name, mime, path: sourcePath })
void stage(file, mime, target, destination)
return true
}
const addInline = async (file: File, mime: string, target: NonNullable<ReturnType<typeof capture>>, clipboard: boolean) => {
const blob = input.store ? await input.store(file) : await createBlobReference(file)
const sourcePath = input.getPathForFile?.(file) || undefined
// Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
@@ -84,40 +64,17 @@ export function createComposerAttachments(
input.duplicate()
return true
}
const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, sourcePath, mime, blob }
const attachment: ComposerAttachment = {
type: "image",
id: uuid(),
filename: file.name,
sourcePath,
mime,
blob,
}
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
}
const addPath = (
target: NonNullable<ReturnType<typeof capture>>,
attachment: Pick<PathAttachmentPart, "filename" | "mime" | "path">,
) => {
if (target.prompt.current().some((part) => part.type === "path" && part.path === attachment.path)) {
input.duplicate()
return true
}
target.prompt.set([...target.prompt.current(), { type: "path", id: uuid(), ...attachment }], target.prompt.cursor())
return true
}
const stage = async (
file: File,
mime: string,
target: NonNullable<ReturnType<typeof capture>>,
destination: AttachmentDestination,
) => {
const id = uuid()
setPending("ids", (ids) => [...ids, id])
const path = await uploads
.track({ id, filename: file.name, mime, size: file.size }, (report, signal) =>
destination.upload(file, report, signal),
)
.catch((error: unknown) => {
input.onUploadError(error)
return undefined
})
.finally(() => setPending("ids", (ids) => ids.filter((item) => item !== id)))
if (path) addPath(target, { filename: file.name, mime, path })
}
const addAttachments = async (files: File[], target = capture()) => {
return files.reduce(async (result, file) => {
const previous = await result
@@ -196,11 +153,6 @@ export function createComposerAttachments(
addAttachments,
handlePaste,
handleDrop,
/** Uploads still in flight for this composer; sending waits for them. */
pending: () => uploads.items().filter((item) => pending.ids.includes(item.id)),
cancel(id: string) {
uploads.items().find((item) => item.id === id)?.cancel()
},
pick(fallback: () => void) {
if (!input.picker) {
fallback()
@@ -213,14 +165,6 @@ export function createComposerAttachments(
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
// Mirrors the attachment kinds the server forwards to the model as message content.
function native(mime: string, input: AttachmentDestination["input"]) {
if (mime === "text/plain") return true
if (imageMimes.has(mime)) return input.image
if (mime === "application/pdf") return input.pdf
return false
}
const imageExtensions = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
@@ -0,0 +1,70 @@
import type { Accessor } from "solid-js"
import { blobBytes, blobDataUrl } from "@/runtime/persistence/drafts"
import { useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { ComposerControls } from "../adapter"
import type { ImageAttachmentPart } from "../state"
// Where a prompt is headed: the model that reads it and the server that runs its tools.
export type AttachmentDestination = {
/** Input modalities the selected model reads natively. */
input: { image: boolean; pdf: boolean }
/** The server shares the client's filesystem, so an attachment's source path resolves as-is. */
local: boolean
/** Copies a file into the server's temporary directory and returns its absolute path there. */
upload: (file: { name: string; data: Uint8Array }) => Promise<string>
}
export type DeliveredAttachment =
| { type: "inline"; attachment: ImageAttachmentPart; dataUrl: string }
| { type: "path"; attachment: ImageAttachmentPart; path: string }
// An attachment travels inline when the model reads its bytes natively. Anything else reaches
// the model as a path on the server, which its tools can open, instead of being rejected.
export function deliverAttachments(attachments: ImageAttachmentPart[], destination: AttachmentDestination) {
return Promise.all(attachments.map((attachment) => deliver(attachment, destination)))
}
async function deliver(
attachment: ImageAttachmentPart,
destination: AttachmentDestination,
): Promise<DeliveredAttachment> {
if (native(attachment.mime, destination.input)) {
return { type: "inline", attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) }
}
if (destination.local && attachment.sourcePath) return { type: "path", attachment, path: attachment.sourcePath }
const path = await destination.upload({ name: attachment.filename, data: await blobBytes(attachment.blob) })
return { type: "path", attachment, path }
}
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
// Mirrors the attachment kinds the server forwards to the model as message content.
function native(mime: string, input: AttachmentDestination["input"]) {
if (mime === "text/plain") return true
if (imageMimes.has(mime)) return input.image
if (mime === "application/pdf") return input.pdf
return false
}
export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
const server = useServer()
const sdk = useServerSDK()
const location = useWorkspaceLocation()
return (): AttachmentDestination => ({
input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false },
local: server.isLocal,
upload: async (file) => {
const info = await sdk.api.server.info()
// One directory per upload keeps the original filename without collisions; the server
// normalizes the separators and returns the resolved path.
const written = await sdk.api.file.write({
location: { directory: location().directory },
path: `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`,
payload: file.data,
})
return written.data.path
},
})
}
@@ -1,57 +0,0 @@
import type { Accessor } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { authTokenFromCredentials } from "@/runtime/server/api"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { ComposerControls } from "../adapter"
// Where a prompt is headed: the model that reads it and the server that runs its tools.
export type AttachmentDestination = {
/** Input modalities the selected model reads natively. */
input: { image: boolean; pdf: boolean }
/** The server shares the client's filesystem, so an attachment's source path resolves as-is. */
local: boolean
/** Streams a file into the server's temporary directory and returns its absolute path there. */
upload: (file: File, report: (loaded: number) => void, signal: AbortSignal) => Promise<string>
}
export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
const server = useServer()
const sdk = useServerSDK()
const location = useWorkspaceLocation()
return (): AttachmentDestination => ({
input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false },
local: server.isLocal,
upload: async (file, report, signal) => {
const info = await sdk.api.server.info({ signal })
// One directory per upload keeps the original filename without collisions; the server
// normalizes the separators and returns the resolved path.
const url = new URL("/api/experimental/fs/write", server.conn.http.url)
url.searchParams.set("location[directory]", location().directory)
url.searchParams.set("path", `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`)
return write(url, file, server.conn.http.password, report, signal)
},
})
}
// fetch cannot report upload progress and Chromium only streams request bodies over HTTP/2, so
// the one request that needs both goes through XMLHttpRequest. The browser streams the File
// from disk; nothing is buffered in the renderer.
function write(url: URL, file: File, password: string | undefined, report: (loaded: number) => void, signal: AbortSignal) {
return new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("POST", url)
xhr.responseType = "json"
xhr.setRequestHeader("content-type", "application/octet-stream")
if (password) xhr.setRequestHeader("authorization", `Basic ${authTokenFromCredentials({ password })}`)
xhr.upload.addEventListener("progress", (event) => report(event.loaded))
xhr.addEventListener("load", () => {
if (xhr.status !== 200) return reject(new Error(`Upload failed with status ${xhr.status}`))
resolve((xhr.response as { data: { path: string } }).data.path)
})
xhr.addEventListener("error", () => reject(new Error("Upload failed")))
xhr.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError")))
signal.addEventListener("abort", () => xhr.abort(), { once: true })
xhr.send(file)
})
}
@@ -1,116 +0,0 @@
import { createEffect, createRoot, For, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { Toast, toaster } from "@opencode/ui/toast"
import { useLanguage } from "@/runtime/i18n/language"
export type Upload = {
id: string
filename: string
mime: string
size: number
loaded: number
cancel: () => void
}
// Uploads outlive the composer that started them, so one process-wide list feeds every chip
// and the single progress toast.
const [state, setState] = createStore<{ items: Upload[] }>({ items: [] })
export const uploads = {
items: () => state.items,
/** Runs `work` while the upload is listed. Resolves to undefined when the user cancels it. */
async track<T>(
input: Pick<Upload, "id" | "filename" | "mime" | "size">,
work: (report: (loaded: number) => void, signal: AbortSignal) => Promise<T>,
): Promise<T | undefined> {
const controller = new AbortController()
setState("items", (items) => [...items, { ...input, loaded: 0, cancel: () => controller.abort() }])
try {
return await work((loaded) => setState("items", (item) => item.id === input.id, "loaded", loaded), controller.signal)
} catch (error) {
if (controller.signal.aborted) return undefined
throw error
} finally {
setState("items", (items) => items.filter((item) => item.id !== input.id))
}
},
}
// Sonner builds toast content outside the app's Solid tree: no context and no owner. This host
// lives inside the providers, lends the toast its language instance, and gives the content a
// root of its own so progress stays reactive.
export function UploadToastHost() {
const language = useLanguage()
let active: { id: number; dispose: () => void } | undefined
const dismiss = () => {
if (!active) return
toaster.dismiss(active.id)
active.dispose()
active = undefined
}
createEffect(
on(
() => state.items.length > 0,
(uploading) => {
if (!uploading) return dismiss()
if (active) return
const id = toaster.show(
(props) =>
createRoot((dispose) => {
active = { id: props.toastId, dispose }
return <UploadToast toastId={props.toastId} language={language} />
}),
{ persistent: true, resize: () => state.items.length },
)
active ??= { id, dispose: () => {} }
},
),
)
onCleanup(dismiss)
return null
}
function UploadToast(props: { toastId: number; language: ReturnType<typeof useLanguage> }) {
const percent = (item: Upload) => (item.size === 0 ? 100 : Math.floor((item.loaded / item.size) * 100))
return (
<Toast toastId={props.toastId}>
<Toast.Content>
<For each={state.items}>
{(item) => (
<div data-component="upload-row">
<div data-slot="upload-row-label">
<span data-slot="upload-row-name" title={item.filename}>
{item.filename}
</span>
<span data-slot="upload-row-percent">
{props.language.t("prompt.toast.uploading.percent", { percent: percent(item) })}
</span>
</div>
<div data-slot="upload-row-track">
<div
data-component="upload-progress"
role="progressbar"
aria-label={item.filename}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent(item)}
>
<div data-slot="upload-progress-bar" style={{ width: `${percent(item)}%` }} />
</div>
<button
type="button"
data-slot="upload-row-cancel"
aria-label={props.language.t("prompt.toast.uploading.cancel")}
onClick={() => item.cancel()}
>
<Icon name="outline-xmark" />
</button>
</div>
</div>
)}
</For>
</Toast.Content>
</Toast>
)
}
@@ -167,7 +167,7 @@ function ComposerStory(props: {
? buildPromptRequest({
prompt: draft.prompt,
context: draft.context.items,
images: [],
attachments: [],
text: value,
sessionDirectory: "C:/repo",
})
+6 -6
View File
@@ -7,7 +7,7 @@ import type {
ComposerPersistedState,
ComposerPrompt,
} from "../types"
import { isAttachment, promptLength } from "../prompt-parts"
import { promptLength } from "../prompt-parts"
export type ComposerStateStore = [
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
@@ -48,7 +48,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
setStore()((state) => ({
prompt: [
{ type: "text", content, start: 0, end: content.length },
...state.prompt.filter(isAttachment),
...state.prompt.filter((part) => part.type === "image"),
],
cursor: content.length,
retry: undefined,
@@ -83,7 +83,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
clearRetry()
},
removeAttachment(id: string) {
setStore()("prompt", (parts) => parts.filter((part) => !isAttachment(part) || part.id !== id))
setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id))
clearRetry()
},
}
@@ -93,7 +93,7 @@ function insertText(prompt: ComposerPrompt, cursor: number, content: string): Co
let position = 0
let inserted = false
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (isAttachment(part)) return [part]
if (part.type === "image") return [part]
const start = position
position += part.content.length
if (inserted) return [part]
@@ -121,7 +121,7 @@ function insertMention(
}
let position = 0
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (isAttachment(part)) return [part]
if (part.type === "image") return [part]
const partStart = position
position += part.content.length
if (part.type !== "text" || start < partStart || end > position) return [part]
@@ -139,7 +139,7 @@ function insertMention(
function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
let offset = 0
return prompt.map((part) => {
if (isAttachment(part)) return part
if (part.type === "image") return part
const next = { ...part, start: offset, end: offset + part.content.length }
offset = next.end
return next
+7 -47
View File
@@ -12,8 +12,6 @@ import { Menu } from "@opencode/ui/menu"
import { Tooltip } from "@opencode/ui/tooltip"
import { ScrollView } from "@opencode/ui/scroll-view"
import { AttachmentCard } from "@opencode/session-ui/attachment-card"
import { ProgressCircle } from "@opencode/ui/progress-circle"
import type { Upload } from "../attachments/uploads"
import { CommentCard } from "@opencode/session-ui/comment-card"
import { typeLabel } from "@opencode/session-ui/message-file"
import { Skill } from "@opencode/schema/skill"
@@ -26,7 +24,6 @@ import type {
ComposerSuggestion,
} from "../types"
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
import { isAttachment } from "../prompt-parts"
import "../attachments/attachments.css"
import "./editor.css"
@@ -151,13 +148,11 @@ export function ComposerEditor(props: ComposerEditorProps) {
<Show when={state.mode === "normal"}>
<ComposerAttachments
attachments={props.controller.attachments()}
uploads={props.controller.uploads()}
comments={props.controller.comments()}
activeCommentID={state.activeContextID}
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
onAttachmentClick={props.controller.openAttachment}
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
/>
@@ -196,9 +191,9 @@ export function ComposerEditor(props: ComposerEditorProps) {
onInput={(event) => {
const cursor = composerCursor(event.currentTarget)
const prompt = parseComposerEditor(event.currentTarget)
const attachments = props.controller.parts().filter(isAttachment)
const images = props.controller.parts().filter((part) => part.type === "image")
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (!view.draftOnly && props.controller.onKeyDown(event)) return
@@ -353,7 +348,7 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
const active = document.activeElement === editor
editor.replaceChildren(
...prompt.flatMap<Node>((part) => {
if (isAttachment(part)) return []
if (part.type === "image") return []
if (part.type === "text") return [document.createTextNode(part.content)]
const mention = document.createElement("span")
mentionParts.set(mention, part)
@@ -480,22 +475,17 @@ function composerCursor(editor: HTMLDivElement) {
export function ComposerAttachments(props: {
attachments: ComposerAttachment[]
uploads?: Upload[]
comments?: ComposerComment[]
activeCommentID?: string
removeLabel: string
onAttachmentClick?: (attachment: ComposerAttachment) => void
onAttachmentRemove: (attachment: ComposerAttachment) => void
onUploadCancel?: (upload: Upload) => void
onCommentClick?: (comment: ComposerComment) => void
onCommentRemove?: (comment: ComposerComment) => void
}) {
const i18n = useI18n()
const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100))
return (
<Show
when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}
>
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
<div
data-slot="composer-attachments-scroll"
@@ -532,30 +522,22 @@ export function ComposerAttachments(props: {
<For each={props.attachments}>
{(attachment) => (
<div class="relative group shrink-0">
<Tooltip
value={attachment.type === "path" ? attachment.path : attachment.filename}
placement="top"
contentClass="break-all"
>
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<Show
when={attachment.type === "image" && attachment.mime.startsWith("image/") ? attachment : undefined}
when={attachment.mime.startsWith("image/")}
fallback={
<AttachmentCard title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
</AttachmentCard>
}
>
{(image) => (
<>
<img
src={image().blob.url}
src={attachment.blob.url}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={() => props.onAttachmentClick?.(attachment)}
/>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
</>
)}
</Show>
</Tooltip>
<button
@@ -569,28 +551,6 @@ export function ComposerAttachments(props: {
</div>
)}
</For>
<For each={props.uploads ?? []}>
{(upload) => (
<div class="relative group shrink-0" data-slot="composer-upload">
<Tooltip value={upload.filename} placement="top" contentClass="break-all">
<AttachmentCard title={upload.filename}>
<span class="inline-flex items-center gap-1">
<ProgressCircle percentage={percent(upload)} />
{i18n.t("ui.promptInput.uploading", { percent: percent(upload) })}
</span>
</AttachmentCard>
</Tooltip>
<button
type="button"
onClick={() => props.onUploadCancel?.(upload)}
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={i18n.t("ui.promptInput.cancelUpload")}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
</div>
<div
data-slot="composer-attachments-fade-left"
@@ -2,7 +2,6 @@ import { createEffect, type Accessor } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useFilteredList } from "@opencode/ui/hooks"
import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments"
import type { Upload } from "../attachments/uploads"
import { createComposerEditorActions, type ComposerStateStoreInput } from "./actions"
import type {
ComposerAttachment,
@@ -19,7 +18,7 @@ import {
type ComposerInteractionCommand,
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, isAttachment, promptLength } from "../prompt-parts"
import { clonePrompt, promptLength } from "../prompt-parts"
import type { ComposerQueue } from "../adapter"
export type ComposerSelectControl = {
@@ -75,7 +74,7 @@ export function createComposerEditor(input: {
const draft = createComposerEditorActions(input.store)
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
function addPart(part: ComposerPersistedState["prompt"][number]) {
if (isAttachment(part)) return false
if (part.type === "image") return false
if (part.type === "file" || part.type === "agent") {
draft.addMention(part)
return true
@@ -170,7 +169,7 @@ export function createComposerEditor(input: {
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
draft.setPrompt(
draft.state.prompt.filter(isAttachment),
draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image"),
0,
)
}
@@ -316,13 +315,7 @@ export function createComposerEditor(input: {
return draft.state.context.items.filter((item) => !!item.comment?.trim())
},
attachments(): ComposerAttachment[] {
return draft.state.prompt.filter(isAttachment)
},
uploads(): Upload[] {
return attachments?.pending() ?? []
},
cancelUpload(id: string) {
attachments?.cancel(id)
return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image")
},
toggleContext(id: string) {
dispatch({ type: "context.active", id })
@@ -343,12 +336,11 @@ export function createComposerEditor(input: {
canSubmit() {
if (input.view.submit.available?.() === false) return false
if (input.view.draftOnly) return false
if (attachments?.pending().length) return false
const persisted = draft.state
if (state.mode === "shell") {
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
}
if (persisted.prompt.some(isAttachment)) return true
if (persisted.prompt.some((part) => part.type === "image")) return true
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
},
@@ -377,7 +369,6 @@ export function createComposerEditor(input: {
submit(options?: { alternate?: boolean }) {
if (input.view.submit.available?.() === false) return
if (input.view.draftOnly) return
if (attachments?.pending().length) return
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
},
+4 -4
View File
@@ -1,6 +1,6 @@
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { clonePrompt, isAttachment } from "../prompt-parts"
import { clonePrompt } from "../prompt-parts"
import type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
export type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
@@ -35,9 +35,9 @@ export function prependHistoryEntry(
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim()
const hasAttachments = prompt.some(isAttachment)
const hasImages = prompt.some((part) => part.type === "image")
const hasComments = comments.some((comment) => !!comment.comment.trim())
if (!text && !hasAttachments && !hasComments) return entries
if (!text && !hasImages && !hasComments) return entries
const entry = {
prompt: clonePrompt(prompt),
@@ -86,7 +86,7 @@ function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistorySt
if (partA.type === "skill") {
if (partB.type !== "skill" || partA.id !== partB.id || partA.name !== partB.name) return false
}
if (isAttachment(partA) && partA.id !== (isAttachment(partB) ? partB.id : "")) return false
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
}
if (entryA.comments.length !== entryB.comments.length) return false
for (let i = 0; i < entryA.comments.length; i++) {
+6 -14
View File
@@ -17,12 +17,12 @@ import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode/schema/skill"
import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter"
import { isAttachment } from "./prompt-parts"
import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
import { useAttachmentDestination } from "./attachments/destination"
import { useAttachmentDestination } from "./attachments/deliver"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -73,7 +73,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
}, [])
})
const attachments = createMemo(() =>
prompt.current().filter(isAttachment),
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
@@ -266,6 +266,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
destination: useAttachmentDestination(adapter.controls),
delivery: (alternate) => {
const queue = options?.queue
if (!queue) return "steer"
@@ -320,10 +321,8 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) => {
if (attachment.type !== "image") return
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename }))
},
openAttachment: (attachment) =>
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, adapter.controls(), layout, files, comments)
@@ -341,15 +340,8 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
attachments: {
picker: platform.openAttachmentPickerDialog,
directory: () => sdk().directory,
destination: useAttachmentDestination(adapter.controls),
isDialogActive: () => !!dialog.active,
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onUploadError: (error) =>
showToast({
variant: "error",
title: language.t("prompt.toast.uploadFailed.title"),
description: composerErrorMessage(language, error),
}),
onError: (error) =>
showToast({
variant: "error",
+2 -7
View File
@@ -1,9 +1,4 @@
import type { ContentPart, ImageAttachmentPart, PathAttachmentPart, Prompt } from "./state"
/** Parts that sit beside the text rather than inside it. */
export function isAttachment(part: ContentPart): part is ImageAttachmentPart | PathAttachmentPart {
return part.type === "image" || part.type === "path"
}
import type { Prompt } from "./state"
export function clonePrompt(prompt: Prompt): Prompt {
return prompt.map((part) =>
@@ -22,7 +17,7 @@ export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
...clonePrompt(prompt),
{ type: "text", content: "\n\n", start, end: offset },
...clonePrompt(following).map((part) =>
isAttachment(part) ? part : { ...part, start: part.start + offset, end: part.end + offset },
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
),
]
}
+22 -17
View File
@@ -1,10 +1,15 @@
import { describe, expect, test } from "bun:test"
import { Skill } from "@opencode/schema/skill"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import type { DeliveredAttachment } from "./attachments/deliver"
import { buildPromptRequest } from "./request"
function inline(filename: string, mime: string, extra?: Partial<ImageAttachmentPart>) {
return { type: "image" as const, id: `img_${filename}`, filename, mime, dataUrl: `data:${mime};base64,AAA`, ...extra }
function inline(filename: string, mime: string, extra?: Partial<ImageAttachmentPart>): DeliveredAttachment {
return {
type: "inline",
attachment: { type: "image", id: `img_${filename}`, filename, mime, blob: { id: filename, url: "" }, ...extra },
dataUrl: `data:${mime};base64,AAA`,
}
}
describe("buildPromptRequest", () => {
@@ -25,7 +30,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [inline("a.png", "image/png")],
attachments: [inline("a.png", "image/png")],
text: "hello @src/foo.ts @planner",
sessionDirectory: "/repo",
})
@@ -47,7 +52,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
attachments: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
text: "check these",
sessionDirectory: "/repo",
})
@@ -62,7 +67,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [],
context: [],
images: [
attachments: [
inline("opencode.global.dat", "text/plain", {
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
}),
@@ -90,7 +95,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
images: [],
attachments: [],
text: "@docs",
sessionDirectory: "/repo/app",
})
@@ -112,7 +117,7 @@ describe("buildPromptRequest", () => {
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
attachments: [],
text: "@src/foo.ts",
sessionDirectory: "/repo",
})
@@ -134,7 +139,7 @@ describe("buildPromptRequest", () => {
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
attachments: [],
text: "look",
sessionDirectory: "/repo",
})
@@ -150,7 +155,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src\\foo.ts",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
@@ -171,7 +176,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@file#name.txt",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
@@ -192,7 +197,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src/app.ts",
sessionDirectory: "/home/user/project",
})
@@ -206,7 +211,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@README.md",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
@@ -221,7 +226,7 @@ describe("buildPromptRequest", () => {
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
attachments: [],
text: "test",
sessionDirectory: "D:\\workspace\\app",
})
@@ -243,7 +248,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@D:\\other\\project\\file.ts",
sessionDirectory: "C:\\current\\project",
})
@@ -270,7 +275,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src\\App.tsx",
sessionDirectory: "C:\\project",
})
@@ -295,7 +300,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@..\\..\\shared\\util.ts",
sessionDirectory: "C:\\projects\\myapp\\src",
})
@@ -325,7 +330,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
images: [],
attachments: [],
text: "@review",
sessionDirectory: "/repo",
})
+11 -11
View File
@@ -1,13 +1,14 @@
import { getFilename } from "@opencode/util/path"
import type { FileSelection } from "@/workspaces/files/model"
import { encodeFilePath } from "@/workspaces/files/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import type { AgentPart, FileAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import {
formatAttachmentReference,
formatCommentNote,
type PromptAttachmentReference,
type PromptComment,
} from "@/composer/comment-note"
import type { DeliveredAttachment } from "@/composer/attachments/deliver"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
type PromptRequest = {
@@ -34,7 +35,7 @@ type ContextFile = {
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
attachments: DeliveredAttachment[]
text: string
sessionDirectory: string
}
@@ -62,7 +63,6 @@ const parseCommentMentions = (comment: string) => {
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill"
const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path"
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({
@@ -113,15 +113,15 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
return [file, ...mentions]
})
const inline = input.images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
const inline = input.attachments.flatMap((item) =>
item.type === "inline"
? [{ uri: item.dataUrl, mime: item.attachment.mime, name: item.attachment.sourcePath ?? item.attachment.filename }]
: [],
)
// Like comments, path references reach the model as text and the message UI through metadata.
const attachments = input.prompt
.filter(isPathAttachment)
.map((part) => ({ name: part.filename, mime: part.mime, path: part.path }))
const attachments = input.attachments.flatMap((item) =>
item.type === "path" ? [{ name: item.attachment.filename, mime: item.attachment.mime, path: item.path }] : [],
)
return {
text: [
+1 -18
View File
@@ -94,24 +94,7 @@ export const ImageAttachmentPart = Schema.Struct({
)
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
// A file the model receives as a path on the server: its bytes never enter the draft store.
export const PathAttachmentPart = Persistence.struct({
type: Schema.Literal("path"),
id: Schema.String,
filename: Schema.String,
mime: Schema.String,
path: Schema.String,
})
export type PathAttachmentPart = typeof PathAttachmentPart.Type
export const ContentPart = Schema.Union([
TextPart,
FileAttachmentPart,
AgentPart,
SkillPart,
ImageAttachmentPart,
PathAttachmentPart,
])
export const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
export type ContentPart = typeof ContentPart.Type
export const Prompt = Persistence.array(ContentPart)
export type Prompt = typeof Prompt.Type
-1
View File
@@ -23,7 +23,6 @@ export type {
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
PromptModel,
SkillPart,
@@ -1,5 +1,5 @@
import type { ComposerState, ContextItem, Prompt } from "./state"
import { appendPrompt, clonePrompt, isAttachment } from "./prompt-parts"
import { appendPrompt, clonePrompt } from "./prompt-parts"
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
@@ -22,7 +22,7 @@ export function createComposerSubmission(input: {
if (initial !== target) {
initial.reset()
// A preparing session may already have an unsent follow-up in its promoted composer.
if (preserveDraft && target.current().some((part) => isAttachment(part) || part.content.length > 0))
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
following = clonePrompt(target.current())
}
if (!following) target.reset()
+10
View File
@@ -3,6 +3,7 @@ import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Skill } from "@opencode/schema/skill"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import type { AttachmentDestination } from "./attachments/deliver"
import { createMemoryComposerState } from "./state"
import { createComposerSubmit } from "./submit"
@@ -48,6 +49,14 @@ function controls(): ComposerControls {
}
}
const destination: AttachmentDestination = {
input: { image: true, pdf: true },
local: false,
upload: async () => {
throw new Error("native attachments must not upload")
},
}
function submitInput(
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
@@ -64,6 +73,7 @@ function submitInput(
resetHistory() {},
setMode() {},
closePopover() {},
destination: () => destination,
notify,
comments: { capture: () => [], clear() {}, restore() {} },
})
+30 -20
View File
@@ -8,8 +8,7 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import { isAttachment } from "./prompt-parts"
import { deliverAttachments, type AttachmentDestination } from "./attachments/deliver"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -35,6 +34,7 @@ type ComposerSubmitInput = {
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
destination: () => AttachmentDestination
delivery?: (alternate: boolean) => ComposerDelivery
notify: {
missingSelection: () => void
@@ -87,10 +87,16 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const optimisticBusy = !input.adapter.working()
if (optimisticBusy && input.adapter.kind === "new-session")
session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
}).then(
const sending = sendPrompt(
session,
value,
input.destination(),
input.adapter.controls().model.selection.trackSessionCommit,
() => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
},
).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -123,9 +129,13 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
void sendCommand(
session,
value,
command,
input.destination(),
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
return
}
} finally {
@@ -152,9 +162,6 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
})),
metadata: {
displayText: value.text,
attachments: value.prompt.flatMap((part) =>
part.type === "path" ? [{ name: part.filename, mime: part.mime, path: part.path }] : [],
),
comments: value.context.flatMap((item) =>
item.comment?.trim()
? [
@@ -189,7 +196,7 @@ function readSubmission(
if (mode === "shell" && !text.trim()) return
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
const comments = context.filter((item) => !!item.comment?.trim()).length
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
if (!text.trim() && images.length === 0 && comments === 0) return
const controls = input.adapter.controls()
const model = controls.model.selection.current()
@@ -297,9 +304,10 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
destination: AttachmentDestination,
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
const request = await buildSubmissionRequest(session, value, destination)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
@@ -338,10 +346,11 @@ async function applySelection(
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value)
const request = await buildSubmissionRequest(session, value, destination)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
@@ -375,14 +384,15 @@ async function sendPrompt(
await sending
}
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
const images = await Promise.all(
value.images.map(async (attachment) => ({ ...attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) })),
)
async function buildSubmissionRequest(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
) {
return buildPromptRequest({
prompt: value.prompt,
context: value.context,
images,
attachments: await deliverAttachments(value.images, destination),
text: value.text,
sessionDirectory: session.directory,
})
@@ -1,5 +1,4 @@
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
import { isAttachment } from "../prompt-parts"
export type ComposerInteractionState = {
mode: "normal" | "shell"
@@ -237,7 +236,7 @@ function populated(persisted: ComposerPersistedState) {
return (
!!promptText(persisted).trim() ||
persisted.context.items.length > 0 ||
persisted.prompt.some((part) => part.type === "file" || isAttachment(part))
persisted.prompt.some((part) => part.type === "file" || part.type === "image")
)
}
+2 -10
View File
@@ -1,17 +1,9 @@
import type {
AgentPart,
ComposerStore,
FileAttachmentPart,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
SkillPart,
} from "./state"
import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state"
export type ComposerFilePart = FileAttachmentPart
export type ComposerAgentPart = AgentPart
export type ComposerSkillPart = SkillPart
export type ComposerAttachment = ImageAttachmentPart | PathAttachmentPart
export type ComposerAttachment = ImageAttachmentPart
export type ComposerPrompt = Prompt
export type ComposerComment = ComposerStore["context"]["items"][number]
export type ComposerPersistedState = ComposerStore
@@ -48,8 +48,7 @@ export function createNewSessionComposerAdapter(props: {
submitted: props.submitted,
async start(selection, submission, message) {
const draftID = props.draftID
const currentDirectory = location().directory
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
const projectDirectory = location().directory
const worktree = props.worktree()
const branch = props.branch()
const mcp = props.mcp.capture()
-13
View File
@@ -74,7 +74,6 @@ export const dict = {
"command.category.workspace": "Worktree",
"command.category.settings": "Settings",
"command.logs.export": "Export logs",
"command.debugBar.toggle": "Toggle debug bar",
"theme.scheme.system": "System",
"theme.scheme.light": "Light",
@@ -367,9 +366,6 @@ export const dict = {
"prompt.action.stop": "Stop",
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
"prompt.toast.uploading.percent": "{{percent}}%",
"prompt.toast.uploading.cancel": "Cancel upload",
"prompt.toast.uploadFailed.title": "Upload failed",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
@@ -967,16 +963,7 @@ export const dict = {
"sidebar.empty.description": "Open a project to get started",
"debugBar.ariaLabel": "Development performance diagnostics",
"debugBar.providerAriaLabel": "Provider performance diagnostics",
"debugBar.na": "n/a",
"debugBar.ttft.label": "TTFT",
"debugBar.ttft.tip": "Time from provider request dispatch to the first model output.",
"debugBar.ttfa.label": "TTFA",
"debugBar.ttfa.tip": "Time from provider request dispatch to the first answer text.",
"debugBar.tps.label": "TPS",
"debugBar.tps.tip": "Output tokens per second after the first model output.",
"debugBar.e2e.label": "E2E",
"debugBar.e2e.tip": "Time from provider request dispatch until its response stream ended.",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Last completed route transition touching a session page, measured from router start until the first paint after it settles.",
+10 -2
View File
@@ -421,9 +421,17 @@ function referenced(json: string) {
return ids
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
async function blobData(blob: BlobReference) {
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
return kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
}
export async function blobBytes(blob: BlobReference) {
return new Uint8Array(await (await blobData(blob)).arrayBuffer())
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
const data = await blobData(blob)
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
@@ -2,6 +2,7 @@ import { createEffect, createMemo, on, type Accessor } from "solid-js"
import type { ComposerControls } from "@/composer/adapter"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerModel } from "@/composer/model"
import { useAttachmentDestination } from "@/composer/attachments/deliver"
import { useSettings } from "@/settings/model"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
@@ -29,6 +30,7 @@ export function createSessionComposerController(input: {
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
destination: useAttachmentDestination(input.controls),
restoreFocus: (cursor) => {
const target = editor
if (!target) return
+28 -40
View File
@@ -5,11 +5,11 @@ import type { SessionInboxInfo } from "@opencode/client/promise"
import { SessionMessage } from "@opencode/schema/session-message"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
import { buildPromptRequest } from "@/composer/request"
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { readPromptPresentation } from "@/composer/comment-note"
import { deliverAttachments, type AttachmentDestination } from "@/composer/attachments/deliver"
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -30,6 +30,7 @@ export function createSessionQueue(input: {
draft: ComposerStateTarget
working: Accessor<boolean>
behavior: Accessor<ComposerDelivery>
destination: () => AttachmentDestination
restoreFocus: (cursor: number) => void
}) {
const data = useData()
@@ -60,6 +61,7 @@ export function createSessionQueue(input: {
change.item,
change.prompt,
change.text,
input.destination(),
)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
@@ -185,15 +187,15 @@ export function createSessionQueue(input: {
if (!editing || mutation.isPending) return
const prompt = clonePrompt(input.draft.current())
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const attachments = prompt.filter(isAttachment)
if (!text.trim() && !attachments.length) return cancelEdit()
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
if (!text.trim() && !images.length) return cancelEdit()
const item = queued().find((entry) => entry.id === editing.id)
const original = item ? queuedPromptAttachments(item) : []
const pristine =
item &&
text.trim() === queuedPromptText(item) &&
attachments.length === original.length &&
attachments.every((attachment, index) => attachment.id === original[index].id)
images.length === original.length &&
images.every((image, index) => image.id === original[index].id)
if (pristine && delivery === "queue") return cancelEdit()
mutation.mutate({
type: "edit",
@@ -249,7 +251,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
attachments: item.payload.files?.length ?? 0,
}))
}
@@ -259,32 +261,18 @@ export function queuedPromptText(item: QueuedPrompt) {
}
// Inline attachments are the files the composer added itself, so they return
// to it as image parts that an edit can remove or extend, and path references
// return as path parts. Mentions and `file://` context stay in the payload; see
// editedPromptInput.
export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPart | PathAttachmentPart)[] {
return [
...(item.payload.files ?? [])
.filter((file) => isComposerAttachment(file))
.map(
(file, index): ImageAttachmentPart => ({
type: "image",
id: `${item.id}:file:${index}`,
filename: file.name ?? "attachment",
mime: file.mime,
blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`),
}),
),
...(readPromptPresentation(item.payload.metadata)?.attachments ?? []).map(
(file, index): PathAttachmentPart => ({
type: "path",
id: `${item.id}:path:${index}`,
filename: file.name,
mime: file.mime,
path: file.path,
}),
),
]
// to it as image parts that an edit can remove or extend. Mentions and
// `file://` context stay in the payload; see editedPromptInput.
export function queuedPromptAttachments(item: QueuedPrompt): ImageAttachmentPart[] {
return (item.payload.files ?? [])
.filter((file) => isComposerAttachment(file))
.map((file, index) => ({
type: "image",
id: `${item.id}:file:${index}`,
filename: file.name ?? "attachment",
mime: file.mime,
blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`),
}))
}
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
@@ -304,13 +292,13 @@ async function editedPromptInput(
item: QueuedPrompt | undefined,
prompt: Prompt,
text: string,
destination: AttachmentDestination,
) {
const images = await Promise.all(
prompt
.filter((part): part is ImageAttachmentPart => part.type === "image")
.map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })),
const attachments = await deliverAttachments(
prompt.filter((part): part is ImageAttachmentPart => part.type === "image"),
destination,
)
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
const request = buildPromptRequest({ prompt, context: [], attachments, text, sessionDirectory: directory })
const payload = item?.payload
const display = item ? queuedPromptText(item) : ""
const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : ""
+88 -196
View File
@@ -1,20 +1,10 @@
import { useIsRouting, useLocation, useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, on, onCleanup, onMount, Show } from "solid-js"
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { base64Encode } from "@opencode/util/encode"
import {
applyProviderMetricEvent,
isProviderMetricEvent,
projectedProviderMetrics,
type ProviderMetrics,
type ProviderMetricState,
} from "./provider-metrics"
type Mem = Performance & {
memory?: {
@@ -49,23 +39,12 @@ const time = (n?: number) => {
return `${Math.round(n)}`
}
const fixed = (n?: number, digits = 0) => {
if (n === undefined || Number.isNaN(n)) return
return n.toFixed(digits)
}
const mb = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
const v = n / 1024 / 1024
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
}
const duration = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
if (n < 1_000) return `${Math.round(n)}ms`
return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}s`
}
const bad = (n: number | undefined, limit: number, low = false) => {
if (n === undefined || Number.isNaN(n)) return false
return low ? n < limit : n > limit
@@ -101,7 +80,6 @@ function Cell(props: {
}}
>
<div
dir="ltr"
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
@@ -109,7 +87,6 @@ function Cell(props: {
{props.label}
</div>
<div
dir="ltr"
classList={{
"uppercase font-bold tabular-nums": true,
"text-[11px] leading-text-tight": !!props.inline,
@@ -159,12 +136,8 @@ function ToggleCell(props: {
"flex-col items-center": !props.inline,
}}
>
<span dir="ltr" class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">
{props.label}
</span>
<span dir="ltr" class="text-[11px] leading-none font-bold">
{props.value}
</span>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
<span class="text-[11px] leading-none font-bold">{props.value}</span>
</span>
</button>
)
@@ -176,11 +149,9 @@ function ToggleCell(props: {
)
}
export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}) {
export function DebugBar(props: { inline?: boolean } = {}) {
const language = useLanguage()
const platform = usePlatform()
const global = useGlobal()
const params = useParams<{ serverKey?: string; id?: string }>()
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
@@ -204,55 +175,8 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
dur: undefined as number | undefined,
pending: false,
},
live: undefined as ProviderMetrics | undefined,
})
const target = createMemo(
() => {
if (!params.serverKey || !params.id) return
const connection = global.servers
.list()
.find((item) => base64Encode(ServerConnection.key(item)) === params.serverKey)
if (!connection) return
return { ctx: global.ensureServerCtx(connection), id: params.id }
},
undefined,
{ equals: (a, b) => a?.ctx === b?.ctx && a?.id === b?.id },
)
// History comes from the already-loaded message projection; live requests refine it in place.
const projected = createMemo(() => {
const current = target()
if (!current) return
return projectedProviderMetrics(current.ctx.data.session.message.list(current.id))
})
const metrics = () => state.live ?? projected()
// Missed events during an outage are never replayed; the refreshed projection must win.
createEffect(
on(
() => target()?.ctx.sdk.connection.status(),
(status) => {
if (status !== "connected") setState("live", undefined)
},
{ defer: true },
),
)
createEffect(
on(target, (current) => {
setState("live", undefined)
if (!current) return
const accumulator: ProviderMetricState = {}
onCleanup(
current.ctx.sdk.event.listen((event) => {
if (!isProviderMetricEvent(event) || event.data.sessionID !== current.id) return
applyProviderMetricEvent(accumulator, event)
if (accumulator.latest) setState("live", accumulator.latest)
}),
)
}),
)
const na = () => language.t("debugBar.na").toUpperCase()
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => {
@@ -280,7 +204,6 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
let two = 0
createEffect(() => {
if (!props.diagnostics) return
const busy = routing()
const next = `${location.pathname}${location.search}`
@@ -325,7 +248,6 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
})
onMount(() => {
if (!props.diagnostics) return
const obs: PerformanceObserver[] = []
const fps: Array<{ at: number; dur: number }> = []
const long: Array<{ at: number; dur: number }> = []
@@ -526,7 +448,7 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
return (
<aside
aria-label={language.t(props.diagnostics ? "debugBar.ariaLabel" : "debugBar.providerAriaLabel")}
aria-label={language.t("debugBar.ariaLabel")}
classList={{
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
@@ -545,132 +467,102 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
}}
>
<Cell
label={language.t("debugBar.tps.label")}
tip={language.t("debugBar.tps.tip")}
value={fixed(metrics()?.tps, 1) ?? na()}
dim={metrics()?.tps === undefined}
label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")}
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.ttft.label")}
tip={language.t("debugBar.ttft.tip")}
value={duration(metrics()?.ttft) ?? na()}
dim={metrics()?.ttft === undefined}
label={language.t("debugBar.fps.label")}
tip={language.t("debugBar.fps.tip")}
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.ttfa.label")}
tip={language.t("debugBar.ttfa.tip")}
value={duration(metrics()?.ttfa) ?? na()}
dim={metrics()?.ttfa === undefined}
label={language.t("debugBar.frame.label")}
tip={language.t("debugBar.frame.tip")}
value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.e2e.label")}
tip={language.t("debugBar.e2e.tip")}
value={duration(metrics()?.e2e) ?? na()}
dim={metrics()?.e2e === undefined}
label={language.t("debugBar.jank.label")}
tip={language.t("debugBar.jank.tip")}
value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
inline={props.inline}
/>
<Show when={props.diagnostics}>
<Cell
label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")}
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.fps.label")}
tip={language.t("debugBar.fps.tip")}
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.frame.label")}
tip={language.t("debugBar.frame.tip")}
value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.jank.label")}
tip={language.t("debugBar.jank.tip")}
value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.long.label")}
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.delay.label")}
tip={language.t("debugBar.delay.tip")}
value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.inp.label")}
tip={language.t("debugBar.inp.tip")}
value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.cls.label")}
tip={language.t("debugBar.cls.tip")}
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.mem.label")}
tip={
state.heap.used === undefined
? language.t("debugBar.mem.tipUnavailable")
: language.t("debugBar.mem.tip", {
used: mb(state.heap.used) ?? na(),
limit: mb(state.heap.limit) ?? na(),
})
}
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
span={platform.setForceFocus ? 2 : 3}
/>
<Cell
label={language.t("debugBar.long.label")}
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.delay.label")}
tip={language.t("debugBar.delay.tip")}
value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.inp.label")}
tip={language.t("debugBar.inp.tip")}
value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.cls.label")}
tip={language.t("debugBar.cls.tip")}
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.mem.label")}
tip={
state.heap.used === undefined
? language.t("debugBar.mem.tipUnavailable")
: language.t("debugBar.mem.tip", {
used: mb(state.heap.used) ?? na(),
limit: mb(state.heap.limit) ?? na(),
})
}
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
span={platform.setForceFocus ? 2 : 3}
/>
<ToggleCell
active={language.direction() === "rtl"}
inline={props.inline}
label={language.t("debugBar.direction.label")}
tip={language.t("debugBar.direction.tip")}
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/>
<Show when={platform.setForceFocus}>
<ToggleCell
active={language.direction() === "rtl"}
active={state.focus}
inline={props.inline}
label={language.t("debugBar.direction.label")}
tip={language.t("debugBar.direction.tip")}
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
label={language.t("debugBar.focus.label")}
tip={language.t("debugBar.focus.tip")}
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
<Show when={platform.setForceFocus}>
<ToggleCell
active={state.focus}
inline={props.inline}
label={language.t("debugBar.focus.label")}
tip={language.t("debugBar.focus.tip")}
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
</Show>
</Show>
</div>
</aside>
@@ -1,153 +0,0 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode/client/promise"
import type { ProviderMetricEvent } from "./provider-metrics"
import { foldProviderMetrics, projectedProviderMetrics } from "./provider-metrics"
const durable = { aggregateID: "ses_test", seq: 0, version: 1 } as const
const events: ProviderMetricEvent[] = [
{
id: "evt_started",
created: 1_000,
type: "session.step.started",
durable,
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
started: 1_000,
},
},
{
id: "evt_reasoning",
created: 1_300,
type: "session.reasoning.started",
durable: { ...durable, seq: 1 },
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0 },
},
{
id: "evt_text",
created: 1_800,
type: "session.text.started",
durable: { ...durable, seq: 2 },
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0 },
},
{
id: "evt_streamed",
created: 3_800,
type: "session.step.streamed",
durable: { ...durable, seq: 3 },
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant" },
},
{
id: "evt_ended",
created: 4_000,
type: "session.step.ended",
durable: { ...durable, seq: 4 },
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
finish: "stop",
cost: 0,
tokens: { input: 200, output: 100, reasoning: 20, cache: { read: 0, write: 0 } },
},
},
]
test("calculates provider response metrics from durable events", () => {
expect(foldProviderMetrics(events)).toEqual({
tps: 50,
ttft: 300,
ttfa: 800,
e2e: 2_800,
})
})
test("ignores failed attempts without usage", () => {
expect(
foldProviderMetrics([
...events.slice(0, 4),
{
id: "evt_failed",
created: 4_000,
type: "session.step.failed",
durable: { ...durable, seq: 4 },
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
error: { type: "aborted", message: "Step interrupted" },
},
},
]),
).toBeUndefined()
})
test("keeps completed metrics while the next provider attempt runs", () => {
expect(
foldProviderMetrics([
...events,
{
id: "evt_retry",
created: 5_000,
type: "session.step.started",
durable: { ...durable, seq: 5 },
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
started: 5_000,
},
},
]),
).toEqual(foldProviderMetrics(events))
})
const assistant: SessionMessageAssistant = {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "reasoning", text: "Think", time: { created: 1_300, completed: 1_700 } },
{ type: "text", text: "Answer" },
],
tokens: { input: 200, output: 100, reasoning: 20, cache: { read: 0, write: 0 } },
time: { created: 1_000, streamed: 3_800, completed: 4_000 },
}
test("derives a baseline from the latest completed projected request", () => {
const messages: SessionMessageInfo[] = [
{ id: "msg_user", type: "user", text: "Hi", time: { created: 1 } },
assistant,
{ ...assistant, id: "msg_running", tokens: undefined, time: { created: 5_000 } },
]
// Reasoning ended at 1_700, so TPS spans 1_700 → 3_800 = 100 / 2.1s.
expect(projectedProviderMetrics(messages)).toEqual({ tps: 100 / 2.1, ttft: 300, ttfa: 700, e2e: 2_800 })
})
const tool = (created: number): SessionMessageAssistant["content"][number] => ({
type: "tool",
id: "call_1",
name: "read",
state: { status: "running", input: {}, metadata: {} },
time: { created, ran: created + 100 },
})
test("leaves text-first history unavailable until a live request", () => {
const unavailable = { tps: undefined, ttft: undefined, ttfa: undefined, e2e: 2_800 }
expect(projectedProviderMetrics([{ ...assistant, content: [{ type: "text", text: "Answer" }] }])).toEqual(unavailable)
expect(
projectedProviderMetrics([{ ...assistant, content: [{ type: "text", text: "Answer" }, tool(2_500)] }]),
).toEqual(unavailable)
})
test("uses the first tool call as first output for tool-first history", () => {
expect(projectedProviderMetrics([{ ...assistant, content: [tool(1_800)] }])).toEqual({
tps: 50,
ttft: 800,
ttfa: undefined,
e2e: 2_800,
})
})
@@ -1,135 +0,0 @@
import type {
SessionLogItem,
SessionMessageAssistant,
SessionMessageInfo,
TokenUsageInfo,
} from "@opencode/client/promise"
type ProviderMetricEventType =
| "session.step.started"
| "session.step.streamed"
| "session.step.ended"
| "session.step.failed"
| "session.text.started"
| "session.reasoning.started"
| "session.tool.input.started"
const types: ReadonlySet<string> = new Set<ProviderMetricEventType>([
"session.step.started",
"session.step.streamed",
"session.step.ended",
"session.step.failed",
"session.text.started",
"session.reasoning.started",
"session.tool.input.started",
])
export type ProviderMetricEvent = Extract<SessionLogItem, { type: ProviderMetricEventType }>
export type ProviderMetrics = {
tps?: number
ttft?: number
ttfa?: number
e2e?: number
}
type Attempt = {
assistantMessageID: string
started: number
first?: number
answer?: number
streamed?: number
tokens?: TokenUsageInfo
}
export type ProviderMetricState = { attempt?: Attempt; latest?: ProviderMetrics }
export function isProviderMetricEvent(event: { type: string }): event is ProviderMetricEvent {
return types.has(event.type)
}
export function applyProviderMetricEvent(state: ProviderMetricState, event: ProviderMetricEvent) {
if (event.type === "session.step.started") {
state.attempt = {
assistantMessageID: event.data.assistantMessageID,
started: event.data.started,
}
return
}
if (!state.attempt || event.data.assistantMessageID !== state.attempt.assistantMessageID) return
if (
event.type === "session.text.started" ||
event.type === "session.reasoning.started" ||
event.type === "session.tool.input.started"
) {
state.attempt.first ??= event.created
if (event.type === "session.text.started") state.attempt.answer ??= event.created
return
}
if (event.type === "session.step.streamed") {
state.attempt.streamed = event.created
return
}
// Interrupted or failed attempts without usage would publish misleading partial numbers.
if (!event.data.tokens || state.attempt.first === undefined || state.attempt.streamed === undefined) return
state.attempt.tokens = event.data.tokens
state.latest = attemptMetrics(state.attempt)
}
export function foldProviderMetrics(events: readonly ProviderMetricEvent[]) {
const state: ProviderMetricState = {}
events.forEach((event) => applyProviderMetricEvent(state, event))
return state.latest
}
/**
* Baseline from already-loaded history. Text parts carry no start timestamp yet, so TTFT, TTFA,
* and TPS stay unavailable for text-first requests until a live request supplies them.
*/
export function projectedProviderMetrics(messages: readonly SessionMessageInfo[]): ProviderMetrics | undefined {
const message = messages.findLast(
(item): item is SessionMessageAssistant =>
item.type === "assistant" && item.time.streamed !== undefined && item.tokens !== undefined,
)
if (!message) return
// Content is chronological; only a non-text head carries the first-output time.
const head = message.content[0]
const first = head && head.type !== "text" ? head.time?.created : undefined
// Reasoning ends when the answer starts, so a reasoning part right before the first text
// approximates the live `session.text.started` timestamp.
const text = message.content.findIndex((item) => item.type === "text")
const before = text > 0 ? message.content[text - 1] : undefined
const answer = first !== undefined && before?.type === "reasoning" ? before.time?.completed : undefined
return attemptMetrics({
assistantMessageID: message.id,
started: message.time.created,
first,
answer,
streamed: message.time.streamed,
tokens: message.tokens,
})
}
function attemptMetrics(attempt: Attempt): ProviderMetrics {
const ttft = elapsed(attempt.started, attempt.first)
const ttfa = elapsed(attempt.started, attempt.answer)
const e2e = elapsed(attempt.started, attempt.streamed)
// Output tokens exclude reasoning, so measure them from the answer start when one exists.
const generation = elapsed(attempt.answer ?? attempt.first, attempt.streamed)
const output = attempt.tokens?.output
return {
tps: generation && output && generation > 0 && output > 0 ? output / (generation / 1_000) : undefined,
ttft,
ttfa,
e2e,
}
}
function elapsed(start: number | undefined, end: number | undefined) {
if (start === undefined || end === undefined) return
return Math.max(0, end - start)
}
+10 -23
View File
@@ -5,14 +5,11 @@ import { ResizeHandle } from "@opencode/ui/resize-handle"
import { Titlebar, type TitlebarUpdate } from "@/shell/titlebar/titlebar"
import { usePlatform } from "@/runtime/platform/platform"
import { ToastRegion } from "@/shell/notifications/toast"
import { UploadToastHost } from "@/composer/attachments/uploads"
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
import { SshAuthentication } from "@/servers/ssh/authentication"
import { useUpdaterInstall } from "@/shell/updates/download"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
@@ -21,8 +18,6 @@ export default function Layout(props: ParentProps) {
const settings = useSettingsSurface()
const preferences = useSettings()
const installUpdate = useUpdaterInstall()
const command = useCommand()
const language = useLanguage()
const mobile = createMediaQuery("(max-width: 767px)")
const [state, setState] = createStore({
debugTools: false,
@@ -39,21 +34,14 @@ export default function Layout(props: ParentProps) {
install: installUpdate,
}
// A plain object avoids the compiler's conditional-prop memo, which leaks when read from event handlers.
const debugTools = {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
command.register("debug-bar", () => [
{
id: "debugBar.toggle",
title: language.t("command.debugBar.toggle"),
category: language.t("command.category.view"),
onSelect: debugTools.toggle,
},
])
const debugTools = import.meta.env.DEV
? {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
: undefined
return (
<TitlebarRightProvider>
@@ -117,13 +105,12 @@ export default function Layout(props: ParentProps) {
</SshAuthentication>
</main>
</div>
<Show when={state.debugTools}>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar diagnostics={import.meta.env.DEV} inline />
<DebugBar inline />
</Suspense>
</Show>
<ToastRegion />
<UploadToastHost />
</div>
</TitlebarRightProvider>
)
@@ -34,15 +34,10 @@ describe("Composer attachment ownership", () => {
addPart: () => false,
setDraggingType() {},
directory: () => "C:/repo",
destination: () => ({
input: { image: true, pdf: true },
local: false,
upload: () => Promise.reject(new Error("native attachments must not upload")),
}),
isDialogActive: () => false,
warn() {},
duplicate() {},
onError: rejectTest,
onUploadError: rejectTest,
store: () => stored.promise,
})
+2 -15
View File
@@ -47,14 +47,7 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
let installing: string | undefined
const updateListeners = new Set<(version: string) => void>()
const update = yield* updater
.run((version) => {
installing = version
updateListeners.forEach((notify) => notify(version))
})
.pipe(Effect.ensuring(Effect.sync(() => (installing = undefined))), Effect.forkScoped)
const update = yield* updater.run().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -99,13 +92,7 @@ export default Runtime.handler(Commands, (input) =>
),
{ signal },
),
check: (signal, notify) => {
if (installing) notify(installing)
updateListeners.add(notify)
return runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }).finally(() =>
updateListeners.delete(notify),
)
},
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
apply: (version) => runPromise(updater.apply(version)),
},
packages: {
+2 -3
View File
@@ -13,7 +13,7 @@ export type RunResult = { readonly type: "available" | "installed"; readonly ver
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
export interface Interface {
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
readonly run: () => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
@@ -275,11 +275,10 @@ const make = Effect.gen(function* () {
})
const run = Effect.fn("cli.updater.run")(
function* (onInstall: (version: string) => void = () => {}) {
function* () {
const result = yield* inspect()
if (!result) return undefined
if (result.policy === "notify") return { type: "available" as const, version: result.version }
onInstall(result.version)
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
return { type: "installed" as const, version: result.version }
},
-12
View File
@@ -1043,18 +1043,6 @@ export function createData(config: CreateDataInput) {
: "interrupted",
time: { created: event.created },
})
if (
store.session.message[event.data.sessionID]?.some(
(item) =>
item.type === "assistant" &&
item.content.some(
(part) => part.type === "tool" && (part.state.status === "streaming" || part.state.status === "running"),
),
)
) {
sync.invalidate(`session.message:${event.data.sessionID}`)
refresh(() => result.session.message.sync(event.data.sessionID))
}
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
-74
View File
@@ -52,80 +52,6 @@ test("uses the configured initial window and retains normal cursor page sizes",
}
})
test("reconciles a stale running tool when execution settles", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
let completed = false
let requests = 0
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () => {
requests++
return Response.json({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "provider", id: "model" },
time: { created: 1, ...(completed ? { completed: 2 } : {}) },
content: [
{
type: "tool",
id: "call_execute",
name: "execute",
time: { created: 1, ran: 1, ...(completed ? { completed: 2 } : {}) },
state: completed
? { status: "completed", input: {}, metadata: {}, content: [] }
: { status: "running", input: {}, metadata: {} },
},
],
},
],
cursor: {},
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => "connected" },
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
completed = true
const interrupted: OpenCodeEvent = {
id: "evt_interrupted",
created: 3,
type: "session.execution.interrupted",
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
data: { sessionID: "ses_refresh", reason: "user" },
}
listeners.forEach((listener) => listener({ name: interrupted.type, details: interrupted }))
await wait(
() =>
setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]?.type === "tool" &&
setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]?.state.status === "completed",
)
expect(requests).toBe(2)
expect(setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]).toMatchObject({
state: { status: "completed" },
})
} finally {
setup.dispose()
}
})
test("revalidates after an event overtakes an active session read", async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
+6 -14
View File
@@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See
## How it differs from JavaScript
- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the
globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable.
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
@@ -94,19 +94,11 @@ receive `{ extension, name, args }`. An `after` hook also receives how the call
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
failure as a thrown error.
### `Extension.make`
### `Values`
Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the
catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean.
```ts
const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } })
const runtime = CodeMode.make({ tools, extensions: [web] })
```
Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as
copies, and a function inside a result is callable the same way. A global that shadows a built-in or another
extension throws at `CodeMode.make`.
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
### OpenAPI tools
+13 -19
View File
@@ -29,7 +29,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array values inside CodeMode.
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
shadowable by program declarations like other globals.
@@ -47,8 +47,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Values and literals
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, custom
synchronous iterators, and synchronous generators.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
iterators, and synchronous generators.
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
- [x] Template literals with interpolation.
@@ -95,8 +95,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `if`/`else` and conditional expressions.
- [x] `switch`, including default clauses and fallthrough.
- [x] `for`, `while`, and `do...while`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, custom synchronous iterators, and
confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
@@ -127,7 +127,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
because `includes` is called without a string `this`.
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`,
like JS.
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
arrow function.
@@ -179,10 +179,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Sequence expressions (the comma operator).
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
`await` still defers its continuation one reaction turn.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new`
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
non-callable values are not constructors. Error constructors take the ES2022 options object, so
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
@@ -451,13 +451,7 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`;
iteration is live and sorted by name, with `set-cookie` values kept apart.
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
- [ ] `Request`, `Response`, and `Blob`.
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
## Extensions
@@ -467,8 +461,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
a built-in or another extension throws at `make`.
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
-2
View File
@@ -17,7 +17,6 @@ import {
record,
SetObj,
URLSearchParamsObj,
HeadersObj,
} from "./interpreter/objects.js"
import { typeofValue } from "./interpreter/references.js"
@@ -70,7 +69,6 @@ const walk = <R>(
)
}
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof HeadersObj) return Object.fromEntries(value.headers)
const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value
if (stack.has(target)) throw typeError("Converting circular structure to JSON.")
stack.add(target)
@@ -27,7 +27,6 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
import { describeValue } from "./references.js"
@@ -53,7 +52,6 @@ export const extensionGlobals = <R>(
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
if (value instanceof URLObj) return new URL(value.url.href)
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
if (value instanceof HeadersObj) return new Headers(value.headers)
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
if (value instanceof SetObj) return new Set([...value.set].map(next))
@@ -127,7 +125,6 @@ export const extensionGlobals = <R>(
if (value instanceof URLSearchParams) {
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
}
if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value))
if (value instanceof Map) {
const wrapped = new MapObj(builtins.Map)
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
@@ -11,7 +11,6 @@ import { objectGlobal } from "../stdlib/object.js"
import { regexpGlobal } from "../stdlib/regexp.js"
import { stringGlobal } from "../stdlib/string.js"
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
import { headersGlobal } from "../stdlib/headers.js"
import { coercion } from "../stdlib/value.js"
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
import { ToolReference } from "../tool-runtime.js"
@@ -81,7 +80,6 @@ const table: Record<string, Factory> = {
Set: (ctx) => setGlobal(ctx),
URL: (ctx) => urlGlobal(ctx),
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
Headers: (ctx) => headersGlobal(ctx),
Uint8Array: (ctx) => uint8ArrayGlobal(ctx),
TextEncoder: (ctx) => textEncoderGlobal(ctx),
TextDecoder: (ctx) => textDecoderGlobal(ctx),
@@ -84,7 +84,6 @@ import {
PromiseObj,
SetObj,
URLSearchParamsObj,
HeadersObj,
record,
remove,
set,
@@ -654,7 +653,7 @@ class Frame<R> {
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
if (iterator === undefined && cursor === undefined) {
throw invalidData(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
node,
)
}
@@ -757,11 +756,9 @@ class Frame<R> {
? value.set.values()
: value instanceof URLSearchParamsObj
? value.params.entries()
: value instanceof HeadersObj
? value.headers.entries()
: value instanceof Bytes
? value.bytes.values()
: undefined
: value instanceof Bytes
? value.bytes.values()
: undefined
if (iterator !== undefined) {
const proto = this.ctx.builtins.Array
return Effect.succeed({
@@ -1851,7 +1848,6 @@ class Frame<R> {
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
) {
const cursor = yield* self.iterate(value, node)
@@ -29,7 +29,6 @@ const builtins = [
"Set",
"URL",
"URLSearchParams",
"Headers",
"Uint8Array",
"TextEncoder",
"TextDecoder",
@@ -81,7 +80,6 @@ export const createBuiltins = (): Builtins => {
Set: plain(),
URL: plain(),
URLSearchParams: plain(),
Headers: plain(),
Uint8Array: plain(),
TextEncoder: plain(),
TextDecoder: plain(),
+1 -11
View File
@@ -156,15 +156,6 @@ export class URLSearchParamsObj extends Obj {
}
}
export class HeadersObj extends Obj {
constructor(
proto: Obj,
readonly headers: Headers,
) {
super(proto)
}
}
export class URLObj extends Obj {
readonly searchParams: URLSearchParamsObj
constructor(
@@ -190,14 +181,13 @@ export class Bytes extends Obj {
/** Built-in objects that wrap a host value; data-like, but never plain data. */
export const isWrapper = (
value: unknown,
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | Bytes =>
value instanceof DateObj ||
value instanceof RegExpObj ||
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
const MAX_ARRAY_INDEX = 4_294_967_295
@@ -16,7 +16,6 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
/** Values that cannot cross the data boundary. */
@@ -86,7 +85,6 @@ export const describeValue = (value: unknown): string => {
if (value instanceof SetObj) return "a Set"
if (value instanceof URLObj) return "a URL"
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
if (value instanceof HeadersObj) return "a Headers"
if (value instanceof Bytes) return "a Uint8Array"
if (value instanceof GeneratorObj) return "a generator"
if (isRuntimeReference(value)) return "a function"
-2
View File
@@ -12,7 +12,6 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -67,7 +66,6 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (value instanceof RegExpObj) return coerceToString(value)
if (value instanceof URLObj) return coerceToString(value)
if (value instanceof URLSearchParamsObj) return coerceToString(value)
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
-119
View File
@@ -1,119 +0,0 @@
import { Effect } from "effect"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { entries, Arr, HeadersObj, Obj } from "../interpreter/objects.js"
import { applyCollectionCallback } from "../interpreter/callback.js"
import { isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToString } from "./value.js"
import { readPairs } from "./url.js"
// The host validates header names and values and throws its own TypeError; the program gets one of its own.
const attempt = <T>(run: () => T): T => {
try {
return run()
} catch (error) {
throw typeError(error instanceof Error ? error.message : String(error))
}
}
const constructHeaders = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
const wrap = (headers: Headers) => new HeadersObj(proto, headers)
if (init === undefined) return Effect.succeed(wrap(new Headers()))
return Effect.gen(function* () {
const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined
if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs)))
if (!(init instanceof Obj) || isRuntimeReference(init)) {
throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.")
}
return wrap(
attempt(() => new Headers(Object.fromEntries(entries(init).map(([key, value]) => [key, coerceToString(value)])))),
)
})
}
export const headersGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.Headers
const headers = constructor<R>(builtins, proto, {
name: "Headers",
call: requiresNew("Headers"),
construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
}
methods(builtins, proto, [
[
"append",
2,
(thisValue, args) => {
requireArgs("append", args, 2)
const target = self(thisValue, "append").headers
return attempt(() => target.append(arg(args, 0), arg(args, 1)))
},
],
[
"delete",
1,
(thisValue, args) => {
requireArgs("delete", args, 1)
const target = self(thisValue, "delete").headers
return attempt(() => target.delete(arg(args, 0)))
},
],
[
"get",
1,
(thisValue, args) => {
requireArgs("get", args, 1)
const target = self(thisValue, "get").headers
return attempt(() => target.get(arg(args, 0)))
},
],
["getSetCookie", 0, (thisValue) => wrap(self(thisValue, "getSetCookie").headers.getSetCookie())],
[
"has",
1,
(thisValue, args) => {
requireArgs("has", args, 1)
const target = self(thisValue, "has").headers
return attempt(() => target.has(arg(args, 0)))
},
],
[
"set",
2,
(thisValue, args) => {
requireArgs("set", args, 2)
const target = self(thisValue, "set").headers
return attempt(() => target.set(arg(args, 0), arg(args, 1)))
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").headers.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").headers.values()))],
[
"entries",
0,
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").headers.entries(), ([key, value]) => wrap([key, value]))),
],
[
"forEach",
1,
(thisValue, args) => {
requireArgs("forEach", args, 1)
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
return undefined
})
},
],
])
return headers
}
+18 -27
View File
@@ -107,10 +107,12 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
return url
}
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(value)
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
if (cursor === undefined) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
const items: Array<string> = []
while (true) {
const step = yield* cursor.next
@@ -124,29 +126,6 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect
}
})
/**
* Reads a synchronous iterable of `[name, value]` pairs as strings; `undefined` when `init` is not iterable. As in
* WebIDL, the whole sequence is converted before any pair's length is checked.
*/
export const readPairs = <R>(
ctx: Interpreter<R>,
init: unknown,
label: string,
): Effect.Effect<Array<[string, string]> | undefined, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(init)
if (cursor === undefined) return undefined
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) throw typeError(`${label} expects iterable [name, value] pairs.`)
return pairs as Array<[string, string]>
}
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value, label)))
}
})
const constructURLSearchParams = <R>(
ctx: Interpreter<R>,
init: unknown,
@@ -160,8 +139,20 @@ const constructURLSearchParams = <R>(
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
}
return Effect.gen(function* () {
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
const cursor = yield* ctx.iterate(init)
if (cursor !== undefined) {
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
}
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value)))
}
}
if (isRuntimeReference(init)) {
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
}
-2
View File
@@ -13,7 +13,6 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -29,7 +28,6 @@ export const coerceToString = (value: unknown): string => {
if (value instanceof SetObj) return "[object Set]"
if (value instanceof URLObj) return value.url.href
if (value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof HeadersObj) return "[object Headers]"
if (value instanceof Bytes) return value.bytes.join(",")
if (value instanceof ErrorObj) {
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
-28
View File
@@ -128,34 +128,6 @@ describe("values are converted at the boundary, never shared", () => {
expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 })
})
test("Headers cross as copies in both directions", async () => {
const stored = new Headers({ "X-A": "1" })
const target = CodeMode.make({
extensions: [
Extension.make({
name: "http",
globals: {
headers: () => stored,
keep: (value: Headers) => {
held.push(value)
return value
},
},
}),
],
})
held.length = 0
expect(
await value(
`const h = headers(); h.set("x-a", "2"); const back = keep(h); back.set("x-a", "3"); return [h instanceof Headers, h.get("x-a"), back === h, back.get("x-a"), [...back]]`,
target,
),
).toEqual([true, "2", false, "3", [["x-a", "3"]]])
expect(stored.get("x-a")).toBe("1")
expect(held[0]).toBeInstanceOf(Headers)
expect((held[0] as Headers).get("x-a")).toBe("2")
})
test("bytes cross as copies in both directions; ArrayBuffer comes in as Uint8Array", async () => {
const stored = new Uint8Array([1, 2, 3])
const target = CodeMode.make({
-148
View File
@@ -1,148 +0,0 @@
{
"openapi": "3.1.0",
"info": {
"title": "CodeMode Transport Coverage",
"version": "1.0.0"
},
"paths": {
"/records/{recordID}": {
"get": {
"operationId": "records.get",
"parameters": [
{
"name": "recordID",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Record",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Record"
}
}
}
}
}
}
},
"/events": {
"get": {
"operationId": "events.subscribe",
"responses": {
"200": {
"description": "Events",
"content": {
"text/event-stream": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/files/{path}": {
"get": {
"operationId": "files.read",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "File",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
},
"put": {
"operationId": "files.write",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"responses": {
"204": {
"description": "Written"
}
}
}
},
"/terminals/{terminalID}/connect": {
"get": {
"operationId": "terminals.connect",
"x-websocket": true,
"parameters": [
{
"name": "terminalID",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"101": {
"description": "Connected"
}
}
}
}
},
"components": {
"schemas": {
"Record": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": ["id", "value"],
"additionalProperties": false
}
}
}
}
+107 -44
View File
@@ -14,8 +14,8 @@ type Recorded = {
readonly body: unknown
}
const transportSpec = async (): Promise<Document> => {
return Bun.file(new URL("./fixtures/openapi-transports.json", import.meta.url)).json() as Promise<Document>
const opencodeSpec = async (): Promise<Document> => {
return Bun.file(new URL("../../protocol/openapi.json", import.meta.url)).json() as Promise<Document>
}
const happyPathSpec = async (): Promise<Document> => {
@@ -219,42 +219,48 @@ describe("OpenAPI.fromSpec", () => {
expect(client.requests[3]!.headers.authorization).toBe("Bearer bearer-secret")
})
test("generates supported operations and reports unsupported transports", async () => {
const spec = await transportSpec()
test("converts representative opencode operations into the expected tool shape", async () => {
const spec = await opencodeSpec()
const result = OpenAPI.fromSpec({ spec, baseUrl })
expect(result.skipped).toEqual([
{
method: "GET",
path: "/events",
reason: "SSE operations are not supported",
},
{
method: "GET",
path: "/files/{path}",
reason: "binary responses are not supported",
},
{
method: "PUT",
path: "/files/{path}",
reason: "request body has no JSON content (declared: application/octet-stream)",
},
{
method: "GET",
path: "/terminals/{terminalID}/connect",
reason: "WebSocket operations are not supported",
},
])
expect(result.skipped).toHaveLength(5)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/pty/{ptyID}/connect",
reason: "WebSocket operations are not supported",
})
expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/fs/read/*",
reason: "binary responses are not supported",
})
expect(toolAt(result.tools, "server.info")).not.toBeUndefined()
expect(toolAt(result.tools, "session.get")).not.toBeUndefined()
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
const get = toolAt(result.tools, "records.get")
expect(Tool.isTool(get)).toBe(true)
if (!Tool.isTool(get)) throw new Error("records.get was not generated")
expect(inputTypeScript(get)).toBe("{ recordID: string }")
expect(outputTypeScript(get)).toBe("{ id: string; value: string }")
expect(toolAt(result.tools, "events.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "files.read")).toBeUndefined()
expect(toolAt(result.tools, "files.write")).toBeUndefined()
expect(toolAt(result.tools, "terminals.connect")).toBeUndefined()
const sessionGet = toolAt(result.tools, "session.get")
expect(Tool.isTool(sessionGet)).toBe(true)
if (!Tool.isTool(sessionGet)) throw new Error("session.get was not generated")
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
expect(outputTypeScript(sessionGet)).toContain("id: string")
expect(outputTypeScript(sessionGet)).toContain("additions: number")
const switchAgent = toolAt(result.tools, "session.switchAgent")
expect(Tool.isTool(switchAgent)).toBe(true)
if (!Tool.isTool(switchAgent)) throw new Error("session.switchAgent was not generated")
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
const instructionPut = toolAt(result.tools, "experimental.session.instructions.entry.put")
expect(Tool.isTool(instructionPut)).toBe(true)
if (!Tool.isTool(instructionPut)) throw new Error("experimental.session.instructions.entry.put was not generated")
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "experimental_session_instructions_entry_put_2")).toBeUndefined()
expect(Tool.isTool(toolAt(result.tools, "pty.connect"))).toBe(false)
expect(toolAt(result.tools, "session.log")).toBeUndefined()
expect(toolAt(result.tools, "event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "fs.read")).toBeUndefined()
expect(toolAt(result.tools, "pty.connect.token")).not.toBeUndefined()
})
test("preserves operation path sanitization and collision handling", () => {
@@ -965,16 +971,30 @@ describe("OpenAPI.fromSpec", () => {
expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } })
})
test("exposes generated operations through CodeMode discovery", async () => {
test("documents that the opencode fixture is unauthenticated", async () => {
const spec = await opencodeSpec()
const components = isRecord(spec.components) ? spec.components : {}
const result = OpenAPI.fromSpec({ spec, baseUrl })
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const info = toolAt(result.tools, "server.info")
const infoInput = Tool.isTool(info) && isRecord(info.input) ? info.input : undefined
expect(infoInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(infoInput) ? infoInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
})
test("exposes real opencode operations through CodeMode discovery", async () => {
const { layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { api: OpenAPI.fromSpec({ spec: await happyPathSpec(), baseUrl }).tools },
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime
.execute(
`
return search({ query: "get a user", namespace: "api", limit: 1 })
return search({ query: "server info", namespace: "opencode", limit: 1 })
`,
)
.pipe(Effect.provide(layer)),
@@ -985,12 +1005,55 @@ describe("OpenAPI.fromSpec", () => {
expect(result.value).toMatchObject({
items: [
{
path: "tools.api.users.get",
description: "Get a user",
path: "tools.opencode.server.info",
description: "Return the server identity, connection URLs, paths, and readiness status.",
},
],
})
expect(JSON.stringify(result.value)).toContain("userId: string")
expect(JSON.stringify(result.value)).toContain("version: string")
})
test("invokes real opencode path parameters and JSON request bodies", async () => {
const { requests, layer } = recordingClient((request) => {
if (request.method === "GET") return json({ id: "ses_123" })
return json({ id: "ses_456" })
})
const runtime = CodeMode.make({
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime
.execute(
`
const existing = await tools.opencode.session.get({ sessionID: "ses_123" })
const created = await tools.opencode.session.create({ id: "ses_456" })
return { existing, created }
`,
)
.pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true })
expect(requests).toHaveLength(2)
expect(requests[0]).toMatchObject({ method: "GET", body: undefined })
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_123")
expect(requests[1]).toMatchObject({
method: "POST",
url: "http://localhost:4096/api/session",
body: { id: "ses_456" },
})
})
test("serializes deep-object query parameters from the opencode fixture", async () => {
const client = recordingClient(() => json({ directory: "/tmp" }))
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "location.get")
if (!Tool.isTool(location)) throw new Error("location.get was not generated")
await Effect.runPromise(location.execute({ location: { directory: "/tmp" } }).pipe(Effect.provide(client.layer)))
const url = new URL(client.requests[0]!.url)
expect(url.searchParams.get("location[directory]")).toBe("/tmp")
})
test("serializes supported simple and form parameter shapes", async () => {
@@ -1398,15 +1461,15 @@ describe("OpenAPI.fromSpec", () => {
test("fails missing required parameters before auth and network", async () => {
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { api: OpenAPI.fromSpec({ spec: await transportSpec(), baseUrl }).tools },
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime.execute("return await tools.api.records.get({})").pipe(Effect.provide(layer)),
runtime.execute("return await tools.opencode.session.get({})").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: false })
expect(JSON.stringify(result)).toContain("Missing required path parameter 'recordID'")
expect(JSON.stringify(result)).toContain("Missing required path parameter 'sessionID'")
expect(requests).toHaveLength(0)
})
-148
View File
@@ -635,154 +635,6 @@ describe("URL and URI helpers", () => {
})
})
describe("Headers", () => {
test("constructs from records, pairs, Maps, and Headers; names fold to lowercase and values combine", async () => {
expect(
await value(`
const headers = new Headers({ "Content-Type": "text/plain", "X-Count": 1, "X-Null": null })
headers.append("Accept", "text/html")
headers.append("accept", "application/json")
headers.set("x-count", "2")
headers.delete("x-null")
const copy = new Headers(headers)
copy.set("content-type", "text/html")
return {
get: headers.get("content-type"),
missing: headers.get("x-missing"),
combined: headers.get("ACCEPT"),
has: [headers.has("Accept"), headers.has("x-null")],
count: headers.get("x-count"),
copied: [headers.get("content-type"), copy.get("content-type")],
pairs: [...new Headers([["b", "2"], ["A", "1"]])],
map: [...new Headers(new Map([["k", "v"]]))],
keys: headers.keys(),
values: headers.values(),
entries: headers.entries(),
}
`),
).toEqual({
get: "text/plain",
missing: null,
combined: "text/html, application/json",
has: [true, false],
count: "2",
copied: ["text/plain", "text/html"],
pairs: [
["a", "1"],
["b", "2"],
],
map: [["k", "v"]],
keys: ["accept", "content-type", "x-count"],
values: ["text/html, application/json", "text/plain", "2"],
entries: [
["accept", "text/html, application/json"],
["content-type", "text/plain"],
["x-count", "2"],
],
})
})
test("iterates in sorted order everywhere iteration is allowed, and getSetCookie keeps cookies apart", async () => {
expect(
await value(`
const headers = new Headers({ b: "2", a: "1" })
headers.append("Set-Cookie", "x=1")
headers.append("set-cookie", "y=2")
const seen = []
headers.forEach((value, name, self) => seen.push(name + "=" + value + ":" + (self === headers)))
const [first] = headers
function* pairs() { yield* headers }
return {
seen,
first,
spread: [...headers],
from: Array.from(headers).length,
generator: [...pairs()].length,
object: Object.fromEntries(headers),
cookies: headers.getSetCookie(),
}
`),
).toEqual({
seen: ["a=1:true", "b=2:true", "set-cookie=x=1:true", "set-cookie=y=2:true"],
first: ["a", "1"],
spread: [
["a", "1"],
["b", "2"],
["set-cookie", "x=1"],
["set-cookie", "y=2"],
],
from: 4,
generator: 4,
object: { a: "1", b: "2", "set-cookie": "y=2" },
cookies: ["x=1", "y=2"],
})
})
test("serializes as a name-to-value object at the boundary and in JSON; prints for console", async () => {
const result = await run(`
const headers = new Headers({ "X-A": "1", b: "2" })
console.log(headers)
return { headers, json: JSON.stringify({ headers }), text: String(headers), type: typeof headers, is: headers instanceof Headers }
`)
expect(result.ok && result.value).toEqual({
headers: { b: "2", "x-a": "1" },
json: '{"headers":{"b":"2","x-a":"1"}}',
text: "[object Headers]",
type: "object",
is: true,
})
expect(result.ok && result.logs?.[0]).toBe('Headers {"b":"2","x-a":"1"}')
})
test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => {
expect(
await value(`
function message(run) {
try { run(); return null } catch (error) { return error instanceof TypeError ? error.message : error }
}
const headers = new Headers()
return [
message(() => Headers()),
message(() => new Headers(null)),
message(() => new Headers(1)),
message(() => new Headers("a=1")),
message(() => new Headers(new Date())),
message(() => new Headers(() => 1)),
message(() => new Headers([["name"]])),
message(() => new Headers([["a", "b", "c"]])),
message(() => new Headers({ "bad name": "x" })),
message(() => new Headers({ name: "bad\u0000value" })),
message(() => headers.get("invalid\u0100")),
message(() => headers.has({})),
message(() => headers.set("a", "invalid\u0100")),
message(() => headers.append("a")),
message(() => headers.forEach()),
message(() => headers.forEach(1)),
message(() => { const get = headers.get; return get("a") }),
]
`),
).toEqual([
"Constructor Headers requires 'new'.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects iterable [name, value] pairs.",
"new Headers(...) expects iterable [name, value] pairs.",
expect.stringContaining("bad name"),
expect.stringContaining("invalid value"),
expect.stringContaining("Invalid header name"),
expect.stringContaining("[object Object]"),
expect.stringContaining("invalid value"),
"Headers.append requires 2 arguments.",
"Headers.forEach requires 1 argument.",
"Headers.forEach expects a function callback.",
"Headers.prototype.get called on incompatible receiver undefined.",
])
})
})
describe("Map", () => {
test("get/set/has/size with chaining", async () => {
expect(
-225
View File
@@ -3,13 +3,10 @@
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
* - WebCryptoAPI/randomUUID.https.any.js
* - fetch/api/headers/{headers-basic,headers-errors}.any.js
*
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
*
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException.
* Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns
* arrays), or a custom iterator on a Headers instance are left out.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
@@ -169,225 +166,3 @@ describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)",
).toEqual([true, true, true, 768])
})
})
// Enough of testharness.js to run the Headers files close to verbatim; each `test` records its failure, if any.
const testharness = `
const failures = []
function test(run, name) { try { run() } catch (error) { failures.push(name + ": " + (error && error.message ? error.message : error)) } }
function assert_equals(actual, expected, message) { if (actual !== expected) throw new Error((message || "") + " expected " + JSON.stringify(expected) + " got " + JSON.stringify(actual)) }
function assert_true(actual, message) { assert_equals(actual, true, message) }
function assert_false(actual, message) { assert_equals(actual, false, message) }
function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) }
function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") }
function assert_unreached() { throw new Error("unreachable") }
`
describe("Headers WPT parity (fetch/api/headers)", () => {
test("headers-basic.any.js", async () => {
expect(
await value(`
${testharness}
test(function() { new Headers() }, "Create headers from no parameter")
test(function() { new Headers(undefined) }, "Create headers from undefined parameter")
test(function() { new Headers({}) }, "Create headers from empty object")
var parameters = [null, 1]
parameters.forEach(function(parameter) {
test(function() { assert_throws_js(TypeError, function() { new Headers(parameter) }) }, "Create headers with " + parameter + " should throw")
})
var headerDict = {"name1": "value1", "name2": "value2", "name3": "value3", "name4": null, "name5": undefined, "name6": 1, "Content-Type": "value4"}
var headerSeq = []
for (var name in headerDict) headerSeq.push([name, headerDict[name]])
test(function() {
var headers = new Headers(headerSeq)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary")
}, "Create headers with sequence")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}, "Create headers with record")
test(function() {
var headers = new Headers(headerDict)
var headers2 = new Headers(headers)
for (name in headerDict) assert_equals(headers2.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}, "Create headers with existing headers")
test(function() {
var headers = new Headers()
for (name in headerDict) {
headers.append(name, headerDict[name])
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}
}, "Check append method")
test(function() {
var headers = new Headers()
for (name in headerDict) {
headers.set(name, headerDict[name])
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}
}, "Check set method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_true(headers.has(name), "headers has name " + name)
assert_false(headers.has("nameNotInHeaders"), "headers do not have header: nameNotInHeaders")
}, "Check has method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) {
assert_true(headers.has(name), "headers have a header: " + name)
headers.delete(name)
assert_true(!headers.has(name), "headers do not have anymore a header: " + name)
}
}, "Check delete method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value")
}, "Check get method")
var headerEntriesDict = {"name1": "value1", "Name2": "value2", "name": "value3", "content-Type": "value4", "Content-Typ": "value5", "Content-Types": "value6"}
var sortedHeaderDict = {}
var headerValues = []
var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) {
sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]
headerValues.push(headerEntriesDict[value])
return value.toLowerCase()
}).sort()
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.keys(), sortedHeaderKeys)
for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1)
}, "Check keys method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.values(), sortedHeaderKeys.map((key) => sortedHeaderDict[key]))
for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1)
}, "Check values method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.entries(), sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]])
}, "Check entries method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals([...headers], sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
}, "Check Symbol.iterator method")
test(function() {
var headers = new Headers(headerEntriesDict)
var index = 0
headers.forEach(function(value, key, container) {
assert_equals(headers, container)
assert_equals(key, sortedHeaderKeys[index])
assert_equals(value, sortedHeaderDict[sortedHeaderKeys[index]])
index++
})
assert_equals(index, sortedHeaderKeys.length)
}, "Check forEach method")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
headers.delete("foo")
}
assert_array_equals(actualKeys, ["bar", "baz"])
assert_array_equals(actualValues, ["0", "1"])
}, "Iteration skips elements removed while iterating")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.delete("bar")
}
assert_array_equals(actualKeys, ["bar", "baz", "quux"])
assert_array_equals(actualValues, ["0", "1", "3"])
}, "Removing elements already iterated over causes an element to be skipped during iteration")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.append("X-yZ", "4")
}
assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"])
assert_array_equals(actualValues, ["0", "1", "2", "3", "4"])
}, "Appending a value pair during iteration causes it to be reached during iteration")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.append("abc", "-1")
}
assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"])
assert_array_equals(actualValues, ["0", "1", "1", "2", "3"])
}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time")
return failures
`),
).toEqual([])
})
test("headers-errors.any.js", async () => {
expect(
await value(`
${testharness}
test(function() { assert_throws_js(TypeError, function() { new Headers([["name"]]) }) }, "Create headers giving an array having one string as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]) }) }, "Create headers giving an array having three strings as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid\u0100", "Value1"]]) }) }, "Create headers giving bad header name as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["name", "invalidValue\u0100"]]) }) }, "Create headers giving bad header value as init argument")
var badNames = ["invalid\u0100", {}]
var badValues = ["invalid\u0100"]
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.get(name) }) }, "Check headers get with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.delete(name) }) }, "Check headers delete with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.has(name) }) }, "Check headers has with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set(name, "Value1") }) }, "Check headers set with an invalid name " + name)
})
badValues.forEach(function(value) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set("name", value) }) }, "Check headers set with an invalid value " + value)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("invalid\u0100", "Value1") }) }, "Check headers append with an invalid name " + name)
})
badValues.forEach(function(value) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("name", value) }) }, "Check headers append with an invalid value " + value)
})
test(function() {
var headers = new Headers([["name", "value"]])
assert_throws_js(TypeError, function() { headers.forEach() })
assert_throws_js(TypeError, function() { headers.forEach(undefined) })
assert_throws_js(TypeError, function() { headers.forEach(1) })
}, "Headers forEach throws if argument is not callable")
test(function() {
var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]])
var counter = 0
try {
headers.forEach(function(value, name) {
counter++
if (name == "name2") throw "error"
})
} catch (e) {
assert_equals(counter, 2)
assert_equals(e, "error")
return
}
assert_unreached()
}, "Headers forEach loop should stop if callback is throwing exception")
return failures
`),
).toEqual([])
})
})
-28
View File
@@ -218,20 +218,6 @@ export const DEFAULT_THEME = {
imageText: "$hue.cyan.600",
codeBlock: "$hue.neutral.900",
},
"@context:elevated": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.raised.base",
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.raised.high" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.raised.high",
action: { primary: { default: "$hue.interactive.500" } },
},
},
},
dark: {
hue: {
@@ -440,19 +426,5 @@ export const DEFAULT_THEME = {
imageText: "$hue.cyan.400",
codeBlock: "$hue.neutral.100",
},
"@context:elevated": {
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
default: "$background.raised.base",
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.raised.high" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
default: "$background.raised.high",
action: { primary: { default: "$hue.interactive.400" } },
},
},
},
} satisfies ThemeDocument
+1 -9
View File
@@ -8,15 +8,7 @@ import type {
import { ActionState } from "./schema.js"
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
return {
...definition,
...expandTokens(definition),
...Object.fromEntries(
Object.entries(definition)
.filter(([key]) => key.startsWith("@context:"))
.map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]),
),
}
return { ...definition, ...expandTokens(definition) }
}
export function expandTokens(definition: ThemeTokensDefinition): ThemeTokensDefinition {
+1 -2
View File
@@ -26,14 +26,12 @@ export {
type MergeModeDefinition,
type Mode,
type StatefulColorDefinition,
type ContextKey,
type TextDefinition,
type ThemeTokensDefinition,
} from "./schema.js"
export type {
Categorical,
ContextName,
FormfieldColor,
Hue,
HueSource,
@@ -43,6 +41,7 @@ export type {
ResolvedTheme,
ResolvedThemeTokens,
StatefulColor,
SurfaceName,
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
export { expandTheme } from "./expand.js"
+36 -75
View File
@@ -3,25 +3,14 @@ import { Schema } from "effect"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
import { fallback } from "./fallback.js"
import {
ActionState,
ActionVariant,
BaseHue,
FeedbackKind,
HueAlias,
HueStep,
ThemeDefinition,
ThemeDocument,
} from "./schema.js"
import { ActionState, BaseHue, HueAlias, HueStep, ThemeDefinition, ThemeDocument } from "./schema.js"
import type {
ActionStateKey,
ContextName,
HueDefinition,
HueScale,
ResolvedActionState,
Mode,
ResolvedTheme,
ResolvedThemeTokens,
StatefulColorDefinition,
SurfaceName,
ThemeTokensDefinition,
} from "./index.js"
import { selectTheme, selectThemeMode } from "./select.js"
@@ -42,40 +31,54 @@ export function themeDecodeError(error: unknown, name: string) {
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
}
export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") {
export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) {
const selected = selectThemeMode(document, mode)
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
const core = expandTokens(fallback(selected.mode))
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
return resolveExpandedTheme({
...merged,
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
} as ThemeDefinition)
return resolveExpandedTheme(
{
...merged,
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
} as ThemeDefinition,
selected.mode,
)
}
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
export function resolveTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)), mode)
}
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
// Surfaces are code-owned: a theme file describes one palette, and each surface is that palette
// re-resolved with a raised background so `$background.default` references follow it.
const SURFACES: Record<SurfaceName, ThemeTokensDefinition> = {
raised: {
background: {
default: "$background.raised.base",
action: { primary: { $hovered: "$background.raised.high" } },
},
},
overlay: { background: { default: "$background.raised.high" } },
}
function resolveExpandedTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
const hue = resolveHue(definition.hue)
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
const hueSteps = compileHueSteps(hue)
const raise = (color: RGBA) => (mode === "light" ? hueSteps.increase(color) : hueSteps.decrease(color))
const base = tokens(definition)
const resolved = resolveView(base, hue, categorical, hueSteps)
const context = (name: ContextName) => {
const override = definition[`@context:${name}`]
if (!override) return resolved
return resolveView(contextualize(base, override), hue, categorical, hueSteps)
const views = {} as Record<SurfaceName, ResolvedTheme>
const view = (tokens: ThemeTokensDefinition): ResolvedTheme => ({
...resolveView(tokens, hue, categorical, hueSteps),
raise,
surface: (name) => views[name],
})
for (const name of Object.keys(SURFACES) as SurfaceName[]) {
views[name] = view(mergeTheme(base, SURFACES[name]) as ThemeTokensDefinition)
}
const contextual = {
elevated: context("elevated"),
overlay: context("overlay"),
}
return { ...resolved, contextual } as ResolvedTheme
return view(base)
}
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
@@ -90,48 +93,6 @@ function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
}
}
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
const result = mergeTheme(base, override)
const baseText = base.text?.action
const contextText = override.text?.action
const baseBackground = base.background?.action
const contextBackground = override.background?.action
const text = result["text"] as NonNullable<ThemeTokensDefinition["text"]>
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
return {
...result,
text: { ...text, action: contextualActions(baseText, contextText) },
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
} as ThemeTokensDefinition
}
function contextualActions(
base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
) {
return Object.fromEntries(
ActionVariant.literals.map((variant) => {
const baseVariant = base?.[variant]
const contextVariant = context?.[variant]
return [
variant,
Object.fromEntries(
(["default", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
return [
key ?? "default",
(key ? contextVariant?.[key] : undefined) ??
contextVariant?.default ??
(key ? baseVariant?.[key] : undefined) ??
baseVariant?.default,
]
}),
),
]
}),
)
}
function resolveView(
definition: ThemeTokensDefinition,
hue: ResolvedThemeTokens["hue"],
-9
View File
@@ -40,9 +40,6 @@ export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLen
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
@@ -229,8 +226,6 @@ const ThemeDefinitionFields = Schema.Struct({
hue: HueDefinition,
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export const ThemeDefinition = ThemeDefinitionFields
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
@@ -239,8 +234,6 @@ const FileThemeDefinition = Schema.Struct({
hue: Schema.optional(HueOverrideDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
@@ -249,8 +242,6 @@ const MergeModeDefinition = Schema.Struct({
hue: Schema.optional(HueOverrideDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
+5 -2
View File
@@ -69,8 +69,11 @@ export type ResolvedThemeTokens = {
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
}
export type ContextName = "elevated" | "overlay"
export type SurfaceName = "raised" | "overlay"
export type ResolvedTheme = ResolvedThemeTokens & {
readonly contextual: Readonly<Record<ContextName, ResolvedThemeTokens>>
/** Lift a ramp color one step away from the background: lighter in light mode, darker in dark mode. */
readonly raise: (color: RGBA) => RGBA
/** The same theme re-resolved on a raised surface. Absolute: every view's surfaces are the base theme's. */
readonly surface: (name: SurfaceName) => ResolvedTheme
}
-7
View File
@@ -173,13 +173,6 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
imageText: color("markdownImageText"),
codeBlock: color("markdownCodeBlock"),
},
"@context:elevated": {
background: {
default: "$background.raised.base",
action: { primary: { $hovered: "$background.raised.high" } },
},
},
"@context:overlay": { background: { default: "$background.raised.high" } },
})
}
+2 -2
View File
@@ -14,7 +14,7 @@ test.each(["light", "dark"] as const)("built-in %s themes resolve status colors"
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.unread.equals(theme.hue.accent[mode === "light" ? 800 : 200])).toBeTrue()
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
expect(theme.surface("raised").text.status).toEqual(theme.text.status)
}
})
@@ -34,7 +34,7 @@ test.each(["light", "dark"] as const)("custom %s themes inherit the unread atten
expect(theme.text.status.unread.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
expect(theme.surface("raised").text.status).toEqual(theme.text.status)
}
})
+6 -6
View File
@@ -39,7 +39,7 @@ export function DevToolsBar() {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = useTheme("elevated")
const elevatedTheme = useTheme().surface("raised")
const [panel, setPanel] = createSignal<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
@@ -474,7 +474,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
}
function PanelBox(props: ParentProps) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const renderer = useRenderer()
return (
<box
@@ -500,7 +500,7 @@ function PanelBox(props: ParentProps) {
}
function PanelTitle(props: ParentProps) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
return (
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
@@ -509,7 +509,7 @@ function PanelTitle(props: ParentProps) {
}
function Row(props: { label: string; value: string }) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
return (
<box flexDirection="row">
<text fg={theme.text.subdued}>{props.label}</text>
@@ -520,7 +520,7 @@ function Row(props: { label: string; value: string }) {
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const [hovered, setHovered] = createSignal(false)
return (
<box
@@ -545,7 +545,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
}
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const value = () => {
const value = props.values.at(-1)
if (value === undefined) return "--"
@@ -27,7 +27,7 @@ export function DialogErrorDetails(props: {
const location = useLocation()
const route = useRoute()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig().data
@@ -13,7 +13,7 @@ type ImagePreviewItem = Readonly<{
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
const [failed, setFailed] = createSignal(false)
const current = createMemo(() => props.images[index()])
@@ -78,7 +78,7 @@ export function DialogIntegration(
const data = useData()
const currentLocation = useLocation()
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const location = currentLocation.ref ?? data.location.default()
const integrations = createMemo(() =>
integrationOptions(data.location.integration.list(location) ?? []).filter(
@@ -153,7 +153,7 @@ function manageConnections(
const data = useData()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const shortcuts = Keymap.useShortcuts()
const [deleting, setDeleting] = createSignal<string>()
const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id)
@@ -427,8 +427,8 @@ function CommandPending(props: {
function CommandView(props: { title: string; output: string; message: string }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const theme = useTheme().surface("raised")
const overlayTheme = useTheme().surface("overlay")
onMount(() => dialog.setSize("large"))
return (
<box gap={1} paddingBottom={1}>
@@ -467,7 +467,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const [error, setError] = createSignal<string>()
return (
@@ -672,7 +672,7 @@ function OAuthCode(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const [error, setError] = createSignal<string>()
let settled = false
@@ -724,7 +724,7 @@ function OAuthView(props: {
open?: boolean
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -852,7 +852,7 @@ function textAnswer(
return new Promise<FormValue | undefined | typeof CANCELLED>((resolve) => {
dialog.replace(
() => {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const [error, setError] = createSignal<string>()
return (
<DialogPrompt
+1 -1
View File
@@ -40,7 +40,7 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
const client = useClient()
const location = useLocation()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const current = () => location.ref ?? data.location.default()
const servers = createMemo(() =>
pipe(
+1 -1
View File
@@ -44,7 +44,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
const sessionTabs = useSessionTabs()
const toast = useToast()
const themes = useThemes()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const mode = themes.mode
const paths = useTuiPaths()
const dimensions = useTerminalDimensions()
+1 -1
View File
@@ -17,7 +17,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
const client = useClient()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const [loadError, setLoadError] = createSignal<unknown>()
const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = createSignal(false)
@@ -30,7 +30,7 @@ export function DialogSessionList() {
const route = useRoute()
const data = useData()
const themes = useThemes()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const mode = themes.mode
const client = useClient()
const local = useLocal()
@@ -13,7 +13,7 @@ const PAGE_BYTES = 64 * 1024
export function DialogShellOutput(props: { shell: ShellInfo; location: LocationRef }) {
const client = useClient()
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const dimensions = useTerminalDimensions()
const [info, setInfo] = createSignal(props.shell)
const [output, setOutput] = createSignal<string>()
+1 -1
View File
@@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string {
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const shortcuts = Keymap.useShortcuts()
const [toDelete, setToDelete] = createSignal<number>()
+1 -1
View File
@@ -6,7 +6,7 @@ import { For, Match, Switch, Show, createMemo } from "solid-js"
export function DialogStatus() {
const data = useData()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
+7 -11
View File
@@ -15,7 +15,7 @@ export function DialogUpdate(props: {
restart: () => void
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const [error, setError] = createSignal<string>()
const [active, setActive] = createSignal(0)
const controller = new AbortController()
@@ -32,14 +32,12 @@ export function DialogUpdate(props: {
}),
)
const state = createMemo(() => {
const message = error()
if (message) return { type: "check-failed" as const, message }
const current = props.state()
if (current?.type === "installing") return current
if (check.loading) return { type: "checking" as const }
const unavailable = check()
if (unavailable) return { type: "unavailable" as const, message: unavailable }
return current ?? { type: "current" as const }
const message = error()
if (message) return { type: "check-failed" as const, message }
return props.state() ?? { type: "current" as const }
})
const buttons = createMemo(() => {
const type = state().type
@@ -89,11 +87,9 @@ export function DialogUpdate(props: {
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{state().type === "installing"
? "Updating OpenCode"
: state().type === "available" || state().type === "failed"
? "Update available"
: "Update"}
{state().type === "available" || state().type === "installing" || state().type === "failed"
? "Update available"
: "Update"}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
message?: string
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const theme = useTheme().surface("raised")
const overlayTheme = useTheme().surface("overlay")
const config = useConfig().data
const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -40,7 +40,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
const dialog = useDialog()
const client = useClient()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const sessionData = useData()
const route = useRoute()
const toast = useToast()
@@ -8,7 +8,7 @@ import { useConfig } from "../config"
export function DialogWorktreeName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const shortcuts = Keymap.useShortcuts()
const config = useConfig().data
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
@@ -10,7 +10,7 @@ type Progress = { label: string; numerator?: number; denominator?: number }
export function MigrationOverlay() {
const client = useClient()
const toast = useToast()
const theme = useTheme("overlay")
const theme = useTheme().surface("overlay")
const [progress, setProgress] = createSignal<Progress>()
const abort = new AbortController()
+6 -5
View File
@@ -2,7 +2,7 @@ import type { BoxRenderable } from "@opentui/core"
import { onCleanup, onMount } from "solid-js"
import { usePanel, type PanelTarget } from "../context/panel"
import { InteractivityProvider } from "../context/interactivity"
import { ThemeContextProvider, useTheme } from "../context/theme"
import { useTheme } from "../context/theme"
import { Slot } from "../plugin/render"
export function PanelHost(props: {
@@ -19,6 +19,9 @@ export function PanelHost(props: {
const Content = () => {
const theme = useTheme()
// Side panels sit on a raised surface; fullscreen takes over the base background.
const background = () =>
panels.presentation() === "panel" ? theme.surface("raised").background.default : theme.background.default
return (
<box
id="session-panel"
@@ -27,7 +30,7 @@ export function PanelHost(props: {
minWidth={0}
minHeight={0}
focusable
backgroundColor={theme.background.default}
backgroundColor={background()}
onMouseDown={props.onFocus}
>
<Slot
@@ -55,9 +58,7 @@ export function PanelHost(props: {
return (
<InteractivityProvider enabled={props.focused}>
<ThemeContextProvider context={panels.presentation() === "panel" ? "elevated" : undefined}>
<Content />
</ThemeContextProvider>
<Content />
</InteractivityProvider>
)
}
@@ -77,7 +77,7 @@ export function Autocomplete(props: {
const data = useData()
const keymap = Keymap.use()
const keymapCommands = Keymap.useCommands()
const theme = useTheme("overlay")
const theme = useTheme().surface("overlay")
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const config = useConfig().data
+1 -1
View File
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting(props: { managed?: boolean }) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
return (
<box
@@ -10,7 +10,7 @@ export function SessionTabsRailControls(props: {
tabs: SessionTabsController
belowHighlighted: boolean
}) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const keymap = Keymap.use()
const [hovered, setHovered] = createSignal(false)
const hoverColor = createMemo(() =>
+2 -2
View File
@@ -366,7 +366,7 @@ export function createTabMarquee(animations: () => boolean) {
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const dialog = useDialog()
onCleanup(Keymap.use().mode.push("menu"))
Keymap.createLayer(() => ({
@@ -515,7 +515,7 @@ function VerticalSessionTabs(props: {
const data = props.controller ? undefined : useData()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const base = useTheme()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function StartupLoading(props: { ready: () => boolean }) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const [show, setShow] = createSignal(false)
const text = createMemo(() => (props.ready() ? "Finishing startup…" : "Loading plugins…"))
let wait: NodeJS.Timeout | undefined
+3 -3
View File
@@ -32,7 +32,7 @@ export function TerminalPane(props: {
const client = useClient()
const keymap = Keymap.use()
const leader = Keymap.useLeaderActive()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const themes = useThemes()
const renderer = useRenderer()
const [failure, setFailure] = createSignal<string>()
@@ -155,7 +155,7 @@ export function TerminalPane(props: {
})
createEffect(() => {
const tokens = themes.currentTokens().contextual.elevated
const tokens = themes.currentTokens().surface("raised")
terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.default)
applyTerminalTheme()
})
@@ -284,7 +284,7 @@ export function TerminalPane(props: {
minWidth={0}
minHeight={0}
overflow="hidden"
backgroundColor={themes.currentTokens().contextual.elevated.background.default}
backgroundColor={themes.currentTokens().surface("raised").background.default}
onSizeChange={function () {
size = { cols: Math.max(1, this.width - 2), rows: this.height }
if (controller && restored) interact()
+5 -25
View File
@@ -5,7 +5,6 @@ import {
resolveThemeDocument,
themeModes,
type ResolvedTheme,
type ContextName,
} from "@opencode/theme/tui"
import {
DEFAULT_THEMES,
@@ -23,8 +22,8 @@ import {
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes } from "../theme/discovery"
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createComponentTheme, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
@@ -122,7 +121,6 @@ type Themes = {
}
type ThemeContextValue = {
current: ComponentTheme["contextual"][ContextName]
themes: Themes
readonly ready: boolean
}
@@ -322,7 +320,7 @@ const themeContext = createSimpleContext({
const tokens = () => selected().theme
tokens()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
const current = createComponentTheme(tokens, mode)
const current = createComponentTheme(tokens)
createEffect(() => renderer.setBackgroundColor(tokens().background.default))
@@ -365,7 +363,6 @@ const themeContext = createSimpleContext({
},
}
return {
current,
themes: service,
get ready() {
return service.ready
@@ -377,11 +374,8 @@ const themeContext = createSimpleContext({
export function useThemes() {
return themeContext.use().themes
}
export function useTheme(): ComponentTheme
export function useTheme(context: ContextName): ComponentTheme["contextual"][ContextName]
export function useTheme(context?: ContextName) {
const value = themeContext.use()
return context ? value.themes.current.contextual[context] : value.current
export function useTheme(): ComponentTheme {
return themeContext.use().themes.current
}
export const ThemeProvider = themeContext.provider
@@ -391,20 +385,6 @@ function usablePalette(colors: TerminalColors | undefined): colors is TerminalCo
)
}
/** Switches context without remounting children; undefined inherits the enclosing view. */
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) {
const value = themeContext.use()
const current = createComponentThemeView(() => {
const name = props.context
return name ? value.themes.currentTokens().contextual[name] : value.current
}, value.themes.mode)
return (
<themeContext.context.Provider value={{ current, themes: value.themes, ready: value.ready }}>
{props.children}
</themeContext.context.Provider>
)
}
function loadTheme(source: ThemeDocumentSource, name: string, requested: "dark" | "light") {
const document = parseTheme(source, name)
const modes = themeModes(document)
@@ -20,7 +20,6 @@ export type UpdateSource = {
readonly subscribe: (notify: (notice: ClientNotice) => void, signal: AbortSignal) => Promise<void>
readonly check: (
signal: AbortSignal,
onInstall: (version: string) => void,
) => Promise<ClientNotice | { readonly type: "unavailable"; readonly message: string } | undefined>
readonly apply: (version: string) => Promise<void>
}
@@ -75,13 +74,7 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider
const check = async (signal: AbortSignal) => {
const updater = props.updater
if (!updater || state()?.type === "installing") return
const result = await updater
.check(signal, (version) => {
if (!signal.aborted) setState({ type: "installing", version })
})
.finally(() => {
if (state()?.type === "installing") setState(undefined)
})
const result = await updater.check(signal)
if (signal.aborted) return
if (result?.type === "unavailable") return result.message
setState(result)
@@ -86,8 +86,8 @@ function Answer(props: { question: string; answer: string }) {
const toast = useToast()
const clipboard = useClipboard()
const plugins = usePlugin()
const theme = useTheme("elevated")
const overlay = useTheme("overlay")
const theme = useTheme().surface("raised")
const overlay = useTheme().surface("overlay")
const syntax = useThemes().currentSyntax
const config = useConfig().data
const dimensions = useTerminalDimensions()
@@ -12,7 +12,7 @@ export function DiffFileMenu(props: {
onClose: () => void
}) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.overlay
const theme = props.context.theme.surface("overlay")
const [hovered, setHovered] = createSignal(false)
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
const width = () => Math.min(19, dimensions().width)
@@ -27,7 +27,7 @@ export type DiffViewerFileTreeProps = {
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const [sourceHovered, setSourceHovered] = createSignal(false)
const list = () => props.layout === "list"
const tree = createMemo(() => buildFileTree(props.files))
@@ -214,7 +214,7 @@ function DiffBaseDialog(props: {
current?: string
onSelect: (ref: string) => void
}) {
const theme = props.context.theme.contextual.elevated
const theme = props.context.theme.surface("raised")
const [search, setSearch] = createDebouncedSignal("", 150)
const [branches] = createResource(search, (search) =>
props.context.client.vcs.branch.list({ location: props.location, search, limit: 100 }),
@@ -1079,7 +1079,7 @@ export function DiffViewerContent(props: {
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const theme = props.context.theme.surface("raised")
const shortcut =
(...ids: string[]) =>
() =>
@@ -14,7 +14,7 @@ export function StoryFooter(props: {
message?: string
controls: readonly StoryFooterControl[]
}) {
const theme = props.context.theme.contextual.elevated
const theme = props.context.theme.surface("raised")
return (
<box flexShrink={0} flexDirection="column" backgroundColor={theme.background.default}>
@@ -11,7 +11,7 @@ const directory = "/Users/kit/code/open-source/opencode-workerd-profile"
function SessionLocationMissingStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const theme = props.context.theme.surface("raised")
const [message, setMessage] = createSignal("Choose another directory to continue")
const open = () =>
props.context.ui.dialog.show(() => (
+2 -2
View File
@@ -115,7 +115,7 @@ function map(
syntax?: SyntaxStyle,
system = false,
): RunTheme {
const elevated = theme.contextual.elevated
const elevated = theme.surface("raised")
// V1 system migration serializes colors; restore terminal defaults before quantizing scrollback.
const exact = (color: RGBA) => {
if (system && color.equals(theme.text.default)) return RGBA.defaultForeground(color)
@@ -159,7 +159,7 @@ function map(
text: exact(theme.text.default),
shade: exact(elevated.background.default),
surface: exact(elevated.background.default),
pane: exact(theme.contextual.overlay.background.default),
pane: exact(theme.surface("overlay").background.default),
border: exact(theme.border.default),
line: exact(theme.background.raised.high),
},
@@ -42,7 +42,7 @@ export type ComposerProps = {
}
export function Composer(props: ComposerProps) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const config = useConfig().data
const [store, setStore] = createStore({
@@ -21,7 +21,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal<"code" | "output">()
@@ -224,7 +224,7 @@ function GutteredCode(props: {
digits: number
blocks: Set<CodeRenderable>
}) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const syntax = useThemes().currentSyntax
const gutter = createMemo(() =>
props.content
+1 -1
View File
@@ -59,7 +59,7 @@ const drafts = new Map<string, FormDraft>()
export function FormPrompt(props: { form: FormWithLocation }) {
const data = useData()
const themes = useThemes()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const themeMode = themes.mode
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
+13 -34
View File
@@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
@@ -1936,7 +1936,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
const config = useConfig()
const data = useData()
const local = useLocal()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const model = createMemo(
() =>
ctx
@@ -2016,13 +2016,9 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const ctx = use()
const theme = useTheme()
const renderer = useRenderer()
const { navigate } = useRoute()
const [hover, setHover] = createSignal(false)
const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined)
const source = () => stringValue(metadata()?.source)
const completion = () => source() === "subagent" || source() === "shell"
const childID = () => (source() === "subagent" ? stringValue(metadata()?.childID) : undefined)
const state = () => stringValue(metadata()?.state)
const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent"))
const text = () => {
@@ -2041,7 +2037,6 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const color = () => {
if (state() === "error") return theme.text.feedback.error.default
if (state() === "cancelled") return theme.text.feedback.warning.default
if (hover() && childID()) return theme.text.default
return theme.text.feedback.info.default
}
return (
@@ -2053,16 +2048,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
</InlineToolRow>
}
>
<box
marginLeft={3}
onMouseOver={() => childID() && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
const id = childID()
if (id) navigate({ type: "session", sessionID: id })
}}
>
<box marginLeft={3}>
<text wrapMode="none">
<span style={{ fg: color() }}>{heading()}</span>
<span style={{ fg: theme.text.subdued }}>{suffix()}</span>
@@ -2179,7 +2165,7 @@ function RevertMessage(props: {
}>
}) {
const ctx = use()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const route = useRouteData("session")
const client = useClient()
const toast = useToast()
@@ -2282,7 +2268,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
),
)
const themes = useThemes()
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const mode = themes.mode
const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
@@ -2398,7 +2384,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
}
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
const theme = useTheme("elevated")
const theme = useTheme().surface("raised")
const [hover, setHover] = createSignal(false)
const next = createMemo(() => props.prompts[0]?.text.replaceAll("\n", " "))
@@ -2758,8 +2744,9 @@ function InlineTool(props: {
)
}
function StatusBadge(props: { children: string }) {
const theme = useTheme()
function StatusBadge(props: { children: string; raised?: boolean }) {
const base = useTheme()
const theme = props.raised ? base.surface("raised") : base
return (
<text flexShrink={0} bg={theme.raise(theme.background.default)} fg={theme.text.subdued}>
{" "}
@@ -2781,16 +2768,8 @@ type BlockToolProps = {
}
function BlockTool(props: BlockToolProps) {
const parentTheme = useTheme()
return (
<ThemeContextProvider context="elevated">
<BlockToolContent {...props} borderColor={parentTheme.background.default} />
</ThemeContextProvider>
)
}
function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
const theme = useTheme()
const base = useTheme()
const theme = base.surface("raised")
const ctx = use()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
@@ -2808,7 +2787,7 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
gap={1}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={props.borderColor}
borderColor={base.background.default}
onMouseOver={() => props.onClick && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
@@ -3041,7 +3020,7 @@ function ShellDisplay(props: {
</Show>
</Show>
<Show when={props.background}>
<StatusBadge>Background</StatusBadge>
<StatusBadge raised>Background</StatusBadge>
</Show>
</box>
</BlockTool>

Some files were not shown because too many files have changed in this diff Show More