mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-18 06:46:24 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c92541a40d | ||
|
|
ee7317fbcf | ||
|
|
2f8f4975f3 | ||
|
|
c91b330887 | ||
|
|
7b01b0224a | ||
|
|
d4303a9ca5 | ||
|
|
735556eab8 | ||
|
|
10cac9ab5d | ||
|
|
2d0ce64111 | ||
|
|
3355c93efd | ||
|
|
469e1c035e | ||
|
|
b2e3569add | ||
|
|
0ac458b3b3 | ||
|
|
b278ef6b82 | ||
|
|
3e3a4ae46b | ||
|
|
fe0d9579a7 |
@@ -1,18 +1,5 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("home connection progress stays in the server row", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--home-connecting")
|
||||
await component.getByRole("button", { name: "Projects" }).click()
|
||||
await expect(page.getByRole("status", { name: "Connecting over SSH…" })).toBeVisible()
|
||||
await expect(page.locator('[data-action="home-server-authenticate"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("home authentication action appears when required", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--authentication-required")
|
||||
await component.getByRole("button", { name: "Projects" }).click()
|
||||
await expect(page.getByRole("button", { name: "Authenticate", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("settings menu reconnect retains its prompt handler across server updates", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--settings-reconnect")
|
||||
await component.getByRole("button", { name: "More options" }).click()
|
||||
|
||||
@@ -485,6 +485,7 @@ export function stepStarted(message: SessionMessageAssistant) {
|
||||
assistantMessageID: message.id,
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
started: message.time.created,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,15 @@ 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")
|
||||
@@ -296,7 +305,12 @@ 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" } = {}) {
|
||||
async function openDraft(
|
||||
page: Page,
|
||||
worktree = "main",
|
||||
options: { git?: boolean; direction?: "ltr" | "rtl"; currentDirectory?: string } = {},
|
||||
) {
|
||||
const currentDirectory = options.currentDirectory ?? directory
|
||||
const project = {
|
||||
id: "proj_new_summary",
|
||||
worktree: directory,
|
||||
@@ -315,7 +329,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
directory: currentDirectory,
|
||||
project,
|
||||
sessions,
|
||||
provider: {
|
||||
@@ -442,7 +456,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
|
||||
},
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server, draftID, secondDraftID, worktree }) => {
|
||||
({ directory, currentDirectory, server, draftID, secondDraftID, worktree }) => {
|
||||
if (!localStorage.getItem("opencode.global.dat:server"))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
@@ -455,12 +469,12 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "draft", draftID, server, directory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
|
||||
{ type: "draft", draftID, server, directory: currentDirectory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory: currentDirectory, worktree },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, server, draftID, secondDraftID, worktree },
|
||||
{ directory, currentDirectory, 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 })
|
||||
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model, started: Date.now() })
|
||||
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 })
|
||||
mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() })
|
||||
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,6 +16,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
[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,8 +1,12 @@
|
||||
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 { ComposerAttachment, ComposerPrompt } from "../types"
|
||||
import type { ComposerPrompt } from "../types"
|
||||
import type { ImageAttachmentPart, PathAttachmentPart } from "../state"
|
||||
import type { AttachmentDestination } from "./destination"
|
||||
import { uploads } from "./uploads"
|
||||
|
||||
type PromptTarget = {
|
||||
current: () => ComposerPrompt
|
||||
@@ -16,9 +20,11 @@ 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
|
||||
@@ -43,9 +49,23 @@ 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
|
||||
@@ -64,17 +84,40 @@ export function createComposerAttachments(
|
||||
input.duplicate()
|
||||
return true
|
||||
}
|
||||
const attachment: ComposerAttachment = {
|
||||
type: "image",
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
sourcePath,
|
||||
mime,
|
||||
blob,
|
||||
}
|
||||
const attachment: ImageAttachmentPart = { 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
|
||||
@@ -153,6 +196,11 @@ 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()
|
||||
@@ -165,6 +213,14 @@ 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"],
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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,
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: value,
|
||||
sessionDirectory: "C:/repo",
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ComposerPersistedState,
|
||||
ComposerPrompt,
|
||||
} from "../types"
|
||||
import { promptLength } from "../prompt-parts"
|
||||
import { isAttachment, 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((part) => part.type === "image"),
|
||||
...state.prompt.filter(isAttachment),
|
||||
],
|
||||
cursor: content.length,
|
||||
retry: undefined,
|
||||
@@ -83,7 +83,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
|
||||
clearRetry()
|
||||
},
|
||||
removeAttachment(id: string) {
|
||||
setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id))
|
||||
setStore()("prompt", (parts) => parts.filter((part) => !isAttachment(part) || 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 (part.type === "image") return [part]
|
||||
if (isAttachment(part)) 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 (part.type === "image") return [part]
|
||||
if (isAttachment(part)) 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 (part.type === "image") return part
|
||||
if (isAttachment(part)) return part
|
||||
const next = { ...part, start: offset, end: offset + part.content.length }
|
||||
offset = next.end
|
||||
return next
|
||||
|
||||
@@ -12,6 +12,8 @@ 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"
|
||||
@@ -24,6 +26,7 @@ import type {
|
||||
ComposerSuggestion,
|
||||
} from "../types"
|
||||
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
|
||||
import { isAttachment } from "../prompt-parts"
|
||||
import "../attachments/attachments.css"
|
||||
import "./editor.css"
|
||||
|
||||
@@ -148,11 +151,13 @@ 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)}
|
||||
/>
|
||||
@@ -191,9 +196,9 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onInput={(event) => {
|
||||
const cursor = composerCursor(event.currentTarget)
|
||||
const prompt = parseComposerEditor(event.currentTarget)
|
||||
const images = props.controller.parts().filter((part) => part.type === "image")
|
||||
const attachments = props.controller.parts().filter(isAttachment)
|
||||
localInput = true
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!view.draftOnly && props.controller.onKeyDown(event)) return
|
||||
@@ -348,7 +353,7 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
|
||||
const active = document.activeElement === editor
|
||||
editor.replaceChildren(
|
||||
...prompt.flatMap<Node>((part) => {
|
||||
if (part.type === "image") return []
|
||||
if (isAttachment(part)) return []
|
||||
if (part.type === "text") return [document.createTextNode(part.content)]
|
||||
const mention = document.createElement("span")
|
||||
mentionParts.set(mention, part)
|
||||
@@ -475,17 +480,22 @@ 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.comments?.length ?? 0) > 0}>
|
||||
<Show
|
||||
when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}
|
||||
>
|
||||
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
|
||||
<div
|
||||
data-slot="composer-attachments-scroll"
|
||||
@@ -522,22 +532,30 @@ export function ComposerAttachments(props: {
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<div class="relative group shrink-0">
|
||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
||||
<Tooltip
|
||||
value={attachment.type === "path" ? attachment.path : attachment.filename}
|
||||
placement="top"
|
||||
contentClass="break-all"
|
||||
>
|
||||
<Show
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
when={attachment.type === "image" && attachment.mime.startsWith("image/") ? attachment : undefined}
|
||||
fallback={
|
||||
<AttachmentCard title={attachment.filename}>
|
||||
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
|
||||
</AttachmentCard>
|
||||
}
|
||||
>
|
||||
{(image) => (
|
||||
<>
|
||||
<img
|
||||
src={attachment.blob.url}
|
||||
src={image().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
|
||||
@@ -551,6 +569,28 @@ 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,6 +2,7 @@ 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,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
type ComposerInteractionCommand,
|
||||
type ComposerInteractionEvent,
|
||||
} from "../suggestions/machine"
|
||||
import { clonePrompt, promptLength } from "../prompt-parts"
|
||||
import { clonePrompt, isAttachment, promptLength } from "../prompt-parts"
|
||||
import type { ComposerQueue } from "../adapter"
|
||||
|
||||
export type ComposerSelectControl = {
|
||||
@@ -74,7 +75,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 (part.type === "image") return false
|
||||
if (isAttachment(part)) return false
|
||||
if (part.type === "file" || part.type === "agent") {
|
||||
draft.addMention(part)
|
||||
return true
|
||||
@@ -169,7 +170,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((part): part is ComposerAttachment => part.type === "image"),
|
||||
draft.state.prompt.filter(isAttachment),
|
||||
0,
|
||||
)
|
||||
}
|
||||
@@ -315,7 +316,13 @@ export function createComposerEditor(input: {
|
||||
return draft.state.context.items.filter((item) => !!item.comment?.trim())
|
||||
},
|
||||
attachments(): ComposerAttachment[] {
|
||||
return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image")
|
||||
return draft.state.prompt.filter(isAttachment)
|
||||
},
|
||||
uploads(): Upload[] {
|
||||
return attachments?.pending() ?? []
|
||||
},
|
||||
cancelUpload(id: string) {
|
||||
attachments?.cancel(id)
|
||||
},
|
||||
toggleContext(id: string) {
|
||||
dispatch({ type: "context.active", id })
|
||||
@@ -336,11 +343,12 @@ 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((part) => part.type === "image")) return true
|
||||
if (persisted.prompt.some(isAttachment)) return true
|
||||
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
},
|
||||
@@ -369,6 +377,7 @@ 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" })
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { clonePrompt } from "../prompt-parts"
|
||||
import { clonePrompt, isAttachment } 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 hasImages = prompt.some((part) => part.type === "image")
|
||||
const hasAttachments = prompt.some(isAttachment)
|
||||
const hasComments = comments.some((comment) => !!comment.comment.trim())
|
||||
if (!text && !hasImages && !hasComments) return entries
|
||||
if (!text && !hasAttachments && !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 (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
|
||||
if (isAttachment(partA) && partA.id !== (isAttachment(partB) ? partB.id : "")) return false
|
||||
}
|
||||
if (entryA.comments.length !== entryB.comments.length) return false
|
||||
for (let i = 0; i < entryA.comments.length; i++) {
|
||||
|
||||
@@ -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 type { ImageAttachmentPart } from "./state"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import { createComposerHistory } from "./history/store"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
import { useAttachmentDestination } from "./attachments/deliver"
|
||||
import { useAttachmentDestination } from "./attachments/destination"
|
||||
|
||||
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((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
prompt.current().filter(isAttachment),
|
||||
)
|
||||
const commentCount = createMemo(() => {
|
||||
if (mode() === "shell") return 0
|
||||
@@ -266,7 +266,6 @@ 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"
|
||||
@@ -321,8 +320,10 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
onContextRemove(item) {
|
||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment) =>
|
||||
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
|
||||
openAttachment: (attachment) => {
|
||||
if (attachment.type !== "image") return
|
||||
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)
|
||||
@@ -340,8 +341,15 @@ 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",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Prompt } from "./state"
|
||||
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"
|
||||
}
|
||||
|
||||
export function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map((part) =>
|
||||
@@ -17,7 +22,7 @@ export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
|
||||
...clonePrompt(prompt),
|
||||
{ type: "text", content: "\n\n", start, end: offset },
|
||||
...clonePrompt(following).map((part) =>
|
||||
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
|
||||
isAttachment(part) ? part : { ...part, start: part.start + offset, end: part.end + offset },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
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>): DeliveredAttachment {
|
||||
return {
|
||||
type: "inline",
|
||||
attachment: { type: "image", id: `img_${filename}`, filename, mime, blob: { id: filename, url: "" }, ...extra },
|
||||
dataUrl: `data:${mime};base64,AAA`,
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
describe("buildPromptRequest", () => {
|
||||
@@ -30,7 +25,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||
attachments: [inline("a.png", "image/png")],
|
||||
images: [inline("a.png", "image/png")],
|
||||
text: "hello @src/foo.ts @planner",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -52,7 +47,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
attachments: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
|
||||
images: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
|
||||
text: "check these",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -67,7 +62,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [],
|
||||
context: [],
|
||||
attachments: [
|
||||
images: [
|
||||
inline("opencode.global.dat", "text/plain", {
|
||||
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
}),
|
||||
@@ -95,7 +90,7 @@ describe("buildPromptRequest", () => {
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@docs",
|
||||
sessionDirectory: "/repo/app",
|
||||
})
|
||||
@@ -117,7 +112,7 @@ describe("buildPromptRequest", () => {
|
||||
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
||||
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
||||
],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@src/foo.ts",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -139,7 +134,7 @@ describe("buildPromptRequest", () => {
|
||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||
},
|
||||
],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "look",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -155,7 +150,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@src\\foo.ts",
|
||||
sessionDirectory: "D:\\projects\\myapp", // Windows path
|
||||
})
|
||||
@@ -176,7 +171,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@file#name.txt",
|
||||
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
|
||||
})
|
||||
@@ -197,7 +192,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@src/app.ts",
|
||||
sessionDirectory: "/home/user/project",
|
||||
})
|
||||
@@ -211,7 +206,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@README.md",
|
||||
sessionDirectory: "/Users/kelvin/Projects/opencode",
|
||||
})
|
||||
@@ -226,7 +221,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" },
|
||||
],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "test",
|
||||
sessionDirectory: "D:\\workspace\\app",
|
||||
})
|
||||
@@ -248,7 +243,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@D:\\other\\project\\file.ts",
|
||||
sessionDirectory: "C:\\current\\project",
|
||||
})
|
||||
@@ -275,7 +270,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@src\\App.tsx",
|
||||
sessionDirectory: "C:\\project",
|
||||
})
|
||||
@@ -300,7 +295,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@..\\..\\shared\\util.ts",
|
||||
sessionDirectory: "C:\\projects\\myapp\\src",
|
||||
})
|
||||
@@ -330,7 +325,7 @@ describe("buildPromptRequest", () => {
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@review",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { encodeFilePath } from "@/workspaces/files/path"
|
||||
import type { AgentPart, FileAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, 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 = {
|
||||
@@ -35,7 +34,7 @@ type ContextFile = {
|
||||
type BuildPromptRequestInput = {
|
||||
prompt: Prompt
|
||||
context: ContextFile[]
|
||||
attachments: DeliveredAttachment[]
|
||||
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
||||
text: string
|
||||
sessionDirectory: string
|
||||
}
|
||||
@@ -63,6 +62,7 @@ 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.attachments.flatMap((item) =>
|
||||
item.type === "inline"
|
||||
? [{ uri: item.dataUrl, mime: item.attachment.mime, name: item.attachment.sourcePath ?? item.attachment.filename }]
|
||||
: [],
|
||||
)
|
||||
const inline = input.images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
mime: attachment.mime,
|
||||
name: attachment.sourcePath ?? attachment.filename,
|
||||
}))
|
||||
// Like comments, path references reach the model as text and the message UI through metadata.
|
||||
const attachments = input.attachments.flatMap((item) =>
|
||||
item.type === "path" ? [{ name: item.attachment.filename, mime: item.attachment.mime, path: item.path }] : [],
|
||||
)
|
||||
const attachments = input.prompt
|
||||
.filter(isPathAttachment)
|
||||
.map((part) => ({ name: part.filename, mime: part.mime, path: part.path }))
|
||||
|
||||
return {
|
||||
text: [
|
||||
|
||||
@@ -94,7 +94,24 @@ export const ImageAttachmentPart = Schema.Struct({
|
||||
)
|
||||
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
|
||||
|
||||
export const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
|
||||
// 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 type ContentPart = typeof ContentPart.Type
|
||||
export const Prompt = Persistence.array(ContentPart)
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
@@ -23,6 +23,7 @@ export type {
|
||||
FileAttachmentPart,
|
||||
FileContextItem,
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
SkillPart,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComposerState, ContextItem, Prompt } from "./state"
|
||||
import { appendPrompt, clonePrompt } from "./prompt-parts"
|
||||
import { appendPrompt, clonePrompt, isAttachment } 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) => part.type === "image" || part.content.length > 0))
|
||||
if (preserveDraft && target.current().some((part) => isAttachment(part) || part.content.length > 0))
|
||||
following = clonePrompt(target.current())
|
||||
}
|
||||
if (!following) target.reset()
|
||||
|
||||
@@ -3,7 +3,6 @@ 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"
|
||||
|
||||
@@ -49,14 +48,6 @@ 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) {} },
|
||||
@@ -73,7 +64,6 @@ function submitInput(
|
||||
resetHistory() {},
|
||||
setMode() {},
|
||||
closePopover() {},
|
||||
destination: () => destination,
|
||||
notify,
|
||||
comments: { capture: () => [], clear() {}, restore() {} },
|
||||
})
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
|
||||
import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { deliverAttachments, type AttachmentDestination } from "./attachments/deliver"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
|
||||
const submitting = new WeakSet<object>()
|
||||
@@ -34,7 +35,6 @@ type ComposerSubmitInput = {
|
||||
resetHistory: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
closePopover: () => void
|
||||
destination: () => AttachmentDestination
|
||||
delivery?: (alternate: boolean) => ComposerDelivery
|
||||
notify: {
|
||||
missingSelection: () => void
|
||||
@@ -87,16 +87,10 @@ 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.destination(),
|
||||
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.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 }),
|
||||
)
|
||||
@@ -129,13 +123,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
|
||||
if (command) {
|
||||
clearSubmission(input, submission)
|
||||
void sendCommand(
|
||||
session,
|
||||
value,
|
||||
command,
|
||||
input.destination(),
|
||||
input.adapter.controls().model.selection.trackSessionCommit,
|
||||
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
|
||||
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
|
||||
(error) => failSubmission(input, session, "command", error, restore, value.id),
|
||||
)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
@@ -162,6 +152,9 @@ 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()
|
||||
? [
|
||||
@@ -196,7 +189,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() && images.length === 0 && comments === 0) return
|
||||
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
|
||||
|
||||
const controls = input.adapter.controls()
|
||||
const model = controls.model.selection.current()
|
||||
@@ -304,10 +297,9 @@ async function sendCommand(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
command: { command: string; arguments: string },
|
||||
destination: AttachmentDestination,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value, destination)
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// 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({
|
||||
@@ -346,11 +338,10 @@ async function applySelection(
|
||||
async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
destination: AttachmentDestination,
|
||||
track: ModelSelection["trackSessionCommit"] | undefined,
|
||||
onAdmit: () => void,
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value, destination)
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// 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
|
||||
@@ -384,15 +375,14 @@ async function sendPrompt(
|
||||
await sending
|
||||
}
|
||||
|
||||
async function buildSubmissionRequest(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
destination: AttachmentDestination,
|
||||
) {
|
||||
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) })),
|
||||
)
|
||||
return buildPromptRequest({
|
||||
prompt: value.prompt,
|
||||
context: value.context,
|
||||
attachments: await deliverAttachments(value.images, destination),
|
||||
images,
|
||||
text: value.text,
|
||||
sessionDirectory: session.directory,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
|
||||
import { isAttachment } from "../prompt-parts"
|
||||
|
||||
export type ComposerInteractionState = {
|
||||
mode: "normal" | "shell"
|
||||
@@ -236,7 +237,7 @@ function populated(persisted: ComposerPersistedState) {
|
||||
return (
|
||||
!!promptText(persisted).trim() ||
|
||||
persisted.context.items.length > 0 ||
|
||||
persisted.prompt.some((part) => part.type === "file" || part.type === "image")
|
||||
persisted.prompt.some((part) => part.type === "file" || isAttachment(part))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state"
|
||||
import type {
|
||||
AgentPart,
|
||||
ComposerStore,
|
||||
FileAttachmentPart,
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
Prompt,
|
||||
SkillPart,
|
||||
} from "./state"
|
||||
|
||||
export type ComposerFilePart = FileAttachmentPart
|
||||
export type ComposerAgentPart = AgentPart
|
||||
export type ComposerSkillPart = SkillPart
|
||||
export type ComposerAttachment = ImageAttachmentPart
|
||||
export type ComposerAttachment = ImageAttachmentPart | PathAttachmentPart
|
||||
export type ComposerPrompt = Prompt
|
||||
export type ComposerComment = ComposerStore["context"]["items"][number]
|
||||
export type ComposerPersistedState = ComposerStore
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ProjectAvatar } from "@opencode/ui/project-avatar"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Spinner } from "@opencode/ui/spinner"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/shell/state/layout"
|
||||
@@ -255,7 +256,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
collapsed={collapsed()}
|
||||
health={props.serverHealth(item)}
|
||||
/>
|
||||
<Show when={authentication()}>
|
||||
<Show when={authentication() || connecting()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<div class="px-1.5 py-1">
|
||||
<Button
|
||||
@@ -263,9 +264,14 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
class="w-full"
|
||||
size="small"
|
||||
variant="neutral"
|
||||
disabled={connecting()}
|
||||
aria-busy={!!connecting()}
|
||||
onClick={() => props.onAuthenticateServer?.(item)}
|
||||
>
|
||||
{props.language.t("ssh.action.authenticate")}
|
||||
<Show when={connecting()}>
|
||||
<Spinner class="size-3.5" />
|
||||
</Show>
|
||||
{props.language.t(connecting() ? "ssh.stage.connecting" : "ssh.action.authenticate")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -48,7 +48,8 @@ export function createNewSessionComposerAdapter(props: {
|
||||
submitted: props.submitted,
|
||||
async start(selection, submission, message) {
|
||||
const draftID = props.draftID
|
||||
const projectDirectory = location().directory
|
||||
const currentDirectory = location().directory
|
||||
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
|
||||
const worktree = props.worktree()
|
||||
const branch = props.branch()
|
||||
const mcp = props.mcp.capture()
|
||||
|
||||
@@ -74,6 +74,7 @@ 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",
|
||||
@@ -366,6 +367,9 @@ 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",
|
||||
@@ -963,7 +967,16 @@ 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.",
|
||||
|
||||
@@ -421,17 +421,9 @@ function referenced(json: string) {
|
||||
return ids
|
||||
}
|
||||
|
||||
async function blobData(blob: BlobReference) {
|
||||
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
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)
|
||||
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => reject(reader.error))
|
||||
|
||||
@@ -14,7 +14,6 @@ import { SshConnectionPanel } from "./connection-panel"
|
||||
import { SshServerSettings } from "./settings"
|
||||
|
||||
function Fixture(props: {
|
||||
home?: boolean
|
||||
session?: boolean
|
||||
settings?: boolean
|
||||
incompatible?: boolean
|
||||
@@ -26,15 +25,10 @@ function Fixture(props: {
|
||||
const state: { item?: SshItem; before?: SshItem; step: number; timer?: ReturnType<typeof setTimeout> } = {
|
||||
step: 0,
|
||||
item:
|
||||
props.initial === "required" || props.home
|
||||
props.initial === "required"
|
||||
? {
|
||||
config: { id: "story", target: "ssh linuxbook", name: "" },
|
||||
stage:
|
||||
props.session || props.settings
|
||||
? "disconnected"
|
||||
: props.initial === "connecting"
|
||||
? "connecting"
|
||||
: "authentication",
|
||||
stage: props.session || props.settings ? "disconnected" : "authentication",
|
||||
saved: true,
|
||||
detail: "",
|
||||
}
|
||||
@@ -147,7 +141,7 @@ function Fixture(props: {
|
||||
<AuthenticationSettings />
|
||||
) : props.session ? (
|
||||
<AuthenticationSession />
|
||||
) : props.initial === "required" || props.home ? (
|
||||
) : props.initial === "required" ? (
|
||||
<AuthenticationHome />
|
||||
) : (
|
||||
<Open initial={props.initial} />
|
||||
@@ -283,7 +277,6 @@ function Open(props: { initial?: string }) {
|
||||
|
||||
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
|
||||
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
|
||||
export const HomeConnecting = { render: () => <Fixture home initial="connecting" /> }
|
||||
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
|
||||
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
|
||||
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
|
||||
|
||||
@@ -2,7 +2,6 @@ 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"
|
||||
@@ -30,7 +29,6 @@ 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
|
||||
|
||||
@@ -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, Prompt } from "@/composer/state"
|
||||
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
|
||||
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
|
||||
import { buildPromptRequest } from "@/composer/request"
|
||||
import { deliverAttachments, type AttachmentDestination } from "@/composer/attachments/deliver"
|
||||
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
|
||||
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
|
||||
import { readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -30,7 +30,6 @@ export function createSessionQueue(input: {
|
||||
draft: ComposerStateTarget
|
||||
working: Accessor<boolean>
|
||||
behavior: Accessor<ComposerDelivery>
|
||||
destination: () => AttachmentDestination
|
||||
restoreFocus: (cursor: number) => void
|
||||
}) {
|
||||
const data = useData()
|
||||
@@ -61,7 +60,6 @@ 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({
|
||||
@@ -187,15 +185,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 images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
if (!text.trim() && !images.length) return cancelEdit()
|
||||
const attachments = prompt.filter(isAttachment)
|
||||
if (!text.trim() && !attachments.length) return cancelEdit()
|
||||
const item = queued().find((entry) => entry.id === editing.id)
|
||||
const original = item ? queuedPromptAttachments(item) : []
|
||||
const pristine =
|
||||
item &&
|
||||
text.trim() === queuedPromptText(item) &&
|
||||
images.length === original.length &&
|
||||
images.every((image, index) => image.id === original[index].id)
|
||||
attachments.length === original.length &&
|
||||
attachments.every((attachment, index) => attachment.id === original[index].id)
|
||||
if (pristine && delivery === "queue") return cancelEdit()
|
||||
mutation.mutate({
|
||||
type: "edit",
|
||||
@@ -251,7 +249,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments: item.payload.files?.length ?? 0,
|
||||
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -261,18 +259,32 @@ 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. 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}`),
|
||||
}))
|
||||
// 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,
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
|
||||
@@ -292,13 +304,13 @@ async function editedPromptInput(
|
||||
item: QueuedPrompt | undefined,
|
||||
prompt: Prompt,
|
||||
text: string,
|
||||
destination: AttachmentDestination,
|
||||
) {
|
||||
const attachments = await deliverAttachments(
|
||||
prompt.filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
destination,
|
||||
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 request = buildPromptRequest({ prompt, context: [], attachments, text, sessionDirectory: directory })
|
||||
const request = buildPromptRequest({ prompt, context: [], images, 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) : ""
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useIsRouting, useLocation, useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, on, 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?: {
|
||||
@@ -39,12 +49,23 @@ 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
|
||||
@@ -80,6 +101,7 @@ function Cell(props: {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dir="ltr"
|
||||
classList={{
|
||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||
}}
|
||||
@@ -87,6 +109,7 @@ function Cell(props: {
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
dir="ltr"
|
||||
classList={{
|
||||
"uppercase font-bold tabular-nums": true,
|
||||
"text-[11px] leading-text-tight": !!props.inline,
|
||||
@@ -136,8 +159,12 @@ function ToggleCell(props: {
|
||||
"flex-col items-center": !props.inline,
|
||||
}}
|
||||
>
|
||||
<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 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>
|
||||
</button>
|
||||
)
|
||||
@@ -149,9 +176,11 @@ function ToggleCell(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
export function DebugBar(props: { diagnostics?: boolean; 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({
|
||||
@@ -175,8 +204,55 @@ export function DebugBar(props: { 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 = () => {
|
||||
@@ -204,6 +280,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
let two = 0
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.diagnostics) return
|
||||
const busy = routing()
|
||||
const next = `${location.pathname}${location.search}`
|
||||
|
||||
@@ -248,6 +325,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!props.diagnostics) return
|
||||
const obs: PerformanceObserver[] = []
|
||||
const fps: Array<{ at: number; dur: number }> = []
|
||||
const long: Array<{ at: number; dur: number }> = []
|
||||
@@ -448,7 +526,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={language.t("debugBar.ariaLabel")}
|
||||
aria-label={language.t(props.diagnostics ? "debugBar.ariaLabel" : "debugBar.providerAriaLabel")}
|
||||
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,
|
||||
@@ -467,102 +545,132 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
label={language.t("debugBar.tps.label")}
|
||||
tip={language.t("debugBar.tps.tip")}
|
||||
value={fixed(metrics()?.tps, 1) ?? na()}
|
||||
dim={metrics()?.tps === undefined}
|
||||
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}
|
||||
label={language.t("debugBar.ttft.label")}
|
||||
tip={language.t("debugBar.ttft.tip")}
|
||||
value={duration(metrics()?.ttft) ?? na()}
|
||||
dim={metrics()?.ttft === 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}
|
||||
label={language.t("debugBar.ttfa.label")}
|
||||
tip={language.t("debugBar.ttfa.tip")}
|
||||
value={duration(metrics()?.ttfa) ?? na()}
|
||||
dim={metrics()?.ttfa === 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}
|
||||
label={language.t("debugBar.e2e.label")}
|
||||
tip={language.t("debugBar.e2e.tip")}
|
||||
value={duration(metrics()?.e2e) ?? na()}
|
||||
dim={metrics()?.e2e === 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}
|
||||
/>
|
||||
<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={state.focus}
|
||||
<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}
|
||||
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()}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
<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={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>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
}
|
||||
@@ -5,11 +5,14 @@ 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 })))
|
||||
|
||||
@@ -18,6 +21,8 @@ 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,
|
||||
@@ -34,14 +39,21 @@ 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 = import.meta.env.DEV
|
||||
? {
|
||||
get visible() {
|
||||
return state.debugTools
|
||||
},
|
||||
toggle: () => setState("debugTools", (value) => !value),
|
||||
}
|
||||
: undefined
|
||||
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,
|
||||
},
|
||||
])
|
||||
|
||||
return (
|
||||
<TitlebarRightProvider>
|
||||
@@ -105,12 +117,13 @@ export default function Layout(props: ParentProps) {
|
||||
</SshAuthentication>
|
||||
</main>
|
||||
</div>
|
||||
<Show when={import.meta.env.DEV && state.debugTools}>
|
||||
<Show when={state.debugTools}>
|
||||
<Suspense>
|
||||
<DebugBar inline />
|
||||
<DebugBar diagnostics={import.meta.env.DEV} inline />
|
||||
</Suspense>
|
||||
</Show>
|
||||
<ToastRegion />
|
||||
<UploadToastHost />
|
||||
</div>
|
||||
</TitlebarRightProvider>
|
||||
)
|
||||
|
||||
@@ -34,10 +34,15 @@ 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,
|
||||
})
|
||||
|
||||
|
||||
@@ -47,7 +47,14 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
const update = yield* updater.run().pipe(Effect.forkScoped)
|
||||
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)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -92,7 +99,13 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
{ signal },
|
||||
),
|
||||
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { 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),
|
||||
)
|
||||
},
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
packages: {
|
||||
|
||||
@@ -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: () => Effect.Effect<RunResult | undefined>
|
||||
readonly run: (onInstall?: (version: string) => void) => 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,10 +275,11 @@ const make = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
const run = Effect.fn("cli.updater.run")(
|
||||
function* () {
|
||||
function* (onInstall: (version: string) => void = () => {}) {
|
||||
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 }
|
||||
},
|
||||
|
||||
@@ -1043,6 +1043,18 @@ 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)
|
||||
|
||||
@@ -52,6 +52,80 @@ 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))
|
||||
|
||||
@@ -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 and supported JavaScript built-ins. APIs
|
||||
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **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.
|
||||
- **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,11 +94,19 @@ 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.
|
||||
|
||||
### `Values`
|
||||
### `Extension.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.
|
||||
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`.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
|
||||
@@ -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, and Uint8Array values inside CodeMode.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, 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, custom synchronous
|
||||
iterators, and synchronous generators.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, 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, custom synchronous iterators, and confined
|
||||
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [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...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`, `Promise`) throw a `TypeError`,
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `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, 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, 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
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
@@ -451,7 +451,13 @@ 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.
|
||||
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
- [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.
|
||||
|
||||
## Extensions
|
||||
|
||||
@@ -461,8 +467,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`, `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`, `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),
|
||||
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
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
record,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./interpreter/objects.js"
|
||||
import { typeofValue } from "./interpreter/references.js"
|
||||
|
||||
@@ -69,6 +70,7 @@ 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,6 +27,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
import { describeValue } from "./references.js"
|
||||
|
||||
@@ -52,6 +53,7 @@ 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))
|
||||
@@ -125,6 +127,7 @@ 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,6 +11,7 @@ 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"
|
||||
@@ -80,6 +81,7 @@ 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,6 +84,7 @@ import {
|
||||
PromiseObj,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
record,
|
||||
remove,
|
||||
set,
|
||||
@@ -653,7 +654,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, or URLSearchParams, or custom iterator value.`,
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
@@ -756,9 +757,11 @@ class Frame<R> {
|
||||
? value.set.values()
|
||||
: value instanceof URLSearchParamsObj
|
||||
? value.params.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: undefined
|
||||
: value instanceof HeadersObj
|
||||
? value.headers.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: undefined
|
||||
if (iterator !== undefined) {
|
||||
const proto = this.ctx.builtins.Array
|
||||
return Effect.succeed({
|
||||
@@ -1848,6 +1851,7 @@ 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,6 +29,7 @@ const builtins = [
|
||||
"Set",
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"Headers",
|
||||
"Uint8Array",
|
||||
"TextEncoder",
|
||||
"TextDecoder",
|
||||
@@ -80,6 +81,7 @@ export const createBuiltins = (): Builtins => {
|
||||
Set: plain(),
|
||||
URL: plain(),
|
||||
URLSearchParams: plain(),
|
||||
Headers: plain(),
|
||||
Uint8Array: plain(),
|
||||
TextEncoder: plain(),
|
||||
TextDecoder: plain(),
|
||||
|
||||
@@ -156,6 +156,15 @@ 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(
|
||||
@@ -181,13 +190,14 @@ 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 | Bytes =>
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | 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,6 +16,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
|
||||
/** Values that cannot cross the data boundary. */
|
||||
@@ -85,6 +86,7 @@ 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"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
@@ -66,6 +67,7 @@ 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]"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
@@ -107,12 +107,10 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return url
|
||||
}
|
||||
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(value)
|
||||
if (cursor === undefined) {
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
|
||||
const items: Array<string> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -126,6 +124,29 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<s
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -139,20 +160,8 @@ const constructURLSearchParams = <R>(
|
||||
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
|
||||
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
|
||||
if (isRuntimeReference(init)) {
|
||||
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
|
||||
@@ -28,6 +29,7 @@ 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.
|
||||
|
||||
@@ -128,6 +128,34 @@ 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({
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ type Recorded = {
|
||||
readonly body: unknown
|
||||
}
|
||||
|
||||
const opencodeSpec = async (): Promise<Document> => {
|
||||
return Bun.file(new URL("../../protocol/openapi.json", import.meta.url)).json() as Promise<Document>
|
||||
const transportSpec = async (): Promise<Document> => {
|
||||
return Bun.file(new URL("./fixtures/openapi-transports.json", import.meta.url)).json() as Promise<Document>
|
||||
}
|
||||
|
||||
const happyPathSpec = async (): Promise<Document> => {
|
||||
@@ -219,48 +219,42 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(client.requests[3]!.headers.authorization).toBe("Bearer bearer-secret")
|
||||
})
|
||||
|
||||
test("converts representative opencode operations into the expected tool shape", async () => {
|
||||
const spec = await opencodeSpec()
|
||||
test("generates supported operations and reports unsupported transports", async () => {
|
||||
const spec = await transportSpec()
|
||||
const result = OpenAPI.fromSpec({ spec, baseUrl })
|
||||
|
||||
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()
|
||||
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",
|
||||
},
|
||||
])
|
||||
|
||||
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()
|
||||
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()
|
||||
})
|
||||
|
||||
test("preserves operation path sanitization and collision handling", () => {
|
||||
@@ -971,30 +965,16 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } })
|
||||
})
|
||||
|
||||
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 () => {
|
||||
test("exposes generated operations through CodeMode discovery", async () => {
|
||||
const { layer } = recordingClient(() => json({}))
|
||||
const runtime = CodeMode.make({
|
||||
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
|
||||
tools: { api: OpenAPI.fromSpec({ spec: await happyPathSpec(), baseUrl }).tools },
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
runtime
|
||||
.execute(
|
||||
`
|
||||
return search({ query: "server info", namespace: "opencode", limit: 1 })
|
||||
return search({ query: "get a user", namespace: "api", limit: 1 })
|
||||
`,
|
||||
)
|
||||
.pipe(Effect.provide(layer)),
|
||||
@@ -1005,55 +985,12 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(result.value).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
path: "tools.opencode.server.info",
|
||||
description: "Return the server identity, connection URLs, paths, and readiness status.",
|
||||
path: "tools.api.users.get",
|
||||
description: "Get a user",
|
||||
},
|
||||
],
|
||||
})
|
||||
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")
|
||||
expect(JSON.stringify(result.value)).toContain("userId: string")
|
||||
})
|
||||
|
||||
test("serializes supported simple and form parameter shapes", async () => {
|
||||
@@ -1461,15 +1398,15 @@ describe("OpenAPI.fromSpec", () => {
|
||||
test("fails missing required parameters before auth and network", async () => {
|
||||
const { requests, layer } = recordingClient(() => json({}))
|
||||
const runtime = CodeMode.make({
|
||||
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
|
||||
tools: { api: OpenAPI.fromSpec({ spec: await transportSpec(), baseUrl }).tools },
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute("return await tools.opencode.session.get({})").pipe(Effect.provide(layer)),
|
||||
runtime.execute("return await tools.api.records.get({})").pipe(Effect.provide(layer)),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: false })
|
||||
expect(JSON.stringify(result)).toContain("Missing required path parameter 'sessionID'")
|
||||
expect(JSON.stringify(result)).toContain("Missing required path parameter 'recordID'")
|
||||
expect(requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -635,6 +635,154 @@ 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(
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
* - 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"
|
||||
@@ -166,3 +169,225 @@ 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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,7 +76,7 @@ import { SessionSystemPrompt } from "@opencode/core/session/system-prompt"
|
||||
import { ID, Model } from "@opencode/core/model"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
|
||||
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { asc, desc, eq, sql } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -3391,11 +3391,17 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
scenario("consumes the full provider stream before recording its boundary and settling local tools", function* (s) {
|
||||
yield* s.admit("Echo this")
|
||||
const request = yield* s.llm.gate
|
||||
const tail = yield* Deferred.make<void>()
|
||||
const complete = yield* Deferred.make<void>()
|
||||
const finished = yield* Deferred.make<void>()
|
||||
yield* s.llm.push(
|
||||
Stream.fromIterable(TestLLM.tool("call-streamed", "echo", { text: "hello" })).pipe(
|
||||
Stream.fromIterable(
|
||||
TestLLM.complete(
|
||||
{ reason: { normalized: "tool-calls" }, usage: { outputTokens: 100, reasoningTokens: 80 } },
|
||||
LLMEvent.toolCall({ id: "call-streamed", name: "echo", input: { text: "hello" } }),
|
||||
),
|
||||
).pipe(
|
||||
Stream.concat(
|
||||
Stream.fromEffect(Deferred.succeed(tail, undefined).pipe(Effect.andThen(Deferred.await(complete)))).pipe(
|
||||
Stream.drain,
|
||||
@@ -3413,25 +3419,39 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
const run = yield* Effect.forkChild(s.resume)
|
||||
|
||||
yield* request.started
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* request.release
|
||||
yield* tools.started
|
||||
yield* Deferred.await(tail)
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.streamed.1")
|
||||
expect(requireAssistant(yield* s.context).time.completed).toBeUndefined()
|
||||
yield* TestClock.adjust("3 seconds")
|
||||
yield* Deferred.succeed(complete, undefined)
|
||||
yield* Fiber.join(streamed)
|
||||
expect(yield* Deferred.isDone(finished)).toBe(true)
|
||||
const assistant = requireAssistant(yield* s.context)
|
||||
expect(assistant.time.streamed).toBeDefined()
|
||||
expect(DateTime.toEpochMillis(assistant.time.streamed!) - DateTime.toEpochMillis(assistant.time.created)).toBe(
|
||||
5_000,
|
||||
)
|
||||
expect(assistant.time.completed).toBeUndefined()
|
||||
expect(assistant.content).toMatchObject([{ type: "tool", state: { status: "running" } }])
|
||||
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
const events = yield* recordedEventTypes(sessionID)
|
||||
expect(events.indexOf("session.step.streamed.1")).toBeLessThan(events.indexOf("session.tool.success.2"))
|
||||
expect(events.indexOf("session.tool.success.2")).toBeLessThan(events.indexOf("session.step.ended.1"))
|
||||
expect(events.filter((type) => type === "session.step.streamed.1")).toHaveLength(2)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
const replayed = (yield* s.context).find((message) => message.id === assistant.id)
|
||||
expect(replayed).toMatchObject({
|
||||
time: { created: assistant.time.created, streamed: assistant.time.streamed },
|
||||
tokens: { output: 20, reasoning: 80 },
|
||||
})
|
||||
})
|
||||
|
||||
scenario("restores durable reasoning provider metadata in the next request", function* (s) {
|
||||
@@ -5037,14 +5057,18 @@ describe("SessionRunnerLLM", () => {
|
||||
scenario(`bounds jittered exponential backoff before output for ${failure.name}`, function* (s) {
|
||||
yield* s.admit("Retry transport")
|
||||
yield* s.llm.push(TestLLM.failAfter(failure(), LLMEvent.stepStart({ index: 0 })))
|
||||
yield* s.llm.push(TestLLM.text("Recovered", "retry-success"))
|
||||
yield* s.llm.push(
|
||||
Stream.fromEffect(Effect.sleep(400)).pipe(
|
||||
Stream.flatMap(() => Stream.fromIterable(TestLLM.text("Recovered", "retry-success"))),
|
||||
),
|
||||
)
|
||||
|
||||
const scheduled = yield* subscribeRetries(s)
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* Queue.take(scheduled)
|
||||
yield* TestClock.adjust("1599 millis")
|
||||
expect(s.requests).toHaveLength(1)
|
||||
yield* TestClock.adjust("801 millis")
|
||||
yield* TestClock.adjust("1201 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
@@ -5058,6 +5082,10 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect((yield* s.context).filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
const assistant = requireAssistant(yield* s.context)
|
||||
expect(DateTime.toEpochMillis(assistant.time.streamed!) - DateTime.toEpochMillis(assistant.time.created)).toBe(
|
||||
400,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -136,9 +136,10 @@ export const DEFAULT_THEME = {
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.200",
|
||||
surface: {
|
||||
offset: "$hue.neutral.300",
|
||||
overlay: "$hue.neutral.400",
|
||||
raised: {
|
||||
base: "$hue.neutral.300",
|
||||
high: "$hue.neutral.400",
|
||||
max: "$hue.neutral.500",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
@@ -161,7 +162,7 @@ export const DEFAULT_THEME = {
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$hovered: "$background.surface.offset",
|
||||
$hovered: "$background.raised.base",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
@@ -220,14 +221,14 @@ export const DEFAULT_THEME = {
|
||||
"@context:elevated": {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$background.surface.offset",
|
||||
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.surface.overlay" } },
|
||||
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.surface.overlay",
|
||||
default: "$background.raised.high",
|
||||
action: { primary: { default: "$hue.interactive.500" } },
|
||||
},
|
||||
},
|
||||
@@ -357,9 +358,10 @@ export const DEFAULT_THEME = {
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.800",
|
||||
surface: {
|
||||
offset: "$hue.neutral.700",
|
||||
overlay: "$hue.neutral.600",
|
||||
raised: {
|
||||
base: "$hue.neutral.700",
|
||||
high: "$hue.neutral.600",
|
||||
max: "$hue.neutral.500",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
@@ -382,7 +384,7 @@ export const DEFAULT_THEME = {
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$hovered: "$background.surface.offset",
|
||||
$hovered: "$background.raised.base",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
@@ -441,14 +443,14 @@ export const DEFAULT_THEME = {
|
||||
"@context:elevated": {
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.surface.offset",
|
||||
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.surface.overlay" } },
|
||||
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.surface.overlay",
|
||||
default: "$background.raised.high",
|
||||
action: { primary: { default: "$hue.interactive.400" } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ export function fallback(mode: Mode): ThemeTokensDefinition {
|
||||
},
|
||||
background: {
|
||||
default: red,
|
||||
surface: { offset: red, overlay: red },
|
||||
raised: { base: red, high: red, max: red },
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
|
||||
@@ -129,10 +129,11 @@ export type TextDefinition = Schema.Schema.Type<typeof TextDefinition>
|
||||
|
||||
const BackgroundDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
surface: Schema.optional(
|
||||
raised: Schema.optional(
|
||||
Schema.Struct({
|
||||
offset: Schema.optional(ColorValue),
|
||||
overlay: Schema.optional(ColorValue),
|
||||
base: Schema.optional(ColorValue),
|
||||
high: Schema.optional(ColorValue),
|
||||
max: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
action: Schema.optional(ActionColorDefinition),
|
||||
|
||||
@@ -40,9 +40,10 @@ export type ResolvedThemeTokens = {
|
||||
}
|
||||
readonly background: {
|
||||
readonly default: RGBA
|
||||
readonly surface: {
|
||||
readonly offset: RGBA
|
||||
readonly overlay: RGBA
|
||||
readonly raised: {
|
||||
readonly base: RGBA
|
||||
readonly high: RGBA
|
||||
readonly max: RGBA
|
||||
}
|
||||
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
|
||||
readonly formfield: FormfieldColor
|
||||
|
||||
@@ -57,6 +57,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
const background = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
|
||||
const backgroundPanel = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
|
||||
const backgroundMenu = mode === "light" ? "$hue.neutral.400" : "$hue.neutral.600"
|
||||
const backgroundRaisedMax = "$hue.neutral.500"
|
||||
|
||||
return referenceHues({
|
||||
hue: {
|
||||
@@ -102,9 +103,10 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
},
|
||||
background: {
|
||||
default: background,
|
||||
surface: {
|
||||
offset: backgroundPanel,
|
||||
overlay: backgroundMenu,
|
||||
raised: {
|
||||
base: backgroundPanel,
|
||||
high: backgroundMenu,
|
||||
max: backgroundRaisedMax,
|
||||
},
|
||||
action: {
|
||||
primary: { default: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: "transparent" },
|
||||
@@ -173,11 +175,11 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
},
|
||||
"@context:elevated": {
|
||||
background: {
|
||||
default: "$background.surface.offset",
|
||||
action: { primary: { $hovered: "$background.surface.overlay" } },
|
||||
default: "$background.raised.base",
|
||||
action: { primary: { $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": { background: { default: "$background.surface.overlay" } },
|
||||
"@context:overlay": { background: { default: "$background.raised.high" } },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +32,14 @@ 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 }
|
||||
const message = error()
|
||||
if (message) return { type: "check-failed" as const, message }
|
||||
return props.state() ?? { type: "current" as const }
|
||||
return current ?? { type: "current" as const }
|
||||
})
|
||||
const buttons = createMemo(() => {
|
||||
const type = state().type
|
||||
@@ -87,9 +89,11 @@ 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 === "available" || state().type === "installing" || state().type === "failed"
|
||||
? "Update available"
|
||||
: "Update"}
|
||||
{state().type === "installing"
|
||||
? "Updating OpenCode"
|
||||
: state().type === "available" || state().type === "failed"
|
||||
? "Update available"
|
||||
: "Update"}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
|
||||
@@ -350,7 +350,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
|
||||
input.cursorColor = disabled() ? theme.background.raised.base : theme.text.default
|
||||
if (config.cursor) input.cursorStyle = config.cursor
|
||||
})
|
||||
|
||||
@@ -1114,7 +1114,17 @@ export function Prompt(props: PromptProps) {
|
||||
if (!trimmed && (!props.sessionID || store.mode === "shell" || delivery === "queue"))
|
||||
return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
|
||||
const exitWord = trimmed === "exit" || trimmed === "quit" || trimmed === ":q"
|
||||
const slash = argumentSlash(store.prompt.text, keymapCommands())
|
||||
const inputText = expandTrackedPastedText(
|
||||
store.prompt.text,
|
||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||
const ref = store.extmarkToPart.get(extmark.id)
|
||||
if (ref?.type !== "pasted") return []
|
||||
const part = store.prompt.pasted[ref.index]
|
||||
if (!part) return []
|
||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||
}),
|
||||
)
|
||||
const slash = argumentSlash(inputText, keymapCommands())
|
||||
if (delivery === "queue" && (store.mode === "shell" || exitWord || slash)) {
|
||||
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||
return false
|
||||
@@ -1128,16 +1138,6 @@ export function Prompt(props: PromptProps) {
|
||||
await slash.command.run(slash.input)
|
||||
return true
|
||||
}
|
||||
const inputText = expandTrackedPastedText(
|
||||
store.prompt.text,
|
||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||
const ref = store.extmarkToPart.get(extmark.id)
|
||||
if (ref?.type !== "pasted") return []
|
||||
const part = store.prompt.pasted[ref.index]
|
||||
if (!part) return []
|
||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||
}),
|
||||
)
|
||||
const slashHead = parseSlashHead(inputText, /\s/)
|
||||
const isCommand =
|
||||
slashHead !== undefined &&
|
||||
@@ -1642,7 +1642,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
|
||||
|
||||
const promptBg = createMemo(() => theme.raise(theme.background.surface.offset))
|
||||
const promptBg = createMemo(() => theme.raise(theme.background.raised.base))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1799,7 +1799,7 @@ export function Prompt(props: PromptProps) {
|
||||
setTimeout(() => {
|
||||
// setTimeout is a workaround and needs to be addressed properly
|
||||
if (!input || input.isDestroyed) return
|
||||
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
|
||||
input.cursorColor = disabled() ? theme.background.raised.base : theme.text.default
|
||||
if (config.cursor) input.cursorStyle = config.cursor
|
||||
}, 0)
|
||||
}}
|
||||
@@ -1818,7 +1818,7 @@ export function Prompt(props: PromptProps) {
|
||||
r.stopPropagation()
|
||||
}}
|
||||
focusedBackgroundColor="transparent"
|
||||
cursorColor={disabled() ? theme.background.surface.offset : theme.text.default}
|
||||
cursorColor={disabled() ? theme.background.raised.base : theme.text.default}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
|
||||
@@ -1530,7 +1530,7 @@ function HorizontalSessionTabs(props: {
|
||||
const lifted = (hovered() === tab.sessionID || dragged()) && !selected()
|
||||
const base = lifted ? theme.background.action.primary.hovered : theme.background.default
|
||||
// A dragged tab lifts to full selected elevation while it is held.
|
||||
return tint(base, theme.raise(theme.background.surface.offset), dragged() ? 1 : selection())
|
||||
return tint(base, theme.raise(theme.background.raised.base), dragged() ? 1 : selection())
|
||||
})
|
||||
const pulseColor = () => tint(background(), theme.text.default, 0.45)
|
||||
// The edge flash washes toward a brighter stop on the same background-to-text ramp,
|
||||
|
||||
@@ -158,7 +158,7 @@ export const Info = Schema.Struct({
|
||||
description: "Show user attachment and tool-result images in the session transcript",
|
||||
}),
|
||||
tps: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show output tokens per second in assistant footers",
|
||||
description: "Show average tokens per second",
|
||||
}),
|
||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||
|
||||
@@ -126,6 +126,7 @@ export const Definitions = {
|
||||
"session.interrupt": keybind("escape", "Interrupt current session"),
|
||||
"session.background": keybind("ctrl+b", "Background blocking session tools"),
|
||||
"session.compact": keybind("<leader>c", "Compact the session"),
|
||||
"session.aside": keybind("none", "Ask a side question"),
|
||||
"session.cd": keybind("none", "Change working directory"),
|
||||
"session.queued_prompts": keybind("<leader>q", "Manage queued prompts"),
|
||||
"queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
|
||||
|
||||
@@ -20,6 +20,7 @@ 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>
|
||||
}
|
||||
@@ -74,7 +75,13 @@ 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)
|
||||
const result = await updater
|
||||
.check(signal, (version) => {
|
||||
if (!signal.aborted) setState({ type: "installing", version })
|
||||
})
|
||||
.finally(() => {
|
||||
if (state()?.type === "installing") setState(undefined)
|
||||
})
|
||||
if (signal.aborted) return
|
||||
if (result?.type === "unavailable") return result.message
|
||||
setState(result)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Plugin } from "@opencode/plugin/tui"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { Spinner } from "../../component/spinner"
|
||||
import { useConfig } from "../../config"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
|
||||
// session.generate exposes the session's tools but runs no tool loop, so a
|
||||
// tool call would surface as an empty answer.
|
||||
const instructions = [
|
||||
"The user is asking a quick side question about the conversation so far.",
|
||||
"Answer directly and concisely in markdown from what you already know.",
|
||||
"Do not call any tools and do not take any actions.",
|
||||
].join(" ")
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.btw",
|
||||
setup(context) {
|
||||
const [pending, setPending] = createSignal(0)
|
||||
|
||||
context.ui.slot({
|
||||
append: "prompt.footer.status",
|
||||
render: () => {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<Show when={pending() > 0}>
|
||||
<box flexShrink={0}>
|
||||
<Spinner color={theme.text.status.running}>/btw</Spinner>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
context.ui.slot({
|
||||
append: "app",
|
||||
render() {
|
||||
const toast = useToast()
|
||||
context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
id: "session.aside",
|
||||
title: "Ask a side question",
|
||||
description: "One-shot answer from the session's context without adding to the conversation",
|
||||
group: "Session",
|
||||
palette: true,
|
||||
slash: { name: "btw", arguments: true },
|
||||
async run(input) {
|
||||
const route = context.ui.router.current()
|
||||
if (route.type !== "session") {
|
||||
toast.show({ message: "Open a session first", variant: "warning" })
|
||||
return
|
||||
}
|
||||
const question =
|
||||
input?.trim() || (await context.ui.dialog.prompt({ title: "/btw", placeholder: "Ask anything" }))
|
||||
if (!question) return
|
||||
setPending((count) => count + 1)
|
||||
await context.client.session
|
||||
.generate({ sessionID: route.sessionID, prompt: [instructions, question].join("\n\n") })
|
||||
.then((result) => {
|
||||
context.ui.dialog.show(() => <Answer question={question} answer={result.text.trim()} />)
|
||||
context.ui.dialog.set({ size: "large", centered: true })
|
||||
})
|
||||
.catch((cause: unknown) => toast.error(cause))
|
||||
.finally(() => setPending((count) => count - 1))
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
return null
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function Answer(props: { question: string; answer: string }) {
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const clipboard = useClipboard()
|
||||
const plugins = usePlugin()
|
||||
const theme = useTheme("elevated")
|
||||
const overlay = useTheme("overlay")
|
||||
const syntax = useThemes().currentSyntax
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2)))
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
const copy = () => {
|
||||
void clipboard
|
||||
.write(props.answer)
|
||||
.then(() => setCopied(true))
|
||||
.catch(toast.error)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [{ bind: "c", title: "Copy answer", group: "Dialog", run: copy }],
|
||||
}))
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (!scroll) return
|
||||
if (event.name === "up") return scroll.scrollBy(-1)
|
||||
if (event.name === "down") return scroll.scrollBy(1)
|
||||
if (event.name === "pageup") return scroll.scrollBy(-maxHeight())
|
||||
if (event.name === "pagedown") return scroll.scrollBy(maxHeight())
|
||||
if (event.name === "home") return scroll.scrollTo(0)
|
||||
if (event.name === "end") return scroll.scrollTo(scroll.scrollHeight)
|
||||
})
|
||||
|
||||
return (
|
||||
<box gap={1}>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
/btw
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{props.question}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
maxHeight={maxHeight()}
|
||||
backgroundColor={overlay.background.default}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={getScrollAcceleration(config)}
|
||||
>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||
<markdown
|
||||
syntaxStyle={syntax()}
|
||||
renderNode={plugins.markdown()}
|
||||
content={props.answer}
|
||||
conceal
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "grid", cellPaddingX: 1 }}
|
||||
fg={overlay.markdown.text}
|
||||
bg={overlay.background.default}
|
||||
/>
|
||||
</box>
|
||||
</scrollbox>
|
||||
<box flexDirection="row" gap={3} paddingLeft={2} paddingRight={2} paddingBottom={1}>
|
||||
<text onMouseUp={copy}>
|
||||
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
|
||||
<b>{copied() ? "✓ copied" : "c"}</b>
|
||||
</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy"}</span>
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>↑/↓ scroll</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -887,7 +887,7 @@ export function DiffViewerContent(props: {
|
||||
)
|
||||
edge.backgroundColor =
|
||||
entry && reviewedFileNames().has(entry.file.file)
|
||||
? theme.background.surface.overlay
|
||||
? theme.background.raised.high
|
||||
: theme.diff.background.context
|
||||
}
|
||||
renderer.registerLifecyclePass(edge)
|
||||
@@ -911,7 +911,7 @@ export function DiffViewerContent(props: {
|
||||
{(entry, index) => {
|
||||
const reviewed = () => reviewedFileNames().has(entry.file.file)
|
||||
const background = () =>
|
||||
reviewed() ? theme.background.surface.overlay : theme.diff.background.context
|
||||
reviewed() ? theme.background.raised.high : theme.diff.background.context
|
||||
const image = () => isDiffImageFile(entry.file.file)
|
||||
const countsWidth = () =>
|
||||
(image() ? 6 : String(entry.file.additions).length + String(entry.file.deletions).length + 5) +
|
||||
|
||||
@@ -161,7 +161,7 @@ function map(
|
||||
surface: exact(elevated.background.default),
|
||||
pane: exact(theme.contextual.overlay.background.default),
|
||||
border: exact(theme.border.default),
|
||||
line: exact(theme.background.surface.overlay),
|
||||
line: exact(theme.background.raised.high),
|
||||
},
|
||||
entry: {
|
||||
system: { body: scrollback(theme.text.subdued) },
|
||||
@@ -174,7 +174,7 @@ function map(
|
||||
splash: {
|
||||
left: nearestIndexed(indexed, theme.text.subdued),
|
||||
right: nearestIndexed(indexed, theme.text.default),
|
||||
leftShadow: nearestIndexed(indexed, theme.background.surface.offset),
|
||||
leftShadow: nearestIndexed(indexed, theme.background.raised.base),
|
||||
},
|
||||
block: {
|
||||
text: scrollback(theme.text.default),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import HomeFooter from "../feature-plugins/home/footer"
|
||||
import PromptBtw from "../feature-plugins/prompt/btw"
|
||||
import PromptFooter from "../feature-plugins/prompt/footer"
|
||||
import SidebarContext from "../feature-plugins/sidebar/context"
|
||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||
@@ -14,6 +15,7 @@ import Merman from "@opencode/merman/plugin"
|
||||
export const builtins = [
|
||||
HomeFooter,
|
||||
PromptFooter,
|
||||
PromptBtw,
|
||||
SidebarContext,
|
||||
SidebarMcp,
|
||||
SidebarFooter,
|
||||
|
||||
@@ -1292,7 +1292,7 @@ export function Session(props: {
|
||||
paddingLeft: 1,
|
||||
visible: showScrollbar(),
|
||||
trackOptions: {
|
||||
backgroundColor: theme.raise(theme.background.surface.offset),
|
||||
backgroundColor: theme.raise(theme.background.raised.base),
|
||||
foregroundColor: theme.border.default,
|
||||
},
|
||||
}}
|
||||
@@ -1836,7 +1836,7 @@ function SessionReasoningGroupView(props: {
|
||||
<box
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.raise(theme.background.surface.offset)}
|
||||
borderColor={theme.raise(theme.background.raised.base)}
|
||||
paddingLeft={1}
|
||||
>
|
||||
<code
|
||||
@@ -2016,9 +2016,13 @@ 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 = () => {
|
||||
@@ -2037,6 +2041,7 @@ 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 (
|
||||
@@ -2048,7 +2053,16 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
</InlineToolRow>
|
||||
}
|
||||
>
|
||||
<box marginLeft={3}>
|
||||
<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 })
|
||||
}}
|
||||
>
|
||||
<text wrapMode="none">
|
||||
<span style={{ fg: color() }}>{heading()}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{suffix()}</span>
|
||||
@@ -2787,6 +2801,7 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
|
||||
return (
|
||||
<box
|
||||
border={["left"]}
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
|
||||
@@ -378,7 +378,7 @@ export function turnTokensPerSecond(
|
||||
step.time.streamed === undefined ? [] : [Math.max(0, step.time.streamed - step.time.created)],
|
||||
)
|
||||
if (steps.length === 0 || durations.length !== steps.length) return
|
||||
const output = steps.reduce((total, step) => total + (step.tokens?.output ?? 0), 0)
|
||||
const output = steps.reduce((total, step) => total + (step.tokens?.output ?? 0) + (step.tokens?.reasoning ?? 0), 0)
|
||||
const duration = durations.reduce((total, value) => total + value, 0)
|
||||
if (output <= 0 || duration <= 0) return
|
||||
// Aggregate before dividing so each step is weighted by its provider-active duration.
|
||||
|
||||
@@ -762,7 +762,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
backgroundColor={
|
||||
active()
|
||||
? actionFocused()
|
||||
? theme.background.surface.overlay
|
||||
? theme.background.raised.high
|
||||
: (option.bg ?? theme.background.action.primary.focused)
|
||||
: RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
@@ -25,12 +25,13 @@ test("measures turn duration from the user prompt across assistant steps", () =>
|
||||
expect(turnDuration(final, messages)).toBe(29_000)
|
||||
})
|
||||
|
||||
test("measures turn output throughput across model steps without tool time", () => {
|
||||
test("measures request throughput including reasoning across model changes without tool time", () => {
|
||||
const first = assistant("assistant-1", [])
|
||||
first.time = { created: 8_000, streamed: 10_000, completed: 20_000 }
|
||||
first.time = { created: 6_000, streamed: 10_000, completed: 20_000 }
|
||||
first.tokens = { input: 10, output: 20, reasoning: 5, cache: { read: 0, write: 0 } }
|
||||
const final = assistant("assistant-2", [])
|
||||
final.time = { created: 27_000, streamed: 30_000, completed: 31_000 }
|
||||
final.model = { id: "other-model", providerID: "other-provider", variant: "other-variant" }
|
||||
final.time = { created: 24_000, streamed: 30_000, completed: 31_000 }
|
||||
final.tokens = { input: 20, output: 30, reasoning: 10, cache: { read: 0, write: 0 } }
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Question", time: { created: 1_000 } },
|
||||
@@ -38,7 +39,9 @@ test("measures turn output throughput across model steps without tool time", ()
|
||||
final,
|
||||
]
|
||||
|
||||
expect(turnTokensPerSecond(final, messages)).toBe(10)
|
||||
expect(turnTokensPerSecond(final, messages)).toBe(6.5)
|
||||
first.time.streamed = undefined
|
||||
expect(turnTokensPerSecond(final, messages)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("omits turn throughput when a stream boundary is unavailable", () => {
|
||||
@@ -79,10 +82,10 @@ test.each([false, true])(
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
[2_000, 5],
|
||||
[3_000, 10],
|
||||
[6_000, 15],
|
||||
[4_000, 6],
|
||||
[2_000, 7],
|
||||
[3_000, 12],
|
||||
[6_000, 17],
|
||||
[4_000, 7],
|
||||
[0, undefined],
|
||||
])
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Global } from "@opencode/util/global"
|
||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test.each([40, 120])("completion notices do not navigate at width %s", async (width) => {
|
||||
test.each([40, 120])("shell completion notices do not navigate at width %s", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width, height: 36, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
@@ -35,15 +35,6 @@ test.each([40, 120])("completion notices do not navigate at width %s", async (wi
|
||||
label: "Shell cancelled",
|
||||
description: "Cancelled command",
|
||||
},
|
||||
{ source: "subagent", state: "completed", sessionID: "child-1", label: "Subagent finished", description: "Done" },
|
||||
{ source: "subagent", state: "error", sessionID: "child-1", label: "Subagent failed", description: "Failed" },
|
||||
{
|
||||
source: "subagent",
|
||||
state: "cancelled",
|
||||
sessionID: "child-1",
|
||||
label: "Subagent cancelled",
|
||||
description: "Cancelled",
|
||||
},
|
||||
]
|
||||
const messages = [
|
||||
{ id: "user-0", type: "user", text: "Run background tasks", time: { created: 0 } },
|
||||
@@ -108,7 +99,7 @@ test.each([40, 120])("completion notices do not navigate at width %s", async (wi
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
try {
|
||||
await setup.waitForFrame((frame) => frame.includes("Subagent cancelled"))
|
||||
await setup.waitForFrame((frame) => frame.includes("Shell cancelled"))
|
||||
await setup.waitForVisualIdle()
|
||||
const find = (root: Renderable): ScrollBoxRenderable | undefined =>
|
||||
root instanceof ScrollBoxRenderable && root.getRenderable("history-19")
|
||||
@@ -135,3 +126,70 @@ test.each([40, 120])("completion notices do not navigate at width %s", async (wi
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([40, 120])("subagent completion notices navigate to the child session at width %s", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width, height: 20, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const parent = {
|
||||
id: "ses_parent",
|
||||
title: "Parent session",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
const child = { ...parent, id: "ses_child", title: "Diagnose authentication", parentID: parent.id }
|
||||
const messages = [
|
||||
{ id: "user-0", type: "user", text: "Run a background subagent", time: { created: 0 } },
|
||||
{
|
||||
id: "notice-0",
|
||||
type: "synthetic",
|
||||
text: "Subagent result",
|
||||
description: "Diagnose subagent search auth",
|
||||
metadata: { source: "subagent", childID: child.id, agent: "general", state: "completed" },
|
||||
time: { created: 1 },
|
||||
},
|
||||
]
|
||||
const childMessages = [{ id: "child-user", type: "user", text: "Investigate authentication", time: { created: 0 } }]
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [parent, child], cursor: {} })
|
||||
if (url.pathname === `/api/session/${parent.id}`) return json({ data: parent })
|
||||
if (url.pathname === `/api/session/${child.id}`) return json({ data: child })
|
||||
if (url.pathname === `/api/session/${parent.id}/message`) return json({ data: messages.toReversed(), cursor: {} })
|
||||
if (url.pathname === `/api/session/${child.id}/message`)
|
||||
return json({ data: childMessages.toReversed(), cursor: {} })
|
||||
if (url.pathname.endsWith("/inbox") || url.pathname.endsWith("/permission")) return json({ data: [] })
|
||||
return undefined
|
||||
}, createEventStream())
|
||||
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: {
|
||||
get: async () => ({ animations: false, tabs: { enabled: false } }),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
args: { sessionID: parent.id },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
try {
|
||||
await setup.waitForFrame((frame) => frame.includes("General finished"))
|
||||
await setup.waitForVisualIdle()
|
||||
const lines = setup.captureCharFrame().split("\n")
|
||||
const y = lines.findIndex((line) => line.includes("General finished"))
|
||||
const x = lines[y].indexOf("General finished")
|
||||
await setup.mockMouse.click(x + 1, y)
|
||||
await setup.waitForFrame((frame) => frame.includes("Investigate authentication"))
|
||||
} finally {
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { DialogUpdate } from "../../src/component/dialog-update"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import type { UpdateState } from "../../src/context/update-notification"
|
||||
import { Keymap } from "../../src/context/keymap"
|
||||
import { ThemeProvider } from "../../src/context/theme"
|
||||
import { DialogProvider } from "../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../src/ui/toast"
|
||||
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
test("installation progress replaces checking while the update job is still pending", async () => {
|
||||
await using temporary = await tmpdir()
|
||||
const [state, setState] = createSignal<UpdateState>()
|
||||
const pending = Promise.withResolvers<string | undefined>()
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={temporary.path} paths={{ state: temporary.path }}>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<DialogUpdate
|
||||
check={() => pending.promise}
|
||||
state={state}
|
||||
skip={() => {}}
|
||||
install={() => Promise.resolve()}
|
||||
restart={() => {}}
|
||||
/>
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 80, height: 24, kittyKeyboard: true },
|
||||
)
|
||||
|
||||
try {
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("Checking for updates"))
|
||||
setState({ type: "installing", version: "2.0.0" })
|
||||
await app.waitForFrame(
|
||||
(frame) =>
|
||||
frame.includes("Updating OpenCode") &&
|
||||
frame.includes("Installing OpenCode 2.0.0") &&
|
||||
!frame.includes("Checking"),
|
||||
)
|
||||
expect(app.captureCharFrame()).not.toContain("Skip")
|
||||
pending.reject(new Error("Update service unavailable"))
|
||||
await app.waitForFrame((frame) => frame.includes("Update service unavailable") && !frame.includes("Installing"))
|
||||
} finally {
|
||||
pending.resolve(undefined)
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -99,7 +99,7 @@ function expectFooter(actual: RunTheme, theme: ResolvedTheme) {
|
||||
surface: theme.contextual.elevated.background.default,
|
||||
pane: theme.contextual.overlay.background.default,
|
||||
border: theme.border.default,
|
||||
line: theme.background.surface.overlay,
|
||||
line: theme.background.raised.high,
|
||||
}
|
||||
Object.entries(expected).forEach(([key, color]) => {
|
||||
expect(rgba(actual.footer[key as keyof typeof expected]).toInts()).toEqual(color.toInts())
|
||||
|
||||
@@ -19,12 +19,12 @@ test("provides reactive properties, states, contexts, and color operations", ()
|
||||
expect(theme.hue.interactive[500]).toBe(resolved().hue.interactive[500])
|
||||
expect(theme.hue.gray[200]).toBe(resolved().hue.gray[200])
|
||||
expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
|
||||
expect(theme.increase(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[400])
|
||||
expect(theme.raise(theme.background.surface.offset)).toBe(resolved().hue.neutral[400])
|
||||
expect(theme.increase(theme.background.raised.base, 1)).toBe(resolved().hue.neutral[400])
|
||||
expect(theme.raise(theme.background.raised.base)).toBe(resolved().hue.neutral[400])
|
||||
expect(theme.decrease(theme.hue.red[300], 2)).toBe(resolved().hue.red[100])
|
||||
expect(theme.increase(theme.hue.red[900], 3)).toBe(resolved().hue.red[900])
|
||||
expect(theme.decrease(theme.hue.red[100], 3)).toBe(resolved().hue.red[100])
|
||||
expect(theme.source(theme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
|
||||
expect(theme.source(theme.background.raised.base)).toEqual({ hue: "neutral", step: 300 })
|
||||
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
|
||||
expect(theme.source(equivalent)).toBeUndefined()
|
||||
expect(theme.increase(equivalent, 1)).toBe(equivalent)
|
||||
@@ -46,8 +46,8 @@ test("provides reactive properties, states, contexts, and color operations", ()
|
||||
expect(theme.background.formfield.selected).toBe(resolved().background.formfield.selected)
|
||||
expect(theme.background.formfield.focused).toBe(resolved().background.formfield.focused)
|
||||
expect(theme.background.formfield.disabled).toBe(resolved().background.formfield.disabled)
|
||||
expect(theme.background.surface.offset).toBe(resolved().background.surface.offset)
|
||||
expect(theme.background.surface.overlay).toBe(resolved().background.surface.overlay)
|
||||
expect(theme.background.raised.base).toBe(resolved().background.raised.base)
|
||||
expect(theme.background.raised.high).toBe(resolved().background.raised.high)
|
||||
expect(theme.scrollbar.default).toBe(resolved().scrollbar.default)
|
||||
expect(theme.diff.text.added).toBe(resolved().diff.text.added)
|
||||
|
||||
@@ -58,14 +58,14 @@ test("provides reactive properties, states, contexts, and color operations", ()
|
||||
expect(current().background.action.primary.focused).toBe(
|
||||
resolved().contextual.elevated.background.action.primary.focused,
|
||||
)
|
||||
expect(current().background.action.primary.hovered).toBe(resolved().background.surface.overlay)
|
||||
expect(current().background.action.primary.hovered).toBe(resolved().background.raised.high)
|
||||
expect(current().background.formfield.selected).toBe(resolved().contextual.elevated.background.formfield.selected)
|
||||
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||
setMode("dark")
|
||||
expect(current().text.default).toBe(resolved().contextual.elevated.text.default)
|
||||
expect(current().decrease(current().background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
|
||||
expect(current().raise(current().background.surface.offset)).toBe(resolved().hue.neutral[600])
|
||||
expect(current().decrease(current().background.raised.base, 1)).toBe(resolved().hue.neutral[600])
|
||||
expect(current().raise(current().background.raised.base)).toBe(resolved().hue.neutral[600])
|
||||
})
|
||||
|
||||
test("a stable component theme view follows presentation context changes", () => {
|
||||
|
||||
@@ -71,21 +71,21 @@ test("resolves independent definitions and hue aliases", () => {
|
||||
expect(lightTheme.categorical[0]).toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.source(lightTheme.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||
expect(lightTheme.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
|
||||
expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
|
||||
expect(lightTheme.source(lightTheme.background.raised.base)).toEqual({ hue: "neutral", step: 300 })
|
||||
expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
|
||||
expect(lightTheme.contextual.elevated.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
|
||||
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300])
|
||||
expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400])
|
||||
expect(lightTheme.background.raised.base).toBe(lightTheme.hue.neutral[300])
|
||||
expect(lightTheme.background.raised.high).toBe(lightTheme.hue.neutral[400])
|
||||
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200])
|
||||
expect(lightTheme.contextual.elevated.background.action.primary.default).toBe(lightTheme.hue.interactive[500])
|
||||
expect(lightTheme.contextual.elevated.background.default).toBe(lightTheme.background.surface.offset)
|
||||
expect(lightTheme.contextual.elevated.background.default).toBe(lightTheme.background.raised.base)
|
||||
expect(lightTheme.contextual.elevated.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||
expect(lightTheme.contextual.overlay.background.action.primary.default).toBe(lightTheme.hue.interactive[500])
|
||||
expect(lightTheme.contextual.overlay.background.default).toBe(lightTheme.background.surface.overlay)
|
||||
expect(lightTheme.contextual.overlay.background.default).toBe(lightTheme.background.raised.high)
|
||||
expect(lightTheme.contextual.overlay.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||
expect(darkTheme.contextual.elevated.background.action.primary.default).toBe(darkTheme.hue.interactive[400])
|
||||
expect(darkTheme.contextual.elevated.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
|
||||
@@ -244,7 +244,7 @@ test("resolves elevated hover surfaces from direct colors", () => {
|
||||
const theme = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: { background: { surface: { offset: "#123456", overlay: "#234567" } } },
|
||||
light: { background: { raised: { base: "#123456", high: "#234567" } } },
|
||||
dark: {},
|
||||
},
|
||||
"light",
|
||||
|
||||
@@ -19,7 +19,7 @@ const text = {
|
||||
|
||||
const background = {
|
||||
default: "$hue.neutral.100",
|
||||
surface: { offset: "$hue.neutral.200", overlay: "$hue.neutral.300" },
|
||||
raised: { base: "$hue.neutral.200", high: "$hue.neutral.300", max: "$hue.neutral.400" },
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.interactive.600",
|
||||
|
||||
@@ -24,17 +24,17 @@ test("migrates resolved V1 modes into V2 tokens", () => {
|
||||
expect(migrated.light.text?.subdued).toBe("$hue.neutral.600")
|
||||
expect(migrated.light.background?.action?.primary?.default).toBe("transparent")
|
||||
expect(migrated.light.background?.default).toBe("$hue.neutral.200")
|
||||
expect(migrated.light.background?.surface?.offset).toBe("$hue.neutral.300")
|
||||
expect(migrated.light.background?.surface?.overlay).toBe("$hue.neutral.400")
|
||||
expect(migrated.light.background?.raised?.base).toBe("$hue.neutral.300")
|
||||
expect(migrated.light.background?.raised?.high).toBe("$hue.neutral.400")
|
||||
expect(migrated.dark.background?.default).toBe("$hue.neutral.800")
|
||||
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.700")
|
||||
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.600")
|
||||
expect(migrated.dark.background?.raised?.base).toBe("$hue.neutral.700")
|
||||
expect(migrated.dark.background?.raised?.high).toBe("$hue.neutral.600")
|
||||
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
|
||||
expect(migrated.light.text?.action?.secondary?.default).toBe("$text.subdued")
|
||||
expect(migrated.light.text?.action?.secondary?.$hovered).toBe("$text.default")
|
||||
expect(migrated.light.background?.action?.primary?.$selected).toBe("transparent")
|
||||
expect(resolved.background.surface.offset.toInts()).toEqual(legacy.backgroundPanel.toInts())
|
||||
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundElement.toInts())
|
||||
expect(resolved.background.raised.base.toInts()).toEqual(legacy.backgroundPanel.toInts())
|
||||
expect(resolved.background.raised.high.toInts()).toEqual(legacy.backgroundElement.toInts())
|
||||
expect(resolved.background.formfield.selected.toInts()).toEqual(legacy.background.toInts())
|
||||
expect(resolved.background.formfield.focused.toInts()).toEqual(legacy.background.toInts())
|
||||
expect(resolved.text.formfield.default.toInts()).toEqual(legacy.text.toInts())
|
||||
|
||||
@@ -79,6 +79,15 @@
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
/* Custom toasts render no close button, so their content takes the whole width. */
|
||||
&:not(:has(> [data-close-button])) > [data-content] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
[data-description]:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> [data-close-button] {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
|
||||
@@ -140,13 +140,30 @@ export const Toast = Object.assign(ToastRoot, {
|
||||
let toastV2Id = 0
|
||||
|
||||
export const toaster = {
|
||||
show(render: (props: { toastId: number }) => JSX.Element, options?: { duration?: number; persistent?: boolean }) {
|
||||
show(
|
||||
render: (props: { toastId: number }) => JSX.Element,
|
||||
options?: {
|
||||
duration?: number
|
||||
persistent?: boolean
|
||||
/** Reactive value the toast's height depends on; sonner re-measures when it changes. */
|
||||
resize?: () => unknown
|
||||
},
|
||||
) {
|
||||
const toastId = --toastV2Id
|
||||
toast.custom((id) => render({ toastId: Number(id) }), {
|
||||
id: toastId,
|
||||
className: "toast-v2",
|
||||
duration: options?.persistent ? Number.POSITIVE_INFINITY : options?.duration,
|
||||
unstyled: true,
|
||||
// Sonner only re-measures on title/description changes, so a function-valued description is
|
||||
// the hook for a custom toast whose height depends on reactive state. It renders nothing;
|
||||
// toast.css collapses the empty description element.
|
||||
description: options?.resize
|
||||
? (): JSX.Element => {
|
||||
options.resize?.()
|
||||
return undefined
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
return toastId
|
||||
},
|
||||
|
||||
@@ -147,6 +147,8 @@ const source = {
|
||||
"ui.promptInput.dropFiles.pdf": "Drop PDFs or files to add",
|
||||
"ui.promptInput.dropFiles.imagePdf": "Drop images, PDFs, or files to add",
|
||||
"ui.promptInput.removeAttachment": "Remove attachment",
|
||||
"ui.promptInput.cancelUpload": "Cancel upload",
|
||||
"ui.promptInput.uploading": "{{percent}}%",
|
||||
"ui.promptInput.label": "Prompt",
|
||||
"ui.promptInput.placeholder.shell": "Enter shell command…",
|
||||
"ui.promptInput.placeholder.normal": "Ask anything, {{slash}} for commands, {{at}} for context…",
|
||||
|
||||
@@ -194,6 +194,7 @@ The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, an
|
||||
| `session.interrupt` | `escape` | Interrupt current session |
|
||||
| `session.background` | `ctrl+b` | Background blocking session tools |
|
||||
| `session.compact` | `<leader>c` | Compact the session |
|
||||
| `session.aside` | `none` | Ask a side question |
|
||||
| `session.cd` | `none` | Change working directory |
|
||||
| `session.queued_prompts` | `<leader>q` | Manage queued prompts |
|
||||
| `queued_prompt.delete` | `ctrl+d` | Delete queued prompt |
|
||||
|
||||
@@ -50,6 +50,14 @@ Type `/` to list slash commands. Continue typing to filter the list, then press
|
||||
|
||||
Common commands include `/new`, `/sessions`, `/models`, `/agents`, `/undo`, `/redo`, and `/editor`. Press **Ctrl+P** to open the command palette for every action available in the current view.
|
||||
|
||||
## Side questions
|
||||
|
||||
Run `/btw <question>` to ask a quick question about the conversation so far. The answer uses the session's context and current model but is not added to the conversation, so it does not affect later prompts. A `/btw` indicator shows in the prompt footer while it runs, and the answer opens in a dialog; press **C** to copy it.
|
||||
|
||||
```text
|
||||
/btw why did the test in step 2 fail?
|
||||
```
|
||||
|
||||
## Models
|
||||
|
||||
Press **Ctrl+X**, then **M** to choose a model, or run `/models`. Press **F2** to cycle through recently used models.
|
||||
|
||||
Reference in New Issue
Block a user